| status | done | |
|---|---|---|
| depends |
|
|
| specs |
|
|
| issues | ||
| pr | 158 |
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
EmailTransportseam underEmailNotifier, with a Postmark adapter as the only vendor-aware file. - Env rename
RESEND_API_KEY→POSTMARK_SERVER_TOKEN, plusPOSTMARK_MESSAGE_STREAM(defaultoutbound). - The T+90
cutover-mailoutscript 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
Notifierinterface, templates, or the fallback-to-LoggingNotifier/ log-not-throw semantics established bynotifier-email. - Sealing
POSTMARK_SERVER_TOKENin the cluster repo — operator step, tracked under Follow-ups. - Bounce/complaint webhooks and Slack DM — still the follow-ups recorded on
notifier-emailand #95.
- architecture.md — "Email: Postmark" in the
stack table;
POSTMARK_SERVER_TOKEN/POSTMARK_MESSAGE_STREAM/CFP_NOTIFICATION_FROMin the env table. The behaviours the notifier serves (help-wanted-roles.md, projects-help-wanted.md, auth.md) are transport-agnostic and unchanged.
- Dependency swap first, alone.
npm install -w apps/api postmarkthennpm uninstall -w apps/api resend, committed on their own with the exact commands in the body. - Specs and docs before code.
specs/architecture.md,specs/deferred.md,docs/operations/{secrets,deploy,cutover,cutover-announcement}.md, and thedeploy/kustomize/base/configmap.yamlcomment. Plans that mention Resend (notifier-email,welcome-notification,test-harness,write-api,cutover-prep,login-migration-impl-phase-c) are all frozendoneand stay as-is. - Introduce the seam.
apps/api/src/notify/transport.tsdeclaresOutboundEmail(from/to/subject/text/html) andEmailTransport.send()→{ messageId }, throwing on any failure.EmailNotifiertakes atransportinstead of a Resend client; its four near-identical send blocks collapse into one#deliver(label, ctx, to, tpl)with a single catch. - Postmark adapter.
apps/api/src/notify/postmark-transport.tswraps aPostmarkSender(thesendEmailslice ofServerClient), maps onto Postmark's PascalCaseMessage, and stampsMessageStream. Boot wiring inplugins/services.tsbuildsnew ServerClient(POSTMARK_SERVER_TOKEN)only when the token is set; otherwiseLoggingNotifierexactly as before. - Cutover script reuses
PostmarkTransportin place of its hand-rolled Resendfetch. - Tests.
email-notifier.test.tsstubs the seam withvi.fn(). Newpostmark-transport.test.tschecks the field mapping with a stub client and runs the realServerClientagainst an MSW intercept ofPOST https://api.postmarkapp.com/email(createPostmarkMock, replacingcreateResendMockintests/helpers/mocks.ts).
-
grep -rniE resend specs docs plans apps packages deploy .env.example README.mdhits only frozendoneplans. -
EmailNotifiersends{ from, to, subject, text, html }through the transport and returnsdelivered: truewith the provider message id logged. - Missing recipient →
delivered: false, no transport call, warning logged (all four notification kinds). - Transport throwing (network blip or Postmark rejection carrying
code/statusCode) →delivered: false, error logged, nothing thrown to the caller. -
PostmarkTransportmaps onto{ From, To, Subject, TextBody, HtmlBody, MessageStream }, defaultsMessageStreamtooutbound, honours an override, and propagates SDK errors untouched. - Real
ServerClientover MSW POSTs that exact JSON body tohttps://api.postmarkapp.com/emailand surfacesMessageID. - When
POSTMARK_SERVER_TOKENis unset the services plugin installsLoggingNotifier— every pre-existing API test passes unchanged. -
POSTMARK_MESSAGE_STREAMdefaults tooutboundin both the Zod schema and the@fastify/envJSON schema. -
import { ServerClient } from 'postmark'resolves under plain Node ESM (Postmark ships CJS; verified withnode --input-type=module). -
npm run type-check && npm run lint && npm testclean: api 427/427, web 89/89, shared 75/75.
- Message stream must exist on the server. Postmark 422s a send whose
MessageStreamis unknown to that server. Defaultoutboundexists on every server; anyone overriding it must create the stream first. Documented indocs/operations/secrets.md. - Inactive recipients. Postmark refuses to send to addresses it has
previously hard-bounced or that complained (
InactiveRecipientsError, code 406). Samedelivered: falsepath as any failure; the loggederrcarries the code so operators can spot it. - CJS interop. The
postmarkpackage is CommonJS with noexportsmap; named ESM imports rely on Node's cjs-module-lexer detectingexports.ServerClient = …. Verified for 5.1.0; a future SDK build that switches toObject.defineProperty-only exports would need a default import.
- Only one failure shape now. Resend's SDK could throw or resolve with
{ error }; Postmark's throws aPostmarkErrorsubclass on every non-2xx. That let the notifier drop its per-methodif (result.error)branches and share a single#deliver. Theerrlogged carriescode+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. MessageSendingResponseis not a top-level export. It lives under theModelsnamespace (import type { Message, Models } from 'postmark');Messageitself is top-level.- Stale local
node_modulesmasqueraded as a type-check failure. The first gate run failed inapps/webon a missingmarkedthat was already in the lockfile ondevelop;npm install(no lockfile change) fixed it. Not related to this plan, noted so the next person doesn't chase it. createResendMockhad no callers. It was harness scaffolding fromtest-harness; renamed tocreatePostmarkMockand given its first real consumer inpostmark-transport.test.ts.--body-fileis not agh-axi pr createflag. Pass--body "$(cat …)".
- Tracked as: seal
POSTMARK_SERVER_TOKEN(and optionallyPOSTMARK_MESSAGE_STREAM) incfp-sandbox-clustercodeforphilly-ng.secrets/perdocs/operations/secrets.md; delete anyRESEND_API_KEYsealed 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— Postmark offers the same webhook hooks, so nothing about those follow-ups changes.