Skip to content

feat(email): add Postmark send provider#164

Open
mattgwagner wants to merge 3 commits into
choyiny:mainfrom
mattgwagner:feat/postmark-provider
Open

feat(email): add Postmark send provider#164
mattgwagner wants to merge 3 commits into
choyiny:mainfrom
mattgwagner:feat/postmark-provider

Conversation

@mattgwagner

Copy link
Copy Markdown
Contributor

What & why

Adds Postmark as an outbound email provider alongside Cloudflare Email Sending, Resend, and Bavimail.

PostmarkSender POSTs to https://api.postmarkapp.com/email with an X-Postmark-Server-Token header. It maps Reply-To to Postmark's dedicated ReplyTo field and routes any other headers through the Headers array, base64-encodes attachments into Attachments, and treats both non-2xx responses and 200 + non-zero ErrorCode as failures. It's fetch-based with an injectable fetchFn (so tests never touch globalThis.fetch), mirroring BavimailSender.

Selected via the POSTMARK_API_KEY secret. Runtime precedence: Bavimail > Postmark > Resend > Cloudflare Email Sending.

Stacked on #163

⚠️ This PR is stacked on #163 (the email-sender/ folder refactor) and includes its commit. Please merge #163 first; I'll rebase this onto main so it shows only the Postmark commit.

Changes

  • worker/src/lib/email-sender/providers/postmark.ts — new PostmarkSender.
  • types.ts union + index.ts factory branch & re-export.
  • Tests: provider selection (+ precedence), header/cc/attachment mapping, error paths, maxAttachmentBytes — appended to email-sender.test.ts.
  • Docs: README (provider matrix, prerequisites, secrets, .dev.vars), wrangler.jsonc.example, .dev.vars.example, the onboarding skill (Option D), and CHANGELOG.md.

Checklist

  • yarn tsc --noEmit clean
  • yarn test — 405 passed / 1 skipped (+11 Postmark tests)
  • No schema change → no migration
  • CHANGELOG updated under [Unreleased]
  • Docs updated (README, examples, onboarding)
  • e2e — n/a (no UI/API contract change)

mattgwagner and others added 3 commits June 22, 2026 13:57
Extract the single ~360-line email-sender.ts into a focused folder:
shared types (types.ts), helpers (shared.ts), one class per provider
under providers/, and the createEmailSender factory + re-export surface
in index.ts. The ./email-sender import path is unchanged (resolves to
index.ts), so no callers change. Pure code move, no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Postmark as an outbound provider alongside Cloudflare, Resend, and
Bavimail. PostmarkSender posts to https://api.postmarkapp.com/email with
an X-Postmark-Server-Token header; Reply-To maps to Postmark's ReplyTo
field and other headers go through the Headers array. Selected via
POSTMARK_API_KEY, with runtime precedence Bavimail > Postmark > Resend >
Cloudflare Email Sending.

Docs (README provider matrix/prereqs/secrets, wrangler + dev.vars
examples, onboarding skill) and CHANGELOG updated to cover Postmark
everywhere the other providers appear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ocation

The global `fetch` invoked as a method reference (`this.fetchFn(...)`)
throws "Illegal invocation" in the Cloudflare Workers runtime. Because
every test injects its own fetch, the unbound default only ran in
production — where it silently turned every send into a failed result
(nothing reached Postmark). Bind the default to globalThis and log the
failure (matching CloudflareSender).

No unit test for the un-injected default path: exercising it makes a real
outbound fetch, which the vitest-pool-workers runtime blocks with a
"Network connection lost" rejection that leaks across isolates and fails
unrelated tests. A code comment documents the binding requirement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mattgwagner mattgwagner force-pushed the feat/postmark-provider branch from 77e402a to 7a6a8e5 Compare June 22, 2026 18:28
@mattgwagner

Copy link
Copy Markdown
Contributor Author

Heads-up while this is fresh: BavimailSender has the same latent bug the fix here addresses. It also takes private fetchFn: typeof fetch = fetch and calls this.fetchFn(...), so the un-injected production default would throw Illegal invocation in the Workers runtime the moment a real Bavimail send runs (its unit tests all inject a mock fetch, so it's currently green but untested on that path).

We hit exactly this with Postmark in production — every send silently failed before reaching the API. I've deliberately left bavimail.ts untouched in this PR to keep the diff focused, but you'll likely want the same fetch.bind(globalThis) treatment there before anyone relies on Bavimail. Happy to send a separate one-line PR if useful.

@mattgwagner

Copy link
Copy Markdown
Contributor Author

I've been running this in one of my three production instances for the 4 days and it's working nicely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant