Send EmDash CMS emails through Postmark — settings parity with the official WordPress plugin, plus a delivery log, real-time webhook tracking (deliveries, bounces, opens, clicks), and live stream + sender pickers.
Postmark Settingsappears in the EmDash admin sidebar after install.
| ✓ | Drop-in replacement for EmDash's email transport — registers as the exclusive email:deliver provider |
| ✓ | Full settings parity with the official WordPress Postmark plugin (token, sender, stream, force-from, force-HTML, track opens / links, tags, metadata) |
| ✓ | Environment-variable overrides compatible with WordPress (POSTMARK_API_KEY, POSTMARK_SENDER_ADDRESS, POSTMARK_STREAM_NAME) |
| ✓ | Live picker — Default Stream dropdown populated from /message-streams |
| ✓ | Live picker — Default Sender dropdown populated from /senders (with optional Account API Token) |
| ✓ | Webhook receiver — secured with a per-installation secret, transitions deliveries through delivered → bounced → spam_complaint, counts opens & clicks |
| ✓ | Test connection button validates the token without sending email |
| ✓ | Send test email button sends to the signed-in admin |
| ✓ | Delivery log with status badges + relative timestamps; queryable by messageId (used by the webhook receiver) |
| ✓ | Source-aware Postmark Tag + Metadata — filter EmDash sends by source in the Postmark dashboard |
| ✓ | Retry on 5xx, 429, and network errors with exponential backoff |
| ✓ | Sandbox-compatible — no Node built-ins; network:request capability restricted to api.postmarkapp.com |
pnpm add emdash-plugin-postmark
# or
npm install emdash-plugin-postmarkRegister in your EmDash site's astro.config.mjs:
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { postmarkPlugin } from "emdash-plugin-postmark";
export default defineConfig({
integrations: [
emdash({
plugins: [postmarkPlugin()],
}),
],
});For a sandboxed install (Cloudflare Workers), use sandboxed: [postmarkPlugin()] instead. The plugin is identical in both modes.
Open the EmDash admin → Postmark Settings in the sidebar.
| Setting | KV key | Env var override | Notes |
|---|---|---|---|
| Server API Token | settings:serverToken |
POSTMARK_API_KEY |
Required. From Postmark → your server → API Tokens. |
| Account API Token | settings:accountToken |
POSTMARK_ACCOUNT_TOKEN |
Optional. Enables the live sender-signature picker. From Postmark → Account → API Tokens. |
| Default Sender | settings:defaultSender |
POSTMARK_SENDER_ADDRESS |
Required. Must be a verified sender signature. |
| Force Default Sender | settings:forceSender |
— | Always use the Default Sender regardless of caller-supplied from. |
| Message Stream | settings:defaultStream |
POSTMARK_STREAM_NAME |
Defaults to outbound. Live-picker dropdown. |
| Track Opens | settings:trackOpens |
— | Maps to Postmark TrackOpens. |
| Track Links | settings:trackLinks |
— | None / HtmlOnly / HtmlAndText / TextOnly. |
| Force HTML | settings:forceHtml |
— | Wrap text-only messages in minimal HTML so links render in all clients. |
| Default Tag | settings:defaultTag |
— | Postmark Tag added to every send when "Auto-tag with source" is off. |
| Auto-tag with Source | settings:autoTagSource |
— | Postmark Tag = source identifier (system, plugin:newsletter, …). |
| Include Source as Metadata | settings:metadataSource |
— | Adds Metadata.source = "<source>" to every send. |
| Retry on Transient Errors | settings:retryEnabled |
— | 3 attempts with exponential backoff. Default on. |
Resolution order: KV → environment variable → built-in default. The admin UI labels each field with its source.
After saving the token, click Make active provider. EmDash will start routing all transactional email — magic-links, invites, password resets, plugin emails — through Postmark.
The settings page displays a webhook URL like:
https://your-site.example.com/_emdash/api/plugins/postmark/webhook?key=<secret>
Paste it into Postmark → Servers → your server → Streams → your stream → Webhooks. Enable the Delivery, Bounce, Spam Complaint, Open, and Click events you care about.
What you get:
- Delivery rows transition
sent → deliveredonce Postmark accepts the message at the recipient MX. - Bounces and spam complaints update the row status with the bounce type and description.
- Opens and clicks are aggregated on the row (
opens,clicks,firstOpenAt,lastClickedUrl).
The Regenerate webhook secret button rotates the secret. The old URL stops working immediately — update Postmark's webhook config when you rotate.
Security note: Postmark webhooks are not signed (the documented options are URL-embedded Basic Auth or a shared secret in the URL). The plugin uses a 192-bit URL-safe random secret stored in
state:webhookSecret. Bad keys get a401with no body. Treat the URL as a credential.
Other plugins can send email via the standard EmDash email pipeline once Postmark is the active provider:
import { definePlugin } from "emdash";
export default definePlugin({
capabilities: ["email:send"],
hooks: {
"content:afterPublish": async (event, ctx) => {
await ctx.email!.send({
to: "team@example.com",
subject: `Just published: ${event.content.title}`,
text: `${event.content.title} is live.`,
});
},
},
});If autoTagSource is on, the email lands in Postmark with Tag = "plugin:<your-id>" so you can slice metrics by sender.
When Auto-tag with source is on, every email is tagged with its EmDash source:
| Source | Postmark Tag |
|---|---|
| EmDash core (magic links, invites, password resets) | system |
| Plugin emails | plugin:<plugin-id> |
| The "Send test email" button | plugin:test |
This lets you filter the Postmark Activity screen, Deliverability stats, and Outbound webhooks by source. Combine with Include Source as Metadata for richer Postmark searches.
| Postmark Error | Plugin behavior | Fix |
|---|---|---|
10 (Invalid token) |
Non-transient, logged + thrown | Re-check the Server API Token. Make sure it's the server token, not the account token. |
300 (Invalid sender) |
Non-transient, logged + thrown | Add and verify the sender in Postmark → Sender Signatures, then update Default Sender. |
406 (Inactive recipient) |
Non-transient | Recipient is suppressed. Reactivate in Postmark → Suppressions. |
100 (Maintenance) |
Transient — retried | Postmark window — usually resolves automatically. |
| HTTP 429 | Transient — retried | Rate-limited; the retry helper backs off. Increase your Postmark plan if persistent. |
| HTTP 5xx | Transient — retried | Postmark service issue. The retry helper handles short outages. |
The Recent deliveries table on the settings page shows the last 25 attempts with status badges, source, stream, opens, and timestamps.
Yes. The plugin is shipped in EmDash's standard format (no Node built-ins). It declares only hooks.email-transport:register and network:request capabilities, with allowedHosts: ["api.postmarkapp.com"]. Sandboxed installs see a capability consent dialog listing exactly that.
Postmark has two token types:
- Server tokens — scoped to a single Postmark "server" (which is really an outbound mail stream). Used for sending email and managing message streams.
- Account tokens — scoped to your whole Postmark account. Used to enumerate sender signatures (
/senders).
The plugin needs a server token to send. The account token is optional — supply it only if you want the Default Sender dropdown populated automatically.
The plugin currently exposes one Default Sender. Per-source routing rules and multi-server support are on the roadmap.
Yes — EmDash's email:deliver hook is exclusive, so installing Postmark and clicking Make active provider swaps providers atomically. The previous provider's plugin can stay installed (it just stops receiving traffic).
The retry helper handles transient outages. For longer outages, you'll see failed rows in the delivery log. Email pipeline errors propagate to callers (e.g., the user-invite endpoint will fail), so the operator sees the issue.
git clone https://github.com/drudge/emdash-plugin-postmark
cd emdash-plugin-postmark
pnpm install
pnpm test # run vitest
pnpm test:coverage # with coverage report
pnpm typecheck # tsc --noEmit
pnpm build # produce dist/ via tsdownThe plugin runs against emdash >= 0.7.0. Tests use vitest with mocked storage / KV / fetch — no live Postmark account required for CI.
To run the plugin against a local EmDash dev site, point a workspace path dependency at this repo's root and add postmarkPlugin() to your astro.config.mjs.
MIT © Nicholas Penree