Skip to content

Runbook: Sending Email via the Free Kanban Tool Postmark Server

Last Verified: 2026-05-11 Phase: 3 (Corporate Updates) Account approval status: arda-prod (the PostmarkProd account this server runs under) was manually approved by Postmark Support on 2026-05-11 (ticket #11236087, welcome email “Welcome to the Postmark family!”). The account is no longer in sandbox mode; sends to arbitrary recipients are now permitted. The arda-nonprod account (used by dev / stage partitions in Phase 4) remains in approval-pending status as of the same date — its first partition Sender Signature is the unlocking step.

This runbook is the canonical reference for engineers integrating the Free Kanban Tool with its dedicated Postmark sending server. It covers server identity, token resolution, From: address constraints, TypeScript SDK and curl recipes, and troubleshooting.

It assumes the Phase 3 Corporate stack has been deployed and the Phase 3 Corporate CLI Phase A has run. If Phase A has not yet run, the 1Password item does not yet exist — see Phase 3 phases.md for the deploy sequence.


PropertyValue
Server name (Postmark Console)FreeKanbanTool
Postmark accountPostmarkProd (the production account)
Server colorGreen (cosmetic; used to locate the server quickly in the Postmark Console)
Sending sub-domainfreekanban.arda.ardamails.com
Sender Signature (verified at)arda.ardamails.com (parent zone; leaves inherit DKIM per DQ-R1-009)
Account-level token (for Console/Admin API)op://Arda-SystemsOAM/Postmark-Prod/credential
Server token (for sending)op://Arda-CorporateOAM/Free-Kanban-Generator-Postmark-Server/credential

The server token is the per-server credential that authenticates POST /email calls. It is stored in the Arda-CorporateOAM vault — separate from the account-level token in Arda-SystemsOAM — so a compromise of the CI service-account (OP_SERVICE_ACCOUNT_TOKEN, scoped to Arda-SystemsOAM) does not expose the sending credential. This vault separation is recorded in DQ-R1-007.

The server token is written to 1Password by corporate-cli prepare free-kanban (Phase A) and is never stored in CDK context, GitHub Actions secrets, or environment files.


The verified Sender Signature is the parent zone arda.ardamails.com. Postmark verifies DKIM at the parent; all sub-domains that share the same DKIM selector inherit the verified status.

The Free Kanban Tool’s sending sub-domain is freekanban.arda.ardamails.com. Any local-part is acceptable:

noreply@freekanban.arda.ardamails.com
notifications@freekanban.arda.ardamails.com
<local>@freekanban.arda.ardamails.com

Sending from <local>@arda.ardamails.com (the parent apex) is also technically valid because the Sender Signature covers the parent. Use the freekanban. sub-domain to keep corporate-tooling traffic clearly scoped.

Do not use addresses outside the arda.ardamails.com hierarchy. Postmark will reject the send with ErrorCode 405 (sender signature not found) — see Section 7.

  • Reply-To: — any valid email address the Free Kanban Tool owns. Set per-message via the ReplyTo field on the Postmark POST /email payload (or the replyTo field on the EmailMessage shape in § 5.6). Independent of From:; not subject to Postmark sender verification; does not affect DKIM, DMARC, or deliverability. Common pattern is From: noreply@freekanban.arda.ardamails.com paired with Reply-To: support@arda.cards so recipients composing a reply land in a real human-monitored mailbox.
    • Multiple addresses: ReplyTo accepts a comma-separated list, e.g. "support@arda.cards, oncall@arda.cards".
    • Cross-domain “via” indicator: when Reply-To is at a different domain from From: (e.g. From: at arda.ardamails.com, Reply-To: at arda.cards), Gmail and some other clients display a “via arda.ardamails.com” annotation next to the From line. Cosmetic; does not affect deliverability. To avoid the indicator, keep both addresses at the same domain.
    • Inbound replies: for two-way mail (system processing replies), Postmark’s separate Inbound feature is required and is not configured for this server in Phase 3 — see § 7 and the Phase 5b email-module forward reference. Today, replies sent to noreply@freekanban.arda.ardamails.com (the default From:) bounce because no inbound mailbox exists; setting Reply-To is the supported way to route replies somewhere useful.
  • Return-Path: — Postmark controls this automatically via the Return-Path CNAME deployed by the Phase 3 CDK stack. The CNAME at pm-bounces.freekanban.arda.ardamails.com resolves to pm.mtasv.net (Postmark’s bounce handling infrastructure). Do not attempt to override Return-Path in the API call; Postmark sets it based on the server configuration and the deployed CNAME.

For a quick manual test, resolve the token from the Arda-CorporateOAM vault (requires DesktopAuth or a service-account scoped to that vault):

Terminal window
op read 'op://Arda-CorporateOAM/Free-Kanban-Generator-Postmark-Server/credential'

The platform delivers secrets to application pods via the External Secrets Operator (ESO). The operations component never calls Secrets Manager or 1Password at runtime; secrets are injected as environment variables at pod startup. See workspace memory feedback_eso_pattern for the canonical description.

Inside CDK code, use the OnePasswordItem type and parseOpReference() helper from platform/one-password.ts in the infrastructure repository:

import { FREE_KANBAN_POSTMARK_ITEM, parseOpReference } from "../platform/one-password";
// At deploy time — resolve the reference and write to Secrets Manager or ESO:
const { vault, title, field } = parseOpReference(FREE_KANBAN_POSTMARK_ITEM.reference);
// vault = "Arda-CorporateOAM", title = "Free-Kanban-Generator-Postmark-Server", field = "credential"

FREE_KANBAN_POSTMARK_ITEM is the canonical typed reference; do not inline the op:// string anywhere except one-password.ts.

At runtime the application reads the token from the injected environment variable (name determined by the ESO configuration in the Corporate stack). A pattern used across the codebase:

const serverToken = process.env.POSTMARK_SERVER_TOKEN;
if (!serverToken) {
throw new Error("POSTMARK_SERVER_TOKEN is not set — check ESO configuration");
}

For CI scripts that need the token (e.g., smoke tests against a staging instance), use the 1Password CLI with a service-account token scoped to Arda-CorporateOAM:

Terminal window
op run \
--env-file .env.postmark.tpl \
-- node dist/send-test.js

Where .env.postmark.tpl contains:

Terminal window
POSTMARK_SERVER_TOKEN=op://Arda-CorporateOAM/Free-Kanban-Generator-Postmark-Server/credential

Set OP_SERVICE_ACCOUNT_TOKEN to a token scoped to Arda-CorporateOAM (not the CI-wide token in Arda-SystemsOAM, which does not have access to that vault).


Verify the server is live and the token resolves before writing integration code:

Terminal window
SERVER_TOKEN=$(op read 'op://Arda-CorporateOAM/Free-Kanban-Generator-Postmark-Server/credential')
curl --silent --fail --show-error \
-X POST https://api.postmarkapp.com/email \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Server-Token: ${SERVER_TOKEN}" \
-d '{
"From": "noreply@freekanban.arda.ardamails.com",
"To": "you@example.com",
"Subject": "Free Kanban Tool — test send",
"TextBody": "This is a connectivity test from the Free Kanban Tool Postmark server.",
"HtmlBody": "<p>This is a connectivity test from the Free Kanban Tool Postmark server.</p>",
"MessageStream": "outbound"
}'

