/** * Email sending utilities using Postmark * Implements Workstream G: Email Service (Backend) */ import type { OrgRole } from "@reviq/db-schema"; import { DurationFormat } from "@formatjs/intl-durationformat"; import { ServerClient } from "postmark"; import { BASE_URL, EMAIL_DEV_MODE, EMAIL_FROM, EMAIL_VERIFICATION_EXPIRY_HOURS, LOGIN_CONFIRMATION_EXPIRY_MINUTES, ORG_INVITE_EXPIRY_DAYS, PASSWORD_RESET_EXPIRY_HOURS, POSTMARK_API_KEY, } from "../constants.js"; // ===== Types ===== /** * Email send result */ export interface EmailResult { success: boolean; messageId?: string; error?: string; } // ===== Postmark Client ===== let postmarkClient: ServerClient | null = null; const getPostmarkClient = (): ServerClient => { if (!postmarkClient) { if (!POSTMARK_API_KEY) { throw new Error( "POSTMARK_API_KEY is required when EMAIL_DEV_MODE is false", ); } postmarkClient = new ServerClient(POSTMARK_API_KEY); } return postmarkClient; }; // ===== URL Helpers ===== /** * Build a URL with query parameters using the URL constructor */ const buildUrl = (path: string, params: Record): string => { const url = new URL(path, BASE_URL); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } return url.toString(); }; // ===== HTML Escaping ===== /** * Escape HTML special characters to prevent XSS */ const escapeHtml = (unsafe: string): string => unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); // ===== Core Email Function ===== interface SendEmailParams { to: string; subject: string; htmlBody: string; textBody: string; } /** * Send an email via Postmark (or log in dev mode) */ const sendEmail = async (params: SendEmailParams): Promise => { const { to, subject, htmlBody, textBody } = params; // Dev mode: log instead of sending if (EMAIL_DEV_MODE) { console.log("=== DEV MODE EMAIL ==="); console.log(`To: ${to}`); console.log(`Subject: ${subject}`); console.log(`Body:\n${textBody}`); console.log("======================"); return { success: true, messageId: "dev-mode" }; } try { const client = getPostmarkClient(); const result = await client.sendEmail({ From: EMAIL_FROM, To: to, Subject: subject, HtmlBody: htmlBody, TextBody: textBody, }); return { success: true, messageId: result.MessageID }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; console.error(`Failed to send email to ${to}:`, message); return { success: false, error: message }; } }; // ===== Template Helpers ===== const durationFormatter = new DurationFormat("en", { style: "long" }); const formatExpiryHours = (hours: number): string => durationFormatter.format({ hours }); const formatExpiryMinutes = (minutes: number): string => durationFormatter.format({ minutes }); const formatExpiryDays = (days: number): string => durationFormatter.format({ days }); const roleLabels: Record = { owner: "Owner", admin: "Admin", member: "Member", }; const formatRoleDisplay = (role: OrgRole): string => roleLabels[role]; /** * Get the correct article (a/an) for a role */ const getArticleForRole = (role: OrgRole): string => { return role === "owner" || role === "admin" ? "an" : "a"; }; // ===== Email Templates ===== // Common styles const emailStyles = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;`; const containerStyles = "max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 40px;"; const headingStyles = "margin: 0 0 24px; font-size: 24px; color: #1a1a1a;"; const paragraphStyles = "margin: 0 0 24px; font-size: 16px; color: #4a4a4a; line-height: 1.5;"; const buttonStyles = "display: inline-block; background-color: #0066cc; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;"; const footerStyles = "margin: 24px 0 0; font-size: 14px; color: #6a6a6a;"; // Verification Email const buildVerificationEmailHtml = ( verifyUrl: string, expiresIn: string, ): string => `

Verify your email

Please verify your email address by clicking the button below:

Verify Email

This link expires in ${expiresIn}.

If you didn't create an account, you can safely ignore this email.

`; const buildVerificationEmailText = ( verifyUrl: string, expiresIn: string, ): string => `Verify your email Please verify your email address by clicking the link below: ${verifyUrl} This link expires in ${expiresIn}. If you didn't create an account, you can safely ignore this email. `; // Password Reset Email const buildPasswordResetEmailHtml = ( resetUrl: string, expiresIn: string, ): string => `

