diff --git a/.env.example b/.env.example index 4c1e940..240dcb5 100644 --- a/.env.example +++ b/.env.example @@ -92,14 +92,19 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars # CFP_SITE_HOST=codeforphilly.org # --------------------------------------------------------------------------- -# Outbound notifications (Resend) +# Outbound notifications (Postmark) # --------------------------------------------------------------------------- -# Resend API key for the help-wanted email notifier. When unset, the -# notifier falls back to a no-op LoggingNotifier so dev + tests work -# without an account. See plans/notifier-email.md. -# RESEND_API_KEY=re_… +# Postmark server token for the email notifier (help-wanted, welcome, +# password-reset). When unset, the notifier falls back to a no-op +# LoggingNotifier so dev + tests work without an account. See +# plans/postmark-notifier.md and docs/operations/secrets.md. +# POSTMARK_SERVER_TOKEN=… + +# Postmark message stream to send on. Defaults to `outbound`, the +# transactional stream every Postmark server ships with. +# POSTMARK_MESSAGE_STREAM=outbound # From-address for outbound notifications. RFC 5322 form. -# Only used when RESEND_API_KEY is set. +# Only used when POSTMARK_SERVER_TOKEN is set. # CFP_NOTIFICATION_FROM="Code for Philly " diff --git a/apps/api/package.json b/apps/api/package.json index a7ff31e..85d4050 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -37,7 +37,7 @@ "fastify": "^5.8.5", "gitsheets": "^2.2.0", "jose": "^6.2.3", - "resend": "^6.12.4", + "postmark": "^5.1.0", "samlify": "^2.13.0", "sharp": "^0.34.5", "uuidv7": "^1.2.1", diff --git a/apps/api/scripts/cutover-mailout.ts b/apps/api/scripts/cutover-mailout.ts index 93a72a8..032f674 100644 --- a/apps/api/scripts/cutover-mailout.ts +++ b/apps/api/scripts/cutover-mailout.ts @@ -6,7 +6,7 @@ * them to sign in and claim their account. Run manually at T+90 per * specs/behaviors/account-migration.md#cutover-window-policy. * - * --dry-run prints the would-be send list and exits — no Resend calls, no + * --dry-run prints the would-be send list and exits — no Postmark calls, no * disk writes. The CI test exercises only --dry-run. * * Usage: @@ -14,7 +14,8 @@ * npm run -w apps/api script:cutover-mailout -- --send --from=hello@codeforphilly.org * * Env: - * RESEND_API_KEY — required for actual sends (otherwise --send refuses) + * POSTMARK_SERVER_TOKEN — required for actual sends (otherwise --send refuses) + * POSTMARK_MESSAGE_STREAM — optional; defaults to `outbound` * CFP_PUBLIC_URL — base URL used in the email body (defaults to * https://codeforphilly.org) * CFP_DATA_REPO_PATH + STORAGE_BACKEND + bucket envs — same shape as the API @@ -22,6 +23,9 @@ import { writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; +import { ServerClient } from 'postmark'; + +import { PostmarkTransport } from '../src/notify/postmark-transport.js'; import { openPublicStore, type PublicStore } from '../src/store/public.js'; import { FilesystemPrivateStore, @@ -195,7 +199,7 @@ export async function runMailout(opts: MailoutOptions): Promise { } // --------------------------------------------------------------------------- -// Env wiring + Resend send +// Env wiring + Postmark send // --------------------------------------------------------------------------- function requireEnv(name: string): string { @@ -220,33 +224,19 @@ function buildPrivateStore(): PrivateStore { }); } -/** Resend HTTP send. Fetch-based to avoid adding a new dep at this stage. */ -async function resendSend(input: { - to: string; - from: string; - subject: string; - html: string; - text: string; -}): Promise { - const apiKey = requireEnv('RESEND_API_KEY'); - const res = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - 'authorization': `Bearer ${apiKey}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ - from: input.from, - to: input.to, - subject: input.subject, - html: input.html, - text: input.text, - }), +/** + * Postmark send via the same transport the API's notifier uses. The SDK + * throws on any non-2xx, which runMailout() records per-recipient in + * `failed` rather than aborting the run. + */ +function buildPostmarkSend(): NonNullable { + const transport = new PostmarkTransport({ + client: new ServerClient(requireEnv('POSTMARK_SERVER_TOKEN')), + messageStream: process.env['POSTMARK_MESSAGE_STREAM'] || undefined, }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`Resend ${res.status}: ${body.slice(0, 200)}`); - } + return async (input) => { + await transport.send(input); + }; } // --------------------------------------------------------------------------- @@ -299,7 +289,7 @@ async function main(): Promise { mode: args.dryRun ? 'dry-run' : 'send', from: args.from, publicUrl: args.publicUrl ?? process.env['CFP_PUBLIC_URL'], - send: args.send ? resendSend : undefined, + send: args.send ? buildPostmarkSend() : undefined, }); process.stderr.write( diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index b171799..c460c84 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -68,15 +68,21 @@ export const EnvSchema = z.object({ */ CFP_SITE_HOST: z.string().default('codeforphilly.org'), /** - * Resend API key for the email notifier. When unset, the services plugin - * falls back to LoggingNotifier so dev + test runs don't need a real key. - * See plans/notifier-email.md. + * Postmark server token for the email notifier. When unset, the services + * plugin falls back to LoggingNotifier so dev + test runs don't need a + * real token. See plans/postmark-notifier.md. */ - RESEND_API_KEY: z.string().optional(), + POSTMARK_SERVER_TOKEN: z.string().optional(), + /** + * Postmark message stream outbound mail is sent on. `outbound` is the + * transactional default stream every Postmark server ships with. Only + * relevant when POSTMARK_SERVER_TOKEN is set. + */ + POSTMARK_MESSAGE_STREAM: z.string().default('outbound'), /** * From-address for outbound notifications. RFC 5322 form * (e.g. `"Code for Philly "`). Only - * relevant when RESEND_API_KEY is set. + * relevant when POSTMARK_SERVER_TOKEN is set. */ CFP_NOTIFICATION_FROM: z .string() @@ -118,7 +124,8 @@ export const envJsonSchema = { SLACK_TEAM_HOST: { type: 'string', default: 'codeforphilly.slack.com' }, CFP_WEB_DIST_PATH: { type: 'string' }, CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' }, - RESEND_API_KEY: { type: 'string' }, + POSTMARK_SERVER_TOKEN: { type: 'string' }, + POSTMARK_MESSAGE_STREAM: { type: 'string', default: 'outbound' }, CFP_NOTIFICATION_FROM: { type: 'string', default: 'Code for Philly ', diff --git a/apps/api/src/notify/email-notifier.ts b/apps/api/src/notify/email-notifier.ts index 40144b1..0b6cdcd 100644 --- a/apps/api/src/notify/email-notifier.ts +++ b/apps/api/src/notify/email-notifier.ts @@ -1,18 +1,18 @@ /** - * EmailNotifier — Resend-backed implementation of the Notifier interface. + * EmailNotifier — transport-backed implementation of the Notifier interface. * - * Sends help-wanted notifications via the Resend HTTPS API. Delivery - * failures are logged but never thrown — per - * `specs/api/projects-help-wanted.md`, the express-interest endpoint - * returns 202 to the caller regardless of downstream notification - * outcome. + * Renders each notification through the templates module and hands the + * result to an `EmailTransport` (Postmark in production — see + * `postmark-transport.ts`). Delivery failures are logged but never thrown — + * per `specs/api/projects-help-wanted.md`, the express-interest endpoint + * returns 202 to the caller regardless of downstream notification outcome, + * and the auth routes fire-and-forget for the same reason. * * Slack DM is deliberately out of scope here (tracked at #95); this is * the email-only first cut. The Notifier interface still accepts * `maintainerSlackHandle` so the data flow is ready when Slack lands. */ import type { FastifyBaseLogger } from 'fastify'; -import type { Resend } from 'resend'; import type { HelpWantedFillNotification, @@ -27,10 +27,18 @@ import { renderPasswordResetEmail, renderWelcomeEmail, } from './templates.js'; +import type { EmailTransport } from './transport.js'; + +/** Common shape of every template renderer's output. */ +interface RenderedEmail { + readonly subject: string; + readonly text: string; + readonly html: string; +} export interface EmailNotifierOptions { - /** Resend client (constructed at boot with the API key from env). */ - readonly resend: Resend; + /** Provider adapter (constructed at boot from env; a stub in tests). */ + readonly transport: EmailTransport; /** Sender address — RFC 5322 form, e.g. `"Code for Philly "`. */ readonly fromAddress: string; /** Public site host (no scheme), used to construct absolute URLs in email bodies. */ @@ -40,13 +48,13 @@ export interface EmailNotifierOptions { } export class EmailNotifier implements Notifier { - readonly #resend: Resend; + readonly #transport: EmailTransport; readonly #from: string; readonly #siteHost: string; readonly #log: FastifyBaseLogger; constructor(opts: EmailNotifierOptions) { - this.#resend = opts.resend; + this.#transport = opts.transport; this.#from = opts.fromAddress; this.#siteHost = opts.siteHost; this.#log = opts.logger; @@ -55,174 +63,82 @@ export class EmailNotifier implements Notifier { async notifyHelpWantedInterest( n: HelpWantedInterestNotification, ): Promise<{ delivered: boolean }> { + const ctx = { kind: 'help-wanted.interest', projectSlug: n.projectSlug, roleId: n.roleId }; if (!n.maintainerEmail) { - this.#log.warn( - { kind: 'help-wanted.interest', projectSlug: n.projectSlug, roleId: n.roleId }, - 'help-wanted interest: no maintainer email; skipped', - ); - return { delivered: false }; - } - const tpl = renderInterestEmail(n, this.#siteHost); - try { - const result = await this.#resend.emails.send({ - from: this.#from, - to: n.maintainerEmail, - subject: tpl.subject, - text: tpl.text, - html: tpl.html, - }); - if (result.error) { - this.#log.error( - { - kind: 'help-wanted.interest', - err: result.error, - projectSlug: n.projectSlug, - roleId: n.roleId, - }, - 'help-wanted interest: Resend reported delivery failure', - ); - return { delivered: false }; - } - this.#log.info( - { - kind: 'help-wanted.interest', - projectSlug: n.projectSlug, - roleId: n.roleId, - resendId: result.data?.id, - }, - 'help-wanted interest: email queued for delivery', - ); - return { delivered: true }; - } catch (err) { - this.#log.error( - { - kind: 'help-wanted.interest', - err, - projectSlug: n.projectSlug, - roleId: n.roleId, - }, - 'help-wanted interest: email send threw', - ); + this.#log.warn(ctx, 'help-wanted interest: no maintainer email; skipped'); return { delivered: false }; } + return this.#deliver( + 'help-wanted interest', + ctx, + n.maintainerEmail, + renderInterestEmail(n, this.#siteHost), + ); } async notifyWelcomeOnSignup(n: WelcomeNotification): Promise<{ delivered: boolean }> { + const ctx = { kind: 'auth.welcome', slug: n.slug }; if (!n.email) { - this.#log.warn( - { kind: 'auth.welcome', slug: n.slug }, - 'welcome: no email address; skipped', - ); - return { delivered: false }; - } - const tpl = renderWelcomeEmail(n, this.#siteHost); - try { - const result = await this.#resend.emails.send({ - from: this.#from, - to: n.email, - subject: tpl.subject, - text: tpl.text, - html: tpl.html, - }); - if (result.error) { - this.#log.error( - { kind: 'auth.welcome', err: result.error, slug: n.slug }, - 'welcome: Resend reported delivery failure', - ); - return { delivered: false }; - } - this.#log.info( - { kind: 'auth.welcome', slug: n.slug, resendId: result.data?.id }, - 'welcome: email queued for delivery', - ); - return { delivered: true }; - } catch (err) { - this.#log.error( - { kind: 'auth.welcome', err, slug: n.slug }, - 'welcome: email send threw', - ); + this.#log.warn(ctx, 'welcome: no email address; skipped'); return { delivered: false }; } + return this.#deliver('welcome', ctx, n.email, renderWelcomeEmail(n, this.#siteHost)); } async notifyPasswordReset(n: PasswordResetNotification): Promise<{ delivered: boolean }> { + const ctx = { kind: 'auth.password-reset', slug: n.slug }; if (!n.email) { - this.#log.warn( - { kind: 'auth.password-reset', slug: n.slug }, - 'password-reset: no email address; skipped', - ); - return { delivered: false }; - } - const tpl = renderPasswordResetEmail(n, this.#siteHost); - try { - const result = await this.#resend.emails.send({ - from: this.#from, - to: n.email, - subject: tpl.subject, - text: tpl.text, - html: tpl.html, - }); - if (result.error) { - this.#log.error( - { kind: 'auth.password-reset', err: result.error, slug: n.slug }, - 'password-reset: Resend reported delivery failure', - ); - return { delivered: false }; - } - this.#log.info( - { kind: 'auth.password-reset', slug: n.slug, resendId: result.data?.id }, - 'password-reset: email queued for delivery', - ); - return { delivered: true }; - } catch (err) { - this.#log.error( - { kind: 'auth.password-reset', err, slug: n.slug }, - 'password-reset: email send threw', - ); + this.#log.warn(ctx, 'password-reset: no email address; skipped'); return { delivered: false }; } + return this.#deliver( + 'password-reset', + ctx, + n.email, + renderPasswordResetEmail(n, this.#siteHost), + ); } async notifyHelpWantedFilled( n: HelpWantedFillNotification, ): Promise<{ delivered: boolean }> { + const ctx = { kind: 'help-wanted.filled', projectTitle: n.projectTitle }; if (!n.maintainerEmail) { - this.#log.warn( - { kind: 'help-wanted.filled', projectTitle: n.projectTitle }, - 'help-wanted fill: no maintainer email; skipped', - ); + this.#log.warn(ctx, 'help-wanted fill: no maintainer email; skipped'); return { delivered: false }; } - const tpl = renderFilledEmail(n, this.#siteHost); + return this.#deliver( + 'help-wanted fill', + ctx, + n.maintainerEmail, + renderFilledEmail(n, this.#siteHost), + ); + } + + /** + * Shared send path. The transport's only failure shape is a throw (the + * Postmark SDK raises on every non-2xx), so one catch covers network + * blips and provider rejections alike; the `err` field carries the + * provider's code/status for operators to tell them apart. + */ + async #deliver( + label: string, + ctx: Record, + to: string, + tpl: RenderedEmail, + ): Promise<{ delivered: boolean }> { try { - const result = await this.#resend.emails.send({ + const { messageId } = await this.#transport.send({ from: this.#from, - to: n.maintainerEmail, + to, subject: tpl.subject, text: tpl.text, html: tpl.html, }); - if (result.error) { - this.#log.error( - { kind: 'help-wanted.filled', err: result.error, projectTitle: n.projectTitle }, - 'help-wanted fill: Resend reported delivery failure', - ); - return { delivered: false }; - } - this.#log.info( - { - kind: 'help-wanted.filled', - projectTitle: n.projectTitle, - resendId: result.data?.id, - }, - 'help-wanted fill: email queued for delivery', - ); + this.#log.info({ ...ctx, messageId }, `${label}: email queued for delivery`); return { delivered: true }; } catch (err) { - this.#log.error( - { kind: 'help-wanted.filled', err, projectTitle: n.projectTitle }, - 'help-wanted fill: email send threw', - ); + this.#log.error({ ...ctx, err }, `${label}: email send failed`); return { delivered: false }; } } diff --git a/apps/api/src/notify/index.ts b/apps/api/src/notify/index.ts index 2d98fcc..85035d7 100644 --- a/apps/api/src/notify/index.ts +++ b/apps/api/src/notify/index.ts @@ -5,7 +5,7 @@ * Slack integration exists. Failures are logged but never fail the request — * the spec says express-interest returns 202 to the caller regardless. * - * The Resend / email transport is also stubbed; this module exists so the + * The email transport is also stubbed; this module exists so the * surface is in place for write-api to call and for tests to spy on. */ import type { FastifyBaseLogger } from 'fastify'; @@ -66,7 +66,8 @@ export interface Notifier { /** * Default no-op notifier — logs the intent and returns delivered:true. - * Replace with a real notifier once the Resend / Slack transports land. + * Replaced at boot by EmailNotifier when POSTMARK_SERVER_TOKEN is set; the + * Slack transport is still to come (#95). */ export class LoggingNotifier implements Notifier { readonly #log: FastifyBaseLogger; diff --git a/apps/api/src/notify/postmark-transport.ts b/apps/api/src/notify/postmark-transport.ts new file mode 100644 index 0000000..29ed5de --- /dev/null +++ b/apps/api/src/notify/postmark-transport.ts @@ -0,0 +1,48 @@ +/** + * PostmarkTransport — EmailTransport backed by the official `postmark` SDK. + * + * Postmark is the provider the legacy site already sends through, so the + * `codeforphilly.org` sender signature is verified there (see + * specs/architecture.md and docs/operations/secrets.md). The SDK throws a + * `PostmarkError` subclass on every non-2xx response (bad token, inactive + * recipient, rate limit, 5xx) and resolves with `{ MessageID, ... }` on + * success — so this adapter needs no `{ error }` branch; a throw is the + * only failure shape and the notifier catches it. + */ +import type { Message, Models } from 'postmark'; + +import type { EmailTransport, OutboundEmail } from './transport.js'; + +/** The slice of `postmark.ServerClient` this adapter touches. */ +export interface PostmarkSender { + sendEmail(email: Message): Promise; +} + +export interface PostmarkTransportOptions { + /** `new ServerClient(POSTMARK_SERVER_TOKEN)` at boot; anything with `sendEmail` in tests. */ + readonly client: PostmarkSender; + /** Postmark message stream. Defaults to `outbound` (the transactional default stream). */ + readonly messageStream?: string; +} + +export class PostmarkTransport implements EmailTransport { + readonly #client: PostmarkSender; + readonly #messageStream: string; + + constructor(opts: PostmarkTransportOptions) { + this.#client = opts.client; + this.#messageStream = opts.messageStream ?? 'outbound'; + } + + async send(email: OutboundEmail): Promise<{ messageId: string }> { + const result = await this.#client.sendEmail({ + From: email.from, + To: email.to, + Subject: email.subject, + TextBody: email.text, + HtmlBody: email.html, + MessageStream: this.#messageStream, + }); + return { messageId: result.MessageID }; + } +} diff --git a/apps/api/src/notify/transport.ts b/apps/api/src/notify/transport.ts new file mode 100644 index 0000000..1dc9448 --- /dev/null +++ b/apps/api/src/notify/transport.ts @@ -0,0 +1,25 @@ +/** + * EmailTransport — the one-method seam between the Notifier and whichever + * provider actually delivers mail. + * + * `EmailNotifier` composes subject/text/html from templates and hands the + * result here. The transport either resolves with a provider message id + * or throws; it never swallows failures — the notifier owns the + * log-and-return-`delivered: false` contract. Keeping the seam this narrow + * means tests exercise the notifier with a `vi.fn()` and the provider + * adapter (`postmark-transport.ts`) is the only file that knows a vendor. + */ +export interface OutboundEmail { + /** RFC 5322 sender, e.g. `"Code for Philly "`. */ + readonly from: string; + /** Single recipient address. */ + readonly to: string; + readonly subject: string; + readonly text: string; + readonly html: string; +} + +export interface EmailTransport { + /** Deliver one message. Resolves with the provider's message id; throws on any failure. */ + send(email: OutboundEmail): Promise<{ messageId: string }>; +} diff --git a/apps/api/src/plugins/services.ts b/apps/api/src/plugins/services.ts index 92bcbd5..f78d828 100644 --- a/apps/api/src/plugins/services.ts +++ b/apps/api/src/plugins/services.ts @@ -30,7 +30,8 @@ import { GitHubAccountService } from '../services/github-account.js'; import { AccountClaimService } from '../services/account-claim.js'; import { LoggingNotifier, type Notifier } from '../notify/index.js'; import { EmailNotifier } from '../notify/email-notifier.js'; -import { Resend } from 'resend'; +import { PostmarkTransport } from '../notify/postmark-transport.js'; +import { ServerClient } from 'postmark'; declare module 'fastify' { interface FastifyInstance { @@ -67,13 +68,17 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { // (relevant in tests where multiple buildApp() runs share the module). invalidateFacets(); const fts = buildFtsEngine(state); - // Email notifier when RESEND_API_KEY is configured; otherwise fall back to - // the no-op LoggingNotifier so tests + dev runs work without a real key. + // Email notifier when POSTMARK_SERVER_TOKEN is configured; otherwise fall + // back to the no-op LoggingNotifier so tests + dev runs work without a + // real token. // Slack DM is deferred (#95) — when it lands it'll compose alongside email // here or via a CompoundNotifier wrapper. - const notifier: Notifier = fastify.config.RESEND_API_KEY + const notifier: Notifier = fastify.config.POSTMARK_SERVER_TOKEN ? new EmailNotifier({ - resend: new Resend(fastify.config.RESEND_API_KEY), + transport: new PostmarkTransport({ + client: new ServerClient(fastify.config.POSTMARK_SERVER_TOKEN), + messageStream: fastify.config.POSTMARK_MESSAGE_STREAM, + }), fromAddress: fastify.config.CFP_NOTIFICATION_FROM, siteHost: fastify.config.CFP_SITE_HOST, logger: fastify.log, diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index c249c0c..88d37d2 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -541,7 +541,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise { }; await fastify.store.private.putPasswordToken(tokenRecord); - // Fire-and-forget — never block the response on Resend latency. + // Fire-and-forget — never block the response on email-provider latency. void fastify.notifier .notifyPasswordReset({ email: profile.email, diff --git a/apps/api/tests/cutover-mailout.test.ts b/apps/api/tests/cutover-mailout.test.ts index 2c57648..5983426 100644 --- a/apps/api/tests/cutover-mailout.test.ts +++ b/apps/api/tests/cutover-mailout.test.ts @@ -190,13 +190,13 @@ describe('cutover-mailout', () => { privateStore, mode: 'send', send: async () => { - throw new Error('Resend 429'); + throw new Error('Postmark 429'); }, now: NOW, }); expect(report.sent).toBe(0); expect(report.failed).toHaveLength(1); - expect(report.failed[0]?.error).toContain('Resend 429'); + expect(report.failed[0]?.error).toContain('Postmark 429'); } finally { await repo.cleanup(); await priv.cleanup(); diff --git a/apps/api/tests/email-notifier.test.ts b/apps/api/tests/email-notifier.test.ts index 69629a1..1e59f7a 100644 --- a/apps/api/tests/email-notifier.test.ts +++ b/apps/api/tests/email-notifier.test.ts @@ -1,9 +1,11 @@ /** - * Tests for the Resend-backed EmailNotifier (apps/api/src/notify/email-notifier.ts). + * Tests for the transport-backed EmailNotifier (apps/api/src/notify/email-notifier.ts). * - * Mocks the Resend SDK at the `emails.send` boundary — verifies that the + * Stubs the `EmailTransport` seam with a `vi.fn()` — verifies that the * notifier composes the right payload + handles delivery success/failure * per the spec (express-interest must return 202 to the caller regardless). + * The Postmark adapter behind that seam has its own test + * (postmark-transport.test.ts). * * Template renderers are also exercised here with snapshot-style asserts * on the interpolated fields, since they're pure functions with simple @@ -62,10 +64,9 @@ const baseWelcome: WelcomeNotification = { slug: 'new-user', }; -function makeNotifier(emails: { send: ReturnType }): EmailNotifier { +function makeNotifier(transport: { send: ReturnType }): EmailNotifier { return new EmailNotifier({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - resend: { emails } as any, + transport, fromAddress: 'Code for Philly ', siteHost: 'codeforphilly.org', logger: noopLogger, @@ -120,8 +121,8 @@ describe('renderFilledEmail', () => { }); describe('EmailNotifier.notifyHelpWantedInterest', () => { - it('sends via Resend and returns delivered:true', async () => { - const send = vi.fn().mockResolvedValue({ data: { id: 'msg-123' }, error: null }); + it('sends via the transport and returns delivered:true', async () => { + const send = vi.fn().mockResolvedValue({ messageId: 'msg-123' }); const notifier = makeNotifier({ send }); const result = await notifier.notifyHelpWantedInterest(baseInterest); @@ -135,7 +136,7 @@ describe('EmailNotifier.notifyHelpWantedInterest', () => { expect(arg.html).toContain('Jane Doe'); }); - it('returns delivered:false when maintainerEmail is null (no Resend call)', async () => { + it('returns delivered:false when maintainerEmail is null (no transport call)', async () => { const send = vi.fn(); const notifier = makeNotifier({ send }); @@ -147,17 +148,20 @@ describe('EmailNotifier.notifyHelpWantedInterest', () => { expect(send).not.toHaveBeenCalled(); }); - it('returns delivered:false when Resend reports an error', async () => { + it('returns delivered:false when the provider rejects the send', async () => { + // Postmark surfaces API rejections (unverified sender, inactive + // recipient, bad token) as thrown errors carrying code + statusCode. const send = vi .fn() - .mockResolvedValue({ data: null, error: { message: 'Sender domain unverified' } }); + .mockRejectedValue(Object.assign(new Error('Sender signature not found'), { code: 400, statusCode: 422 })); const notifier = makeNotifier({ send }); const result = await notifier.notifyHelpWantedInterest(baseInterest); expect(result).toEqual({ delivered: false }); + expect(noopLogger.error).toHaveBeenCalled(); }); - it('returns delivered:false when the Resend SDK throws', async () => { + it('returns delivered:false when the transport throws', async () => { const send = vi.fn().mockRejectedValue(new Error('network blip')); const notifier = makeNotifier({ send }); @@ -196,8 +200,8 @@ describe('renderWelcomeEmail', () => { }); describe('EmailNotifier.notifyWelcomeOnSignup', () => { - it('sends via Resend and returns delivered:true', async () => { - const send = vi.fn().mockResolvedValue({ data: { id: 'msg-welcome' }, error: null }); + it('sends via the transport and returns delivered:true', async () => { + const send = vi.fn().mockResolvedValue({ messageId: 'msg-welcome' }); const notifier = makeNotifier({ send }); const result = await notifier.notifyWelcomeOnSignup(baseWelcome); @@ -210,11 +214,10 @@ describe('EmailNotifier.notifyWelcomeOnSignup', () => { expect(arg.html).toContain('New User'); }); - it('returns delivered:false when Resend reports an error', async () => { - const send = vi.fn().mockResolvedValue({ - data: null, - error: { message: 'Sender domain unverified' }, - }); + it('returns delivered:false when the provider rejects the send', async () => { + const send = vi + .fn() + .mockRejectedValue(Object.assign(new Error('Inactive recipient'), { code: 406, statusCode: 422 })); const notifier = makeNotifier({ send }); const result = await notifier.notifyWelcomeOnSignup(baseWelcome); expect(result).toEqual({ delivered: false }); @@ -237,8 +240,8 @@ describe('EmailNotifier.notifyWelcomeOnSignup', () => { }); describe('EmailNotifier.notifyHelpWantedFilled', () => { - it('sends via Resend and returns delivered:true', async () => { - const send = vi.fn().mockResolvedValue({ data: { id: 'msg-456' }, error: null }); + it('sends via the transport and returns delivered:true', async () => { + const send = vi.fn().mockResolvedValue({ messageId: 'msg-456' }); const notifier = makeNotifier({ send }); const result = await notifier.notifyHelpWantedFilled(baseFill); diff --git a/apps/api/tests/github-oauth.test.ts b/apps/api/tests/github-oauth.test.ts index 34226a7..609ae14 100644 --- a/apps/api/tests/github-oauth.test.ts +++ b/apps/api/tests/github-oauth.test.ts @@ -519,7 +519,7 @@ describe('GET /api/auth/github/callback — fresh user outcome', () => { const ip = nextTestIp(); const flow = await startFlow(app, '/', ip); - // Spy on the boot-installed LoggingNotifier (no Resend in tests). + // Spy on the boot-installed LoggingNotifier (no Postmark in tests). // The notifier call is fire-and-forget — we await the OAuth response // first, then assert the spy. The notifier's spawn is synchronous up // to the await inside it, so it's guaranteed to have been called by diff --git a/apps/api/tests/helpers/mocks.ts b/apps/api/tests/helpers/mocks.ts index f7b4786..5dcad22 100644 --- a/apps/api/tests/helpers/mocks.ts +++ b/apps/api/tests/helpers/mocks.ts @@ -22,12 +22,14 @@ export interface GitHubEmail { /** * A captured outbound email send — inspectable in tests. */ +/** Postmark `POST /email` body — PascalCase fields as the API wants them. */ export interface CapturedEmail { - readonly to: string | string[]; - readonly from: string; - readonly subject: string; - readonly html?: string; - readonly text?: string; + readonly To: string; + readonly From: string; + readonly Subject: string; + readonly HtmlBody?: string; + readonly TextBody?: string; + readonly MessageStream?: string; } /** @@ -92,23 +94,32 @@ export function createGitHubMock(defaults?: { } /** - * No-op Resend mock. Intercepts POST /emails via MSW and collects sends - * into an in-memory array for inspection. Does not call the real Resend API. + * No-op Postmark mock. Intercepts POST /email via MSW and collects sends + * into an in-memory array for inspection. Does not call the real Postmark API. * * Usage: - * const { server, sentEmails } = createResendMock(); + * const { server, sentEmails } = createPostmarkMock(); * beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); * afterEach(() => { server.resetHandlers(); sentEmails.length = 0; }); * afterAll(() => server.close()); */ -export function createResendMock() { +export function createPostmarkMock() { const sentEmails: CapturedEmail[] = []; const server = setupServer( - http.post('https://api.resend.com/emails', async ({ request }) => { + http.post('https://api.postmarkapp.com/email', async ({ request }) => { const body = (await request.json()) as CapturedEmail; sentEmails.push(body); - return HttpResponse.json({ id: `mock-${Date.now()}` }, { status: 200 }); + return HttpResponse.json( + { + To: body.To, + SubmittedAt: new Date().toISOString(), + MessageID: `mock-${Date.now()}`, + ErrorCode: 0, + Message: 'OK', + }, + { status: 200 }, + ); }), ); diff --git a/apps/api/tests/postmark-transport.test.ts b/apps/api/tests/postmark-transport.test.ts new file mode 100644 index 0000000..a34c308 --- /dev/null +++ b/apps/api/tests/postmark-transport.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for PostmarkTransport (apps/api/src/notify/postmark-transport.ts). + * + * Two layers: + * - unit: a stub `sendEmail` proves the OutboundEmail → Postmark Message + * field mapping and that SDK errors propagate (the notifier owns + * catch-and-log, so the transport must not swallow them). + * - integration: the real `ServerClient` against an MSW intercept of + * `POST https://api.postmarkapp.com/email`, so a change in the SDK's + * wire format or auth header would surface here rather than in prod. + */ +import { ServerClient } from 'postmark'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { PostmarkTransport } from '../src/notify/postmark-transport.js'; +import { createPostmarkMock } from './helpers/mocks.js'; + +const email = { + from: 'Code for Philly ', + to: 'maintainer@example.com', + subject: 'Hello', + text: 'plain body', + html: '

