From c2b815dd6a88c077487680a10ecede13ec443560 Mon Sep 17 00:00:00 2001 From: igm Date: Mon, 12 Jan 2026 15:45:40 +0800 Subject: [PATCH] Extract emails into separate package with clean interface - Create packages/emails/ with EmailClient interface abstraction - Wrap Postmark ServerClient in adapter for clean typing - Add createLoggingEmailClient for dev mode (logs to console) - Split email templates into individual files with full test coverage - Update api-server to use new package via context injection - Remove EMAIL_DEV_MODE - now uses POSTMARK_API_KEY presence - Delete apps/api-server/src/utils/email.ts Co-Authored-By: Claude Opus 4.5 --- apps/api-server/package.json | 2 +- apps/api-server/src/constants.ts | 5 +- apps/api-server/src/context.ts | 12 + apps/api-server/src/index.ts | 22 + .../src/procedures/auth/forgot-password.ts | 13 +- .../src/procedures/auth/login-password.ts | 11 +- .../procedures/auth/resend-verification.ts | 13 +- apps/api-server/src/procedures/auth/signup.ts | 13 +- .../api-server/src/procedures/orgs/invites.ts | 14 +- apps/api-server/src/utils/email.ts | 419 ------------------ bun.lock | 28 +- packages/emails/eslint.config.js | 12 + packages/emails/package.json | 33 ++ packages/emails/src/client.test.ts | 46 ++ packages/emails/src/client.ts | 27 ++ packages/emails/src/emails/index.ts | 39 ++ .../src/emails/login-confirmation.test.ts | 108 +++++ .../emails/src/emails/login-confirmation.ts | 84 ++++ packages/emails/src/emails/org-invite.test.ts | 195 ++++++++ packages/emails/src/emails/org-invite.ts | 134 ++++++ .../emails/src/emails/password-reset.test.ts | 112 +++++ packages/emails/src/emails/password-reset.ts | 84 ++++ .../emails/src/emails/verification.test.ts | 115 +++++ packages/emails/src/emails/verification.ts | 84 ++++ packages/emails/src/helpers.test.ts | 140 ++++++ packages/emails/src/helpers.ts | 54 +++ packages/emails/src/index.ts | 26 ++ packages/emails/src/logging-client.test.ts | 61 +++ packages/emails/src/logging-client.ts | 23 + packages/emails/src/send.test.ts | 69 +++ packages/emails/src/send.ts | 31 ++ packages/emails/src/styles.ts | 12 + packages/emails/src/types.ts | 21 + packages/emails/tsconfig.json | 6 + 34 files changed, 1626 insertions(+), 442 deletions(-) delete mode 100644 apps/api-server/src/utils/email.ts create mode 100644 packages/emails/eslint.config.js create mode 100644 packages/emails/package.json create mode 100644 packages/emails/src/client.test.ts create mode 100644 packages/emails/src/client.ts create mode 100644 packages/emails/src/emails/index.ts create mode 100644 packages/emails/src/emails/login-confirmation.test.ts create mode 100644 packages/emails/src/emails/login-confirmation.ts create mode 100644 packages/emails/src/emails/org-invite.test.ts create mode 100644 packages/emails/src/emails/org-invite.ts create mode 100644 packages/emails/src/emails/password-reset.test.ts create mode 100644 packages/emails/src/emails/password-reset.ts create mode 100644 packages/emails/src/emails/verification.test.ts create mode 100644 packages/emails/src/emails/verification.ts create mode 100644 packages/emails/src/helpers.test.ts create mode 100644 packages/emails/src/helpers.ts create mode 100644 packages/emails/src/index.ts create mode 100644 packages/emails/src/logging-client.test.ts create mode 100644 packages/emails/src/logging-client.ts create mode 100644 packages/emails/src/send.test.ts create mode 100644 packages/emails/src/send.ts create mode 100644 packages/emails/src/styles.ts create mode 100644 packages/emails/src/types.ts create mode 100644 packages/emails/tsconfig.json diff --git a/apps/api-server/package.json b/apps/api-server/package.json index fa4e108..6563016 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -12,13 +12,13 @@ "test": "bun test src/ --no-parallel" }, "dependencies": { - "@formatjs/intl-durationformat": "^0.9.2", "@noble/hashes": "^2.0.1", "@orpc/experimental-pino": "^1.13.2", "@orpc/server": "^1.13.2", "@reviq/api-contract": "workspace:*", "@reviq/db": "workspace:*", "@reviq/db-schema": "workspace:*", + "@reviq/emails": "workspace:*", "@reviq/server-utils": "workspace:*", "@scure/base": "^2.0.0", "@simplewebauthn/server": "^13.2.2", diff --git a/apps/api-server/src/constants.ts b/apps/api-server/src/constants.ts index 78d4ae7..104ac5b 100644 --- a/apps/api-server/src/constants.ts +++ b/apps/api-server/src/constants.ts @@ -36,10 +36,7 @@ export const EMAIL_FROM = Bun.env.EMAIL_FROM ?? "noreply@reviq.io"; /** Base URL for generating email links */ export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827"; -/** Dev mode: log emails instead of sending (default: true) */ -export const EMAIL_DEV_MODE = Bun.env.EMAIL_DEV_MODE !== "false"; - -/** Postmark API key (required when EMAIL_DEV_MODE is false) */ +/** Postmark API key (optional - uses logging client if not set) */ export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY; // ===== Token Expiration Times ===== diff --git a/apps/api-server/src/context.ts b/apps/api-server/src/context.ts index ac030e6..18c9c02 100644 --- a/apps/api-server/src/context.ts +++ b/apps/api-server/src/context.ts @@ -3,8 +3,18 @@ */ import type { Database } from "@reviq/db-schema"; +import type { EmailClient } from "@reviq/emails"; import type { Kysely } from "kysely"; +/** + * Email configuration for the API + */ +export interface EmailConfig { + client: EmailClient; + fromAddress: string; + baseUrl: string; +} + /** * Base API context available to all handlers */ @@ -23,6 +33,8 @@ export interface APIContext { resHeaders: Headers; /** Client IP address from direct connection (fallback when no proxy headers) */ clientIP?: string | null; + /** Email client and configuration */ + email: EmailConfig; } /** diff --git a/apps/api-server/src/index.ts b/apps/api-server/src/index.ts index ff87f83..37056b5 100644 --- a/apps/api-server/src/index.ts +++ b/apps/api-server/src/index.ts @@ -2,10 +2,17 @@ import type { APIContext } from "./context.js"; import { LoggingHandlerPlugin } from "@orpc/experimental-pino"; import { RPCHandler } from "@orpc/server/fetch"; import { createDb } from "@reviq/db"; +import { + createLoggingEmailClient, + createPostmarkClient, +} from "@reviq/emails"; import pino from "pino"; import { + BASE_URL, DEFAULT_PORT, DEFAULT_RP_NAME, + EMAIL_FROM, + POSTMARK_API_KEY, getAllowedOrigins, } from "./constants.js"; import { router } from "./router.js"; @@ -24,6 +31,16 @@ if (!databaseUrl) { throw new Error("DATABASE_URL environment variable is required"); } const db = createDb(databaseUrl); + +// Create email client - use Postmark if API key is set, otherwise log to console +const emailClient = POSTMARK_API_KEY + ? createPostmarkClient(POSTMARK_API_KEY) + : createLoggingEmailClient(); + +if (!POSTMARK_API_KEY) { + logger.info("POSTMARK_API_KEY not set - emails will be logged to console"); +} + const handler = new RPCHandler(router, { plugins: [ new LoggingHandlerPlugin({ @@ -62,6 +79,11 @@ Bun.serve({ reqHeaders: request.headers, resHeaders, clientIP, + email: { + client: emailClient, + fromAddress: EMAIL_FROM, + baseUrl: BASE_URL, + }, }; const { response } = await handler.handle(request, { diff --git a/apps/api-server/src/procedures/auth/forgot-password.ts b/apps/api-server/src/procedures/auth/forgot-password.ts index 40a57c6..b3dca93 100644 --- a/apps/api-server/src/procedures/auth/forgot-password.ts +++ b/apps/api-server/src/procedures/auth/forgot-password.ts @@ -6,12 +6,12 @@ * This prevents attackers from determining which emails are registered */ +import { sendPasswordResetEmail } from "@reviq/emails"; import { TOKEN_DURATIONS } from "../../utils/cookies.js"; import { generateExpiry, generateSecureBase58Token, } from "../../utils/crypto.js"; -import { sendPasswordResetEmail } from "../../utils/email.js"; import { os } from "../base.js"; export const forgotPassword = os.auth.forgotPassword.handler( @@ -51,8 +51,15 @@ export const forgotPassword = os.auth.forgotPassword.handler( }) .execute(); - // Send password reset email (stubbed) - await sendPasswordResetEmail(user.email, token); + // Send password reset email + await sendPasswordResetEmail({ + client: context.email.client, + fromAddress: context.email.fromAddress, + baseUrl: context.email.baseUrl, + email: user.email, + token, + expiryHours: 1, + }); } // Always return success (anti-enumeration) diff --git a/apps/api-server/src/procedures/auth/login-password.ts b/apps/api-server/src/procedures/auth/login-password.ts index 6db8663..b4d2906 100644 --- a/apps/api-server/src/procedures/auth/login-password.ts +++ b/apps/api-server/src/procedures/auth/login-password.ts @@ -4,8 +4,8 @@ */ import { ORPCError } from "@orpc/server"; +import { sendLoginConfirmationEmail } from "@reviq/emails"; import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js"; -import { sendLoginConfirmationEmail } from "../../utils/email.js"; import { verifyPassword } from "../../utils/password.js"; import { isDeviceTrusted } from "../../utils/session.js"; import { os } from "../base.js"; @@ -108,7 +108,14 @@ export const loginPassword = os.auth.loginPassword.handler( } else { // Device is untrusted - send confirmation email with existing token // The same base58 token is used for both cookie lookup and email confirmation - await sendLoginConfirmationEmail(result.email, result.token); + await sendLoginConfirmationEmail({ + client: context.email.client, + fromAddress: context.email.fromAddress, + baseUrl: context.email.baseUrl, + email: result.email, + token: result.token, + expiryMinutes: 15, + }); } return { success: true }; diff --git a/apps/api-server/src/procedures/auth/resend-verification.ts b/apps/api-server/src/procedures/auth/resend-verification.ts index 66bade8..391c838 100644 --- a/apps/api-server/src/procedures/auth/resend-verification.ts +++ b/apps/api-server/src/procedures/auth/resend-verification.ts @@ -10,12 +10,12 @@ * 5. Send verification email (stubbed) */ +import { sendVerificationEmail } from "@reviq/emails"; import { TOKEN_DURATIONS } from "../../utils/cookies.js"; import { generateExpiry, generateSecureBase58Token, } from "../../utils/crypto.js"; -import { sendVerificationEmail } from "../../utils/email.js"; import { authMiddleware, os } from "../base.js"; export const resendVerificationEmail = os.auth.resendVerificationEmail @@ -47,8 +47,15 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail }) .execute(); - // Send verification email (stubbed) - await sendVerificationEmail(context.user.email, token); + // Send verification email + await sendVerificationEmail({ + client: context.email.client, + fromAddress: context.email.fromAddress, + baseUrl: context.email.baseUrl, + email: context.user.email, + token, + expiryHours: 24, + }); return { success: true }; }); diff --git a/apps/api-server/src/procedures/auth/signup.ts b/apps/api-server/src/procedures/auth/signup.ts index 7e1cc67..f18d02a 100644 --- a/apps/api-server/src/procedures/auth/signup.ts +++ b/apps/api-server/src/procedures/auth/signup.ts @@ -10,6 +10,7 @@ import type { import type { Kysely } from "kysely"; import type { RPInfo } from "../../utils/webauthn.js"; import { ORPCError } from "@orpc/server"; +import { sendVerificationEmail } from "@reviq/emails"; import { verifyRegistrationResponse } from "@simplewebauthn/server"; import { COOKIE_NAMES, @@ -21,7 +22,6 @@ import { generateExpiry, generateSecureBase58Token, } from "../../utils/crypto.js"; -import { sendVerificationEmail } from "../../utils/email.js"; import { getGeoInfo, getUserAgent } from "../../utils/geo.js"; import { hashPassword, validatePassword } from "../../utils/password.js"; import { createSession } from "../../utils/session.js"; @@ -300,8 +300,15 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => { }) .execute(); - // Send verification email (stubbed) - await sendVerificationEmail(email, verificationToken); + // Send verification email + await sendVerificationEmail({ + client: context.email.client, + fromAddress: context.email.fromAddress, + baseUrl: context.email.baseUrl, + email, + token: verificationToken, + expiryHours: 24, + }); return { success: true }; }); diff --git a/apps/api-server/src/procedures/orgs/invites.ts b/apps/api-server/src/procedures/orgs/invites.ts index 842c90f..df65238 100644 --- a/apps/api-server/src/procedures/orgs/invites.ts +++ b/apps/api-server/src/procedures/orgs/invites.ts @@ -3,12 +3,12 @@ */ import { ORPCError } from "@orpc/server"; +import { sendOrgInviteEmail } from "@reviq/emails"; import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js"; import { generateExpiry, generateSecureBase58Token, } from "../../utils/crypto.js"; -import { sendOrgInviteEmail } from "../../utils/email.js"; import { authMiddleware, os } from "../base.js"; import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js"; @@ -122,7 +122,17 @@ export const invitesCreate = os.orgs.invites.create // Send invitation email const inviterName = context.user.displayName ?? context.user.email; - await sendOrgInviteEmail(email, token, org.displayName, inviterName, role); + await sendOrgInviteEmail({ + client: context.email.client, + fromAddress: context.email.fromAddress, + baseUrl: context.email.baseUrl, + email, + token, + orgName: org.displayName, + inviterName, + role, + expiryDays: ORG_INVITE_EXPIRY_DAYS, + }); return { success: true }; }); diff --git a/apps/api-server/src/utils/email.ts b/apps/api-server/src/utils/email.ts deleted file mode 100644 index dc4aa35..0000000 --- a/apps/api-server/src/utils/email.ts +++ /dev/null @@ -1,419 +0,0 @@ -/** - * 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, - ), - }); -} diff --git a/bun.lock b/bun.lock index be10ed2..a73d42f 100644 --- a/bun.lock +++ b/bun.lock @@ -15,13 +15,13 @@ "name": "@reviq/api-server", "version": "0.0.0", "dependencies": { - "@formatjs/intl-durationformat": "^0.9.2", "@noble/hashes": "^2.0.1", "@orpc/experimental-pino": "^1.13.2", "@orpc/server": "^1.13.2", "@reviq/api-contract": "workspace:*", "@reviq/db": "workspace:*", "@reviq/db-schema": "workspace:*", + "@reviq/emails": "workspace:*", "@reviq/server-utils": "workspace:*", "@scure/base": "^2.0.0", "@simplewebauthn/server": "^13.2.2", @@ -180,6 +180,22 @@ "typescript": "catalog:", }, }, + "packages/emails": { + "name": "@reviq/emails", + "version": "0.0.1", + "dependencies": { + "@formatjs/intl-durationformat": "^0.7.0", + "@reviq/db-schema": "workspace:*", + "postmark": "^4.0.5", + }, + "devDependencies": { + "@macalinao/eslint-config": "catalog:", + "@macalinao/tsconfig": "catalog:", + "@types/bun": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + }, + }, "packages/frontend-utils": { "name": "@reviq/frontend-utils", "version": "0.0.1", @@ -348,13 +364,13 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@3.0.8", "", { "dependencies": { "@formatjs/fast-memoize": "3.0.3", "@formatjs/intl-localematcher": "0.7.5", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-NRiqvxAvhbARZRFSRFPjN0y8txxmVutv2vMYvW2HSdCVf58w9l4osLj6Ujif643vImwZBcbKqhiKE0IOhY+DvA=="], + "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], - "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.0.3", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-CArYtQKGLAOruCMeq5/RxCg6vUXFx3OuKBdTm30Wn/+gCefehmZ8Y2xSMxMrO2iel7hRyE3HKfV56t3vAU6D4Q=="], + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], - "@formatjs/intl-durationformat": ["@formatjs/intl-durationformat@0.9.2", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.0.8", "@formatjs/intl-localematcher": "0.7.5", "tslib": "^2.8.0" } }, "sha512-/QOJeY96qGj1j9saz32VANfgDYhChbbTRyjWLzjf7dc4OHIEWqGBIO4rQzUKDBVzqtRLJQMh4QKp37Uxkk0d8g=="], + "@formatjs/intl-durationformat": ["@formatjs/intl-durationformat@0.7.6", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/intl-localematcher": "0.6.2", "tslib": "^2.8.0" } }, "sha512-jatAN3E84X6aP2UOGK1jTrwD1a7BiG3qWUSEDAhtyNd1BgYeS5wQPtXlnuGF1QRx0DjnwwNOIssyd7oQoRlQeg=="], - "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.7.5", "", { "dependencies": { "@formatjs/fast-memoize": "3.0.3", "tslib": "^2.8.0" } }, "sha512-7/nd90cn5CT7SVF71/ybUKAcnvBlr9nZlJJp8O8xIZHXFgYOC4SXExZlSdgHv2l6utjw1byidL06QzChvQMHwA=="], + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], @@ -454,6 +470,8 @@ "@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"], + "@reviq/emails": ["@reviq/emails@workspace:packages/emails"], + "@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"], "@reviq/server-utils": ["@reviq/server-utils@workspace:packages/server-utils"], diff --git a/packages/emails/eslint.config.js b/packages/emails/eslint.config.js new file mode 100644 index 0000000..ee789e3 --- /dev/null +++ b/packages/emails/eslint.config.js @@ -0,0 +1,12 @@ +import { configs } from "@macalinao/eslint-config"; + +export default [ + ...configs.fast, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/emails/package.json b/packages/emails/package.json new file mode 100644 index 0000000..f7ba76b --- /dev/null +++ b/packages/emails/package.json @@ -0,0 +1,33 @@ +{ + "name": "@reviq/emails", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", + "lint": "eslint . --cache", + "test": "bun test src/", + "test:cov": "bun test --coverage src/" + }, + "dependencies": { + "@formatjs/intl-durationformat": "^0.7.0", + "@reviq/db-schema": "workspace:*", + "postmark": "^4.0.5" + }, + "devDependencies": { + "@macalinao/eslint-config": "catalog:", + "@macalinao/tsconfig": "catalog:", + "@types/bun": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/emails/src/client.test.ts b/packages/emails/src/client.test.ts new file mode 100644 index 0000000..5226116 --- /dev/null +++ b/packages/emails/src/client.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, mock } from "bun:test"; +import { createPostmarkClient } from "./client.js"; + +const mockSendEmail = mock(() => + Promise.resolve({ MessageID: "test-message-id-123" }), +); + +void mock.module("postmark", () => ({ + ServerClient: class MockServerClient { + sendEmail = mockSendEmail; + }, +})); + +describe("createPostmarkClient", () => { + it("should create an EmailClient with sendEmail method", () => { + const client = createPostmarkClient("test-api-key"); + expect(typeof client.sendEmail).toBe("function"); + }); + + it("should throw an error if API key is empty", () => { + expect(() => createPostmarkClient("")).toThrow( + "Postmark API key is required", + ); + }); + + it("should convert our interface to Postmark format and return converted result", async () => { + const client = createPostmarkClient("test-api-key"); + + const result = await client.sendEmail({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Test Subject", + htmlBody: "

HTML

", + textBody: "Text", + }); + + expect(mockSendEmail).toHaveBeenCalledWith({ + From: "sender@example.com", + To: "recipient@example.com", + Subject: "Test Subject", + HtmlBody: "

HTML

", + TextBody: "Text", + }); + expect(result.messageId).toBe("test-message-id-123"); + }); +}); diff --git a/packages/emails/src/client.ts b/packages/emails/src/client.ts new file mode 100644 index 0000000..beb390f --- /dev/null +++ b/packages/emails/src/client.ts @@ -0,0 +1,27 @@ +import { ServerClient } from "postmark"; +import type { + ClientSendParams, + ClientSendResult, + EmailClient, +} from "./types.js"; + +export function createPostmarkClient(apiKey: string): EmailClient { + if (!apiKey) { + throw new Error("Postmark API key is required"); + } + + const serverClient = new ServerClient(apiKey); + + return { + sendEmail: async (params: ClientSendParams): Promise => { + const result = await serverClient.sendEmail({ + From: params.from, + To: params.to, + Subject: params.subject, + HtmlBody: params.htmlBody, + TextBody: params.textBody, + }); + return { messageId: result.MessageID }; + }, + }; +} diff --git a/packages/emails/src/emails/index.ts b/packages/emails/src/emails/index.ts new file mode 100644 index 0000000..d9245d0 --- /dev/null +++ b/packages/emails/src/emails/index.ts @@ -0,0 +1,39 @@ +export { + buildLoginConfirmationEmailHtml, + buildLoginConfirmationEmailText, + sendLoginConfirmationEmail, +} from "./login-confirmation.js"; +export type { + LoginConfirmationEmailParams, + SendLoginConfirmationEmailParams, +} from "./login-confirmation.js"; + +export { + buildOrgInviteEmailHtml, + buildOrgInviteEmailText, + sendOrgInviteEmail, +} from "./org-invite.js"; +export type { + OrgInviteEmailParams, + SendOrgInviteEmailParams, +} from "./org-invite.js"; + +export { + buildPasswordResetEmailHtml, + buildPasswordResetEmailText, + sendPasswordResetEmail, +} from "./password-reset.js"; +export type { + PasswordResetEmailParams, + SendPasswordResetEmailParams, +} from "./password-reset.js"; + +export { + buildVerificationEmailHtml, + buildVerificationEmailText, + sendVerificationEmail, +} from "./verification.js"; +export type { + SendVerificationEmailParams, + VerificationEmailParams, +} from "./verification.js"; diff --git a/packages/emails/src/emails/login-confirmation.test.ts b/packages/emails/src/emails/login-confirmation.test.ts new file mode 100644 index 0000000..b11c89e --- /dev/null +++ b/packages/emails/src/emails/login-confirmation.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, mock, beforeEach } from "bun:test"; +import type { EmailClient } from "../types.js"; +import { + buildLoginConfirmationEmailHtml, + buildLoginConfirmationEmailText, + sendLoginConfirmationEmail, +} from "./login-confirmation.js"; + +describe("buildLoginConfirmationEmailHtml", () => { + const params = { + confirmUrl: "https://example.com/auth/confirm?token=def456", + expiresIn: "15 minutes", + }; + + it("should include the confirm URL", () => { + const html = buildLoginConfirmationEmailHtml(params); + expect(html).toContain( + 'href="https://example.com/auth/confirm?token=def456"', + ); + }); + + it("should include the expiry time", () => { + const html = buildLoginConfirmationEmailHtml(params); + expect(html).toContain("This link expires in 15 minutes."); + }); + + it("should include the heading", () => { + const html = buildLoginConfirmationEmailHtml(params); + expect(html).toContain("Confirm your login"); + }); + + it("should include confirm button text", () => { + const html = buildLoginConfirmationEmailHtml(params); + expect(html).toContain(">Confirm Login"); + }); + + it("should include warning about unauthorized access", () => { + const html = buildLoginConfirmationEmailHtml(params); + expect(html).toContain("Someone is trying to sign in to your account."); + }); +}); + +describe("buildLoginConfirmationEmailText", () => { + const params = { + confirmUrl: "https://example.com/auth/confirm?token=def456", + expiresIn: "15 minutes", + }; + + it("should include the confirm URL", () => { + const text = buildLoginConfirmationEmailText(params); + expect(text).toContain("https://example.com/auth/confirm?token=def456"); + }); + + it("should include the expiry time", () => { + const text = buildLoginConfirmationEmailText(params); + expect(text).toContain("This link expires in 15 minutes."); + }); +}); + +describe("sendLoginConfirmationEmail", () => { + const createMockClient = () => { + const sendEmailMock = mock(() => + Promise.resolve({ messageId: "test-message-id" }), + ); + return { + sendEmail: sendEmailMock, + } as EmailClient & { sendEmail: ReturnType }; + }; + + let mockClient: ReturnType; + + beforeEach(() => { + mockClient = createMockClient(); + }); + + it("should send login confirmation email with correct URL and expiry", async () => { + const result = await sendLoginConfirmationEmail({ + client: mockClient, + fromAddress: "noreply@example.com", + baseUrl: "https://app.example.com", + email: "user@example.com", + token: "confirm-token-456", + expiryMinutes: 15, + }); + + expect(result.success).toBe(true); + expect(mockClient.sendEmail).toHaveBeenCalledTimes(1); + + const call = mockClient.sendEmail.mock.calls[0]; + const params = call?.[0] as { + from: string; + to: string; + subject: string; + htmlBody: string; + textBody: string; + }; + + expect(params.to).toBe("user@example.com"); + expect(params.subject).toBe("Confirm your login"); + expect(params.htmlBody).toContain( + "https://app.example.com/auth/confirm?token=confirm-token-456", + ); + expect(params.htmlBody).toContain("15 minutes"); + expect(params.textBody).toContain( + "https://app.example.com/auth/confirm?token=confirm-token-456", + ); + }); +}); diff --git a/packages/emails/src/emails/login-confirmation.ts b/packages/emails/src/emails/login-confirmation.ts new file mode 100644 index 0000000..a54d53e --- /dev/null +++ b/packages/emails/src/emails/login-confirmation.ts @@ -0,0 +1,84 @@ +import { buildUrl, formatExpiryMinutes } from "../helpers.js"; +import { sendEmail } from "../send.js"; +import { + buttonStyles, + containerStyles, + emailStyles, + footerStyles, + headingStyles, + paragraphStyles, +} from "../styles.js"; +import type { EmailClient, EmailResult } from "../types.js"; + +export interface LoginConfirmationEmailParams { + confirmUrl: string; + expiresIn: string; +} + +export function buildLoginConfirmationEmailHtml({ + confirmUrl, + expiresIn, +}: LoginConfirmationEmailParams): string { + return ` + + + + + + + +
+

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.

+
+ + +`; +} + +export function buildLoginConfirmationEmailText({ + confirmUrl, + expiresIn, +}: LoginConfirmationEmailParams): string { + return `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. +`; +} + +export interface SendLoginConfirmationEmailParams { + client: EmailClient; + fromAddress: string; + baseUrl: string; + email: string; + token: string; + expiryMinutes: number; +} + +export async function sendLoginConfirmationEmail({ + client, + fromAddress, + baseUrl, + email, + token, + expiryMinutes, +}: SendLoginConfirmationEmailParams): Promise { + const confirmUrl = buildUrl(baseUrl, "/auth/confirm", { token }); + const expiresIn = formatExpiryMinutes(expiryMinutes); + + return sendEmail(client, fromAddress, { + to: email, + subject: "Confirm your login", + htmlBody: buildLoginConfirmationEmailHtml({ confirmUrl, expiresIn }), + textBody: buildLoginConfirmationEmailText({ confirmUrl, expiresIn }), + }); +} diff --git a/packages/emails/src/emails/org-invite.test.ts b/packages/emails/src/emails/org-invite.test.ts new file mode 100644 index 0000000..4f1387d --- /dev/null +++ b/packages/emails/src/emails/org-invite.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, mock, beforeEach } from "bun:test"; +import type { EmailClient } from "../types.js"; +import { + buildOrgInviteEmailHtml, + buildOrgInviteEmailText, + sendOrgInviteEmail, +} from "./org-invite.js"; + +describe("buildOrgInviteEmailHtml", () => { + const params = { + email: "user@example.com", + orgName: "Acme Corp", + inviterName: "John Doe", + role: "admin" as const, + inviteUrl: "https://example.com/invite/accept?token=invite123", + expiresIn: "7 days", + }; + + it("should include the invite URL", () => { + const html = buildOrgInviteEmailHtml(params); + expect(html).toContain( + 'href="https://example.com/invite/accept?token=invite123"', + ); + }); + + it("should include the organization name", () => { + const html = buildOrgInviteEmailHtml(params); + expect(html).toContain("Acme Corp"); + }); + + it("should include the inviter name", () => { + const html = buildOrgInviteEmailHtml(params); + expect(html).toContain("John Doe"); + }); + + it("should include the role with correct article", () => { + const html = buildOrgInviteEmailHtml(params); + expect(html).toContain("an Admin"); + }); + + it("should include the expiry time", () => { + const html = buildOrgInviteEmailHtml(params); + expect(html).toContain("This invitation expires in 7 days."); + }); + + it("should include the recipient email", () => { + const html = buildOrgInviteEmailHtml(params); + expect(html).toContain("user@example.com"); + }); + + it("should escape HTML in organization name", () => { + const paramsWithXss = { + ...params, + orgName: '', + }; + const html = buildOrgInviteEmailHtml(paramsWithXss); + expect(html).not.toContain("