Expected success response (HTTP 200):

{
"To": "you@example.com",
"SubmittedAt": "2026-05-07T12:00:00.0000000-05:00",
"MessageID": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ErrorCode": 0,
"Message": "OK"
}

Note: MessageStream defaults to "outbound" and can be omitted; including it is explicit and harmless.


The Arda codebase uses the official postmark npm package. Install it alongside the bundled types:

Terminal window
npm install postmark

No separate @types/postmark package is needed; types ship with the package.

import * as postmark from "postmark";
function buildPostmarkClient(serverToken: string): postmark.ServerClient {
return new postmark.ServerClient(serverToken);
}

Construct the client once at application startup. The ServerClient is thread-safe across concurrent sends.

import * as postmark from "postmark";
interface Message {
to: string;
subject: string;
textBody: string;
htmlBody: string;
replyTo?: string;
}
interface SendResult {
messageId: string;
submittedAt: string;
}
async function sendMail(
client: postmark.ServerClient,
message: Message,
): Promise<SendResult> {
const response = await client.sendEmail({
From: "noreply@freekanban.arda.ardamails.com",
To: message.to,
Subject: message.subject,
TextBody: message.textBody,
HtmlBody: message.htmlBody,
ReplyTo: message.replyTo,
MessageStream: "outbound",
});
return {
messageId: response.MessageID,
submittedAt: response.SubmittedAt,
};
}

Postmark Templates are defined in the Postmark Console (or via the Template API) and referenced by alias or numeric ID at send time. Template management is a separate operational concern; this section covers only the API call shape.

async function sendTemplatedMail(
client: postmark.ServerClient,
templateAlias: string,
templateModel: Record<string, unknown>,
to: string,
): Promise<SendResult> {
const response = await client.sendEmailWithTemplate({
TemplateAlias: templateAlias,
TemplateModel: templateModel,
From: "noreply@freekanban.arda.ardamails.com",
To: to,
MessageStream: "outbound",
});
return {
messageId: response.MessageID,
submittedAt: response.SubmittedAt,
};
}

Template variables are merged server-side by Postmark. The templateModel object must match the variable names declared in the template. If a required variable is absent, Postmark returns ErrorCode 1101 (template render error).

