diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts index 230e9ec..1ebbb36 100644 --- a/src/lib/server/email.ts +++ b/src/lib/server/email.ts @@ -65,6 +65,125 @@ export async function sendShareNotification( await sendEmail(toEmail, subject, html); } +/** + * Send a welcome email to a newly created user. + */ +export async function sendWelcomeEmail( + toEmail: string, + toName: string, + appUrl: string +): Promise { + if (!toEmail) return; + + const subject = 'Welcome to Crumbs'; + const html = buildWelcomeEmailHtml(toName, appUrl); + await sendEmail(toEmail, subject, html); +} + +/** + * Send a password reset notification to a user (triggered by admin reset). + */ +export async function sendPasswordResetEmail( + toEmail: string, + toName: string, + appUrl: string +): Promise { + if (!toEmail) return; + + const subject = 'Your Crumbs password has been reset'; + const html = buildPasswordResetEmailHtml(toName, appUrl); + await sendEmail(toEmail, subject, html); +} + +/** + * Send an account deletion confirmation email. + */ +export async function sendAccountDeletedEmail(toEmail: string, toName: string): Promise { + if (!toEmail) return; + + const subject = 'Your Crumbs account has been deleted'; + const html = buildAccountDeletedEmailHtml(toName); + await sendEmail(toEmail, subject, html); +} + +/** + * Send a notification to the old email when the account email address changes. + */ +export async function sendEmailChangedEmail( + oldEmail: string, + toName: string, + newEmail: string +): Promise { + if (!oldEmail) return; + + const subject = 'Your Crumbs email address has been changed'; + const html = buildEmailChangedEmailHtml(toName, newEmail); + await sendEmail(oldEmail, subject, html); +} + +/** + * Send a role change notification to a user. + */ +export async function sendRoleChangedEmail( + toEmail: string, + toName: string, + newRole: 'admin' | 'user', + appUrl: string +): Promise { + if (!toEmail) return; + + const subject = 'Your Crumbs account role has been updated'; + const html = buildRoleChangedEmailHtml(toName, newRole, appUrl); + await sendEmail(toEmail, subject, html); +} + +/** + * Send a notification to a collaborator that they have been removed from a note. + */ +export async function sendCollaboratorRemovedEmail( + toEmail: string, + toName: string, + ownerName: string, + noteTitle: string, + appUrl: string +): Promise { + if (!toEmail) return; + + const subject = `You've been removed from a shared note`; + const html = buildCollaboratorRemovedEmailHtml(toName, ownerName, noteTitle, appUrl); + await sendEmail(toEmail, subject, html); +} + +/** + * Send a notification to collaborators that a shared note has been permanently deleted. + */ +export async function sendNotePermanentlyDeletedEmail( + toEmail: string, + toName: string, + noteTitle: string +): Promise { + if (!toEmail) return; + + const subject = `A shared note has been deleted`; + const html = buildNotePermanentlyDeletedEmailHtml(toName, noteTitle); + await sendEmail(toEmail, subject, html); +} + +/** + * Send a security alert to a user when their account is locked due to too many failed logins. + */ +export async function sendAccountLockedEmail( + toEmail: string, + toName: string, + retryAfterMinutes: number +): Promise { + if (!toEmail) return; + + const subject = 'Crumbs account temporarily locked'; + const html = buildAccountLockedEmailHtml(toName, retryAfterMinutes); + await sendEmail(toEmail, subject, html); +} + function buildShareEmailHtml( toName: string, fromName: string, @@ -74,7 +193,18 @@ function buildShareEmailHtml( const displayName = toName || 'there'; const escapedTitle = escapeHtml(noteTitle); const escapedFrom = escapeHtml(fromName || 'Someone'); + const content = `

Note shared with you

+

+ Hey ${escapeHtml(displayName)}, +

+

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

+ ${buildOpenButton(appUrl)}`; + return buildEmailLayout(content, 'You’re receiving this because someone shared a note with you on Crumbs.'); +} +function buildEmailLayout(content: string, footer: string): string { return ` @@ -84,21 +214,8 @@ function buildShareEmailHtml(

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. -

+ ${content} +

${footer}

@@ -107,6 +224,126 @@ function buildShareEmailHtml( `; } +function buildOpenButton(appUrl: string, label = 'Open Crumbs'): string { + return ` + +
+ ${escapeHtml(label)} +
`; +} + +function buildWelcomeEmailHtml(toName: string, appUrl: string): string { + const displayName = escapeHtml(toName || 'there'); + const content = `

Welcome to Crumbs

+

+ Hey ${displayName}, +

+

+ An admin has created an account for you on Crumbs. You can log in and start taking notes right away. +

+ ${buildOpenButton(appUrl)}`; + return buildEmailLayout(content, 'You’re receiving this because an admin created an account for you on Crumbs.'); +} + +function buildPasswordResetEmailHtml(toName: string, appUrl: string): string { + const displayName = escapeHtml(toName || 'there'); + const content = `

Password reset

+

+ Hey ${displayName}, +

+

+ An admin has reset your Crumbs password. Please log in and change it to something only you know. +

+ ${buildOpenButton(appUrl)}`; + return buildEmailLayout(content, 'You’re receiving this because an admin reset your password on Crumbs. If you didn’t expect this, please contact your admin.'); +} + +function buildAccountDeletedEmailHtml(toName: string): string { + const displayName = escapeHtml(toName || 'there'); + const content = `

Account deleted

+

+ Hey ${displayName}, +

+

+ Your Crumbs account and all associated data have been permanently deleted. +

`; + return buildEmailLayout(content, 'You’re receiving this as confirmation that your Crumbs account was deleted.'); +} + +function buildEmailChangedEmailHtml(toName: string, newEmail: string): string { + const displayName = escapeHtml(toName || 'there'); + const content = `

Email address changed

+

+ Hey ${displayName}, +

+

+ Your Crumbs account email address has been changed to ${escapeHtml(newEmail)}. If you did not make this change, please contact your admin immediately. +

`; + return buildEmailLayout(content, 'You’re receiving this security notice because your Crumbs account email was updated.'); +} + +function buildRoleChangedEmailHtml(toName: string, newRole: 'admin' | 'user', appUrl: string): string { + const displayName = escapeHtml(toName || 'there'); + const roleLabel = newRole === 'admin' ? 'Admin' : 'User'; + const detail = newRole === 'admin' + ? 'You now have admin privileges, including the ability to manage users and settings.' + : 'Your account no longer has admin privileges.'; + const content = `

Role updated: ${roleLabel}

+

+ Hey ${displayName}, +

+

+ Your Crumbs account role has been updated to ${escapeHtml(roleLabel)}. ${escapeHtml(detail)} +

+ ${buildOpenButton(appUrl)}`; + return buildEmailLayout(content, 'You’re receiving this because your account role was changed on Crumbs.'); +} + +function buildCollaboratorRemovedEmailHtml( + toName: string, + ownerName: string, + noteTitle: string, + appUrl: string +): string { + const displayName = escapeHtml(toName || 'there'); + const escapedOwner = escapeHtml(ownerName || 'Someone'); + const escapedTitle = escapeHtml(noteTitle); + const content = `

Removed from shared note

+

+ Hey ${displayName}, +

+

+ ${escapedOwner} has removed you from the shared note “${escapedTitle}”. You no longer have access to this note. +

+ ${buildOpenButton(appUrl)}`; + return buildEmailLayout(content, 'You’re receiving this because you were removed from a shared note on Crumbs.'); +} + +function buildNotePermanentlyDeletedEmailHtml(toName: string, noteTitle: string): string { + const displayName = escapeHtml(toName || 'there'); + const escapedTitle = escapeHtml(noteTitle); + const content = `

Shared note deleted

+

+ Hey ${displayName}, +

+

+ The shared note “${escapedTitle}” has been permanently deleted by its owner. You no longer have access to it. +

`; + return buildEmailLayout(content, 'You’re receiving this because a shared note you had access to was deleted on Crumbs.'); +} + +function buildAccountLockedEmailHtml(toName: string, retryAfterMinutes: number): string { + const displayName = escapeHtml(toName || 'there'); + const content = `

Account temporarily locked

+

+ Hey ${displayName}, +

+

+ Too many failed login attempts were made on your Crumbs account. For your security, login has been temporarily blocked for ${retryAfterMinutes} minute${retryAfterMinutes === 1 ? '' : 's'}. If this wasn’t you, please change your password once you regain access. +

`; + return buildEmailLayout(content, 'You’re receiving this security alert because of repeated failed login attempts on your Crumbs account.'); +} + function escapeHtml(str: string): string { return str .replace(/&/g, '&') diff --git a/src/lib/stores/preferences.test.ts b/src/lib/stores/preferences.test.ts index 67c90c4..cbee538 100644 --- a/src/lib/stores/preferences.test.ts +++ b/src/lib/stores/preferences.test.ts @@ -8,13 +8,17 @@ describe('preferences defaults', () => { defaultNoteColor: 'default', hideFooter: false, sidebarDefaultState: 'open', - notifyOnShare: true + notifyOnShare: true, + notifyOnCollabRemoved: true, + notifyOnNoteDeleted: 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('notifyOnCollabRemoved')).toBe(true); + expect(BOOLEAN_PREF_KEYS.has('notifyOnNoteDeleted')).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 a0d3872..640d0e6 100644 --- a/src/lib/types/preferences.ts +++ b/src/lib/types/preferences.ts @@ -6,6 +6,8 @@ export interface UserPreferences { hideFooter: boolean; sidebarDefaultState: 'open' | 'collapsed'; notifyOnShare: boolean; + notifyOnCollabRemoved: boolean; + notifyOnNoteDeleted: boolean; } export const DEFAULT_PREFERENCES: UserPreferences = { @@ -13,10 +15,14 @@ export const DEFAULT_PREFERENCES: UserPreferences = { defaultNoteColor: 'default', hideFooter: false, sidebarDefaultState: 'open', - notifyOnShare: true + notifyOnShare: true, + notifyOnCollabRemoved: true, + notifyOnNoteDeleted: true }; export const BOOLEAN_PREF_KEYS: ReadonlySet = new Set([ 'hideFooter', - 'notifyOnShare' + 'notifyOnShare', + 'notifyOnCollabRemoved', + 'notifyOnNoteDeleted' ]); diff --git a/src/routes/(app)/settings/preferences/+page.svelte b/src/routes/(app)/settings/preferences/+page.svelte index 2c1a923..fe755ef 100644 --- a/src/routes/(app)/settings/preferences/+page.svelte +++ b/src/routes/(app)/settings/preferences/+page.svelte @@ -66,15 +66,40 @@ -
- updatePreference('notifyOnShare', !prefs.notifyOnShare)} - class="h-4 w-4 rounded-sm" - data-testid="pref-notify-on-share" - /> - +
+ Email notifications +
+ updatePreference('notifyOnShare', !prefs.notifyOnShare)} + class="h-4 w-4 rounded-sm" + data-testid="pref-notify-on-share" + /> + +
+
+ updatePreference('notifyOnCollabRemoved', !prefs.notifyOnCollabRemoved)} + class="h-4 w-4 rounded-sm" + data-testid="pref-notify-on-collab-removed" + /> + +
+
+ updatePreference('notifyOnNoteDeleted', !prefs.notifyOnNoteDeleted)} + class="h-4 w-4 rounded-sm" + data-testid="pref-notify-on-note-deleted" + /> + +
diff --git a/src/routes/api/admin/users/+server.ts b/src/routes/api/admin/users/+server.ts index 039331a..0b786f8 100644 --- a/src/routes/api/admin/users/+server.ts +++ b/src/routes/api/admin/users/+server.ts @@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { requireAdmin } from '$lib/server/api-utils.js'; import { listUsers, createUser } from '$lib/server/auth.js'; +import { isEmailConfigured, sendWelcomeEmail } from '$lib/server/email.js'; export const GET: RequestHandler = async (event) => { requireAdmin(event); @@ -21,5 +22,12 @@ export const POST: RequestHandler = async ({ request, ...event }) => { } const user = await createUser(email, displayName || email.split('@')[0], password || null, role || 'user'); + + // Send welcome email (fire-and-forget) + if (isEmailConfigured() && user.email) { + const origin = process.env.ORIGIN || 'http://localhost:3000'; + sendWelcomeEmail(user.email, user.displayName, origin).catch(() => {}); + } + return json(user, { status: 201 }); }; diff --git a/src/routes/api/admin/users/[id]/+server.ts b/src/routes/api/admin/users/[id]/+server.ts index 05dc9c5..1afae25 100644 --- a/src/routes/api/admin/users/[id]/+server.ts +++ b/src/routes/api/admin/users/[id]/+server.ts @@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { requireAdmin } from '$lib/server/api-utils.js'; import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js'; +import { isEmailConfigured, sendPasswordResetEmail, sendRoleChangedEmail } from '$lib/server/email.js'; export const PATCH: RequestHandler = async ({ params, request, ...event }) => { const admin = requireAdmin(event); @@ -11,12 +12,19 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => { const user = getUser(userId); if (!user) throw error(404, 'User not found'); + const origin = process.env.ORIGIN || 'http://localhost:3000'; + if (body.role !== undefined) { // Prevent admin from demoting themselves if (userId === admin.id && body.role !== 'admin') { throw error(400, 'Cannot change your own role'); } updateUserRole(userId, body.role); + + // Notify user of role change (fire-and-forget) + if (isEmailConfigured() && user.email) { + sendRoleChangedEmail(user.email, user.displayName, body.role, origin).catch(() => {}); + } } if (body.newPassword !== undefined) { @@ -24,6 +32,11 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => { throw error(400, 'Password must be at least 8 characters'); } await resetPassword(userId, body.newPassword); + + // Notify user of password reset (fire-and-forget) + if (isEmailConfigured() && user.email) { + sendPasswordResetEmail(user.email, user.displayName, origin).catch(() => {}); + } } if (body.revokeSessions) { diff --git a/src/routes/api/auth/account/+server.ts b/src/routes/api/auth/account/+server.ts index 7fb48c8..9021fef 100644 --- a/src/routes/api/auth/account/+server.ts +++ b/src/routes/api/auth/account/+server.ts @@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { getUserId } from '$lib/server/api-utils.js'; import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js'; +import { isEmailConfigured, sendAccountDeletedEmail } from '$lib/server/email.js'; export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => { const userId = getUserId(event); @@ -17,6 +18,9 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => if (!verified) throw error(401, 'Invalid password'); } + // Capture email/name before deletion + const { email: userEmail, displayName: userName } = user; + // Delete user and all associated data await deleteUser(userId); @@ -27,5 +31,10 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => cookies.delete('session', { path: '/' }); } + // Send deletion confirmation email (fire-and-forget) + if (isEmailConfigured() && userEmail) { + sendAccountDeletedEmail(userEmail, userName).catch(() => {}); + } + return json({ success: true }); }; diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index ce156ee..93124aa 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -3,6 +3,9 @@ import type { RequestHandler } from './$types.js'; import { verifyPassword, createSession } from '$lib/server/auth.js'; import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.js'; import { db } from '$lib/server/db/index.js'; +import { users } from '$lib/server/db/schema.js'; +import { eq } from 'drizzle-orm'; +import { isEmailConfigured, sendAccountLockedEmail } from '$lib/server/email.js'; export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { const ip = getClientAddress(); @@ -23,6 +26,27 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress const user = await verifyPassword(email, password); if (!user) { recordLoginAttempt(db, ip, email, false); + + // Check if this failure just triggered a lockout — if so, notify the user + if (isEmailConfigured()) { + const newCheck = checkRateLimit(db, ip, email); + if (!newCheck.allowed) { + const retryAfterMinutes = Math.ceil((newCheck.retryAfter ?? 900) / 60); + const accountUser = db + .select({ email: users.email, displayName: users.displayName }) + .from(users) + .where(eq(users.email, email)) + .get(); + if (accountUser?.email) { + sendAccountLockedEmail( + accountUser.email, + accountUser.displayName, + retryAfterMinutes + ).catch(() => {}); + } + } + } + throw error(401, 'Invalid email or password'); } diff --git a/src/routes/api/auth/profile/+server.ts b/src/routes/api/auth/profile/+server.ts index 80452df..432a7cd 100644 --- a/src/routes/api/auth/profile/+server.ts +++ b/src/routes/api/auth/profile/+server.ts @@ -1,7 +1,8 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { getUserId } from '$lib/server/api-utils.js'; -import { updateUserProfile } from '$lib/server/auth.js'; +import { updateUserProfile, getUser } from '$lib/server/auth.js'; +import { isEmailConfigured, sendEmailChangedEmail } from '$lib/server/email.js'; export const PUT: RequestHandler = async ({ request, ...event }) => { const userId = getUserId(event); @@ -11,6 +12,15 @@ export const PUT: RequestHandler = async ({ request, ...event }) => { throw error(400, 'Email cannot be empty'); } + // Capture old email before update to notify on change + const existingUser = email !== undefined ? getUser(userId) : null; + updateUserProfile(userId, { displayName, email }); + + // Send notification to old email when email address changes + if (isEmailConfigured() && existingUser?.email && email && existingUser.email !== email) { + sendEmailChangedEmail(existingUser.email, existingUser.displayName, email).catch(() => {}); + } + return json({ success: true }); }; diff --git a/src/routes/api/notes/[id]/+server.ts b/src/routes/api/notes/[id]/+server.ts index f5d8755..bed4853 100644 --- a/src/routes/api/notes/[id]/+server.ts +++ b/src/routes/api/notes/[id]/+server.ts @@ -3,6 +3,9 @@ import type { RequestHandler } from './$types.js'; import { db } from '$lib/server/db/index.js'; import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js'; import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js'; +import { fetchCollaboratorsForNotes } from '$lib/server/collaborators.js'; +import { isEmailConfigured, sendNotePermanentlyDeletedEmail } from '$lib/server/email.js'; +import { getPreferences } from '$lib/server/preferences.js'; export const GET: RequestHandler = async ({ params, ...event }) => { const userId = getUserId(event); @@ -25,8 +28,32 @@ export const DELETE: RequestHandler = async ({ params, ...event }) => { const userId = getUserId(event); requireNoteOwnership(db, params.id, userId); + // Fetch note and collaborators before deletion to send notifications + let collaboratorsToNotify: Array<{ email: string; displayName: string }> = []; + let noteTitle = 'Untitled'; + + if (isEmailConfigured()) { + const note = getNote(db, userId, params.id); + if (note) { + noteTitle = note.title || 'Untitled'; + const collabMap = fetchCollaboratorsForNotes(db, [params.id]); + const collaborators = collabMap.get(params.id) ?? []; + for (const collab of collaborators) { + const prefs = getPreferences(db, collab.userId); + if (prefs.notifyOnNoteDeleted !== 'false' && collab.email) { + collaboratorsToNotify.push({ email: collab.email, displayName: collab.displayName }); + } + } + } + } + const deleted = deleteNote(db, userId, params.id); if (!deleted) throw error(404, 'Note not found'); + // Notify collaborators about the deleted note (fire-and-forget) + for (const collab of collaboratorsToNotify) { + sendNotePermanentlyDeletedEmail(collab.email, collab.displayName, noteTitle).catch(() => {}); + } + return json({ success: true }); }; diff --git a/src/routes/api/notes/[id]/collaborators/+server.ts b/src/routes/api/notes/[id]/collaborators/+server.ts index dbae010..c23c91d 100644 --- a/src/routes/api/notes/[id]/collaborators/+server.ts +++ b/src/routes/api/notes/[id]/collaborators/+server.ts @@ -5,7 +5,11 @@ 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 { + isEmailConfigured, + sendShareNotification, + sendCollaboratorRemovedEmail +} from '$lib/server/email.js'; import { getPreferences } from '$lib/server/preferences.js'; export const GET: RequestHandler = async ({ params, ...event }) => { @@ -102,7 +106,50 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => { throw error(403, 'Only the owner can remove other collaborators'); } + // Fetch collaborator info and note title before removal for email notification + let removedUserEmail: string | undefined; + let removedUserName: string | undefined; + let noteTitle: string | undefined; + let ownerName: string | undefined; + + if (isEmailConfigured() && isOwner && targetUserId !== userId) { + const targetPrefs = getPreferences(db, targetUserId); + if (targetPrefs.notifyOnCollabRemoved !== 'false') { + const target = db + .select({ email: users.email, displayName: users.displayName }) + .from(users) + .where(eq(users.id, targetUserId)) + .get(); + const noteData = db + .select({ title: notes.title }) + .from(notes) + .where(eq(notes.id, params.id)) + .get(); + const owner = db + .select({ displayName: users.displayName }) + .from(users) + .where(eq(users.id, userId)) + .get(); + removedUserEmail = target?.email ?? undefined; + removedUserName = target?.displayName ?? undefined; + noteTitle = noteData?.title ?? undefined; + ownerName = owner?.displayName ?? undefined; + } + } + removeCollaborator(db, params.id, targetUserId); + // Send notification to removed collaborator (fire-and-forget) + if (removedUserEmail) { + const origin = process.env.ORIGIN || 'http://localhost:3000'; + sendCollaboratorRemovedEmail( + removedUserEmail, + removedUserName || '', + ownerName || 'Someone', + noteTitle || 'Untitled', + origin + ).catch(() => {}); + } + return json({ success: true }); };