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); } /** * 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, noteTitle: string, appUrl: string ): string { 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 `

Crumbs

${content}

${footer}

`; } 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, '&') .replace(//g, '>') .replace(/"/g, '"'); }