diff --git a/.env.example b/.env.example index 5e69214..b040d1a 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,11 @@ NODE_ENV=development # AUTH_OIDC_CLIENT_ID= # AUTH_OIDC_CLIENT_SECRET= # AUTH_OIDC_DISPLAY_NAME=SSO + +# --- Email Notifications (optional, auto-enabled when SMTP_HOST is set) --- + +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER= +# SMTP_PASS= +# SMTP_FROM=Crumbs diff --git a/docker-compose.yml b/docker-compose.yml index 26a981a..b9edc53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,11 @@ services: - DATA_DIR=/data - DATABASE_URL=/data/crumbs.db - ORIGIN=${ORIGIN:-http://localhost:3000} + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASS=${SMTP_PASS:-} + - SMTP_FROM=${SMTP_FROM:-} restart: unless-stopped volumes: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 2265f33..9ef98ef 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -114,6 +114,20 @@ node build Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported. Providers are auto-enabled when their env vars are set. See **[AUTH.md](AUTH.md)** for environment variables, callback URLs, provider-specific setup guides, and troubleshooting. +### Email Notifications (optional) + +Email notifications are sent when a note is shared with a user. Auto-enabled when `SMTP_HOST` is set. + +| Variable | Default | Description | +|----------|---------|-------------| +| `SMTP_HOST` | — | SMTP server hostname (e.g. `smtp.example.com`) | +| `SMTP_PORT` | `587` | SMTP port (`587` for STARTTLS, `465` for SSL) | +| `SMTP_USER` | — | SMTP username (optional for unauthenticated relays) | +| `SMTP_PASS` | — | SMTP password | +| `SMTP_FROM` | `Crumbs ` | Sender address | + +Users can opt out of share notifications in **Settings > Preferences**. + ## Health Check The Docker image includes a health check that pings `/login`: diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 7f1c533..4d92b3e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -91,6 +91,7 @@ - Share dialog accessible from editor toolbar - Collaborators can leave shared notes - Shared notes appear mixed in main list alongside owned notes +- Email notifications when a note is shared with you (requires SMTP config, opt-out in preferences) ### Full-Text Search - Search across note titles, content, and tags diff --git a/package.json b/package.json index 1ebd9b1..c00ea8e 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "idb": "^8.0.3", "lucide-svelte": "^0.577.0", "markdown-it": "^14.1.1", + "nodemailer": "^8.0.2", "svelte-dnd-action": "^0.9.69", "tiptap-markdown": "^0.9.0", "uuid": "^11.1.0", @@ -68,6 +69,7 @@ "@tailwindcss/vite": "^4.2.1", "@types/better-sqlite3": "^7.6.13", "@types/markdown-it": "^14.1.2", + "@types/nodemailer": "^7.0.11", "@types/uuid": "^10.0.0", "@types/zxcvbn": "^4.4.5", "@vite-pwa/sveltekit": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d65a6d9..5c98e43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: markdown-it: specifier: ^14.1.1 version: 14.1.1 + nodemailer: + specifier: ^8.0.2 + version: 8.0.2 svelte-dnd-action: specifier: ^0.9.69 version: 0.9.69(svelte@5.53.7) @@ -116,6 +119,9 @@ importers: '@types/markdown-it': specifier: ^14.1.2 version: 14.1.2 + '@types/nodemailer': + specifier: ^7.0.11 + version: 7.0.11 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -1653,6 +1659,9 @@ packages: '@types/node@25.3.5': resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} + '@types/nodemailer@7.0.11': + resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -3005,6 +3014,10 @@ packages: node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + nodemailer@8.0.2: + resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==} + engines: {node: '>=6.0.0'} + nyc@18.0.0: resolution: {integrity: sha512-G5UyHinFkB1BxqGTrmZdB6uIYH0+v7ZnVssuflUDi+J+RhKWyAhRT1RCehBSI6jLFLuUUgFDyLt49mUtdO1XeQ==} engines: {node: 20 || >=22} @@ -5435,6 +5448,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/nodemailer@7.0.11': + dependencies: + '@types/node': 25.3.5 + '@types/resolve@1.20.2': {} '@types/trusted-types@2.0.7': {} @@ -6770,6 +6787,8 @@ snapshots: node-releases@2.0.36: {} + nodemailer@8.0.2: {} + nyc@18.0.0: dependencies: '@istanbuljs/load-nyc-config': 1.1.0 diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts new file mode 100644 index 0000000..230e9ec --- /dev/null +++ b/src/lib/server/email.ts @@ -0,0 +1,116 @@ +import nodemailer from 'nodemailer'; +import type { Transporter } from 'nodemailer'; + +let transporter: Transporter | null = null; + +/** + * Check if SMTP email is configured via environment variables. + */ +export function isEmailConfigured(): boolean { + return !!process.env.SMTP_HOST; +} + +/** + * Get or create the nodemailer transport (lazy singleton). + */ +function getTransporter(): Transporter { + if (!transporter) { + transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || '587', 10), + secure: process.env.SMTP_PORT === '465', + auth: + process.env.SMTP_USER + ? { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS || '' + } + : undefined + }); + } + return transporter; +} + +/** + * Send an email. Best-effort: logs errors but does not throw. + */ +async function sendEmail(to: string, subject: string, html: string): Promise { + try { + const transport = getTransporter(); + await transport.sendMail({ + from: process.env.SMTP_FROM || 'Crumbs ', + to, + subject, + html + }); + } catch (err) { + console.error('[email] Failed to send:', err); + } +} + +/** + * Send a share notification email to a collaborator. + */ +export async function sendShareNotification( + toEmail: string, + toName: string, + fromName: string, + noteTitle: string, + appUrl: string +): Promise { + if (!toEmail) return; + + const subject = `${fromName} shared a note with you`; + const html = buildShareEmailHtml(toName, fromName, noteTitle, appUrl); + await sendEmail(toEmail, subject, html); +} + +function buildShareEmailHtml( + toName: string, + fromName: string, + noteTitle: string, + appUrl: string +): string { + const displayName = toName || 'there'; + const escapedTitle = escapeHtml(noteTitle); + const escapedFrom = escapeHtml(fromName || 'Someone'); + + return ` + + + + + +
+ + +
+

