@capitalthought/postmark
v0.1.0
Published
Shared Postmark transactional email client + template registry for Capital Thought projects.
Downloads
760
Readme
@capitalthought/postmark
Shared Postmark transactional email client + template registry for Capital Thought projects.
Why
Postmark is the standing vendor for transactional email across the portfolio (per global CLAUDE.md vendor preference, 2026-04-28 — see ~/icloud/Claude/CLAUDE.md § "Vendor preferences"). Today the same sendEmail shape is re-implemented in five different places, each one slightly different, none sharing fixes:
| Repo | Has | Notes |
|---|---|---|
| multipov | ✅ canonical sendEmail + 4 named senders + From-mailbox pinning | Lift target — most complete impl |
| mikey | partial — Postmark not yet wired, scattered Resend remnants | Migrates in Phase 2 |
| pitch2 | inline fetch('https://api.postmarkapp.com/email') in 2 sites | Migrates in Phase 3 |
| personalize | inline + suppression-list reads | Migrates in Phase 3 |
| doorman | partial — magic-link sender only | Migrates in Phase 2 |
This package consolidates the canonical patterns and exposes them via a typed client + a registered template registry. Every consumer drops into one shared client + a shared template renderer instead of inlining HTML strings five different ways.
Phase 1 — what's in this commit
Stubs + contracts + a working in-memory client + ESLint rule. No consumer changes yet. Phase 1 commits zero runtime risk.
| Module | Status |
|---|---|
| client.ts — createPostmarkClient() (sendTransactional, sendBatch, bounces, suppress) | ✅ shipped |
| templates.ts — registerTemplate() + slug → typed renderer | ✅ shipped |
| types.ts — SendArgs, SendResult, BounceResult, PostmarkTemplate, … | ✅ shipped |
| no-op mode when serverToken is undefined | ✅ shipped |
| ESLint rule no-inline-postmark-html + plugin entry | ✅ shipped |
Postmark's API is HTTP-only and identical across runtimes (Workers, Node, Vercel) — no per-runtime adapters, just fetch.
Migration phases
- Phase 1 (this scaffold). Create the package + client + template registry + ESLint rule + tests. No consumer changes.
- Phase 2. Migrate
multipov(lift target — already canonical) andmikeyto consume the package. Each drops its ownsendEmail+ replaces inline HTML strings with registered templates. - Phase 3. Migrate
pitch2,personalize,doorman. By end of phase, every Capital Thought repo that sends email uses this package and only this package. - Phase 4. Add a Postmark Webhooks helper (signed inbound bounce / spam complaint webhook) so each consumer can plug a single handler into its router. Add a tiny CLI for inspecting the suppression list.
Each consumer migrates on its own PR, on its own timeline.
Design decisions (2026-05-02)
Templates as code, not Postmark Server Templates
Postmark itself supports Mustachio-rendered templates managed in the Postmark dashboard (POST /email/withTemplate). We deliberately don't use that path. Two reasons:
- Reviewable diffs. A template change should land in a PR, not in a Postmark dashboard the rest of the team can't see.
- Live testability. Code-rendered templates are pure functions —
subject(params)/htmlBody(params)/textBody(params). They unit-test as plain functions, no HTTP roundtrip needed.
The downside is that Postmark's per-template open/click stats can't tag our sends by template slug. We can still tag them via the Tag field — which we do.
Slug registry, not per-client
Templates are registered into a process-wide registry (registerTemplate("welcome", { ... })) that any client can resolve. This mirrors how every consumer already structures its email module: a single templates.ts imported once. Tests get isolation via clearTemplates() between cases.
If we ever need per-client overrides (different copy for different brand domains in one process), the contract supports it — just create one client per brand and have the consumer dispatch by brand. No package change needed.
No-op mode when serverToken is undefined
createPostmarkClient({ serverToken: undefined }) returns a working client that logs every send to console.log and returns a synthetic SendResult with messageId: noop-.... Local dev, CI, and preview deploys often run without Postmark wired — opting in to send is cheaper than opting out. Mirrors the no-op-when-DSN-unset pattern from @capitalthought/observability.
ESLint rule, not just convention
no-inline-postmark-html flags any client.sendTransactional({ htmlBody: "..." }) call where the object literal contains htmlBody: or textBody: and no template:. Configurable per-file allowlist for the few legitimately-dynamic sites. Lifted from the same RuleTester-based shape as @capitalthought/anthropic's no-llm-identity-param.
private: true for now, publishConfig.access: "public" set in advance
The package ships as private: true during Phase 1 — no npm publish until consumer migration is ready. publishConfig.access is pre-set to "public" so the eventual publish doesn't trip the same 404-on-metadata trap that @capitalthought/anthropic hit on first publish (the @capitalthought scope has no paid private-packages subscription, so npm needs the --access public signal up front). Future maintainer: when you flip private off and run npm publish, the access level is already correct.
Usage (once Phase 2 lands)
// Module load — register every template the app uses.
import { registerTemplate } from "@capitalthought/postmark";
interface ReviewParams { fileName: string; jobId: string; pageCount: number }
registerTemplate<ReviewParams>("review-complete", {
subject: (p) => `Your review of ${p.fileName} is ready`,
htmlBody: (p) => `<!DOCTYPE html><html>...${p.fileName}...</html>`,
textBody: (p) => `Your review of ${p.fileName} is ready: https://multipov.ai/review/${p.jobId}`,
from: '"MultiPOV Help" <[email protected]>',
});
// Per-request — pass the slug + params, the client renders.
import { createPostmarkClient } from "@capitalthought/postmark";
const postmark = createPostmarkClient({
serverToken: env.POSTMARK_SERVER_TOKEN, // optional → no-op when undefined
defaultFrom: '"MultiPOV Help" <[email protected]>',
defaultMessageStream: "outbound",
});
await postmark.sendTransactional<ReviewParams>({
template: "review-complete",
params: { fileName: "deck.pdf", jobId: "j-123", pageCount: 14 },
to: "[email protected]",
tag: "review-complete",
metadata: { jobId: "j-123", kind: "review" },
});
// Bulk + bounces + suppression
const results = await postmark.sendBatch([
{ template: "welcome", params: { name: "Alice" }, to: "[email protected]" },
{ template: "welcome", params: { name: "Bob" }, to: "[email protected]" },
]);
const bounces = await postmark.bounces({ type: "HardBounce", count: 50 });
await postmark.suppress("[email protected]", { reason: "HardBounce" });Wiring the ESLint rule
Add the plugin to your repo's flat config:
import postmarkPlugin from "@capitalthought/postmark/eslint-rules";
export default [
{
plugins: { postmark: postmarkPlugin },
rules: { "postmark/no-inline-postmark-html": "error" },
},
];To allow inline HTML in a few one-off paths (e.g. dynamic fact-check reports rendered from a thousand findings):
"postmark/no-inline-postmark-html": ["error", {
allowFiles: ["src/email/factcheck-report.ts"],
}],Scripts
npm run check # tsc --noEmit
npm run build # tsc → dist/
npm run test # vitest runRelated
~/icloud/Claude/CLAUDE.md§ "Vendor preferences" — Postmark is the standing transactional-email vendormultipov/src/email.ts— canonicalsendEmail+ From-mailbox pinning + 4 named senders (lift target for Phase 2)@capitalthought/anthropic— sibling package, same shape (model registry + cost cap + ESLint rule)@capitalthought/observability— sibling package, same no-op-when-secret-unset pattern
