Skip to content

Search docs

Jump between documentation pages.

Browse docs

Send email from DaloyJS with Postmark

Postmark is a transactional-first email provider known for high inbox placement and detailed delivery analytics. This guide uses the official postmark SDK. Read the email overview first: it defines the shared EmailSender, emailPlugin(), headerSafe, escapeHtml(), and the email.send / email.suppress jobs used below.

Send through the postmark SDK
Route handlerServerClientPostmark APIWebhooks
  1. 01requestRoute handlerServerClientclient.sendEmail({ From, To, Subject, MessageStream })MessageStream outbound = transactional
  2. 02requestServerClientPostmark APIPOST /emailheader X-Postmark-Server-Token
  3. 03responsePostmark APIRoute handler{ ErrorCode, MessageID } or a typed errorreturn { id: MessageID }; 406 = inactive recipient
  4. 04asyncPostmark APIWebhooksdelivery, bounce, spam-complaint eventsno signatures: Basic Auth in the URL
The SDK throws typed errors for API failures (InactiveRecipientsError for a deactivated address) and returns the MessageID on success. Pick the transactional or a broadcast MessageStream per send.

1. Provision

  1. Create a Postmark server under Servers -> New server, then open it and copy the Server API Token from API Tokens. Use one server (and token) per environment.
  2. For production, configure a Sender Domain with DKIM and the custom Return-Path CNAME so SPF aligns with your From: domain for DMARC. A single-address Sender Signature is for testing.
  3. Decide which Message Stream each email uses. Every server has a default transactional stream with the ID outbound. Bulk and marketing mail belongs in a separate broadcast stream that you add to the server; use the stream ID shown on the server's Message Streams tab. Keeping them apart protects transactional reputation (see deliverability).

2. Install

bash
pnpm add postmark

3. Environment variables

.envdotenv
POSTMARK_SERVER_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
POSTMARK_STREAM=outbound
POSTMARK_WEBHOOK_USER=postmark
POSTMARK_WEBHOOK_PASSWORD=a-long-random-string-from-your-secret-store
EMAIL_FROM="Acme <no-reply@mail.acme.example.com>"

Add POSTMARK_SERVER_TOKEN: z.string().min(1), POSTMARK_STREAM: z.string().min(1), POSTMARK_WEBHOOK_USER: z.string().min(1), and POSTMARK_WEBHOOK_PASSWORD: z.string().min(32) to the overview's defineConfig() schema.

4. Sender

src/email/postmark.tsts
import { ServerClient } from "postmark";
import type { EmailSender } from "../plugins/email.ts";
import { config } from "../config.ts";

export const postmark = new ServerClient(config.POSTMARK_SERVER_TOKEN, {
  timeout: 10, // seconds; the SDK default is 60
});

export const sender: EmailSender = {
  async send(msg) {
    const res = await postmark.sendEmail({
      From: config.EMAIL_FROM,
      To: msg.to,
      ReplyTo: msg.replyTo,
      Subject: msg.subject,
      TextBody: msg.text,
      HtmlBody: msg.html,
      Headers: msg.headers
        ? Object.entries(msg.headers).map(([Name, Value]) => ({ Name, Value }))
        : undefined,
      MessageStream: config.POSTMARK_STREAM,
    });
    // The SDK throws typed errors on failure; keep a defensive check anyway.
    if (res.ErrorCode !== 0) throw new Error(`Postmark error ${res.ErrorCode}`);
    return { id: res.MessageID };
  },
};

Postmark has no idempotency key on its send API, so queue sends through the overview's email.send job and do not auto-retry a timed-out send.

5. Use it in a route

Receipts come from an authenticated user asking about their order. The client sends only the order id; the server checks ownership, then builds the recipient and body from the stored order:

src/routes/receipts.tsts
import { z } from "zod";
import { App, NotFoundError, UnauthorizedError, jobIdempotencyKey } from "@daloyjs/core";
import { escapeHtml } from "../plugins/email.ts";
import { sendGuards } from "../email/guards.ts";