html body

', +}; + +describe('PostmarkTransport (unit)', () => { + it('maps OutboundEmail onto the Postmark Message shape and returns MessageID', async () => { + const sendEmail = vi.fn().mockResolvedValue({ + To: email.to, + SubmittedAt: '2026-09-08T00:00:00Z', + MessageID: 'pm-123', + ErrorCode: 0, + Message: 'OK', + }); + const transport = new PostmarkTransport({ client: { sendEmail } }); + + const result = await transport.send(email); + expect(result).toEqual({ messageId: 'pm-123' }); + expect(sendEmail).toHaveBeenCalledTimes(1); + expect(sendEmail.mock.calls[0]![0]).toEqual({ + From: email.from, + To: email.to, + Subject: 'Hello', + TextBody: 'plain body', + HtmlBody: '

html body

', + MessageStream: 'outbound', + }); + }); + + it('honours an explicit messageStream', async () => { + const sendEmail = vi.fn().mockResolvedValue({ MessageID: 'pm-1', SubmittedAt: '', ErrorCode: 0, Message: 'OK' }); + const transport = new PostmarkTransport({ client: { sendEmail }, messageStream: 'notifications' }); + await transport.send(email); + expect(sendEmail.mock.calls[0]![0].MessageStream).toBe('notifications'); + }); + + it('propagates SDK errors untouched', async () => { + const boom = Object.assign(new Error('Inactive recipient'), { code: 406, statusCode: 422 }); + const sendEmail = vi.fn().mockRejectedValue(boom); + const transport = new PostmarkTransport({ client: { sendEmail } }); + await expect(transport.send(email)).rejects.toBe(boom); + }); +}); + +describe('PostmarkTransport (real ServerClient over MSW)', () => { + const mock = createPostmarkMock(); + beforeAll(() => mock.server.listen({ onUnhandledRequest: 'error' })); + afterEach(() => { + mock.server.resetHandlers(); + mock.sentEmails.length = 0; + }); + afterAll(() => mock.server.close()); + + it('POSTs the expected JSON body to /email', async () => { + const transport = new PostmarkTransport({ client: new ServerClient('test-server-token') }); + const result = await transport.send(email); + + expect(result.messageId).toMatch(/^mock-/); + expect(mock.sentEmails).toHaveLength(1); + expect(mock.sentEmails[0]).toEqual({ + From: email.from, + To: email.to, + Subject: 'Hello', + TextBody: 'plain body', + HtmlBody: '