Postmark errors surface as instances of postmark.Errors.PostmarkError (or subclasses). Always wrap sends in a try/catch and inspect error.code:

import * as postmark from "postmark";
async function sendMailSafe(
client: postmark.ServerClient,
message: Message,
): Promise<SendResult | null> {
try {
return await sendMail(client, message);
} catch (error) {
if (error instanceof postmark.Errors.PostmarkError) {
handlePostmarkError(error);
}
throw error;
}
}
function handlePostmarkError(error: postmark.Errors.PostmarkError): never {
switch (error.code) {
case 401:
// Wrong or expired server token. Verify the token resolves from 1Password
// and that the ESO configuration is up to date.
throw new Error(`Postmark authentication failed (401): check server token — ${error.message}`);
case 405:
// From: address not verified. The sender domain is not a verified Sender
// Signature in the PostmarkProd account. Check the Postmark Console:
// https://account.postmarkapp.com/signature_domains
throw new Error(`Postmark sender not verified (405): ${error.message}`);
case 300:
// Invalid email address syntax in To:, Cc:, Bcc:, or From:
throw new Error(`Postmark invalid address (300): ${error.message}`);
case 429:
// Rate limit exceeded. Honor the Retry-After header and back off.
// Do NOT auto-retry on 422; see below.
throw new Error(`Postmark rate limit (429): ${error.message}`);
case 500:
default:
// Postmark server-side error. Log and alert; do not retry automatically.
throw new Error(`Postmark server error (${error.code}): ${error.message}`);
}
}
HTTP statusErrorCodeRetry?Notes
2000NoSuccess
401NoWrong token; fix the credential, do not retry
422405NoSender not verified; operator action required
422300NoInvalid address syntax; fix the input
422406NoInactive recipient (hard bounce); do not re-send
429Yes — with backoffRate limited; read Retry-After header; exponential backoff recommended
500Yes — with cautionTransient Postmark error; retry once after 5 s then alert

A message submitted with MessageID is not idempotent from Postmark’s perspective — re-sending with a new API call creates a new message even if the content is identical. Integrate at the application level by checking whether a send has already been recorded before calling the API.

Expose the email-sending capability through a single typed function that hides the Postmark SDK dependency:

email/postmark-sender.ts
import * as postmark from "postmark";
export interface EmailMessage {
readonly to: string;
readonly subject: string;
readonly htmlBody: string;
readonly textBody: string;
readonly replyTo?: string;
}
export interface EmailReceipt {
readonly messageId: string;
readonly submittedAt: string;
}
export interface EmailSender {
send(message: EmailMessage): Promise<EmailReceipt>;
}
export function createPostmarkSender(serverToken: string): EmailSender {
const client = new postmark.ServerClient(serverToken);
const fromAddress = "noreply@freekanban.arda.ardamails.com";
return {
async send(message: EmailMessage): Promise<EmailReceipt> {
const response = await client.sendEmail({
From: fromAddress,
To: message.to,
Subject: message.subject,
HtmlBody: message.htmlBody,
TextBody: message.textBody,
ReplyTo: message.replyTo,
MessageStream: "outbound",
});
return { messageId: response.MessageID, submittedAt: response.SubmittedAt };
},
};
}

Callers depend on EmailSender, not on postmark.ServerClient. This boundary makes the Postmark SDK swappable in tests and keeps the error-handling concern isolated.


6.1 Confirming DKIM signing in delivered mail

Section titled “6.1 Confirming DKIM signing in delivered mail”

After sending a test message, inspect the raw headers at the recipient:

  1. In Gmail: open the message, click the three-dot menu, and select “Show original”.

  2. Look for the DKIM-Signature header. It should include d=arda.ardamails.com (Postmark signs with the parent domain’s key, not the sending sub-domain’s key):

    DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
    d=arda.ardamails.com; s=<selector>;
    ...
  3. Also check Authentication-Results at the recipient’s mail server:

    Authentication-Results: mx.google.com;
    dkim=pass header.d=arda.ardamails.com;
    spf=pass smtp.mailfrom=pm-bounces.freekanban.arda.ardamails.com;
    dmarc=pass (p=QUARANTINE) header.from=freekanban.arda.ardamails.com

If dkim=fail, the DKIM DNS record may not have propagated after deploy. Confirm with:

Terminal window
dig TXT <selector>._domainkey.freekanban.arda.ardamails.com

The selector value is captured in cdk.context.json (key postmark.free-kanban.dkimSelector) after Phase A runs.

6.2 Viewing send activity in the Postmark Console

Section titled “6.2 Viewing send activity in the Postmark Console”
  1. Log in to https://account.postmarkapp.com under the PostmarkProd account.
  2. Open the FreeKanbanTool server (green).
  3. Click Activity. Each submitted message appears with delivery status, bounce events, and raw headers (click a row to expand).