export function receiptRoutes(app: App) {
  app.post(
    "/orders/:orderId/receipt",
    {
      operationId: "emailReceipt",
      hooks: sendGuards, // auth + per-IP + per-user limits, see the overview
      request: { params: z.object({ orderId: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/) }) },
      responses: {
        202: { description: "Queued", body: z.object({ jobId: z.string() }) },
      },
    },
    async ({ params, state }) => {
      const sub = state.user?.sub;
      if (!sub) throw new UnauthorizedError();
      const order = await findOrderForUser(params.orderId, sub);
      if (!order) throw new NotFoundError(); // same answer for "missing" and "not yours"
      const total = (order.totalCents / 100).toFixed(2);
      const { job } = await app.jobs!.enqueue({
        name: "email.send",
        payload: {
          to: order.customerEmail,
          subject: `Receipt for order ${order.id}`,
          text: `Thanks ${order.customerName}. Total: ${total} ${order.currency}`,
          html: `<p>Thanks ${escapeHtml(order.customerName)}.</p><p>Total: <strong>${total} ${escapeHtml(order.currency)}</strong></p>`,
        },
        idempotencyKey: jobIdempotencyKey({ name: "email.send", key: `receipt-${order.id}` }),
      });
      return { status: 202, body: { jobId: job.id } };
    },
  );
}

declare function findOrderForUser(
  orderId: string,
  userId: string,
): Promise<{ id: string; customerEmail: string; customerName: string; totalCents: number; currency: string } | null>;

Server-side templates

