feat: add email notifications when a note is shared

Send an email to collaborators when they are added to a shared note.
Uses nodemailer with optional SMTP configuration (auto-enabled when
SMTP_HOST is set). Users can opt out via the notifyOnShare preference.

https://claude.ai/code/session_01P4HSMr7QvKw9TMNikUJD9e
This commit is contained in:
Claude
2026-03-11 20:33:48 +01:00
committed by Fred Cabassut
parent 07ea482bad
commit 0b1416908e
11 changed files with 221 additions and 3 deletions
+8
View File
@@ -22,3 +22,11 @@ NODE_ENV=development
# AUTH_OIDC_CLIENT_ID= # AUTH_OIDC_CLIENT_ID=
# AUTH_OIDC_CLIENT_SECRET= # AUTH_OIDC_CLIENT_SECRET=
# AUTH_OIDC_DISPLAY_NAME=SSO # 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 <noreply@example.com>
+5
View File
@@ -11,6 +11,11 @@ services:
- DATA_DIR=/data - DATA_DIR=/data
- DATABASE_URL=/data/crumbs.db - DATABASE_URL=/data/crumbs.db
- ORIGIN=${ORIGIN:-http://localhost:3000} - 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 restart: unless-stopped
volumes: volumes:
+14
View File
@@ -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. 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 <noreply@localhost>` | Sender address |
Users can opt out of share notifications in **Settings > Preferences**.
## Health Check ## Health Check
The Docker image includes a health check that pings `/login`: The Docker image includes a health check that pings `/login`:
+1
View File
@@ -91,6 +91,7 @@
- Share dialog accessible from editor toolbar - Share dialog accessible from editor toolbar
- Collaborators can leave shared notes - Collaborators can leave shared notes
- Shared notes appear mixed in main list alongside owned 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 ### Full-Text Search
- Search across note titles, content, and tags - Search across note titles, content, and tags
+2
View File
@@ -54,6 +54,7 @@
"idb": "^8.0.3", "idb": "^8.0.3",
"lucide-svelte": "^0.577.0", "lucide-svelte": "^0.577.0",
"markdown-it": "^14.1.1", "markdown-it": "^14.1.1",
"nodemailer": "^8.0.2",
"svelte-dnd-action": "^0.9.69", "svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0", "tiptap-markdown": "^0.9.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
@@ -68,6 +69,7 @@
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^7.0.11",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0", "@vite-pwa/sveltekit": "^1.1.0",
+19
View File
@@ -79,6 +79,9 @@ importers:
markdown-it: markdown-it:
specifier: ^14.1.1 specifier: ^14.1.1
version: 14.1.1 version: 14.1.1
nodemailer:
specifier: ^8.0.2
version: 8.0.2
svelte-dnd-action: svelte-dnd-action:
specifier: ^0.9.69 specifier: ^0.9.69
version: 0.9.69(svelte@5.53.7) version: 0.9.69(svelte@5.53.7)
@@ -116,6 +119,9 @@ importers:
'@types/markdown-it': '@types/markdown-it':
specifier: ^14.1.2 specifier: ^14.1.2
version: 14.1.2 version: 14.1.2
'@types/nodemailer':
specifier: ^7.0.11
version: 7.0.11
'@types/uuid': '@types/uuid':
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.0 version: 10.0.0
@@ -1653,6 +1659,9 @@ packages:
'@types/node@25.3.5': '@types/node@25.3.5':
resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==}
'@types/nodemailer@7.0.11':
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -3005,6 +3014,10 @@ packages:
node-releases@2.0.36: node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} 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: nyc@18.0.0:
resolution: {integrity: sha512-G5UyHinFkB1BxqGTrmZdB6uIYH0+v7ZnVssuflUDi+J+RhKWyAhRT1RCehBSI6jLFLuUUgFDyLt49mUtdO1XeQ==} resolution: {integrity: sha512-G5UyHinFkB1BxqGTrmZdB6uIYH0+v7ZnVssuflUDi+J+RhKWyAhRT1RCehBSI6jLFLuUUgFDyLt49mUtdO1XeQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -5435,6 +5448,10 @@ snapshots:
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.18.2
'@types/nodemailer@7.0.11':
dependencies:
'@types/node': 25.3.5
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
@@ -6770,6 +6787,8 @@ snapshots:
node-releases@2.0.36: {} node-releases@2.0.36: {}
nodemailer@8.0.2: {}
nyc@18.0.0: nyc@18.0.0:
dependencies: dependencies:
'@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/load-nyc-config': 1.1.0
+116
View File
@@ -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<void> {
try {
const transport = getTransporter();
await transport.sendMail({
from: process.env.SMTP_FROM || 'Crumbs <noreply@localhost>',
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<void> {
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 `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background-color:#f0e6d3;font-family:'JetBrains Mono',monospace;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0e6d3;padding:40px 20px;">
<tr><td align="center">
<table width="500" cellpadding="0" cellspacing="0" style="background-color:#faf5eb;border:1px solid #d4cabb;box-shadow:2px 2px 0px #d4cabb;">
<tr><td style="padding:32px;">
<p style="margin:0 0 8px;font-size:11px;color:#C8860A;text-transform:uppercase;letter-spacing:2px;">Crumbs</p>
<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Note shared with you</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${escapeHtml(displayName)},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
<strong>${escapedFrom}</strong> shared a note with you: <strong>&ldquo;${escapedTitle}&rdquo;</strong>
</p>
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr><td style="background-color:#C8860A;box-shadow:2px 2px 0px #1a1a2e;">
<a href="${escapeHtml(appUrl)}" style="display:inline-block;padding:10px 24px;color:#faf5eb;font-size:13px;font-weight:bold;text-decoration:none;font-family:'JetBrains Mono',monospace;">Open Crumbs</a>
</td></tr>
</table>
<p style="margin:0;font-size:11px;color:#6b6272;line-height:1.5;">
You&rsquo;re receiving this because someone shared a note with you on Crumbs.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+3 -1
View File
@@ -7,12 +7,14 @@ describe('preferences defaults', () => {
defaultNoteMode: 'richtext', defaultNoteMode: 'richtext',
defaultNoteColor: 'default', defaultNoteColor: 'default',
hideFooter: false, hideFooter: false,
sidebarDefaultState: 'open' sidebarDefaultState: 'open',
notifyOnShare: true
}); });
}); });
it('should identify boolean preference keys', () => { it('should identify boolean preference keys', () => {
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true); 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('defaultNoteMode')).toBe(false);
expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false); expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false);
expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false); expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false);
+5 -2
View File
@@ -5,15 +5,18 @@ export interface UserPreferences {
defaultNoteColor: NoteColor; defaultNoteColor: NoteColor;
hideFooter: boolean; hideFooter: boolean;
sidebarDefaultState: 'open' | 'collapsed'; sidebarDefaultState: 'open' | 'collapsed';
notifyOnShare: boolean;
} }
export const DEFAULT_PREFERENCES: UserPreferences = { export const DEFAULT_PREFERENCES: UserPreferences = {
defaultNoteMode: 'richtext', defaultNoteMode: 'richtext',
defaultNoteColor: 'default', defaultNoteColor: 'default',
hideFooter: false, hideFooter: false,
sidebarDefaultState: 'open' sidebarDefaultState: 'open',
notifyOnShare: true
}; };
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([ export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
'hideFooter' 'hideFooter',
'notifyOnShare'
]); ]);
@@ -64,4 +64,17 @@
/> />
<label for="sidebar-default" class="text-sm text-[var(--text)]">Start with sidebar collapsed</label> <label for="sidebar-default" class="text-sm text-[var(--text)]">Start with sidebar collapsed</label>
</div> </div>
<!-- Email notifications -->
<div class="flex items-center gap-3">
<input
type="checkbox"
id="notify-on-share"
checked={prefs.notifyOnShare}
onchange={() => updatePreference('notifyOnShare', !prefs.notifyOnShare)}
class="h-4 w-4 rounded-sm"
data-testid="pref-notify-on-share"
/>
<label for="notify-on-share" class="text-sm text-[var(--text)]">Email me when someone shares a note with me</label>
</div>
</div> </div>
@@ -5,6 +5,8 @@ import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js'; import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm'; 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 }) => { export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event); const userId = getUserId(event);
@@ -48,6 +50,39 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
addCollaborator(db, params.id, targetUserId, userId); 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]); const collaborators = fetchCollaboratorsForNotes(db, [params.id]);
return json(collaborators.get(params.id) ?? [], { status: 201 }); return json(collaborators.get(params.id) ?? [], { status: 201 });
}; };