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 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-12 15:45:40 +08:00
parent f9f1dc7403
commit c2b815dd6a
34 changed files with 1626 additions and 442 deletions

View File

@@ -0,0 +1,12 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View File

@@ -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:"
}
}

View File

@@ -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: "<p>HTML</p>",
textBody: "Text",
});
expect(mockSendEmail).toHaveBeenCalledWith({
From: "sender@example.com",
To: "recipient@example.com",
Subject: "Test Subject",
HtmlBody: "<p>HTML</p>",
TextBody: "Text",
});
expect(result.messageId).toBe("test-message-id-123");
});
});

View File

@@ -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<ClientSendResult> => {
const result = await serverClient.sendEmail({
From: params.from,
To: params.to,
Subject: params.subject,
HtmlBody: params.htmlBody,
TextBody: params.textBody,
});
return { messageId: result.MessageID };
},
};
}

View File

@@ -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";

View File

@@ -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</a>");
});
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<typeof mock> };
};
let mockClient: ReturnType<typeof createMockClient>;
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",
);
});
});

View File

@@ -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 `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Confirm your login</h1>
<p style="${paragraphStyles}">Someone is trying to sign in to your account. If this was you, click the button below to confirm:</p>
<a href="${confirmUrl}" style="${buttonStyles}">Confirm Login</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.</p>
</div>
</body>
</html>
`;
}
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<EmailResult> {
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 }),
});
}

View File

@@ -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 <strong>Admin</strong>");
});
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: '<script>alert("xss")</script>',
};
const html = buildOrgInviteEmailHtml(paramsWithXss);
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
it("should escape HTML in inviter name", () => {
const paramsWithXss = {
...params,
inviterName: '<img src="x" onerror="alert(1)">',
};
const html = buildOrgInviteEmailHtml(paramsWithXss);
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img");
});
it("should use 'a' article for member role", () => {
const memberParams = { ...params, role: "member" as const };
const html = buildOrgInviteEmailHtml(memberParams);
expect(html).toContain("a <strong>Member</strong>");
});
it("should use 'an' article for owner role", () => {
const ownerParams = { ...params, role: "owner" as const };
const html = buildOrgInviteEmailHtml(ownerParams);
expect(html).toContain("an <strong>Owner</strong>");
});
});
describe("buildOrgInviteEmailText", () => {
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 text = buildOrgInviteEmailText(params);
expect(text).toContain(
"https://example.com/invite/accept?token=invite123",
);
});
it("should include the organization name", () => {
const text = buildOrgInviteEmailText(params);
expect(text).toContain("Acme Corp");
});
it("should include the inviter name", () => {
const text = buildOrgInviteEmailText(params);
expect(text).toContain("John Doe");
});
it("should include the role with correct article", () => {
const text = buildOrgInviteEmailText(params);
expect(text).toContain("an Admin");
});
it("should include the expiry time", () => {
const text = buildOrgInviteEmailText(params);
expect(text).toContain("This invitation expires in 7 days.");
});
});
describe("sendOrgInviteEmail", () => {
const createMockClient = () => {
const sendEmailMock = mock(() =>
Promise.resolve({ messageId: "test-message-id" }),
);
return {
sendEmail: sendEmailMock,
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
};
let mockClient: ReturnType<typeof createMockClient>;
beforeEach(() => {
mockClient = createMockClient();
});
it("should send org invite email with correct details", async () => {
const result = await sendOrgInviteEmail({
client: mockClient,
fromAddress: "noreply@example.com",
baseUrl: "https://app.example.com",
email: "newuser@example.com",
token: "invite-token-abc",
orgName: "Acme Corp",
inviterName: "Jane Doe",
role: "admin",
expiryDays: 7,
});
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("newuser@example.com");
expect(params.subject).toBe("You've been invited to join Acme Corp");
expect(params.htmlBody).toContain(
"https://app.example.com/invite/accept?token=invite-token-abc",
);
expect(params.htmlBody).toContain("Acme Corp");
expect(params.htmlBody).toContain("Jane Doe");
expect(params.htmlBody).toContain("Admin");
expect(params.htmlBody).toContain("7 days");
expect(params.textBody).toContain("Acme Corp");
expect(params.textBody).toContain("Jane Doe");
expect(params.textBody).toContain("an Admin");
});
it("should use correct article for member role", async () => {
await sendOrgInviteEmail({
client: mockClient,
fromAddress: "noreply@example.com",
baseUrl: "https://app.example.com",
email: "newuser@example.com",
token: "invite-token-abc",
orgName: "Acme Corp",
inviterName: "Jane Doe",
role: "member",
expiryDays: 7,
});
const call = mockClient.sendEmail.mock.calls[0];
const params = call?.[0] as { textBody: string };
expect(params.textBody).toContain("a Member");
});
});

View File

@@ -0,0 +1,134 @@
import type { OrgRole } from "@reviq/db-schema";
import {
buildUrl,
escapeHtml,
formatExpiryDays,
formatRoleDisplay,
getArticleForRole,
} 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 OrgInviteEmailParams {
email: string;
orgName: string;
inviterName: string;
role: OrgRole;
inviteUrl: string;
expiresIn: string;
}
export function buildOrgInviteEmailHtml({
email,
orgName,
inviterName,
role,
inviteUrl,
expiresIn,
}: OrgInviteEmailParams): string {
const safeOrgName = escapeHtml(orgName);
const safeInviterName = escapeHtml(inviterName);
const safeEmail = escapeHtml(email);
const roleDisplay = formatRoleDisplay(role);
const article = getArticleForRole(role);
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">You've been invited to join ${safeOrgName}</h1>
<p style="${paragraphStyles}">${safeInviterName} has invited you to join <strong>${safeOrgName}</strong> as ${article} <strong>${roleDisplay}</strong>.</p>
<a href="${inviteUrl}" style="${buttonStyles}">Accept Invitation</a>
<p style="${footerStyles}">This invitation expires in ${expiresIn}.</p>
<p style="${footerStyles}">This invitation was sent to ${safeEmail}. If you weren't expecting this invitation, you can safely ignore this email.</p>
</div>
</body>
</html>
`;
}
export function buildOrgInviteEmailText({
email,
orgName,
inviterName,
role,
inviteUrl,
expiresIn,
}: OrgInviteEmailParams): 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.
`;
}
export interface SendOrgInviteEmailParams {
client: EmailClient;
fromAddress: string;
baseUrl: string;
email: string;
token: string;
orgName: string;
inviterName: string;
role: OrgRole;
expiryDays: number;
}
export async function sendOrgInviteEmail({
client,
fromAddress,
baseUrl,
email,
token,
orgName,
inviterName,
role,
expiryDays,
}: SendOrgInviteEmailParams): Promise<EmailResult> {
const inviteUrl = buildUrl(baseUrl, "/invite/accept", { token });
const expiresIn = formatExpiryDays(expiryDays);
return sendEmail(client, fromAddress, {
to: email,
subject: `You've been invited to join ${orgName}`,
htmlBody: buildOrgInviteEmailHtml({
email,
orgName,
inviterName,
role,
inviteUrl,
expiresIn,
}),
textBody: buildOrgInviteEmailText({
email,
orgName,
inviterName,
role,
inviteUrl,
expiresIn,
}),
});
}

View File

@@ -0,0 +1,112 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
import type { EmailClient } from "../types.js";
import {
buildPasswordResetEmailHtml,
buildPasswordResetEmailText,
sendPasswordResetEmail,
} from "./password-reset.js";
describe("buildPasswordResetEmailHtml", () => {
const params = {
resetUrl: "https://example.com/auth/reset-password?token=xyz789",
expiresIn: "1 hour",
};
it("should include the reset URL", () => {
const html = buildPasswordResetEmailHtml(params);
expect(html).toContain(
'href="https://example.com/auth/reset-password?token=xyz789"',
);
});
it("should include the expiry time", () => {
const html = buildPasswordResetEmailHtml(params);
expect(html).toContain("This link expires in 1 hour.");
});
it("should include the heading", () => {
const html = buildPasswordResetEmailHtml(params);
expect(html).toContain("Reset your password");
});
it("should include reset button text", () => {
const html = buildPasswordResetEmailHtml(params);
expect(html).toContain(">Reset Password</a>");
});
it("should include ignore message", () => {
const html = buildPasswordResetEmailHtml(params);
expect(html).toContain(
"If you didn't request a password reset, you can safely ignore this email.",
);
});
});
describe("buildPasswordResetEmailText", () => {
const params = {
resetUrl: "https://example.com/auth/reset-password?token=xyz789",
expiresIn: "1 hour",
};
it("should include the reset URL", () => {
const text = buildPasswordResetEmailText(params);
expect(text).toContain(
"https://example.com/auth/reset-password?token=xyz789",
);
});
it("should include the expiry time", () => {
const text = buildPasswordResetEmailText(params);
expect(text).toContain("This link expires in 1 hour.");
});
});
describe("sendPasswordResetEmail", () => {
const createMockClient = () => {
const sendEmailMock = mock(() =>
Promise.resolve({ messageId: "test-message-id" }),
);
return {
sendEmail: sendEmailMock,
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
};
let mockClient: ReturnType<typeof createMockClient>;
beforeEach(() => {
mockClient = createMockClient();
});
it("should send password reset email with correct URL and expiry", async () => {
const result = await sendPasswordResetEmail({
client: mockClient,
fromAddress: "noreply@example.com",
baseUrl: "https://app.example.com",
email: "user@example.com",
token: "reset-token-789",
expiryHours: 1,
});
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("Reset your password");
expect(params.htmlBody).toContain(
"https://app.example.com/auth/reset-password?token=reset-token-789",
);
expect(params.htmlBody).toContain("1 hour");
expect(params.textBody).toContain(
"https://app.example.com/auth/reset-password?token=reset-token-789",
);
});
});

View File

@@ -0,0 +1,84 @@
import { buildUrl, formatExpiryHours } 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 PasswordResetEmailParams {
resetUrl: string;
expiresIn: string;
}
export function buildPasswordResetEmailHtml({
resetUrl,
expiresIn,
}: PasswordResetEmailParams): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Reset your password</h1>
<p style="${paragraphStyles}">We received a request to reset your password. Click the button below to choose a new password:</p>
<a href="${resetUrl}" style="${buttonStyles}">Reset Password</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
</div>
</body>
</html>
`;
}
export function buildPasswordResetEmailText({
resetUrl,
expiresIn,
}: PasswordResetEmailParams): string {
return `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.
`;
}
export interface SendPasswordResetEmailParams {
client: EmailClient;
fromAddress: string;
baseUrl: string;
email: string;
token: string;
expiryHours: number;
}
export async function sendPasswordResetEmail({
client,
fromAddress,
baseUrl,
email,
token,
expiryHours,
}: SendPasswordResetEmailParams): Promise<EmailResult> {
const resetUrl = buildUrl(baseUrl, "/auth/reset-password", { token });
const expiresIn = formatExpiryHours(expiryHours);
return sendEmail(client, fromAddress, {
to: email,
subject: "Reset your password",
htmlBody: buildPasswordResetEmailHtml({ resetUrl, expiresIn }),
textBody: buildPasswordResetEmailText({ resetUrl, expiresIn }),
});
}

View File

@@ -0,0 +1,115 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
import type { EmailClient } from "../types.js";
import {
buildVerificationEmailHtml,
buildVerificationEmailText,
sendVerificationEmail,
} from "./verification.js";
describe("buildVerificationEmailHtml", () => {
const params = {
verifyUrl: "https://example.com/auth/verify?token=abc123",
expiresIn: "24 hours",
};
it("should include the verify URL", () => {
const html = buildVerificationEmailHtml(params);
expect(html).toContain('href="https://example.com/auth/verify?token=abc123"');
});
it("should include the expiry time", () => {
const html = buildVerificationEmailHtml(params);
expect(html).toContain("This link expires in 24 hours.");
});
it("should include the heading", () => {
const html = buildVerificationEmailHtml(params);
expect(html).toContain("Verify your email");
});
it("should include verify button text", () => {
const html = buildVerificationEmailHtml(params);
expect(html).toContain(">Verify Email</a>");
});
it("should include ignore message", () => {
const html = buildVerificationEmailHtml(params);
expect(html).toContain(
"If you didn't create an account, you can safely ignore this email.",
);
});
});
describe("buildVerificationEmailText", () => {
const params = {
verifyUrl: "https://example.com/auth/verify?token=abc123",
expiresIn: "24 hours",
};
it("should include the verify URL", () => {
const text = buildVerificationEmailText(params);
expect(text).toContain("https://example.com/auth/verify?token=abc123");
});
it("should include the expiry time", () => {
const text = buildVerificationEmailText(params);
expect(text).toContain("This link expires in 24 hours.");
});
it("should include the heading", () => {
const text = buildVerificationEmailText(params);
expect(text).toContain("Verify your email");
});
});
describe("sendVerificationEmail", () => {
const createMockClient = () => {
const sendEmailMock = mock(() =>
Promise.resolve({ messageId: "test-message-id" }),
);
return {
sendEmail: sendEmailMock,
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
};
let mockClient: ReturnType<typeof createMockClient>;
beforeEach(() => {
mockClient = createMockClient();
});
it("should send verification email with correct URL and expiry", async () => {
const result = await sendVerificationEmail({
client: mockClient,
fromAddress: "noreply@example.com",
baseUrl: "https://app.example.com",
email: "user@example.com",
token: "verify-token-123",
expiryHours: 24,
});
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.from).toBe("noreply@example.com");
expect(params.subject).toBe("Verify your email address");
expect(params.htmlBody).toContain(
"https://app.example.com/auth/verify?token=verify-token-123",
);
expect(params.htmlBody).toContain("24 hours");
expect(params.textBody).toContain(
"https://app.example.com/auth/verify?token=verify-token-123",
);
expect(params.textBody).toContain("24 hours");
});
});

View File

@@ -0,0 +1,84 @@
import { buildUrl, formatExpiryHours } 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 VerificationEmailParams {
verifyUrl: string;
expiresIn: string;
}
export function buildVerificationEmailHtml({
verifyUrl,
expiresIn,
}: VerificationEmailParams): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Verify your email</h1>
<p style="${paragraphStyles}">Please verify your email address by clicking the button below:</p>
<a href="${verifyUrl}" style="${buttonStyles}">Verify Email</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't create an account, you can safely ignore this email.</p>
</div>
</body>
</html>
`;
}
export function buildVerificationEmailText({
verifyUrl,
expiresIn,
}: VerificationEmailParams): string {
return `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.
`;
}
export interface SendVerificationEmailParams {
client: EmailClient;
fromAddress: string;
baseUrl: string;
email: string;
token: string;
expiryHours: number;
}
export async function sendVerificationEmail({
client,
fromAddress,
baseUrl,
email,
token,
expiryHours,
}: SendVerificationEmailParams): Promise<EmailResult> {
const verifyUrl = buildUrl(baseUrl, "/auth/verify", { token });
const expiresIn = formatExpiryHours(expiryHours);
return sendEmail(client, fromAddress, {
to: email,
subject: "Verify your email address",
htmlBody: buildVerificationEmailHtml({ verifyUrl, expiresIn }),
textBody: buildVerificationEmailText({ verifyUrl, expiresIn }),
});
}

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "bun:test";
import {
buildUrl,
escapeHtml,
formatExpiryDays,
formatExpiryHours,
formatExpiryMinutes,
formatRoleDisplay,
getArticleForRole,
} from "./helpers.js";
describe("buildUrl", () => {
it("should build a URL with query parameters", () => {
const result = buildUrl("https://example.com", "/auth/verify", {
token: "abc123",
});
expect(result).toBe("https://example.com/auth/verify?token=abc123");
});
it("should handle multiple query parameters", () => {
const result = buildUrl("https://example.com", "/page", {
foo: "bar",
baz: "qux",
});
expect(result).toBe("https://example.com/page?foo=bar&baz=qux");
});
it("should encode special characters in query parameters", () => {
const result = buildUrl("https://example.com", "/search", {
q: "hello world",
});
expect(result).toBe("https://example.com/search?q=hello+world");
});
it("should handle empty params", () => {
const result = buildUrl("https://example.com", "/page", {});
expect(result).toBe("https://example.com/page");
});
it("should handle base URL with trailing slash", () => {
const result = buildUrl("https://example.com/", "/auth/verify", {
token: "abc",
});
expect(result).toBe("https://example.com/auth/verify?token=abc");
});
});
describe("escapeHtml", () => {
it("should escape ampersands", () => {
expect(escapeHtml("foo & bar")).toBe("foo &amp; bar");
});
it("should escape less than signs", () => {
expect(escapeHtml("<script>")).toBe("&lt;script&gt;");
});
it("should escape greater than signs", () => {
expect(escapeHtml("a > b")).toBe("a &gt; b");
});
it("should escape double quotes", () => {
expect(escapeHtml('say "hello"')).toBe("say &quot;hello&quot;");
});
it("should escape single quotes", () => {
expect(escapeHtml("it's")).toBe("it&#039;s");
});
it("should escape all characters in combination", () => {
expect(escapeHtml('<a href="test">O\'Reilly & Co</a>')).toBe(
"&lt;a href=&quot;test&quot;&gt;O&#039;Reilly &amp; Co&lt;/a&gt;",
);
});
it("should return empty string unchanged", () => {
expect(escapeHtml("")).toBe("");
});
it("should return safe strings unchanged", () => {
expect(escapeHtml("hello world")).toBe("hello world");
});
});
describe("formatExpiryHours", () => {
it("should format 1 hour", () => {
expect(formatExpiryHours(1)).toBe("1 hour");
});
it("should format multiple hours", () => {
expect(formatExpiryHours(24)).toBe("24 hours");
});
});
describe("formatExpiryMinutes", () => {
it("should format 1 minute", () => {
expect(formatExpiryMinutes(1)).toBe("1 minute");
});
it("should format multiple minutes", () => {
expect(formatExpiryMinutes(15)).toBe("15 minutes");
});
});
describe("formatExpiryDays", () => {
it("should format 1 day", () => {
expect(formatExpiryDays(1)).toBe("1 day");
});
it("should format multiple days", () => {
expect(formatExpiryDays(7)).toBe("7 days");
});
});
describe("formatRoleDisplay", () => {
it("should format owner role", () => {
expect(formatRoleDisplay("owner")).toBe("Owner");
});
it("should format admin role", () => {
expect(formatRoleDisplay("admin")).toBe("Admin");
});
it("should format member role", () => {
expect(formatRoleDisplay("member")).toBe("Member");
});
});
describe("getArticleForRole", () => {
it("should return 'an' for owner", () => {
expect(getArticleForRole("owner")).toBe("an");
});
it("should return 'an' for admin", () => {
expect(getArticleForRole("admin")).toBe("an");
});
it("should return 'a' for member", () => {
expect(getArticleForRole("member")).toBe("a");
});
});

View File

@@ -0,0 +1,54 @@
import { DurationFormat } from "@formatjs/intl-durationformat";
import type { OrgRole } from "@reviq/db-schema";
export function buildUrl(
baseUrl: string,
path: string,
params: Record<string, string>,
): string {
const url = new URL(path, baseUrl);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return url.toString();
}
export function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
const durationFormatter = new DurationFormat("en", { style: "long" });
export function formatExpiryHours(hours: number): string {
return durationFormatter.format({ hours });
}
export function formatExpiryMinutes(minutes: number): string {
return durationFormatter.format({ minutes });
}
export function formatExpiryDays(days: number): string {
return durationFormatter.format({ days });
}
const roleLabels: Record<OrgRole, string> = {
owner: "Owner",
admin: "Admin",
member: "Member",
};
export function formatRoleDisplay(role: OrgRole): string {
return roleLabels[role];
}
export function getArticleForRole(role: OrgRole): string {
if (role === "owner" || role === "admin") {
return "an";
}
return "a";
}

View File

@@ -0,0 +1,26 @@
// Client factories
export { createPostmarkClient } from "./client.js";
export { createLoggingEmailClient } from "./logging-client.js";
// Core types
export type { EmailClient, EmailResult } from "./types.js";
// Base send function
export { sendEmail } from "./send.js";
export type { SendEmailParams } from "./send.js";
// Email-specific send functions
export {
sendLoginConfirmationEmail,
sendOrgInviteEmail,
sendPasswordResetEmail,
sendVerificationEmail,
} from "./emails/index.js";
// Email param types
export type {
SendLoginConfirmationEmailParams,
SendOrgInviteEmailParams,
SendPasswordResetEmailParams,
SendVerificationEmailParams,
} from "./emails/index.js";

View File

@@ -0,0 +1,61 @@
import { describe, expect, it, mock, beforeEach, afterEach } from "bun:test";
import { createLoggingEmailClient } from "./logging-client.js";
describe("createLoggingEmailClient", () => {
let originalLog: typeof console.log;
let logOutput: string[];
beforeEach(() => {
logOutput = [];
originalLog = console.log;
console.log = mock((...args: unknown[]) => {
logOutput.push(args.join(" "));
});
});
afterEach(() => {
console.log = originalLog;
});
it("should create a client that logs emails to console", async () => {
const client = createLoggingEmailClient();
const result = await client.sendEmail({
from: "noreply@example.com",
to: "user@example.com",
subject: "Test Subject",
htmlBody: "<p>Hello</p>",
textBody: "Hello",
});
expect(result.messageId).toMatch(/^dev-mode-\d+$/);
expect(logOutput).toContain("=== DEV MODE EMAIL ===");
expect(logOutput.some((line) => line.includes("From: noreply@example.com"))).toBe(true);
expect(logOutput.some((line) => line.includes("To: user@example.com"))).toBe(true);
expect(logOutput.some((line) => line.includes("Subject: Test Subject"))).toBe(true);
expect(logOutput.some((line) => line.includes("Hello"))).toBe(true);
expect(logOutput).toContain("======================");
});
it("should generate unique message IDs", async () => {
const client = createLoggingEmailClient();
const result1 = await client.sendEmail({
from: "a@example.com",
to: "b@example.com",
subject: "Test 1",
htmlBody: "",
textBody: "",
});
const result2 = await client.sendEmail({
from: "a@example.com",
to: "b@example.com",
subject: "Test 2",
htmlBody: "",
textBody: "",
});
expect(result1.messageId).not.toBe(result2.messageId);
});
});

View File

@@ -0,0 +1,23 @@
import type {
ClientSendParams,
ClientSendResult,
EmailClient,
} from "./types.js";
let messageIdCounter = 0;
export function createLoggingEmailClient(): EmailClient {
return {
sendEmail: (params: ClientSendParams): Promise<ClientSendResult> => {
console.log("=== DEV MODE EMAIL ===");
console.log(`From: ${params.from}`);
console.log(`To: ${params.to}`);
console.log(`Subject: ${params.subject}`);
console.log(`Text Body:\n${params.textBody}`);
console.log("======================");
messageIdCounter++;
return Promise.resolve({ messageId: `dev-mode-${messageIdCounter.toString()}` });
},
};
}

View File

@@ -0,0 +1,69 @@
import { describe, expect, it, mock } from "bun:test";
import type { EmailClient } from "./types.js";
import { sendEmail } from "./send.js";
describe("sendEmail", () => {
const createMockClient = () => {
const sendEmailMock = mock(() =>
Promise.resolve({ messageId: "test-message-id" }),
);
return {
sendEmail: sendEmailMock,
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
};
it("should send email successfully", async () => {
const mockClient = createMockClient();
const result = await sendEmail(mockClient, "noreply@example.com", {
to: "user@example.com",
subject: "Test Subject",
htmlBody: "<p>Hello</p>",
textBody: "Hello",
});
expect(result.success).toBe(true);
expect(result.messageId).toBe("test-message-id");
expect(mockClient.sendEmail).toHaveBeenCalledTimes(1);
expect(mockClient.sendEmail).toHaveBeenCalledWith({
from: "noreply@example.com",
to: "user@example.com",
subject: "Test Subject",
htmlBody: "<p>Hello</p>",
textBody: "Hello",
});
});
it("should handle errors gracefully", async () => {
const mockClient = {
sendEmail: mock(() => Promise.reject(new Error("API Error"))),
} as EmailClient;
const result = await sendEmail(mockClient, "noreply@example.com", {
to: "user@example.com",
subject: "Test",
htmlBody: "<p>Test</p>",
textBody: "Test",
});
expect(result.success).toBe(false);
expect(result.error).toBe("API Error");
});
it("should handle non-Error exceptions", async () => {
const mockClient = {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
sendEmail: mock(() => Promise.reject("String error")),
} as unknown as EmailClient;
const result = await sendEmail(mockClient, "noreply@example.com", {
to: "user@example.com",
subject: "Test",
htmlBody: "<p>Test</p>",
textBody: "Test",
});
expect(result.success).toBe(false);
expect(result.error).toBe("Unknown error");
});
});

View File

@@ -0,0 +1,31 @@
import type { EmailClient, EmailResult } from "./types.js";
export interface SendEmailParams {
to: string;
subject: string;
htmlBody: string;
textBody: string;
}
export async function sendEmail(
client: EmailClient,
fromAddress: string,
params: SendEmailParams,
): Promise<EmailResult> {
const { to, subject, htmlBody, textBody } = params;
try {
const result = await client.sendEmail({
from: fromAddress,
to,
subject,
htmlBody,
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 };
}
}

View File

@@ -0,0 +1,12 @@
export const emailStyles =
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;";
export const containerStyles =
"max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 40px;";
export const headingStyles =
"margin: 0 0 24px; font-size: 24px; color: #1a1a1a;";
export const paragraphStyles =
"margin: 0 0 24px; font-size: 16px; color: #4a4a4a; line-height: 1.5;";
export const buttonStyles =
"display: inline-block; background-color: #0066cc; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;";
export const footerStyles =
"margin: 24px 0 0; font-size: 14px; color: #6a6a6a;";

View File

@@ -0,0 +1,21 @@
export interface EmailResult {
success: boolean;
messageId?: string;
error?: string;
}
export interface ClientSendParams {
from: string;
to: string;
subject: string;
htmlBody: string;
textBody: string;
}
export interface ClientSendResult {
messageId: string;
}
export interface EmailClient {
sendEmail(params: ClientSendParams): Promise<ClientSendResult>;
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["bun"]
}
}