The Activity tab is the primary OAM surface for per-message diagnostics.


Current state (Phase 3): bounce and spam-complaint events are not forwarded to the Free Kanban Tool application. Monitor using the Postmark Console’s Activity and Email Streams views:

  1. In the FreeKanbanTool server, click Email Streams and select the outbound stream.
  2. Bounced messages appear with type (hard or soft) and the bounce reason from the receiving MTA.
  3. Hard-bounced addresses are automatically suppressed by Postmark; subsequent sends to the same address are blocked by Postmark and return ErrorCode 406.

Future state (Phase 5b): the durable webhook integration for bounce and spam-complaint events will be implemented as part of the email module in the operations repository. Webhooks are not configured for the FreeKanbanTool server in Phase 3. For the planned architecture, see the Phase 5b description.


8. Rate Limits, Quotas, and Stream Discipline

Section titled “8. Rate Limits, Quotas, and Stream Discipline”

These defaults apply to the PostmarkProd account on the Platform plan (post-approval as of 2026-05-11):

LimitDefaultNotes
Sending rate300 messages/minuteVerify current value in the Postmark Console under account settings; the limit can be raised by contacting Postmark support
Maximum message size10 MBIncludes all attachments
Recipients per API call50 (To + Cc + Bcc combined)Split large recipient lists across multiple API calls

When the rate limit is hit, the API returns HTTP 429 with a Retry-After header. Honor the Retry-After value and apply exponential backoff before retrying. Do not retry without a delay — Postmark counts rapid retry attempts against the rate limit.

Stream discipline. The FreeKanbanTool server’s sends go through Postmark’s default Transactional Message Stream. The Free Kanban Tool’s traffic is 1-to-1, user-action-triggered, and non-promotional — it is transactional by design. Bulk / broadcast / newsletter sends are not permitted on this server and would require a separately provisioned Broadcast Message Stream per Postmark policy. See the Message Stream Discipline section in the Phase 1 operator runbook and cross-cutting design § 7a for the project-wide invariant.


SymptomLikely causeResolution
HTTP 401Token wrong, expired, or fetched from the wrong vaultRun op read 'op://Arda-CorporateOAM/Free-Kanban-Generator-Postmark-Server/credential' locally to confirm the token resolves. Re-run corporate-cli prepare free-kanban if the item is absent.
HTTP 422, ErrorCode 405From: address not on a verified Sender SignatureConfirm arda.ardamails.com is Verified in the Postmark Console at https://account.postmarkapp.com/signature_domains. If not, the Phase 3 Sender Signature verification step has not been completed — see operator-domain-verification-checklist.md.
HTTP 422, ErrorCode 300Invalid email address syntax in a recipient or sender fieldCheck the To/Cc/Bcc addresses for syntax errors.
HTTP 422, ErrorCode 406Recipient hard-bounced previouslyPostmark suppresses the address automatically. Remove or replace the recipient.
HTTP 429Rate limit exceededBack off; read the Retry-After header. Do not retry immediately.
dkim=fail at recipientDKIM DNS records not propagated, or DKIM selector mismatchRun dig TXT <selector>._domainkey.freekanban.arda.ardamails.com and compare the returned key to the Postmark Console’s DKIM settings for arda.ardamails.com. If the record is absent, the Phase 3 CDK stack has not deployed or DNS propagation is in progress (allow up to 48 hours for global propagation).
op read returns empty or errorsToken not yet written, or wrong vault / item nameRe-run corporate-cli prepare free-kanban. The item name must be exactly Free-Kanban-Generator-Postmark-Server in the Arda-CorporateOAM vault.
spf=fail at recipientSPF record missing or propagation lagConfirm dig +short TXT arda.ardamails.com includes v=spf1 include:spf.mtasv.net ~all. The Phase 3 CDK stack emits this record; if absent, the stack has not deployed.

DocumentDescription
Postmark Service OverviewAccount topology, credential storage model, drift cadence
Phase 1 Operator RunbookAccount-level provisioning steps (Postmark accounts, 1Password items, GHA secret)
Postmark API ObservationsAuthentication models, error model, idempotency, retry conventions, version-pin assumptions
Phase 3 Decision Log — DQ-R1-007Vault separation for the Free Kanban Tool server token
Phase 3 Decision Log — DQ-R1-009Parent-zone verification (leaf sub-domains inherit DKIM)
Phase 3 Decision Log — DQ-R1-013Phase A failure ordering for the server-token write
Phase 3 Operator Domain Verification ChecklistManual Postmark Console steps to verify DKIM and Return-Path
Phase 5b Email ModuleFuture durable webhook integration (bounce / spam-complaint handling)
Postmark API Reference — Server APIOfficial Postmark documentation for POST /email
platform/one-password.tsOnePasswordItem, parseOpReference() — typed 1Password references