Compare commits

...

3 Commits

Author SHA1 Message Date
Henry
9303fcb7e0 feat: render URLs as links in card description 2025-06-21 23:14:25 +01:00
Henry
500fe8af95 feat: make SMTP auth optional and add SMTP_SECURE config (#92)
* feat: add SMTP_SECURE option to email config

* feat: allow unauthenticated SMTP servers
2025-06-19 22:58:13 +01:00
Henry
e7383ec670 feat: add debug logs to sendEmail (#91) 2025-06-19 21:55:01 +01:00
9 changed files with 98 additions and 55 deletions

View File

@@ -10,10 +10,11 @@ POSTGRES_PASSWORD=
# SMTP (optional) # SMTP (optional)
SMTP_HOST= SMTP_HOST=
SMTP_PORT=465 # Port 587 will not work SMTP_PORT=465
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>" EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
SMTP_SECURE= # set to "false" to use port 587
# S3 storage (optional) # S3 storage (optional)
S3_REGION= S3_REGION=

View File

@@ -138,34 +138,35 @@ pnpm dev
## Environment Variables 🔐 ## Environment Variables 🔐
| Variable | Description | Required | Example | | Variable | Description | Required | Example |
| -------------------------------- | ----------------------------- | ------------------ | --------------------------------------------- | | -------------------------------- | -------------------------------------------------------- | ------------------ | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` | | `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` | | `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` | | `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` | | `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | For Email | `resend` | | `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` | | `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` | | `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string | | `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` | | `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` | | `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` | | `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` | | `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` | | `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` | | `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` | | `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` | | `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` | | `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` | | `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` | | `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` | | `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` | | `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` | | `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` | | `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` | | `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` | | `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
See `.env.example` for a complete list of supported environment variables. See `.env.example` for a complete list of supported environment variables.

View File

@@ -25,6 +25,7 @@
"@t3-oss/env-nextjs": "^0.11.1", "@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-placeholder": "^2.14.0", "@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/pm": "^2.14.0", "@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0", "@tiptap/react": "^2.14.0",

View File

@@ -5,6 +5,7 @@ import type {
} from "@tiptap/suggestion"; } from "@tiptap/suggestion";
import type { Instance as TippyInstance } from "tippy.js"; import type { Instance as TippyInstance } from "tippy.js";
import { Button } from "@headlessui/react"; import { Button } from "@headlessui/react";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder"; import Placeholder from "@tiptap/extension-placeholder";
import { import {
BubbleMenu, BubbleMenu,
@@ -299,6 +300,16 @@ export default function Editor({
? "" ? ""
: "Add description... (type '/' to open commands)", : "Add description... (type '/' to open commands)",
}), }),
Link.configure({
openOnClick: true,
HTMLAttributes: {
class: "text-blue-600 hover:text-blue-800 underline cursor-pointer",
target: "_blank",
rel: "noopener noreferrer",
},
validate: (href) => /^https?:\/\//.test(href),
autolink: true,
}),
SlashCommands.configure({ SlashCommands.configure({
commandItems: CommandItems, commandItems: CommandItems,
suggestion: { suggestion: {

View File

@@ -28,6 +28,7 @@ services:
- SMTP_PORT=${SMTP_PORT} - SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER} - SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD} - SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_SECURE=${SMTP_SECURE}
- EMAIL_FROM=${EMAIL_FROM} - EMAIL_FROM=${EMAIL_FROM}
# S3 storage # S3 storage

View File

@@ -81,14 +81,6 @@ services:
- APPLE_CLIENT_ID=${APPLE_CLIENT_ID} - APPLE_CLIENT_ID=${APPLE_CLIENT_ID}
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET} - APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER} - APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
# Cloud (not required)
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
depends_on: depends_on:
- postgres - postgres
restart: unless-stopped restart: unless-stopped

View File

@@ -16,11 +16,14 @@ const emailTemplates: Record<Templates, React.FC> = {
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT), port: Number(process.env.SMTP_PORT),
secure: true, secure: process.env.SMTP_SECURE !== "false",
auth: { ...(process.env.SMTP_USER &&
user: process.env.SMTP_USER, process.env.SMTP_PASSWORD && {
pass: process.env.SMTP_PASSWORD, auth: {
}, user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
}),
}); });
export const sendEmail = async ( export const sendEmail = async (
@@ -29,22 +32,34 @@ export const sendEmail = async (
template: Templates, template: Templates,
data: Record<string, string>, data: Record<string, string>,
) => { ) => {
const EmailTemplate = emailTemplates[template]; try {
const EmailTemplate = emailTemplates[template];
const html = await render(<EmailTemplate {...data} />, { pretty: true }); const html = await render(<EmailTemplate {...data} />, { pretty: true });
const options = { const options = {
from: process.env.EMAIL_FROM, from: process.env.EMAIL_FROM,
to, to,
subject, subject,
html, html,
}; };
const response = await transporter.sendMail(options); const response = await transporter.sendMail(options);
if (!response.accepted.length) { if (!response.accepted.length) {
throw new Error(`Failed to send email: ${response.response}`); throw new Error(`Failed to send email: ${response.response}`);
}
return response;
} catch (error) {
console.error("Email sending failed:", {
to,
from: process.env.EMAIL_FROM,
subject,
template,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
} }
return response;
}; };

20
pnpm-lock.yaml generated
View File

@@ -108,6 +108,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: 'catalog:' specifier: 'catalog:'
version: 5.62.7(react@18.3.1) version: 5.62.7(react@18.3.1)
'@tiptap/extension-link':
specifier: ^2.22.2
version: 2.22.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-placeholder': '@tiptap/extension-placeholder':
specifier: ^2.14.0 specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0) version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
@@ -2343,6 +2346,12 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': ^2.7.0 '@tiptap/core': ^2.7.0
'@tiptap/extension-link@2.22.2':
resolution: {integrity: sha512-nL4tp6Q6ppjP53HS4E8InBYO2kwXx16SgqlS1mc51OiiQgdXPNKZAzBluuiMMi6+wvVWvD4QCbHhQjQy11cZNA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-list-item@2.14.0': '@tiptap/extension-list-item@2.14.0':
resolution: {integrity: sha512-t1jXDPEd82sC6vZVE/12/CB52uuiydCIcRfwdh21xNgBMckToKO9S0K6XEp4ROtrKQdlIH2JDVPfpUBvVrYN8Q==} resolution: {integrity: sha512-t1jXDPEd82sC6vZVE/12/CB52uuiydCIcRfwdh21xNgBMckToKO9S0K6XEp4ROtrKQdlIH2JDVPfpUBvVrYN8Q==}
peerDependencies: peerDependencies:
@@ -4335,6 +4344,9 @@ packages:
linkify-it@5.0.0: linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
linkifyjs@4.3.1:
resolution: {integrity: sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==}
locate-path@6.0.0: locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -8527,6 +8539,12 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/extension-link@2.22.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)':
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/pm': 2.14.0
linkifyjs: 4.3.1
'@tiptap/extension-list-item@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))': '@tiptap/extension-list-item@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))':
dependencies: dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
@@ -10828,6 +10846,8 @@ snapshots:
dependencies: dependencies:
uc.micro: 2.1.0 uc.micro: 2.1.0
linkifyjs@4.3.1: {}
locate-path@6.0.0: locate-path@6.0.0:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0

View File

@@ -91,6 +91,7 @@
"SMTP_PORT", "SMTP_PORT",
"SMTP_USER", "SMTP_USER",
"SMTP_PASSWORD", "SMTP_PASSWORD",
"SMTP_SECURE",
"NEXT_PUBLIC_KAN_ENV", "NEXT_PUBLIC_KAN_ENV",
"STRIPE_SECRET_KEY", "STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET",