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,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");
});
});