Next.js boilerplate with Postmark
Transactional email with separate broadcast streams and 45 days of history.
Transactional email through Postmark, behind one provider-agnostic send function. React Email templates are rendered to HTML and text before sending. Transactional and broadcast mail use separate message streams. The suppression list is mirrored from Postmark's own. A basic-auth webhook handles bounces, spam complaints and subscription changes.
What Postmark adds to the agent layer: 1 rule · 2 skills · 6 solution docs · 1 MCP server
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Postmark?
Pick it if
Products where the transactional mail has to arrive now: sign-in links, password resets, receipts. Also teams that send some marketing and want it kept on separate infrastructure from the mail that must not fail.
Watch out for
- Transactional first. Broadcast streams exist and handle unsubscribes, but there is no segmentation, no drip builder and no marketing CRM.
- Postmark does not sign webhooks. The endpoint is protected by basic auth over HTTPS, which works but puts the credential in the webhook config.
Show 3 moreShow fewer
- No React rendering on Postmark's side. Templates are rendered to HTML and text in the app before sending, so the preview is exactly what ships.
- No idempotency key on the API. This battery sets a deterministic Message-ID and stores the key as metadata instead.
- New accounts go through a short approval before they can mail outside their own domain. Plan a day for it before launch.
What it costs
Free developer plan: 100 emails a month, no expiry. Paid plans start at $15/month for 10,000 emails. Every plan keeps 45 days of message history by default. Dedicated IPs (from $50/month) and longer retention are add-ons.
Prices change. Check with Postmark before you commit.
registry/tested.yaml
Tested with Postmark
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What Postmark adds to the repo
Read straight from the postmark manifest, so it is exactly what lands in your repo.
Environment variables
POSTMARK_SERVER_TOKENRequired
Server API token, from your server's API Tokens tab. Not the Account token. Server-side only. The literal value POSTMARK_API_TEST makes Postmark validate every send and deliver nothing, which is the right value for local development and CI.
- Where to get it
- https://account.postmarkapp.com/servers
- Placeholder
- POSTMARK_API_TEST
EMAIL_FROMRequired
The From address, in "Name <address@domain>" form. Its domain (or the exact address) must be verified in Postmark under Sender Signatures, or every send fails with a 422. Use a subdomain such as mail.example.com.
- Where to get it
- https://account.postmarkapp.com/signature_domains
- Placeholder
- my-app <hello@mail.example.com>
POSTMARK_MESSAGE_STREAMOptional
Id of the transactional message stream. Defaults to "outbound", the stream every server starts with. Magic links, receipts and every default send go here.
- Placeholder
- outbound
POSTMARK_BROADCAST_STREAMOptional
Id of the broadcast stream, used only when a send passes
stream: "broadcast". Defaults to "broadcast". Postmark adds the unsubscribe link and List-Unsubscribe headers on this stream.- Placeholder
- broadcast
POSTMARK_WEBHOOK_USERNAMEOptional
Basic-auth username you set on the webhook in Postmark. Postmark does not sign webhooks, so this pair is the only proof a request came from Postmark. Without it the route at /api/webhooks/postmark answers 503.
- Where to get it
- https://postmarkapp.com/developer/webhooks/webhooks-overview
- Placeholder
- postmark
POSTMARK_WEBHOOK_PASSWORDOptional
Basic-auth password for the webhook. Long and random:
openssl rand -hex 32. Under 16 characters, or still this placeholder, and the route treats it as unset. Rotate it in Postmark and here together.- Where to get it
- https://postmarkapp.com/developer/webhooks/webhooks-overview
- Placeholder
- replace_with_a_long_random_string
EMAIL_OUTBOX_DIROptional
Development and tests only. When set,
sendEmailwrites each message as a JSON file in this folder instead of sending it, so sign-in and reset links work with no inbox and no provider account. The end-to-end tests read their links from it (.e2e/outbox). Ignored on a deployment: it works in development and in a production build served on localhost.- Placeholder
REPLY_TOOptional
Default Reply-To for every send. Falls back to EMAIL_FROM when unset. Point it at a mailbox a human reads.
- Placeholder
- support@example.com
Dependencies
- @react-email/render^2.1.0
- postmark^5.1.0
- react-email^6.11.0
- server-only^0.0.1
Scripts
- bun run email:dev
bunx email dev --dir src/lib/email/templates
- bun run email:send-test
bun --conditions=react-server scripts/email/send-test.ts
- bun run email:suppressions
bun --conditions=react-server scripts/email/suppressions.ts
MCP server
postmark
- Command
- npx -y @activecampaign/postmark-mcp@2.1.1
- Environment
- DEFAULT_MESSAGE_STREAM, DEFAULT_SENDER_EMAIL, POSTMARK_SERVER_TOKEN
Files it writes
29 files, at these exact paths.
scripts/3 files
email/3 files
- render-samples.ts
- send-test.ts
- suppressions.ts
src/16 files
app/1 file
api/1 file
webhooks/1 file
postmark/1 file
- route.ts
lib/15 files
email/15 files
templates/7 files
- layout.tsx
- magic-link.tsx
- receipt.tsx
- reset-password.tsx
- theme.ts
- verify-email.tsx
- welcome.tsx
- address.ts
- index.ts
- outbox.ts
- postmark.ts
- retry.ts
- suppression.ts
- tags.ts
- webhook-auth.ts
tests/2 files
unit/2 files
- email-outbox.test.ts
- email.test.ts
variants/8 files
errors-none/1 file
src/1 file
lib/1 file
email/1 file
- observability.ts
errors-sentry/1 file
src/1 file
lib/1 file
email/1 file
- observability.ts
orm-drizzle/3 files
slots/1 file
- db-schema.ts
src/2 files
db/1 file
- email-schema.ts
lib/1 file
email/1 file
- store.ts
orm-none/1 file
src/1 file
lib/1 file
email/1 file
- store.ts
orm-prisma/2 files
slots/1 file
- prisma-models.prisma
src/1 file
lib/1 file
email/1 file
- store.ts
Stack slots it fills
The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.
- @slot env-required
- @slot legal-processors
- @slot verify-checks
The differentiator
What Postmark teaches your agent
Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.
Rules (1)
Loaded when the agent opens a matching file.
One send path, the right message stream, and respect for inactive recipients
Loads onsrc/lib/email/**src/app/api/webhooks/postmark/**src/db/email-schema.tsscripts/email/**src/lib/auth/**src/lib/billing/**.claude/rules/email-sending-discipline.md
One send path
sendEmail() from @/lib/email is the only way this app sends mail.
- Nothing else imports
postmark, builds aServerClient, or readsPOSTMARK_SERVER_TOKEN. - The client lives in
src/lib/email/postmark.tsand is built on first use. Never at module scope:next buildimports every route with no secrets set. - The Resend and Mailgun batteries export the same
sendEmail,SendEmailOptions,SendEmailResult,EmailAttachmentandEmailSuppressedError. Keep it that way. Add options, never rename or narrow them. - Postmark-only options:
stream,tag,tracking. Shared callers (auth, billing) must not set them.
import { sendEmail } from "@/lib/email";
import ReceiptEmail from "@/lib/email/templates/receipt";
await sendEmail({
to: user.email,
subject: `Receipt ${payment.reference}`,
react: ReceiptEmail({ reference: payment.reference /* ... */ }),
idempotencyKey: `receipt:${payment.id}`,
tag: "receipt",
});
Message streams
Postmark splits mail into streams. They use separate IP pools and separate suppression lists.
| Stream | Env | Default id | For |
|---|---|---|---|
| Transactional | POSTMARK_MESSAGE_STREAM | outbound | Anything the user just triggered: sign-in, reset, receipt, invite |
| Broadcast | POSTMARK_BROADCAST_STREAM | broadcast | Newsletters, announcements, digests, promos |
sendEmaildefaults to transactional. Broadcast isstream: "broadcast", per send.- Never send marketing through the transactional stream. One promo blast that draws complaints hurts every magic link after it. Postmark's sending policy requires the split.
- A "transactional" email with a promo paragraph is marketing. Keep receipts and welcome mails free of upsells.
- On broadcast, Postmark adds the unsubscribe link and
List-Unsubscribeheaders. Do not add your own. - Never call
messageStream()with a hardcoded id. Streams are configured by env.
Inactive recipients
Postmark deactivates an address after a hard bounce or a spam complaint. A later send to it fails with error 406.
sendEmailturns that 406 intoEmailSuppressedError. Same error as a local hit.- Do not catch
EmailSuppressedErrorand retry, resend through another path, or "fix" it by reactivating. The address is dead or the person said stop. - Do show it to support: "we can't email this address, ask the user for a new one."
- The local list (
src/lib/email/store.ts) mirrors the transactional stream. The webhook fills it.isSuppressedfails open on a database error on purpose. Postmark still enforces. unsuppress()is for an explicit request from the mailbox owner. Postmark will not reactivate a spam complaint through the API. Never bulk-reactivate.suppress(address, "manual")writes to Postmark too. The webhook passes{ mirrored: true }so it never writes back.
The webhook
src/app/api/webhooks/postmark/route.ts.
- Postmark does not sign webhooks. The check is basic auth:
POSTMARK_WEBHOOK_USERNAME/POSTMARK_WEBHOOK_PASSWORD, compared in constant time inwebhook-auth.ts. - Never weaken it: no "skip auth in dev", no query-string secret, no IP check alone.
- Missing credentials means 503, not an open door.
- Every write is an upsert. Postmark retries any non-2xx, so a redelivery must change nothing.
- Hard bounce (
Inactive: true) and spam complaint suppress from any stream. SubscriptionChange acts on the transactional stream only.
Idempotency
Postmark has no idempotency key. idempotencyKey becomes a deterministic Message-ID header plus idempotency_key metadata.
- Scope it to the event:
receipt:${paymentId},welcome:${userId}. Never a timestamp or a random value. - No key on a magic link. A second request must send a second link.
Metadata, tags and tracking
tagsbecomes PostmarkMetadata. At most 10 fields (9 with an idempotency key), names up to 20 characters, values up to 80.tags.tsenforces it.tagis the single PostmarkTagfor stats. A category likereceipt. Never an id or an address.trackingis off unless a person decided otherwise. Never on a magic link, reset or receipt. Tracked links get rewritten, and a rewritten sign-in link is a phishing signal.
Secrets and content
- No API keys, env values, passwords or session tokens in any email body.
- A magic link is the one credential a message may carry. Pass the URL as a prop. Never log it.
- Templates live in
src/lib/email/templatesas React Email components.sendEmailrenders HTML and text. Never build HTML with string concatenation. - Every template takes a
locale. Pass the recipient's.
The Postmark MCP server
.mcp.json ships the official Postmark MCP server. Use it to diagnose: diagnoseDelivery, searchOutboundMessages, searchBounces, getMessageDetails.
- Never use its send tools to mail a real person. Test sends go through
bun run email:send-test. - Never create, edit or delete Postmark templates with it. Templates live in this repo.
Skills (2)
Invoked by name.
- /add-email-template
Add a React Email template on the shared layout, pick its message stream, wire it into a sendEmail call, and prove it lands in a real inbox.
.claude/skills/add-email-template/SKILL.md
- /test-email
Prove Postmark sending works. Check the token and stream, send one of each template, read the activity feed, check suppressions, and test the basic-auth webhook with a fake bounce.
.claude/skills/test-email/SKILL.md
Solution docs (6)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- Postmark bounce and spam complaint webhooks, secured without a signaturePostmark does not sign webhooks. Protect the endpoint with basic auth, act only on deactivating bounces, and make every write an upsert.docs/solutions/postmark/bounces-complaints-and-webhook-auth.md
- Postmark DKIM and custom Return-Path, and why DMARC fails without themPostmark needs two DNS records per domain. DKIM signs the mail. The pm-bounces Return-Path CNAME makes SPF align. Skip one and DMARC alignment rests on the other.docs/solutions/postmark/dkim-return-path-dns.md
- Postmark error 406: you tried to send to a recipient that has been marked as inactiveA 406 means Postmark deactivated the address after a hard bounce, complaint or manual block. Do not retry it. Surface it, and reactivate only on the owner's request.docs/solutions/postmark/inactive-recipient-406.md
- Postmark message streams, transactional vs broadcast, and why the split protects your login emailsPostmark sends transactional and broadcast mail through separate streams with separate reputations and suppression lists. Route every send on purpose, and never put marketing on the transactional stream.docs/solutions/postmark/message-streams-transactional-vs-broadcast.md
- Postmark server-side templates vs React Email rendered in your appPostmark can store templates and fill them with Mustachio. Rendering React Email in your app keeps templates in the repo, typed and reviewed. How to choose, and what each costs.docs/solutions/postmark/postmark-templates-vs-react-email.md
How it fits
What Postmark needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
Nothing. Postmark stands on its own.
Pairs well with
- An ORM battery. Suggested, never added for you.
Cannot be combined with
Compared with the alternatives
Build a repo with Postmark
Free and MIT. The builder opens with Postmark picked. You download the zip right away, and we email you the link too.