html body

', + MessageStream: 'outbound', + }); + }); +}); diff --git a/deploy/kustomize/base/configmap.yaml b/deploy/kustomize/base/configmap.yaml index aafce58..512a734 100644 --- a/deploy/kustomize/base/configmap.yaml +++ b/deploy/kustomize/base/configmap.yaml @@ -11,8 +11,9 @@ data: # Sandbox overlay overrides this to next-v2.codeforphilly.org. CFP_SITE_HOST: "codeforphilly.org" # From-address for outbound notifications. Sender domain (codeforphilly.org) - # must be verified in Resend with SPF + DKIM + DMARC before flipping this - # on in production. See plans/notifier-email.md and docs/operations/secrets.md. + # must be a verified Postmark sender signature (SPF + DKIM + Return-Path) + # before flipping POSTMARK_SERVER_TOKEN on in production. See + # plans/postmark-notifier.md and docs/operations/secrets.md. CFP_NOTIFICATION_FROM: "Code for Philly " GIT_AUTHOR_EMAIL: "api@codeforphilly.org" GIT_AUTHOR_NAME: "CodeForPhilly API" diff --git a/docs/operations/cutover-announcement.md b/docs/operations/cutover-announcement.md index e3183b4..6fd73cc 100644 --- a/docs/operations/cutover-announcement.md +++ b/docs/operations/cutover-announcement.md @@ -34,7 +34,7 @@ What we need from you BEFORE cutover: If you have questions: drop them in this thread or DM @{{ cutover_lead_slack }}. ``` -### Email (Resend, to all members) +### Email (Postmark, to all members) Subject: `codeforphilly.org is migrating on {{ cutover_date_long }}` diff --git a/docs/operations/cutover.md b/docs/operations/cutover.md index 4fa9095..ecd5bf7 100644 --- a/docs/operations/cutover.md +++ b/docs/operations/cutover.md @@ -37,7 +37,7 @@ should be explicit in the cutover Slack post. ## T-7 days: announce + freeze 1. Post the cutover announcement from [cutover-announcement.md](cutover-announcement.md) - to `#announcements` and email all members via Resend. + to `#announcements` and email all members via Postmark. 2. Lower DNS TTL on `codeforphilly.org` to 60s. Verify with `dig`. 3. **Freeze legacy writes.** Either put a banner on the legacy site asking members to hold off on edits, or flip a feature flag making it read-only. @@ -261,10 +261,10 @@ still unclaimed: 3. Send: ```bash - RESEND_API_KEY=... npm run -w apps/api script:cutover-mailout -- --send + POSTMARK_SERVER_TOKEN=... npm run -w apps/api script:cutover-mailout -- --send ``` -4. Monitor Resend dashboard for bounces. Hard bounces are expected — +4. Monitor the Postmark activity stream for bounces. Hard bounces are expected — defunct email providers are exactly why these accounts are unclaimed. diff --git a/docs/operations/deploy.md b/docs/operations/deploy.md index 5389169..9989b5a 100644 --- a/docs/operations/deploy.md +++ b/docs/operations/deploy.md @@ -226,8 +226,9 @@ comments. Production pod gets these mounted: | `CFP_DATA_RELOAD_SECRET` | **Secret** | Shared bearer-token for the hot-reload webhook; when unset the `/api/_internal/reload-data` endpoint returns 503. See [runbook.md](runbook.md#hot-reload-webhook). | | `CFP_WEB_DIST_PATH` | ConfigMap | `/app/apps/web/dist` | | `CFP_SITE_HOST` | ConfigMap | Public-facing host (`codeforphilly.org` base, `next-v2.codeforphilly.org` sandbox). Drives the markdown renderer's external-link transform — anchors with a different host get `target="_blank" rel="noopener nofollow"`. | -| `RESEND_API_KEY` | **Secret** | Resend HTTPS API key for outbound notifications. When unset, the help-wanted notifier falls back to a no-op LoggingNotifier — convenient for dev + tests but means no real emails go out. | -| `CFP_NOTIFICATION_FROM` | ConfigMap | RFC 5322 sender address for outbound notifications (default `"Code for Philly "`). Sender domain must be verified in Resend with SPF/DKIM/DMARC before flipping `RESEND_API_KEY` on. | +| `POSTMARK_SERVER_TOKEN` | **Secret** | Postmark server API token for outbound notifications. When unset, the email notifier falls back to a no-op LoggingNotifier — convenient for dev + tests but means no real emails go out. | +| `POSTMARK_MESSAGE_STREAM` | ConfigMap | Postmark message stream for outbound mail (default `outbound`). Must exist on the server the token belongs to. | +| `CFP_NOTIFICATION_FROM` | ConfigMap | RFC 5322 sender address for outbound notifications (default `"Code for Philly "`). Sender domain must be a verified Postmark sender signature (already true for `codeforphilly.org` via the legacy site) before flipping `POSTMARK_SERVER_TOKEN` on. | | `STORAGE_BACKEND` | ConfigMap | `s3` (prod) / `filesystem` (sandbox) | | `CFP_PRIVATE_STORAGE_PATH` | ConfigMap | `/app/private-storage` (when filesystem) | | `S3_ENDPOINT` / `S3_BUCKET` / `S3_REGION` | ConfigMap | Bucket addressing | diff --git a/docs/operations/secrets.md b/docs/operations/secrets.md index 0b5b204..2e86f0e 100644 --- a/docs/operations/secrets.md +++ b/docs/operations/secrets.md @@ -118,22 +118,28 @@ integration ([specs/api/saml.md](../../specs/api/saml.md)). - **Cadence:** every 36 months (cert expiry), plus immediately on suspected leak. -### `RESEND_API_KEY` - -API key for the [Resend](https://resend.com) HTTPS email API. Drives the -help-wanted email notifier. When unset, the API falls back to a no-op -`LoggingNotifier` — convenient for local dev but means real users get no -outbound mail in production. - -- **Generate:** Resend dashboard → API Keys → Create API key. Scope to - send-only on the `codeforphilly.org` sender domain. -- **Pre-flight:** the sender domain (`codeforphilly.org`) must be verified - in Resend with SPF + DKIM + DMARC records before flipping this on. - Unverified domains get hard-bounced or spam-filtered immediately. +### `POSTMARK_SERVER_TOKEN` + +Server API token for the [Postmark](https://postmarkapp.com) HTTPS email +API. Drives the email notifier (help-wanted, welcome, password-reset). +When unset, the API falls back to a no-op `LoggingNotifier` — convenient +for local dev but means real users get no outbound mail in production. + +- **Generate:** Postmark → the Code for Philly account (the same one the + legacy site sends through) → Servers → pick or create a server for this + app → API Tokens → Create token. One server per environment (sandbox + vs. prod) keeps activity streams and bounces separate. +- **Pre-flight:** the sender domain (`codeforphilly.org`) is already + verified (SPF + DKIM + Return-Path) in the Postmark account from the + legacy site; confirm it still shows verified under Sender Signatures + before flipping this on. The optional `POSTMARK_MESSAGE_STREAM` + ConfigMap value (default `outbound`) must name a transactional stream + that exists on the chosen server. - **Rotation impact:** none in-flight (no in-flight email state on our - end); next outbound mail uses the new key. -- **Rotation procedure:** create new key in Resend → update sealed-secret - → `kubectl rollout restart` → revoke the old key in Resend. + end); next outbound mail uses the new token. +- **Rotation procedure:** create new token in Postmark → update + sealed-secret → `kubectl rollout restart` → delete the old token in + Postmark. - **Cadence:** every 12 months, plus immediately on suspected leak. ### Data-repo deploy key diff --git a/package-lock.json b/package-lock.json index aa3e9f8..6181638 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "fastify": "^5.8.5", "gitsheets": "^2.2.0", "jose": "^6.2.3", - "resend": "^6.12.4", + "postmark": "^5.1.0", "samlify": "^2.13.0", "sharp": "^0.34.5", "uuidv7": "^1.2.1", @@ -5415,12 +5415,6 @@ "node": ">=14.0.0" } }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -8337,12 +8331,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-sha256": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" - }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -11904,12 +11892,6 @@ "node": ">=16.20.0" } }, - "node_modules/postal-mime": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", - "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", - "license": "MIT-0" - }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -11951,6 +11933,15 @@ "node": ">=4" } }, + "node_modules/postmark": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postmark/-/postmark-5.1.0.tgz", + "integrity": "sha512-8lj2Fu94pL3fzx9Sy599opqpK4Z/0/1xCHZS8tbGBnqSS5rAcXtcHQ+TI0Jc4VwlBWma1Ug0Qm98FEe+v94BIQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -12618,27 +12609,6 @@ "node": ">=0.10.0" } }, - "node_modules/resend": { - "version": "6.12.4", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", - "integrity": "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==", - "license": "MIT", - "dependencies": { - "postal-mime": "2.7.4", - "standardwebhooks": "1.0.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@react-email/render": "*" - }, - "peerDependenciesMeta": { - "@react-email/render": { - "optional": true - } - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -13380,16 +13350,6 @@ "dev": true, "license": "MIT" }, - "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", - "license": "MIT", - "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/plans/postmark-notifier.md b/plans/postmark-notifier.md new file mode 100644 index 0000000..f2506db --- /dev/null +++ b/plans/postmark-notifier.md @@ -0,0 +1,135 @@ +--- +status: done +depends: [notifier-email] +specs: + - specs/architecture.md +issues: [] +pr: 158 +--- + +# Plan: Postmark email transport + +## Scope + +Replace the Resend-backed email transport behind `EmailNotifier` with Postmark. +Project owner's call: "I want to keep using Postmark, resend was a random agent +choice." Postmark is the provider the legacy laddr site already sends through, +so the `codeforphilly.org` sender signature is verified there and no new +vendor account or DNS work is needed. + +**In scope:** + +- Spec + operator docs describe Postmark as the transactional email provider + and the env surface it needs. +- A provider-neutral `EmailTransport` seam under `EmailNotifier`, with a + Postmark adapter as the only vendor-aware file. +- Env rename `RESEND_API_KEY` → `POSTMARK_SERVER_TOKEN`, plus + `POSTMARK_MESSAGE_STREAM` (default `outbound`). +- The T+90 `cutover-mailout` script sends through the same adapter. +- Tests for the adapter's field mapping and the SDK's wire format. + +**Out of scope:** + +- Any change to the `Notifier` interface, templates, or the fallback-to- + `LoggingNotifier` / log-not-throw semantics established by + [`notifier-email`](notifier-email.md). +- Sealing `POSTMARK_SERVER_TOKEN` in the cluster repo — operator step, tracked + under Follow-ups. +- Bounce/complaint webhooks and Slack DM — still the follow-ups recorded on + [`notifier-email`](notifier-email.md) and #95. + +## Implements + +- [architecture.md](../specs/architecture.md) — "Email: **Postmark**" in the + stack table; `POSTMARK_SERVER_TOKEN` / `POSTMARK_MESSAGE_STREAM` / + `CFP_NOTIFICATION_FROM` in the env table. The behaviours the notifier + serves ([help-wanted-roles.md](../specs/behaviors/help-wanted-roles.md), + [projects-help-wanted.md](../specs/api/projects-help-wanted.md), + [auth.md](../specs/api/auth.md)) are transport-agnostic and unchanged. + +## Approach + +1. **Dependency swap first, alone.** `npm install -w apps/api postmark` then + `npm uninstall -w apps/api resend`, committed on their own with the exact + commands in the body. +2. **Specs and docs before code.** `specs/architecture.md`, `specs/deferred.md`, + `docs/operations/{secrets,deploy,cutover,cutover-announcement}.md`, and the + `deploy/kustomize/base/configmap.yaml` comment. Plans that mention Resend + (`notifier-email`, `welcome-notification`, `test-harness`, `write-api`, + `cutover-prep`, `login-migration-impl-phase-c`) are all frozen `done` and + stay as-is. +3. **Introduce the seam.** `apps/api/src/notify/transport.ts` declares + `OutboundEmail` (from/to/subject/text/html) and `EmailTransport.send()` → + `{ messageId }`, throwing on any failure. `EmailNotifier` takes a + `transport` instead of a Resend client; its four near-identical send blocks + collapse into one `#deliver(label, ctx, to, tpl)` with a single catch. +4. **Postmark adapter.** `apps/api/src/notify/postmark-transport.ts` wraps a + `PostmarkSender` (the `sendEmail` slice of `ServerClient`), maps onto + Postmark's PascalCase `Message`, and stamps `MessageStream`. Boot wiring in + `plugins/services.ts` builds `new ServerClient(POSTMARK_SERVER_TOKEN)` only + when the token is set; otherwise `LoggingNotifier` exactly as before. +5. **Cutover script** reuses `PostmarkTransport` in place of its hand-rolled + Resend `fetch`. +6. **Tests.** `email-notifier.test.ts` stubs the seam with `vi.fn()`. New + `postmark-transport.test.ts` checks the field mapping with a stub client and + runs the real `ServerClient` against an MSW intercept of + `POST https://api.postmarkapp.com/email` (`createPostmarkMock`, replacing + `createResendMock` in `tests/helpers/mocks.ts`). + +## Validation + +- [x] `grep -rniE resend specs docs plans apps packages deploy .env.example README.md` hits only frozen `done` plans. +- [x] `EmailNotifier` sends `{ from, to, subject, text, html }` through the transport and returns `delivered: true` with the provider message id logged. +- [x] Missing recipient → `delivered: false`, no transport call, warning logged (all four notification kinds). +- [x] Transport throwing (network blip or Postmark rejection carrying `code`/`statusCode`) → `delivered: false`, error logged, nothing thrown to the caller. +- [x] `PostmarkTransport` maps onto `{ From, To, Subject, TextBody, HtmlBody, MessageStream }`, defaults `MessageStream` to `outbound`, honours an override, and propagates SDK errors untouched. +- [x] Real `ServerClient` over MSW POSTs that exact JSON body to `https://api.postmarkapp.com/email` and surfaces `MessageID`. +- [x] When `POSTMARK_SERVER_TOKEN` is unset the services plugin installs `LoggingNotifier` — every pre-existing API test passes unchanged. +- [x] `POSTMARK_MESSAGE_STREAM` defaults to `outbound` in both the Zod schema and the `@fastify/env` JSON schema. +- [x] `import { ServerClient } from 'postmark'` resolves under plain Node ESM (Postmark ships CJS; verified with `node --input-type=module`). +- [x] `npm run type-check && npm run lint && npm test` clean: api 427/427, web 89/89, shared 75/75. + +## Risks / unknowns + +- **Message stream must exist on the server.** Postmark 422s a send whose + `MessageStream` is unknown to that server. Default `outbound` exists on every + server; anyone overriding it must create the stream first. Documented in + `docs/operations/secrets.md`. +- **Inactive recipients.** Postmark refuses to send to addresses it has + previously hard-bounced or that complained (`InactiveRecipientsError`, code + 406). Same `delivered: false` path as any failure; the logged `err` carries + the code so operators can spot it. +- **CJS interop.** The `postmark` package is CommonJS with no `exports` map; + named ESM imports rely on Node's cjs-module-lexer detecting + `exports.ServerClient = …`. Verified for 5.1.0; a future SDK build that + switches to `Object.defineProperty`-only exports would need a default import. + +## Notes + +- **Only one failure shape now.** Resend's SDK could throw *or* resolve with + `{ error }`; Postmark's throws a `PostmarkError` subclass on every non-2xx. + That let the notifier drop its per-method `if (result.error)` branches and + share a single `#deliver`. The `err` logged carries `code` + `statusCode`, so + the "was it the network or the provider" distinction the old two-branch log + gave operators is preserved in the structured field rather than the message. +- **`MessageSendingResponse` is not a top-level export.** It lives under the + `Models` namespace (`import type { Message, Models } from 'postmark'`); + `Message` itself is top-level. +- **Stale local `node_modules` masqueraded as a type-check failure.** The first + gate run failed in `apps/web` on a missing `marked` that was already in the + lockfile on `develop`; `npm install` (no lockfile change) fixed it. Not + related to this plan, noted so the next person doesn't chase it. +- **`createResendMock` had no callers.** It was harness scaffolding from + [`test-harness`](test-harness.md); renamed to `createPostmarkMock` and given + its first real consumer in `postmark-transport.test.ts`. +- **`--body-file` is not a `gh-axi pr create` flag.** Pass `--body "$(cat …)"`. + +## Follow-ups + +- Tracked as: seal `POSTMARK_SERVER_TOKEN` (and optionally + `POSTMARK_MESSAGE_STREAM`) in `cfp-sandbox-cluster` `codeforphilly-ng.secrets/` + per `docs/operations/secrets.md`; delete any `RESEND_API_KEY` sealed secret + that was created. Until sealed, the pod keeps logging instead of sending. +- Bounce / complaint webhooks, PII redaction in notifier logs, and the Slack DM + channel remain as recorded on [`notifier-email`](notifier-email.md) — Postmark + offers the same webhook hooks, so nothing about those follow-ups changes. diff --git a/specs/architecture.md b/specs/architecture.md index 5ff06ee..010a0c8 100644 --- a/specs/architecture.md +++ b/specs/architecture.md @@ -25,7 +25,7 @@ Out of scope for v1: see [deferred.md](deferred.md). | File uploads (avatars, buzz images) | **gitsheets attachments** | Binary blobs stored alongside their record via gitsheets' `setAttachment` API; served via streaming `GET /api/attachments/`. | | Background jobs | **In-process timers + an in-memory queue** | At single-replica civic scale we don't need Redis/BullMQ for fan-out. Image thumbnailing, scheduled rollups, and async git pushes run in the same process. | | Logging | **pino** (Fastify default) | Pretty in dev, JSON in prod. | -| Email | **Resend** (transactional) | For notifications like "help wanted interest expressed" and newsletter delivery (when that ships). Service account, not per-user OAuth. | +| Email | **Postmark** (transactional) | For notifications like "help wanted interest expressed" and newsletter delivery (when that ships). Server token, not per-user OAuth. Postmark is what the legacy site already sends through, so the `codeforphilly.org` sender domain is already verified there. | ### What we deliberately *don't* use @@ -178,8 +178,9 @@ Runtime configuration (sealed-secrets in our cluster): | `CFP_JWT_SIGNING_KEY` | HS256 key for session JWTs | | `SAML_PRIVATE_KEY` / `SAML_CERTIFICATE` | Slack SAML IdP cert chain — see [api/saml.md](api/saml.md) | | `SLACK_TEAM_HOST` | Slack workspace host (default `codeforphilly.slack.com`). Used by the `/chat` redirect ([api/chat](screens/chat.md)) and the SAML SP entity binding. | -| `RESEND_API_KEY` | Optional. When set, mutates the notifier from the no-op `LoggingNotifier` to the live `EmailNotifier` (Resend SDK). | -| `CFP_NOTIFICATION_FROM` | Required when `RESEND_API_KEY` is set; the `From:` address on outbound mail. | +| `POSTMARK_SERVER_TOKEN` | Optional. When set, mutates the notifier from the no-op `LoggingNotifier` to the live `EmailNotifier` (Postmark transport). | +| `POSTMARK_MESSAGE_STREAM` | Optional. Postmark message stream for outbound mail (default `outbound`). | +| `CFP_NOTIFICATION_FROM` | Required when `POSTMARK_SERVER_TOKEN` is set; the `From:` address on outbound mail. | | `CFP_SITE_HOST` | Public site host (e.g., `codeforphilly.org`) — used by notifiers to build canonical URLs in email bodies. | | `CFP_DATA_RELOAD_SECRET` | Bearer token gating `POST /api/_internal/reload-data` — the hot-reload webhook. Optional in dev; required in prod. | diff --git a/specs/deferred.md b/specs/deferred.md index 80e24ad..e4d41f8 100644 --- a/specs/deferred.md +++ b/specs/deferred.md @@ -99,9 +99,9 @@ When a deferred item is promoted, move it from this file into the relevant spec, ### Newsletter sending pipeline -- **What:** A flow that takes a composed newsletter (subject, markdown body) and sends it to all opted-in subscribers via Resend (or whatever transactional-email provider we end up on). +- **What:** A flow that takes a composed newsletter (subject, markdown body) and sends it to all opted-in subscribers via Postmark (the transactional provider the notifier already uses — see [architecture.md](architecture.md)). - **Why deferred:** v1 stores subscription state in `PrivateProfile.newsletter` (see [data-model.md](data-model.md#privateprofile-private) and [behaviors/private-storage.md](behaviors/private-storage.md)) so staff can CSV-export the active subscriber list to whatever sending tool they currently use (MailChimp web UI, etc.). The send-from-the-site pipeline is a follow-up spec when there's an active newsletter author committed to using it. -- **When promoted:** Spec a `/api/newsletter/send` endpoint with admin auth, a Resend-backed worker, unsubscribe-link generation off the existing `PrivateProfile.newsletter.unsubscribeToken`, delivery + bounce tracking. +- **When promoted:** Spec a `/api/newsletter/send` endpoint with admin auth, a Postmark-backed worker, unsubscribe-link generation off the existing `PrivateProfile.newsletter.unsubscribeToken`, delivery + bounce tracking. ### `connectors/` ingestion