Reset your password

We received a request to reset your password. Click the button below to choose a new password:

Reset Password

This link expires in ${expiresIn}.

If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.

`; const buildPasswordResetEmailText = ( resetUrl: string, expiresIn: string, ): string => `Reset your password We received a request to reset your password. Click the link below to choose a new password: ${resetUrl} This link expires in ${expiresIn}. If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged. `; // Login Confirmation Email const buildLoginConfirmationEmailHtml = ( confirmUrl: string, expiresIn: string, ): string => `

Confirm your login

Someone is trying to sign in to your account. If this was you, click the button below to confirm:

Confirm Login

This link expires in ${expiresIn}.

If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.

`; const buildLoginConfirmationEmailText = ( confirmUrl: string, expiresIn: string, ): string => `Confirm your login Someone is trying to sign in to your account. If this was you, click the link below to confirm: ${confirmUrl} This link expires in ${expiresIn}. If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake. `; // Org Invite Email const buildOrgInviteEmailHtml = ( email: string, orgName: string, inviterName: string, role: OrgRole, inviteUrl: string, expiresIn: string, ): string => { const safeOrgName = escapeHtml(orgName); const safeInviterName = escapeHtml(inviterName); const safeEmail = escapeHtml(email); const roleDisplay = formatRoleDisplay(role); const article = getArticleForRole(role); return `

You've been invited to join ${safeOrgName}

${safeInviterName} has invited you to join ${safeOrgName} as ${article} ${roleDisplay}.

Accept Invitation

This invitation expires in ${expiresIn}.

This invitation was sent to ${safeEmail}. If you weren't expecting this invitation, you can safely ignore this email.

`; }; const buildOrgInviteEmailText = ( email: string, orgName: string, inviterName: string, role: OrgRole, inviteUrl: string, expiresIn: string, ): string => { const roleDisplay = formatRoleDisplay(role); const article = getArticleForRole(role); return `You've been invited to join ${orgName} ${inviterName} has invited you to join ${orgName} as ${article} ${roleDisplay}. Click the link below to accept the invitation: ${inviteUrl} This invitation expires in ${expiresIn}. This invitation was sent to ${email}. If you weren't expecting this invitation, you can safely ignore this email. `; }; // ===== Email Helpers ===== /** * Send verification email to user */ export async function sendVerificationEmail( email: string, token: string, ): Promise { const url = buildUrl("/auth/verify", { token }); const expiresIn = formatExpiryHours(EMAIL_VERIFICATION_EXPIRY_HOURS); return sendEmail({ to: email, subject: "Verify your email address", htmlBody: buildVerificationEmailHtml(url, expiresIn), textBody: buildVerificationEmailText(url, expiresIn), }); } /** * Send login confirmation email (for untrusted device flow) */ export async function sendLoginConfirmationEmail( email: string, token: string, ): Promise { const url = buildUrl("/auth/confirm", { token }); const expiresIn = formatExpiryMinutes(LOGIN_CONFIRMATION_EXPIRY_MINUTES); return sendEmail({ to: email, subject: "Confirm your login", htmlBody: buildLoginConfirmationEmailHtml(url, expiresIn), textBody: buildLoginConfirmationEmailText(url, expiresIn), }); } /** * Send password reset email */ export async function sendPasswordResetEmail( email: string, token: string, ): Promise { const url = buildUrl("/auth/reset-password", { token }); const expiresIn = formatExpiryHours(PASSWORD_RESET_EXPIRY_HOURS); return sendEmail({ to: email, subject: "Reset your password", htmlBody: buildPasswordResetEmailHtml(url, expiresIn), textBody: buildPasswordResetEmailText(url, expiresIn), }); } /** * Send org invite email */ export async function sendOrgInviteEmail( email: string, token: string, orgName: string, inviterName: string, role: OrgRole, ): Promise { const url = buildUrl("/invite/accept", { token }); const expiresIn = formatExpiryDays(ORG_INVITE_EXPIRY_DAYS); return sendEmail({ to: email, subject: `You've been invited to join ${orgName}`, htmlBody: buildOrgInviteEmailHtml( email, orgName, inviterName, role, url, expiresIn, ), textBody: buildOrgInviteEmailText( email, orgName, inviterName, role, url, expiresIn, ), }); }