mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
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:
@@ -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 <noreply@example.com>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <noreply@localhost>` | Sender address |
|
||||
|
||||
Users can opt out of share notifications in **Settings > Preferences**.
|
||||
|
||||
## Health Check
|
||||
|
||||
The Docker image includes a health check that pings `/login`:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+19
@@ -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
|
||||
|
||||
@@ -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>“${escapedTitle}”</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’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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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>
|
||||
</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>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user