Crumbs

+

Note shared with you

+

+ Hey ${escapeHtml(displayName)}, +

+

+ ${escapedFrom} shared a note with you: “${escapedTitle}” +

+ + +
+ Open Crumbs +
+

+ You’re receiving this because someone shared a note with you on Crumbs. +

+
+
+ +`; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/src/lib/stores/preferences.test.ts b/src/lib/stores/preferences.test.ts index 8ec1cec..67c90c4 100644 --- a/src/lib/stores/preferences.test.ts +++ b/src/lib/stores/preferences.test.ts @@ -7,12 +7,14 @@ describe('preferences defaults', () => { defaultNoteMode: 'richtext', defaultNoteColor: 'default', hideFooter: false, - sidebarDefaultState: 'open' + sidebarDefaultState: 'open', + notifyOnShare: true }); }); it('should identify boolean preference keys', () => { expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true); + expect(BOOLEAN_PREF_KEYS.has('notifyOnShare')).toBe(true); expect(BOOLEAN_PREF_KEYS.has('defaultNoteMode')).toBe(false); expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false); expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false); diff --git a/src/lib/types/preferences.ts b/src/lib/types/preferences.ts index c05b3b0..a0d3872 100644 --- a/src/lib/types/preferences.ts +++ b/src/lib/types/preferences.ts @@ -5,15 +5,18 @@ export interface UserPreferences { defaultNoteColor: NoteColor; hideFooter: boolean; sidebarDefaultState: 'open' | 'collapsed'; + notifyOnShare: boolean; } export const DEFAULT_PREFERENCES: UserPreferences = { defaultNoteMode: 'richtext', defaultNoteColor: 'default', hideFooter: false, - sidebarDefaultState: 'open' + sidebarDefaultState: 'open', + notifyOnShare: true }; export const BOOLEAN_PREF_KEYS: ReadonlySet = new Set([ - 'hideFooter' + 'hideFooter', + 'notifyOnShare' ]); diff --git a/src/routes/(app)/settings/preferences/+page.svelte b/src/routes/(app)/settings/preferences/+page.svelte index d15b632..2c1a923 100644 --- a/src/routes/(app)/settings/preferences/+page.svelte +++ b/src/routes/(app)/settings/preferences/+page.svelte @@ -64,4 +64,17 @@ /> + + +
+ updatePreference('notifyOnShare', !prefs.notifyOnShare)} + class="h-4 w-4 rounded-sm" + data-testid="pref-notify-on-share" + /> + +
diff --git a/src/routes/api/notes/[id]/collaborators/+server.ts b/src/routes/api/notes/[id]/collaborators/+server.ts index 4ecd178..dbae010 100644 --- a/src/routes/api/notes/[id]/collaborators/+server.ts +++ b/src/routes/api/notes/[id]/collaborators/+server.ts @@ -5,6 +5,8 @@ import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from import { db } from '$lib/server/db/index.js'; import { users, notes, noteCollaborators } from '$lib/server/db/schema.js'; import { eq, and } from 'drizzle-orm'; +import { isEmailConfigured, sendShareNotification } from '$lib/server/email.js'; +import { getPreferences } from '$lib/server/preferences.js'; export const GET: RequestHandler = async ({ params, ...event }) => { const userId = getUserId(event); @@ -48,6 +50,39 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => { addCollaborator(db, params.id, targetUserId, userId); + // Send email notification (fire-and-forget) + if (isEmailConfigured()) { + const targetPrefs = getPreferences(db, targetUserId); + if (targetPrefs.notifyOnShare !== 'false') { + const target = db + .select({ email: users.email, displayName: users.displayName }) + .from(users) + .where(eq(users.id, targetUserId)) + .get(); + const sharer = db + .select({ displayName: users.displayName }) + .from(users) + .where(eq(users.id, userId)) + .get(); + const noteData = db + .select({ title: notes.title }) + .from(notes) + .where(eq(notes.id, params.id)) + .get(); + const origin = process.env.ORIGIN || 'http://localhost:3000'; + + if (target?.email) { + sendShareNotification( + target.email, + target.displayName, + sharer?.displayName || 'Someone', + noteData?.title || 'Untitled', + origin + ).catch(() => {}); + } + } + } + const collaborators = fetchCollaboratorsForNotes(db, [params.id]); return json(collaborators.get(params.id) ?? [], { status: 201 }); };