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.
1. Server Identity
Section titled “1. Server Identity”| Property | Value |
|---|---|
| Server name (Postmark Console) | FreeKanbanTool |
| Postmark account | PostmarkProd (the production account) |
| Server color | Green (cosmetic; used to locate the server quickly in the Postmark Console) |
| Sending sub-domain | freekanban.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.
2. From: Address Constraints
Section titled “2. From: Address Constraints”Permitted sender addresses
Section titled “Permitted sender addresses”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.comnotifications@freekanban.arda.ardamails.com<local>@freekanban.arda.ardamails.comSending 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: and Return-Path: constraints
Section titled “Reply-To: and Return-Path: constraints”Reply-To:— any valid email address the Free Kanban Tool owns. Set per-message via theReplyTofield on the PostmarkPOST /emailpayload (or thereplyTofield on theEmailMessageshape in § 5.6). Independent ofFrom:; not subject to Postmark sender verification; does not affect DKIM, DMARC, or deliverability. Common pattern isFrom: noreply@freekanban.arda.ardamails.compaired withReply-To: support@arda.cardsso recipients composing a reply land in a real human-monitored mailbox.- Multiple addresses:
ReplyToaccepts a comma-separated list, e.g."support@arda.cards, oncall@arda.cards". - Cross-domain “via” indicator: when
Reply-Tois at a different domain fromFrom:(e.g.From:atarda.ardamails.com,Reply-To:atarda.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 defaultFrom:) bounce because no inbound mailbox exists; settingReply-Tois the supported way to route replies somewhere useful.
- Multiple addresses:
Return-Path:— Postmark controls this automatically via the Return-Path CNAME deployed by the Phase 3 CDK stack. The CNAME atpm-bounces.freekanban.arda.ardamails.comresolves topm.mtasv.net(Postmark’s bounce handling infrastructure). Do not attempt to overrideReturn-Pathin the API call; Postmark sets it based on the server configuration and the deployed CNAME.
3. Resolving the Server Token
Section titled “3. Resolving the Server Token”3.1 Local / operator one-shot
Section titled “3.1 Local / operator one-shot”For a quick manual test, resolve the token from the Arda-CorporateOAM vault (requires
DesktopAuth or a service-account scoped to that vault):
op read 'op://Arda-CorporateOAM/Free-Kanban-Generator-Postmark-Server/credential'3.2 Application runtime
Section titled “3.2 Application runtime”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");}3.3 Build-time / CI
Section titled “3.3 Build-time / CI”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:
op run \ --env-file .env.postmark.tpl \ -- node dist/send-test.jsWhere .env.postmark.tpl contains:
POSTMARK_SERVER_TOKEN=op://Arda-CorporateOAM/Free-Kanban-Generator-Postmark-Server/credentialSet 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).
4. Operator curl Example
Section titled “4. Operator curl Example”Verify the server is live and the token resolves before writing integration code:
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.
5. TypeScript / Node.js Integration
Section titled “5. TypeScript / Node.js Integration”The Arda codebase uses the official postmark npm package. Install it alongside the bundled types:
npm install postmarkNo separate @types/postmark package is needed; types ship with the package.
5.1 Client construction
Section titled “5.1 Client construction”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.
5.2 Sending a transactional email
Section titled “5.2 Sending a transactional email”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, };}5.3 Sending a templated email
Section titled “5.3 Sending a templated email”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).
5.4 Error handling
Section titled “5.4 Error handling”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}`); }}5.5 Idempotency and retry policy
Section titled “5.5 Idempotency and retry policy”| HTTP status | ErrorCode | Retry? | Notes |
|---|---|---|---|
| 200 | 0 | No | Success |
| 401 | — | No | Wrong token; fix the credential, do not retry |
| 422 | 405 | No | Sender not verified; operator action required |
| 422 | 300 | No | Invalid address syntax; fix the input |
| 422 | 406 | No | Inactive recipient (hard bounce); do not re-send |
| 429 | — | Yes — with backoff | Rate limited; read Retry-After header; exponential backoff recommended |
| 500 | — | Yes — with caution | Transient 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.
5.6 Recommended integration boundary
Section titled “5.6 Recommended integration boundary”Expose the email-sending capability through a single typed function that hides the Postmark SDK dependency:
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. Verification
Section titled “6. Verification”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:
-
In Gmail: open the message, click the three-dot menu, and select “Show original”.
-
Look for the
DKIM-Signatureheader. It should included=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>;... -
Also check
Authentication-Resultsat 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:
dig TXT <selector>._domainkey.freekanban.arda.ardamails.comThe 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”- Log in to https://account.postmarkapp.com under the
PostmarkProdaccount. - Open the
FreeKanbanToolserver (green). - 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.
7. Bounce and Spam-Complaint Handling
Section titled “7. Bounce and Spam-Complaint Handling”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:
- In the
FreeKanbanToolserver, click Email Streams and select theoutboundstream. - Bounced messages appear with type (
hardorsoft) and the bounce reason from the receiving MTA. - 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):
| Limit | Default | Notes |
|---|---|---|
| Sending rate | 300 messages/minute | Verify current value in the Postmark Console under account settings; the limit can be raised by contacting Postmark support |
| Maximum message size | 10 MB | Includes all attachments |
| Recipients per API call | 50 (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.
9. Troubleshooting
Section titled “9. Troubleshooting”| Symptom | Likely cause | Resolution |
|---|---|---|
| HTTP 401 | Token wrong, expired, or fetched from the wrong vault | Run 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 405 | From: address not on a verified Sender Signature | Confirm 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 300 | Invalid email address syntax in a recipient or sender field | Check the To/Cc/Bcc addresses for syntax errors. |
HTTP 422, ErrorCode 406 | Recipient hard-bounced previously | Postmark suppresses the address automatically. Remove or replace the recipient. |
| HTTP 429 | Rate limit exceeded | Back off; read the Retry-After header. Do not retry immediately. |
dkim=fail at recipient | DKIM DNS records not propagated, or DKIM selector mismatch | Run 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 errors | Token not yet written, or wrong vault / item name | Re-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 recipient | SPF record missing or propagation lag | Confirm 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. |
10. References
Section titled “10. References”| Document | Description |
|---|---|
| Postmark Service Overview | Account topology, credential storage model, drift cadence |
| Phase 1 Operator Runbook | Account-level provisioning steps (Postmark accounts, 1Password items, GHA secret) |
| Postmark API Observations | Authentication models, error model, idempotency, retry conventions, version-pin assumptions |
| Phase 3 Decision Log — DQ-R1-007 | Vault separation for the Free Kanban Tool server token |
| Phase 3 Decision Log — DQ-R1-009 | Parent-zone verification (leaf sub-domains inherit DKIM) |
| Phase 3 Decision Log — DQ-R1-013 | Phase A failure ordering for the server-token write |
| Phase 3 Operator Domain Verification Checklist | Manual Postmark Console steps to verify DKIM and Return-Path |
| Phase 5b Email Module | Future durable webhook integration (bounce / spam-complaint handling) |
| Postmark API Reference — Server API | Official Postmark documentation for POST /email |
platform/one-password.ts | OnePasswordItem, parseOpReference() — typed 1Password references |
Copyright: © Arda Systems 2025-2026, All rights reserved