Create a template in Templates (Mustachio syntax), then send it by TemplateAlias. Mustachio HTML-escapes double-brace values; keep user values out of the triple-brace raw form (check Postmark's template docs if you rely on it):

ts
await postmark.sendEmailWithTemplate({
  From: config.EMAIL_FROM,
  To: order.customerEmail, // looked up server-side
  TemplateAlias: "order-receipt",
  TemplateModel: { name: order.customerName, orderId: order.id, total },
  MessageStream: "outbound",
});

Batch sending

Use postmark.sendEmailBatch([...]) or postmark.sendEmailBatchWithTemplates([...]) to send up to 500 messages per request. The response is an array with one result per message, so inspect per-recipient errors.

Errors and inactive recipients

The SDK maps API failures to typed errors exported as Errors: InvalidAPIKeyError (401), ApiInputError (422), RateLimitExceededError (429), InternalServerError (500), ServiceUnavailablerError (503, spelled that way in the SDK), and UnknownError. A 422 with Postmark error code 406 becomes InactiveRecipientsError: the address hard-bounced, complained, or unsubscribed, and Postmark refuses to send to it. Its recipients property lists the addresses. Retrying will never succeed, so mark the address and stop:

ts
import { Errors } from "postmark";
import { JobFatalError } from "@daloyjs/core";

// Inside the overview's "email.send" job handler:
try {
  await sender.send({ ...msg, signal });
} catch (err) {
  if (err instanceof Errors.InactiveRecipientsError) {
    for (const email of err.recipients) await markUndeliverable(email.toLowerCase(), "inactive");
    throw new JobFatalError("Postmark: inactive recipient"); // dead-letter, no retries
  }
  throw err; // transient: the worker retries with backoff
}

From a route handler, never return these messages to the client. Log them and throw a generic error (never leak provider errors).

Webhooks: bounces and complaints

Postmark does not sign webhooks. Protect the endpoint with HTTPS plus Basic Auth by putting the credentials in the webhook URL (https://postmark:<password>@api.acme.example.com/webhooks/postmark), and check them with core's basicAuth() and constant-time timingSafeEqual(). Postmark also publishes its webhook source IPs on IPs for Firewalls; add them as an extra layer with ipRestriction() if your topology exposes the real client IP, but do not rely on IPs alone.

src/routes/postmark-webhook.tsts
import { z } from "zod";
import { App, basicAuth, jobIdempotencyKey, readBodyLimited, safeJsonParse, timingSafeEqual } from "@daloyjs/core";
import { config } from "../config.ts";

interface PostmarkEvent {
  RecordType: string; // "Bounce", "SpamComplaint", "Delivery", ...
  Type?: string; // on bounces: "HardBounce", "SoftBounce", ...
  ID?: number;
  MessageID?: string;
  Email?: string;
}

export function postmarkWebhookRoutes(app: App) {
  app.post(
    "/webhooks/postmark",
    {
      operationId: "postmarkEvents",
      hooks: basicAuth({
        realm: "postmark-webhooks",
        verify: (user, pass) => {
          // Evaluate both so timing does not reveal which half was wrong.
          const userOk = timingSafeEqual(user, config.POSTMARK_WEBHOOK_USER);
          const passOk = timingSafeEqual(pass, config.POSTMARK_WEBHOOK_PASSWORD);
          return userOk && passOk;
        },
      }),
      responses: {
        200: { description: "Accepted", body: z.object({ ok: z.literal(true) }) },
      },
    },
    async ({ request }) => {
      const raw = new TextDecoder().decode(await readBodyLimited(request, 256 * 1024));
      const event = safeJsonParse(raw) as PostmarkEvent | undefined;
      const hard =
        (event?.RecordType === "Bounce" && event.Type === "HardBounce") ||
        event?.RecordType === "SpamComplaint";
      if (event && hard && event.Email && event.ID !== undefined) {
        await app.jobs!.enqueue({
          name: "email.suppress",
          payload: { email: event.Email, reason: event.RecordType },
          // Postmark retries reuse the same record ID.
          idempotencyKey: jobIdempotencyKey({ name: "email.suppress", key: `pm-${event.RecordType}-${event.ID}` }),
        });
      }
      return { status: 200, body: { ok: true as const } }; // ack fast
    },
  );
}

Postmark retries on 5xx, 408, 429, and network failures, but drops the event on any other 4xx, so a wrong password silently loses events. Watch the webhook's activity in the Postmark UI after you rotate credentials.

Suppression

Postmark marks a recipient inactive after a hard bounce, a spam complaint, or an unsubscribe, tracked per message stream. Sends to an inactive address fail with error 406 as shown above. Reactivate an address from the server's Suppressions view or the Suppressions API once the cause is fixed, and mirror the state into your own undeliverable flag through the webhook.

Runtimes

Postmark SDK v5 (July 2026) replaced axios with the global fetch and accepts a custom fetch in its options, so you can route it through the overview's guarded client: new ServerClient(token, { fetch: providerFetch }). We have not verified v5 on Cloudflare Workers. The conservative edge path is the REST endpoint through resilientFetch + fetchGuard, which never retries the POST:

src/email/postmark-workers.tsts
import { env } from "cloudflare:workers";
import type { EmailSender } from "../plugins/email.ts";
import { providerFetch } from "./fetch.ts"; // allowHosts: ["api.postmarkapp.com"]

export const sender: EmailSender = {
  async send(msg) {
    const res = await providerFetch("https://api.postmarkapp.com/email", {
      method: "POST",
      headers: {
        accept: "application/json",
        "content-type": "application/json",
        "x-postmark-server-token": env.POSTMARK_SERVER_TOKEN,
      },
      signal: msg.signal,
      body: JSON.stringify({
        From: env.EMAIL_FROM,
        To: msg.to,
        ReplyTo: msg.replyTo,
        Subject: msg.subject,
        TextBody: msg.text,
        HtmlBody: msg.html,
        MessageStream: "outbound",
      }),
    });
    const out = (await res.json()) as { ErrorCode: number; MessageID?: string };
    if (!res.ok || out.ErrorCode !== 0) throw new Error(`Postmark error ${out.ErrorCode}`);
    return { id: out.MessageID ?? "" };
  },
};

See also Resend, Mailgun, and the email integrations overview.

Edit this page on GitHub

Last updated