postmark-email-best-practices
PublicRepository: activecampaign/postmark-skills
Low Risk
No security issues found
Skill manifest does not include a 'license' field. Specifying a license helps users understand usage terms.
Remediation Add 'license' field to SKILL.md frontmatter (e.g., MIT, Apache-2.0)
Description
Use when asking about email deliverability, compliance (CAN-SPAM, GDPR, CASL), transactional email design patterns, list management, testing safely, or general email best practices — provider-agnostic knowledge with Postmark-specific guidance.
Details
- author
- postmark
- version
- 1.0.0
Skill Files
# Email Best Practices Postmark has delivered billions of transactional emails over 15+ years. This skill distills that expertise into actionable guidelines for building reliable, compliant, high-deliverability email systems. ## Quick Reference | Topic | Use When | |-------|----------| | **Deliverability** | Setting up SPF/DKIM/DMARC, warming a new domain, diagnosing delivery issues | | **Compliance** | Building unsubscribe flows, handling GDPR/CAN-SPAM/CASL requirements | | **Transactional Design** | Designing welcome emails, password resets, receipts, alerts | | **List Management** | Handling bounces, suppressions, list hygiene | | **Testing** | Testing safely without hurting sender reputation | | **Sending Reliability** | Idempotency, retry logic, rate limits | ## Deliverability Fundamentals The three authentication records every sending domain must have: | Record | Purpose | Priority | |--------|---------|----------| | **SPF** | Authorizes servers to send as your domain | Required | | **DKIM** | Cryptographically signs emails to prove authenticity | Required | | **DMARC** | Policy for handling SPF/DKIM failures | Required | With Postmark, DKIM is configured automatically when you verify a sender domain. SPF and DMARC must be set up in your DNS. See [references/deliverability.md](references/deliverability.md) for DNS setup, reputation factors, and domain warm-up guidance. ## Transactional vs. Broadcast Email **Never mix transactional and broadcast email in the same sending stream.** They have different delivery characteristics, compliance requirements, and reputation profiles. | Type | Examples | Compliance | Unsubscribe Required | |------|----------|------------|---------------------| | **Transactional** | Password resets, receipts, alerts, notifications | CAN-SPAM exemption possible | No (but good practice) | | **Broadcast** | Newsletters, promotions, announcements | CAN-SPAM, GDPR, CASL apply | Yes — legally required | Postmark enforces this separation with **Message Streams** — use `outbound` for transactional, `broadcast` for marketing. See [references/compliance.md](references/compliance.md) for CAN-SPAM, GDPR, and CASL requirements. ## Transactional Email Design Good transactional emails are: - **Expected** — The recipient triggered this email - **Timely** — Sent immediately after the triggering event - **Actionable** — One clear call to action - **Plain** — Minimal design; content over decoration Common transactional email types and their essential elements: | Email Type | Must Include | Avoid | |-----------|--------------|-------| | Welcome | Product name, next step CTA, support contact | Marketing upsell on day 1 | | Password reset | Expiry time, ignore-if-not-you notice, support link | Long copy | | Receipt / Invoice | Line items, total, billing address, support | Promotional content | | Shipping notification | Tracking link, estimated delivery, items | Unrelated promotions | | Security alert | What happened, when, action required, how to secure | Panic-inducing language | See [references/transactional-design.md](references/transactional-design.md) for design patterns, copy guidelines, and HTML email best practices. ## List Health Sending to invalid, inactive, or unengaged addresses is the leading cause of deliverability problems. **Key rules:** - Remove **hard bounces** immediately and permanently - Suppress **spam complaints** immediately — never re-add - Re-permission lists older than 12–18 months before mailing - Never purchase or rent email lists - Validate addresses at the point of collection See [references/list-management.md](references/list-management.md) for suppression strategies, list hygiene schedules, and re-engagement workflows. ## Testing Safely **Never test with real addresses at consumer providers** (gmail.com, yahoo.com, etc.) — it damages sender reputation. | Method | How | Use For | |--------|-----|---------| | API test token | Use `POSTMARK_API_TEST` as your server token | Validating API calls in CI/development | | Black hole | Send to `[email protected]` | Functional testing — appears in activity | | Sandbox server | Create a dedicated sandbox server in dashboard | Full send pipeline without delivery | | Bounce testing | `[email protected]` | Testing bounce webhook handlers | See [references/testing.md](references/testing.md) for full testing setup and domain warm-up schedules. ## Sending Reliability Production email systems need idempotency keys, retry logic, and rate limit handling to avoid duplicate sends and silent failures. See [references/sending-reliability.md](references/sending-reliability.md) for idempotency patterns, retry strategies, and rate limit handling. ## Notes - Postmark is purpose-built for transactional email — use it for triggered 1:1 emails, not bulk marketing - Deliverability is not just about authentication — it's about sending wanted email to engaged recipients - A single spam complaint from a real user is more damaging than 1,000 hard bounces - Monitor your bounce rate (keep below 2%) and spam complaint rate (keep below 0.04%)
# Email Compliance
Email compliance is not optional. Violations of CAN-SPAM, GDPR, or CASL can result in significant fines and lasting damage to sender reputation.
## Transactional vs. Marketing: Why It Matters for Compliance
| Type | Examples | Compliance Burden |
|------|----------|-------------------|
| **Transactional** | Password resets, order confirmations, alerts | Lower — CAN-SPAM exemption possible |
| **Marketing / Broadcast** | Newsletters, promotions, announcements | Full — CAN-SPAM, GDPR, CASL all apply |
**Never mix types in the same Postmark Message Stream.** Use `outbound` for transactional, `broadcast` for marketing. Mixing damages deliverability and creates compliance risk.
---
## CAN-SPAM (United States)
Applies to commercial email sent to US recipients.
### Requirements
| Requirement | Detail |
|-------------|--------|
| No deceptive headers | From, To, Reply-To must accurately identify the sender |
| No deceptive subject lines | Subject must reflect the email's actual content |
| Physical postal address | Include a valid mailing address (P.O. Box acceptable) |
| Unsubscribe mechanism | Clear, working opt-out method in every commercial email |
| Honor opt-outs promptly | Process unsubscribes within 10 business days |
| No opt-out fees | Unsubscribing must be free and require only one action |
### Transactional Exemption
CAN-SPAM exempts **transactional or relationship messages** — order confirmations, password resets, account notifications — from most requirements, including the unsubscribe mandate.
To qualify, the email must be **primarily transactional**. Adding promotional content to a transactional email can void the exemption. When in doubt, include a postal address and an optional unsubscribe link anyway.
---
## GDPR (European Union)
Applies to processing personal data (including email addresses) of individuals in the EU/EEA, regardless of where your company is based.
### Key Principles
| Principle | Email Application |
|-----------|------------------|
| **Lawful basis** | You need a legal basis to email someone (consent, contract, legitimate interest) |
| **Consent for marketing** | Marketing requires explicit, freely-given, informed consent |
| **Purpose limitation** | Can't use an address collected for support to send marketing |
| **Right to erasure** | Must delete all personal data upon request |
| **Data minimization** | Collect only email data you actually need |
| **Transparency** | Inform users how their email address will be used at collection time |
### Valid GDPR Consent for Marketing
Consent must be:
- **Freely given** — not bundled with terms of service or required for account creation
- **Specific** — for the exact type of emails you'll send
- **Informed** — users know what they're agreeing to
- **Unambiguous** — explicit opt-in action (checked checkbox), never pre-ticked
**Record everything:** Timestamp, source, and wording of consent for every subscriber.
### Transactional Email Under GDPR
Sending transactional email (order confirmation, password reset) is generally justified under **performance of a contract** — explicit consent is not required. However:
- Only send emails necessary to deliver the service the user signed up for
- Do not include promotional content that would require consent
### Right to Erasure
When a user requests deletion, remove their email address (and all associated data) from your system — including from any Postmark suppressions you're maintaining.
---
## CASL (Canada)
Canada's Anti-Spam Legislation is stricter than CAN-SPAM. It applies to Commercial Electronic Messages (CEMs) sent to Canadian recipients.
### Consent Types
| Type | When It Applies | Expires |
|------|-----------------|---------|
| **Express consent** | User explicitly opted in (checked a box, confirmed via email) | Never (until withdrawn) |
| **Implied — existing business relationship** | Purchase, donation, or service inquiry within 2 years | 2 years from last transaction |
| **Implied — conspicuous publication** | Email address published publicly without opt-out | Ongoing, for role-related messages only |
### Every CEM Must Include
1. Identification of the sender (name, and whose behalf if different)
2. Mailing address plus phone, email, or web address
3. A clear, easy unsubscribe mechanism
### Key Differences from CAN-SPAM
| Aspect | CAN-SPAM | CASL |
|--------|----------|------|
| Opt-in required? | No (opt-out model) | Yes (opt-in model) |
| Implied consent | Broad | Narrow — 2-year expiry |
| Unsubscribe deadline | 10 business days | 10 business days |
| Max penalty | $16,000 per violation | $10M CAD per violation |
---
## Unsubscribe Implementation
### What Every Marketing Email Must Include
```html
<!-- In every broadcast email footer -->
<p style="font-size: 12px; color: #666; text-align: center;">
You're receiving this because you subscribed to updates from Your Company.<br>
<a href="{{unsubscribe_url}}">Unsubscribe</a> ·
<a href="{{preferences_url}}">Email preferences</a> ·
123 Main St, San Francisco, CA 94107
</p>
```
### One-Click Unsubscribe (List-Unsubscribe Header)
Gmail and Yahoo require bulk senders to support RFC 8058 one-click unsubscribe. Add these headers to broadcast sends:
```javascript
await client.sendEmail({
// ...
Headers: [
{
Name: 'List-Unsubscribe',
Value: '<mailto:[email protected]?subject=unsubscribe>, <https://yourdomain.com/unsubscribe?token={{token}}>'
},
{
Name: 'List-Unsubscribe-Post',
Value: 'List-Unsubscribe=One-Click'
}
],
MessageStream: 'broadcast'
});
```
### Processing Unsubscribes
When a user unsubscribes (via your link or Postmark's `SubscriptionChange` webhook):
1. **Suppress immediately** — do not batch-process
2. **Honor across all lists** — one unsubscribe removes from all marketing email
3. **Record it** — timestamp and source for compliance documentation
4. **Never re-add** without fresh explicit consent
5. **Confirm** — a single confirmation email is acceptable
```javascript
// Handle Postmark's SubscriptionChange webhook
function handleSubscriptionChange(event) {
if (event.SuppressSending) {
db.suppressions.upsert({
email: event.Recipient,
suppressedAt: event.ChangedAt,
reason: event.SuppressionReason,
stream: event.MessageStream
});
}
}
```
---
## Compliance Checklist
- [ ] SPF, DKIM, and DMARC configured for every sending domain
- [ ] Transactional and marketing in separate Postmark Message Streams
- [ ] Physical postal address in every marketing email
- [ ] Working unsubscribe link in every marketing email
- [ ] List-Unsubscribe headers on broadcast sends
- [ ] Consent recorded with timestamp and wording for all marketing subscribers
- [ ] Unsubscribes honored within 10 business days (aim for immediate)
- [ ] GDPR data deletion process documented and operational
- [ ] Suppression list maintained and synced with Postmark
# Email Deliverability ## Authentication Records Every sending domain must have all three authentication records configured. Missing or broken records are the most common cause of email landing in spam. ### SPF (Sender Policy Framework) SPF authorizes specific mail servers to send on behalf of your domain. Add Postmark to your SPF TXT record: ``` v=spf1 include:spf.mtasv.net ~all ``` If you already have an SPF record, add Postmark's include to it — do not create a second record: ``` v=spf1 include:existing-provider.com include:spf.mtasv.net ~all ``` **Rules:** - Only one SPF TXT record per domain — merge all senders into one record - `~all` (soft fail) is recommended over `-all` (hard fail), which can block legitimate forwarded email - SPF has a 10 DNS lookup limit — flatten includes if you're near the limit ### DKIM (DomainKeys Identified Mail) DKIM adds a cryptographic signature to every email, proving it hasn't been tampered with. When you verify a sending domain in Postmark, a DKIM CNAME is provided — add it to your DNS: ``` pm._domainkey.yourdomain.com CNAME pm.mtasv.net ``` Postmark rotates DKIM keys automatically. No manual key rotation needed. ### DMARC (Domain-based Message Authentication, Reporting, and Conformance) DMARC tells receiving servers what to do when SPF or DKIM fails, and enables reporting back to you. **Start with monitoring (safe, no impact on delivery):** ``` _dmarc.yourdomain.com TXT "v=DMARC1; p=none; rua=mailto:[email protected]" ``` **After confirming legitimate traffic passes, move to quarantine:** ``` _dmarc.yourdomain.com TXT "v=DMARC1; p=quarantine; pct=100; rua=mailto:[email protected]" ``` **Then enforce:** ``` _dmarc.yourdomain.com TXT "v=DMARC1; p=reject; pct=100; rua=mailto:[email protected]" ``` | Parameter | Description | |-----------|-------------| | `p` | Policy: `none` (monitor only), `quarantine` (send to spam), `reject` (block) | | `pct` | Percentage of failing mail the policy applies to (0–100) | | `rua` | Address for aggregate reports (daily summaries) | | `ruf` | Address for forensic reports (individual failures) | **Verification tools:** - [MXToolbox SuperTool](https://mxtoolbox.com/SuperTool.aspx) — look up SPF, DKIM, DMARC - [Mail Tester](https://www.mail-tester.com) — send a test email for a deliverability score - [Postmark's DMARC Digests](https://dmarc.postmarkapp.com) — free DMARC reporting tool --- ## Sender Reputation Authentication proves identity. Reputation determines whether your email is trusted. ### Reputation Factors | Factor | Impact | Action | |--------|--------|--------| | Bounce rate | High negative | Remove hard bounces immediately | | Spam complaint rate | Very high negative | Suppress complainers immediately | | Engagement (opens, clicks) | Positive | Send to engaged recipients; segment out inactive | | Sending consistency | Moderate positive | Consistent volume beats unpredictable spikes | | Domain age | Moderate positive | New domains need warm-up | | Content quality | Positive | Relevant, expected email is rarely marked as spam | ### Thresholds to Stay Below | Metric | Warning | Critical — Stop Sending | |--------|---------|------------------------| | Bounce rate | > 2% | > 4% | | Spam complaint rate | > 0.04% | > 0.08% | --- ## Domain Warm-up New sending domains have no reputation. Sending large volumes immediately triggers spam filters. Gradually build volume to establish trust with inbox providers. ### Postmark Recommended Warm-up Schedule | Day | Max per Day | Max per Hour | |-----|-------------|--------------| | 1 | 150 | — | | 2 | 250 | — | | 3 | 400 | — | | 4 | 700 | 50 | | 5 | 1,000 | 75 | | 6 | 1,500 | 100 | | 7 | 2,000 | 150 | After day 7, increase volume gradually — no more than 2x per week. **Warm-up best practices:** - Start with your most engaged recipients (recent signups, active users) - Spread sends evenly throughout the day — avoid hourly spikes - Monitor bounce and complaint rates daily - Pause and investigate if bounce rate exceeds 4% or complaint rate exceeds 0.08% ### Signs the Warm-up Is Working - Bounce rate stays below 2% - Complaint rate stays below 0.04% - Open rates match baseline expectations for your audience - Emails landing in inbox, not spam folder --- ## Diagnosing Deliverability Issues ### Email Landing in Spam 1. Verify SPF, DKIM, and DMARC are all passing (use MXToolbox) 2. Check bounce and complaint rates in the Postmark dashboard 3. Review list quality — old or purchased lists have high invalid/disengaged addresses 4. Check content — excessive images, spam trigger words, broken HTML 5. Check if your domain or IP is on a blocklist (MXToolbox Blacklist Check) ### Emails Not Arriving at All 1. Confirm the `From` address uses a verified domain or sender signature in Postmark 2. Check the Postmark activity log for bounce or block status 3. Review the SMTP response in the bounce dump for the recipient server's reason 4. Test with the recipient on a different domain to isolate whether it's domain-specific ### Low Open Rates 1. Check spam folder placement — send a test to an inbox monitoring service 2. Review subject line length (30–50 characters is optimal for mobile) 3. Confirm sender name is recognizable — recipients recognize names, not addresses 4. Note: Apple Mail Privacy Protection (MPP) artificially inflates open rates. Use click rate as the primary engagement signal.
# List Management
Sending to a healthy list is the single most impactful thing you can do for deliverability. Invalid, inactive, and unengaged addresses are the root cause of most deliverability problems.
## Bounce Types and Actions
| Bounce Type | Meaning | Required Action |
|-------------|---------|-----------------|
| **Hard bounce** | Permanent failure — address doesn't exist or domain is gone | Suppress immediately, permanently |
| **Soft bounce** | Temporary failure — mailbox full, server down | Monitor; suppress after 3–5 consecutive soft bounces |
| **Transient** | Temporary delay — not a bounce yet | Postmark retries automatically; no action needed |
| **Spam complaint** | Recipient marked as spam | Suppress immediately, permanently — never re-add |
### Handling Bounces via Webhook
```javascript
// Postmark Bounce webhook handler
function handleBounce(event) {
if (event.Type === 'HardBounce' || event.Type === 'SpamComplaint') {
// Suppress immediately
db.suppressions.upsert({
email: event.Email,
reason: event.Type,
suppressedAt: event.BouncedAt,
permanent: true
});
// Remove from all lists/audiences
db.subscribers.update(
{ email: event.Email },
{ status: 'suppressed', suppressedAt: new Date() }
);
} else if (event.Type === 'SoftBounce') {
// Increment soft bounce counter; suppress after threshold
const softBounces = db.softBounces.increment(event.Email);
if (softBounces >= 3) {
db.suppressions.upsert({ email: event.Email, reason: 'SoftBounce' });
}
}
}
```
---
## Suppression Management
Postmark maintains suppression lists per Message Stream. When an address is suppressed, Postmark blocks delivery and returns a `406` status — you are not charged for suppressed sends.
### Postmark Suppression API
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
// Get all suppressions for the broadcast stream
const suppressions = await client.getSuppressions('broadcast');
// { Suppressions: [{ EmailAddress, SuppressionReason, CreatedAt, ... }] }
// Create a suppression manually
await client.createSuppressions('broadcast', {
Suppressions: [
{ EmailAddress: '[email protected]' }
]
});
// Delete a suppression (re-activate) — only if user gave fresh consent
await client.deleteSuppressions('broadcast', {
Suppressions: [
{ EmailAddress: '[email protected]' }
]
});
```
### Your Database vs. Postmark's Suppression List
Maintain your own suppression list. Don't rely solely on Postmark's:
- Enables checking before initiating a send
- Survives if you change email providers
- Required for GDPR compliance (right to erasure tracking)
```javascript
async function canSendEmail(emailAddress, streamType = 'outbound') {
// Check your own suppression list first
const suppressed = await db.suppressions.findOne({ email: emailAddress });
if (suppressed) return false;
// For broadcast, also verify they're actively subscribed
if (streamType === 'broadcast') {
const subscriber = await db.subscribers.findOne({ email: emailAddress });
if (!subscriber || subscriber.status !== 'subscribed') return false;
}
return true;
}
```
---
## List Hygiene Schedule
| Frequency | Action |
|-----------|--------|
| **Immediate** | Suppress hard bounces and spam complaints |
| **After each send** | Review bounce and complaint rates |
| **Monthly** | Remove soft bounce accumulations past threshold |
| **Quarterly** | Identify and segment unengaged subscribers (no opens/clicks in 90 days) |
| **Every 12–18 months** | Run re-engagement campaign; remove non-responders |
| **Annually** | Audit consent records for GDPR/CASL compliance |
---
## Re-engagement Campaigns
Before removing inactive subscribers, attempt re-engagement. This recovers some subscribers and ensures you're only removing genuinely disinterested recipients.
### Recommended Pattern
1. **Segment**: Subscribers with no opens or clicks in 90–180 days
2. **Send re-permission email**: Ask if they want to stay subscribed
3. **Wait 7 days**: No response = unsubscribe
4. **Send final notice**: "This is your last email from us unless you opt in"
5. **Remove non-responders**: Suppress anyone who didn't click to stay
**Subject lines that work:**
- "Are you still interested in [Product]? Let us know."
- "We haven't heard from you — should we stop emailing?"
- "One more from us, then we'll leave you alone"
---
## Address Collection Best Practices
The quality of your list starts at the point of collection.
### Double Opt-in for Marketing
Double opt-in sends a confirmation email before adding to the marketing list. It reduces invalid addresses, creates explicit consent records for GDPR/CASL, and produces more engaged lists (confirmed intent).
```javascript
// Step 1: User submits form
async function handleSignupForm(email) {
const token = crypto.randomUUID();
await db.pendingSubscriptions.insert({
email,
token,
expiresAt: Date.now() + 24 * 60 * 60 * 1000 // 24 hours
});
await client.sendEmailWithTemplate({
From: '[email protected]',
To: email,
TemplateAlias: 'confirm-subscription',
TemplateModel: {
confirm_url: `https://yourdomain.com/confirm?token=${token}`
},
MessageStream: 'outbound' // confirmation is transactional
});
}
// Step 2: User clicks confirmation link
async function handleConfirmation(token) {
const pending = await db.pendingSubscriptions.findOne({ token });
if (!pending || pending.expiresAt < Date.now()) return false;
await db.subscribers.insert({
email: pending.email,
status: 'subscribed',
consentTimestamp: new Date(),
consentSource: 'double-opt-in'
});
await db.pendingSubscriptions.delete({ token });
return true;
}
```
### What to Collect at Signup
| Collect | Don't Collect |
|---------|---------------|
| Email address | More fields than necessary (GDPR data minimization) |
| Consent timestamp | Pre-filled or pre-checked marketing consent checkbox |
| Consent source (form URL, context) | Email address without a stated purpose |
| Preference (email types they want) | |
---
## Handling List Imports
When importing an existing list, validate it first:
1. **Remove obvious invalids**: No `@` symbol, no TLD, disposable domains
2. **Check age**: Lists older than 12 months have significant decay
3. **Verify you have legal basis**: Explicit consent or recent relationship
4. **Never import purchased lists**: Always results in high bounce/complaint rates
For large imports of older lists, warm up the list in segments:
- Start with most recently active (last 90 days)
- Add 6-month and 12-month cohorts after confirming good initial metrics
- Never send to the full list on day 1
# Sending Reliability
Production email systems must handle failures gracefully, avoid duplicate sends, and stay within rate limits. Email that silently fails or duplicates triggers causes poor user experience and support burden.
## Idempotency
Email is not inherently idempotent — if your send fails mid-process and you retry, you may send the same email twice. Build idempotency into your system before this happens.
### Idempotency Key Pattern
Generate a deterministic key from the email context before sending. Check it before sending and record it after:
```javascript
const crypto = require('crypto');
async function sendEmailIdempotent({ to, subject, templateAlias, templateModel, eventType, eventId }) {
// Generate a deterministic key from the event context
const idempotencyKey = crypto
.createHash('sha256')
.update(`${eventType}:${eventId}:${to}`)
.digest('hex');
// Check if this email was already sent
const existing = await db.emailLog.findOne({ idempotencyKey });
if (existing) {
console.log(`Skipping duplicate send for key ${idempotencyKey}`);
return existing;
}
const result = await client.sendEmailWithTemplate({
From: '[email protected]',
To: to,
TemplateAlias: templateAlias,
TemplateModel: templateModel,
MessageStream: 'outbound'
});
// Record after successful send
await db.emailLog.insert({
idempotencyKey,
messageId: result.MessageID,
to,
subject,
sentAt: new Date()
});
return result;
}
// Usage
await sendEmailIdempotent({
to: '[email protected]',
subject: 'Order Confirmed',
templateAlias: 'order-confirmation',
templateModel: { order_id: 'ORD-12345', total: '$49.00' },
eventType: 'order.confirmed',
eventId: 'ORD-12345'
});
```
### What to Use as the Idempotency Basis
| Event Type | Key Components |
|-----------|----------------|
| Order confirmation | `order.confirmed:{orderId}:{email}` |
| Password reset | `auth.reset:{userId}:{tokenId}` |
| Welcome email | `onboarding.welcome:{userId}` |
| Shipping notification | `shipment.shipped:{shipmentId}:{email}` |
| Invoice | `billing.invoice:{invoiceId}:{email}` |
---
## Retry Logic
Postmark occasionally returns transient errors (rate limit, temporary server error). Build retries with exponential backoff to handle these without hammering the API.
### Exponential Backoff with Jitter
```javascript
async function sendWithRetry(emailParams, options = {}) {
const { maxAttempts = 3, baseDelayMs = 1000 } = options;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await client.sendEmail(emailParams);
} catch (error) {
const isRetryable = isRetryableError(error);
if (!isRetryable || attempt === maxAttempts) {
throw error;
}
// Exponential backoff with jitter: base * 2^attempt + random 0–1s
const delay = baseDelayMs * Math.pow(2, attempt) + Math.random() * 1000;
console.warn(`Send attempt ${attempt} failed (${error.code}). Retrying in ${Math.round(delay)}ms`);
await sleep(delay);
}
}
}
function isRetryableError(error) {
// Postmark error codes: https://postmarkapp.com/developer/api/overview#error-codes
const retryableCodes = [
429, // Rate limit exceeded
500, // Internal server error
503, // Service unavailable
];
return retryableCodes.includes(error.statusCode);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Non-retryable errors — fix the request, don't retry
// 401: Invalid API token
// 422: Invalid request (bad email address, invalid template, etc.)
// 406: Recipient is suppressed
// 300: Inactive recipient
```
---
## Rate Limits
Postmark rate limits vary by plan. Exceeding limits returns HTTP 429.
| Plan | Default Rate Limit |
|------|--------------------|
| Developer | 25 emails/second |
| Starter | 50 emails/second |
| Pro | 100 emails/second |
| Custom | Contact Postmark |
### Handling Rate Limits
For bulk sending, pace your requests to stay under the limit:
```javascript
const pLimit = require('p-limit'); // npm install p-limit
async function sendBulkEmail(emailList) {
// Limit concurrent sends to 25/second
const limit = pLimit(25);
const results = await Promise.allSettled(
emailList.map(email =>
limit(() => sendWithRetry({
From: '[email protected]',
To: email.address,
TemplateAlias: email.template,
TemplateModel: email.model,
MessageStream: 'outbound'
}))
)
);
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
console.error(`${failures.length} sends failed:`, failures.map(f => f.reason));
}
return results;
}
```
For high-volume sends (thousands per batch), use Postmark's **Batch Send API** — one API call, up to 500 emails:
```javascript
// More efficient than individual calls for batch scenarios
const batch = emailList.slice(0, 500).map(email => ({
From: '[email protected]',
To: email.address,
TemplateAlias: 'newsletter',
TemplateModel: email.model,
MessageStream: 'broadcast'
}));
const results = await client.sendEmailBatch(batch);
// results is an array of per-email results
```
---
## Error Codes and Handling
| HTTP Status | Postmark Code | Meaning | Action |
|-------------|--------------|---------|--------|
| 401 | 10 | Invalid API token | Fix token in config |
| 406 | 406 | Inactive recipient (suppressed) | Remove from list — do not retry |
| 422 | 405 | Not allowed to send | Check message stream type |
| 422 | 407 | Invalid template alias | Fix template name |
| 422 | 408 | Template model mismatch | Fix template model |
| 429 | — | Rate limit exceeded | Back off and retry |
| 500 | — | Server error | Retry with backoff |
Full error code reference: https://postmarkapp.com/developer/api/overview#error-codes
---
## Email Queue Pattern
For high-reliability requirements, don't send email synchronously from your request handlers. Use a queue:
```javascript
// Instead of sending in the request handler:
app.post('/checkout', async (req, res) => {
const order = await processOrder(req.body);
// Enqueue — don't await delivery
await emailQueue.add('order-confirmation', {
orderId: order.id,
email: order.customerEmail,
total: order.total
});
res.json({ orderId: order.id, status: 'confirmed' });
});
// Separate worker process:
emailQueue.process('order-confirmation', async (job) => {
const { orderId, email, total } = job.data;
return sendEmailIdempotent({
to: email,
eventType: 'order.confirmed',
eventId: orderId,
templateAlias: 'order-confirmation',
templateModel: { order_id: orderId, total }
});
});
```
**Benefits:**
- Request handlers return immediately
- Failed sends can be retried without re-running the full order flow
- Idempotency keys prevent duplicates on retry
- Send volume can be smoothed to avoid rate limit spikes
---
## Monitoring
Log and monitor these signals in production:
| Signal | Alert Threshold | Meaning |
|--------|-----------------|---------|
| Send error rate | > 1% | API issues or bad addresses |
| Bounce rate | > 2% | List quality or domain issue |
| Spam complaint rate | > 0.04% | Content or targeting problem |
| Queue depth | Growing > 5 min | Worker is falling behind |
| P95 send latency | > 5s | API or network issue |
# Testing Email Safely
Testing email against real consumer addresses (gmail.com, yahoo.com, outlook.com) trains inbox providers to associate your domain with test traffic, which looks like spam behavior. Use Postmark's dedicated testing tools instead.
## Postmark Testing Options
### API Test Token
Postmark provides a special server token, `POSTMARK_API_TEST`, that accepts the same API calls as a real token but never delivers email. Responses indicate success, and no email is sent.
Use this for unit tests and CI/CD pipelines where you only need to verify the API call is correctly formed:
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient('POSTMARK_API_TEST');
// This call succeeds but sends nothing
const result = await client.sendEmail({
From: '[email protected]',
To: '[email protected]',
Subject: 'Test',
TextBody: 'Test body'
});
// result.Message === 'Test job accepted'
```
**Limitations:**
- No email sent, no activity log entry
- Template rendering is not validated
- Bounce/open/click webhooks are not triggered
---
### Black Hole Address
Send to `[email protected]` — this address accepts email and records it in your Postmark activity log, but does not forward to a real inbox. Useful for functional testing where you want to see the email in your Postmark activity feed.
```javascript
await client.sendEmail({
From: '[email protected]',
To: '[email protected]',
Subject: 'Functional test',
HtmlBody: '<p>Test email</p>',
MessageStream: 'outbound'
});
```
Appears in your Postmark activity log with a "delivered" status.
---
### Bounce Testing Addresses
Postmark provides special addresses for triggering specific bounce types, allowing you to test your bounce webhook handlers without using real addresses.
| Address | Bounce Type Triggered |
|---------|----------------------|
| `[email protected]` | Hard bounce |
| `[email protected]` | Soft bounce |
| `[email protected]` | Subscription change (opt-in) |
| `[email protected]` | Subscription change (opt-out) |
```javascript
// Test bounce handler
await client.sendEmail({
From: '[email protected]',
To: '[email protected]',
Subject: 'Bounce test',
TextBody: 'Testing bounce handler',
MessageStream: 'outbound'
});
// Your bounce webhook fires with a HardBounce event shortly after
```
---
### Sandbox Server
Create a dedicated server in your Postmark account for testing. The sandbox server accepts real API calls and processes the full send pipeline (templates, layouts, attachments, headers) but does not deliver email.
**Use case:** End-to-end testing of your sending logic, template rendering, and email content without reputation risk.
Setup in Postmark dashboard:
1. Account → Servers → Create Server
2. Name it "Sandbox" or "Testing"
3. Use this server's API token in your test environment
---
## CI/CD Pipeline Setup
Use environment variables to switch between test and production tokens:
```javascript
// email.js
const postmark = require('postmark');
const POSTMARK_TOKEN = process.env.NODE_ENV === 'test'
? 'POSTMARK_API_TEST'
: process.env.POSTMARK_SERVER_TOKEN;
const client = new postmark.ServerClient(POSTMARK_TOKEN);
```
```yaml
# .github/workflows/test.yml
env:
POSTMARK_SERVER_TOKEN: POSTMARK_API_TEST
NODE_ENV: test
```
---
## Inbox Rendering Testing
Test how your HTML email renders across email clients before sending. Use these tools (external services):
| Tool | What It Tests |
|------|--------------|
| [Litmus](https://litmus.com) | Rendering across 90+ email clients and devices |
| [Email on Acid](https://www.emailonacid.com) | Rendering plus spam filter testing |
| [Mailtrap](https://mailtrap.io) | SMTP-based inbox for development teams |
---
## Domain Warm-up Testing
When warming up a new domain, monitor these metrics daily:
| Metric | Check | Action if Off |
|--------|-------|---------------|
| Bounce rate | Below 2% | Pause; review list quality |
| Spam complaint rate | Below 0.04% | Pause; review content and list |
| Delivery rate | Above 95% | Investigate SMTP response codes for blocks |
| Inbox placement | Most to inbox | Use inbox testing service to diagnose |
### Warm-up Schedule Reference
| Day | Max per Day | Max per Hour |
|-----|-------------|--------------|
| 1 | 150 | — |
| 2 | 250 | — |
| 3 | 400 | — |
| 4 | 700 | 50 |
| 5 | 1,000 | 75 |
| 6 | 1,500 | 100 |
| 7 | 2,000 | 150 |
After day 7: increase by no more than 2x per week until target volume is reached.
**During warm-up, always start with your best audience:**
1. Recent signups (last 30 days) — highest engagement
2. Actively purchasing customers
3. Users who have opened email in the last 90 days
4. Older actives (90–180 days)
5. Inactive users last
---
## Testing Checklist
Before any production send:
- [ ] Test API call with `POSTMARK_API_TEST` token passes
- [ ] Template renders correctly with actual data (use black hole or sandbox)
- [ ] Plain text version included and readable
- [ ] All links resolve to correct destinations
- [ ] Unsubscribe link works and suppresses the address
- [ ] HTML renders correctly in target email clients (use Litmus / Email on Acid)
- [ ] Subject line is under 50 characters
- [ ] Preheader text is set
- [ ] From address uses a verified Postmark sending domain
- [ ] Message Stream is correct (outbound vs. broadcast)
- [ ] Bounce and spam complaint webhooks are handling test events correctly
# Transactional Email Design
Transactional emails are among the most-read emails in any inbox. They're expected, triggered by user action, and opened at rates that marketing email rarely achieves. Design them accordingly.
## Core Principles
| Principle | What It Means |
|-----------|--------------|
| **One purpose** | Each email does one thing and has one primary CTA |
| **Send immediately** | Trigger within seconds of the event — delays erode trust |
| **Content over design** | The information is why they're reading, not aesthetics |
| **Plain over decorated** | Minimal design renders better across all email clients |
| **Mobile first** | Over 50% of transactional email is opened on mobile |
---
## Common Email Types
### Welcome Email
Sent immediately after signup. Sets expectations for the product.
**Must include:**
- Confirmation that signup succeeded
- Product name and value proposition (1–2 sentences max)
- Single "Get Started" CTA pointing to onboarding
- Support contact (reply address or link)
**Avoid:** Marketing upsell, multiple CTAs, long feature lists on day 1.
Subject line pattern: `Welcome to [Product]` or `Your [Product] account is ready`
---
### Password Reset
**Must include:**
- Clear "Reset your password" button
- Expiry time (15–60 minutes is standard)
- "If you didn't request this, you can safely ignore this email" notice
- Support link for account security concerns
**Must not:**
- Include or reference the current password
- Allow the link to be used more than once
- Expire after more than 24 hours
Subject line: `Reset your password` — direct, no product name needed
---
### Email / Account Verification
**Must include:**
- Confirmation link or numeric code
- How long the link or code is valid
- Instructions if they didn't initiate this
Subject line: `Confirm your email address` or `Verify your [Product] account`
---
### Order Confirmation / Receipt
**Must include:**
- Order or transaction ID
- Line items with quantities and prices
- Subtotal, tax, shipping, and total
- Billing and shipping addresses
- Estimated delivery date (if applicable)
- Link to order status page
- Customer support contact
**Avoid:** Promotional upsell in the primary content area.
Subject line: `Your order #12345 is confirmed` or `Receipt for $49.99`
---
### Shipping Notification
**Must include:**
- Tracking link (make it prominent — this is why they opened the email)
- Carrier name
- Estimated delivery date
- Items being shipped
- Link to full order
Subject line: `Your order is on its way` — put tracking number in preheader text
---
### Security Alert
Sent for new device login, password change, unusual activity, etc.
**Must include:**
- Exactly what happened (device type, location, approximate time)
- A reassurance: "If this was you, no action is needed"
- A clear action if it wasn't them (link to secure account / change password)
**Must not:**
- Include marketing content of any kind
- Use alarmist language unnecessarily
- Send for every routine login
Subject line: `New sign-in to your [Product] account` or `Your password was changed`
---
## HTML Email Best Practices
### Structure
Use table-based layouts — CSS grid and flexbox have poor support in email clients:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Subject</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 20px 0;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0"
style="background: #ffffff; border-radius: 4px;">
<!-- content here -->
</table>
</td>
</tr>
</table>
</body>
</html>
```
### CSS Rules for Email
| Rule | Why |
|------|-----|
| Inline all styles | Many clients strip `<style>` tags |
| Avoid `position`, `float`, `flexbox`, `grid` | Poor or no support in email clients |
| Use `role="presentation"` on layout tables | Improves accessibility for screen readers |
| Max width 600px | Renders well on desktop and mobile |
| Body font size ≥ 14px, mobile ≥ 16px | Readable without zooming |
| Line height 1.5–1.6 | Improves readability |
| Use web-safe fonts with fallbacks | Custom fonts not universally supported |
### CTA Buttons
Avoid image-based buttons — they disappear when images are blocked. Use HTML/CSS:
```html
<a href="{{action_url}}"
style="display: inline-block; background-color: #0a2540; color: #ffffff;
padding: 12px 24px; border-radius: 4px; text-decoration: none;
font-size: 16px; font-weight: bold; font-family: sans-serif;">
Get Started
</a>
```
### Always Include Plain Text
Every HTML email needs a plain text alternative:
- Used by screen readers and accessibility tools
- Preferred by some corporate email servers
- Improves deliverability (HTML-only emails trigger some spam filters)
```javascript
await client.sendEmail({
From: '[email protected]',
To: '[email protected]',
Subject: 'Your order is confirmed',
HtmlBody: '<!-- full HTML -->',
TextBody: 'Your order ORD-12345 is confirmed.\n\nView your order: https://...\n\nNeed help? [email protected]',
MessageStream: 'outbound'
});
```
---
## Subject Lines and Preheader
### Subject Line Guidelines
| Do | Avoid |
|----|-------|
| Be specific: `Your order #12345 is confirmed` | Vague: `Update from Your Company` |
| Keep under 50 characters for mobile | Over 70 characters (truncated on most clients) |
| Match the email body | Misleading or clickbait phrasing |
### Preheader Text
The preheader is the short preview text shown after the subject line in the inbox. It acts as a second subject line. Set it explicitly — otherwise email clients pull the first visible text, which may be "View in browser" or a navigation link.
```html
<!-- Add immediately after <body> tag -->
<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">
Track your shipment: estimated delivery February 15 · View order status
</div>
```
---
## Sender Name and From Address
| Best Practice | Why |
|--------------|-----|
| Use a recognizable sender name | `Acme Support` not `[email protected]` |
| Match sender to email type | `security@` for alerts, `orders@` for receipts |
| Avoid `noreply@` for customer-facing email | Signals you don't want a reply; use `support@` instead |
| Keep sender name consistent | Changing it confuses recipients and hurts open rates |
| Use a verified domain | Required by Postmark; also improves deliverability |