postmark-inbound
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 processing incoming emails with Postmark inbound webhooks — building reply-by-email, email-to-ticket, document extraction, or any workflow that receives and parses email.
Details
- author
- postmark
- version
- 1.0.0
Skill Files
# Process Inbound Email with Postmark
## Overview
Postmark's inbound processing parses incoming emails and delivers them as structured JSON to your webhook endpoint. This enables workflows like:
- **Reply-by-email** — Threading replies back to conversations
- **Email-to-ticket** — Converting emails into support tickets
- **Document extraction** — Processing email attachments automatically
- **Command processing** — Parsing structured data from emails
- **Forwarding/routing** — Routing emails to different services based on content
## How It Works
1. **Configure** an inbound address or domain in your Postmark server
2. **Set webhook URL** where Postmark will POST parsed email data
3. **Receive JSON** — Postmark processes the raw email and delivers structured data
4. **Respond with 200** — Your endpoint must return HTTP 200 to acknowledge receipt
```
Sender → Email → Postmark → Parses email → POST JSON → Your webhook endpoint
```
## Quick Start
1. **Set up inbound domain** — Configure MX records or email forwarding for your domain
2. **Set webhook URL** — In your Postmark server settings, set the Inbound webhook URL
3. **Build your endpoint** — Create an HTTP POST handler that accepts the inbound JSON payload
4. **Return 200** — Always respond with HTTP 200 to confirm receipt
### Error Handling and Retries
If your endpoint returns a **non-200 status code**, Postmark will automatically retry delivery up to **10 times** over approximately **10.5 hours** with escalating intervals:
| Retry | Interval After Previous Attempt |
|-------|-------------------------------|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 10 minutes |
| 4 | 10 minutes |
| 5 | 10 minutes |
| 6 | 15 minutes |
| 7 | 30 minutes |
| 8 | 1 hour |
| 9 | 2 hours |
| 10 | 6 hours |
**Important:** A **403 response** immediately stops all retries — Postmark interprets this as intentional rejection. After all retries are exhausted, the message is marked as "Failed" and appears as an "Inbound Error" in your activity page. You can manually retry failed messages via the API (`PUT /messages/inbound/{messageid}/retry`).
## Inbound Configuration
Two setup options — MX record (recommended) or email forwarding. Constraints: one inbound stream per server, one domain per stream, one webhook URL per stream.
See [references/inbound-setup.md](references/inbound-setup.md) for full DNS steps, forwarding caveats, retry schedule, and how to set your webhook URL.
## Webhook Payload
Key fields in the JSON Postmark POSTs to your endpoint:
| Field | Description |
|-------|-------------|
| `From` | Sender email address |
| `Subject` | Email subject line |
| `MailboxHash` | The `+` hash from the recipient address — primary routing mechanism |
| `TextBody` | Full plain text body (includes quoted replies) |
| `StrippedTextReply` | Reply text only — quoted content stripped |
| `HtmlBody` | Full HTML body |
| `Attachments` | Array of `{Name, Content, ContentType, ContentLength, ContentID}` |
| `MessageID` | Unique Postmark message identifier |
| `Headers` | All email headers as `[{Name, Value}]` |
See [references/payload-structure.md](references/payload-structure.md) for the full payload JSON, attachment fields, and header threading examples.
## MailboxHash for Routing
Use `+` addressing to route emails to specific records or conversations:
```
[email protected] → MailboxHash: "ticket-456"
[email protected] → MailboxHash: "order-789"
```
This is the primary mechanism for threading replies back to conversations or routing to specific records.
## Basic Endpoint
```javascript
const express = require('express');
const app = express();
app.use(express.json({ limit: '50mb' }));
app.post('/webhooks/inbound', (req, res) => {
const { From, Subject, MailboxHash, StrippedTextReply, TextBody } = req.body;
if (MailboxHash) {
// Threaded reply — parse the hash to find the related record
const [type, id] = MailboxHash.split('-');
console.log(`Reply for ${type} #${id} from ${From}`);
} else {
console.log(`New inbound email from ${From}: ${Subject}`);
}
// Always prefer StrippedTextReply for replies
const replyText = StrippedTextReply || TextBody;
res.sendStatus(200); // Must return 200
});
```
See [references/handler-examples.md](references/handler-examples.md) for Node.js, Python, attachment processing, reply-by-email, and async processing patterns.
## Inbound Rules and Messages API
Block unwanted senders by address or domain, and query/retry processed messages via the API.
See [references/inbound-api.md](references/inbound-api.md) for inbound rules endpoints and the Messages API.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Not returning HTTP 200 | Always respond 200 — even if you process asynchronously |
| Returning 403 accidentally | This permanently stops retries for that message |
| Not parsing MailboxHash | Use `+` addressing for routing — it's the primary threading mechanism |
| Using `TextBody` instead of `StrippedTextReply` | `StrippedTextReply` removes quoted content from replies |
| No size limit on body parser | Set body parser limit to `50mb` for messages with attachments |
| Slow webhook processing | Process async (queue the work) and respond 200 immediately |
| Ignoring `ContentID` on attachments | Attachments with `ContentID` are inline images, not standalone files |
## Notes
- One Inbound Stream per server — use separate servers for different inbound domains
- Inbound webhook payloads can be large due to attachments — set appropriate body size limits
- `StrippedTextReply` strips quoted content, giving you just the new reply text
- The `MailboxHash` field is the portion after `+` in the recipient address — use it for routing
- Headers array contains all original email headers for advanced processing
- Same server can handle both inbound and outbound email
- Inbound processing is separate from outbound — different streams, different configuration
# Inbound Email Handler Examples
## Node.js / Express
```javascript
const express = require('express');
const app = express();
app.use(express.json({ limit: '50mb' })); // attachments can be large
app.post('/webhooks/inbound', (req, res) => {
const inbound = req.body;
console.log('From:', inbound.From);
console.log('Subject:', inbound.Subject);
console.log('MailboxHash:', inbound.MailboxHash);
console.log('Text:', inbound.StrippedTextReply || inbound.TextBody);
console.log('Attachments:', inbound.Attachments?.length || 0);
// Route based on MailboxHash
if (inbound.MailboxHash) {
handleThreadedReply(inbound.MailboxHash, inbound);
} else {
handleNewInbound(inbound);
}
// Always respond 200 — even if processing happens asynchronously
res.sendStatus(200);
});
function handleThreadedReply(hash, inbound) {
const [type, id] = hash.split('-');
console.log(`Threaded reply for ${type} #${id}`);
// Look up the related record and add this reply
}
function handleNewInbound(inbound) {
console.log('New inbound email from:', inbound.From);
// Create a new ticket, record, etc.
}
```
## Python / Flask
```python
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/inbound', methods=['POST'])
def handle_inbound():
inbound = request.get_json()
print(f"From: {inbound['From']}")
print(f"Subject: {inbound['Subject']}")
print(f"MailboxHash: {inbound.get('MailboxHash', '')}")
print(f"Text: {inbound.get('StrippedTextReply') or inbound.get('TextBody')}")
print(f"Attachments: {len(inbound.get('Attachments', []))}")
mailbox_hash = inbound.get('MailboxHash', '')
if mailbox_hash:
handle_threaded_reply(mailbox_hash, inbound)
else:
handle_new_inbound(inbound)
return '', 200
def handle_threaded_reply(hash_value, inbound):
parts = hash_value.split('-', 1)
if len(parts) == 2:
record_type, record_id = parts
print(f"Threaded reply for {record_type} #{record_id}")
def handle_new_inbound(inbound):
print(f"New inbound email from: {inbound['From']}")
```
## Processing Attachments
```javascript
const fs = require('fs');
const path = require('path');
function processAttachments(inbound) {
if (!inbound.Attachments || inbound.Attachments.length === 0) return [];
return inbound.Attachments.map(attachment => {
// Skip inline images (they are embedded in HtmlBody, not standalone files)
if (attachment.ContentID) {
return { name: attachment.Name, isInline: true };
}
// Decode Base64 content
const buffer = Buffer.from(attachment.Content, 'base64');
// Save to disk or upload to cloud storage
const filePath = path.join('/tmp/attachments', attachment.Name);
fs.writeFileSync(filePath, buffer);
return {
name: attachment.Name,
contentType: attachment.ContentType,
size: attachment.ContentLength,
path: filePath,
isInline: false
};
});
}
```
**Note:** Set your body parser limit to `50mb` — inbound payloads can be large when attachments are included.
## Reply-by-Email Pattern
A complete implementation for threading email replies back to records.
### Step 1: Send the Original Email with a Hashed Reply-To
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
async function sendTicketEmail(ticketId, customerEmail) {
await client.sendEmail({
From: '[email protected]',
To: customerEmail,
// Hash encodes the ticket ID so replies route back to it
ReplyTo: `support+ticket-${ticketId}@yourdomain.com`,
Subject: `[Ticket #${ticketId}] We received your request`,
TextBody: 'Our team is looking into your issue and will reply shortly.',
MessageStream: 'outbound'
});
}
```
### Step 2: Handle the Reply in Your Inbound Webhook
```javascript
app.post('/webhooks/inbound', async (req, res) => {
const { MailboxHash, StrippedTextReply, TextBody, From, Attachments } = req.body;
if (MailboxHash && MailboxHash.startsWith('ticket-')) {
const ticketId = MailboxHash.replace('ticket-', '');
await addReplyToTicket(ticketId, {
from: From,
body: StrippedTextReply || TextBody, // prefer StrippedTextReply
attachments: Attachments || []
});
}
res.sendStatus(200);
});
```
## Async Processing Pattern
For slow operations (database writes, file uploads), respond 200 immediately and process in the background:
```javascript
const Queue = require('bull'); // or any queue library
const inboundQueue = new Queue('inbound-email');
app.post('/webhooks/inbound', (req, res) => {
// Queue the work — do not block the response
inboundQueue.add(req.body);
// Respond immediately to prevent Postmark from retrying
res.sendStatus(200);
});
inboundQueue.process(async (job) => {
const inbound = job.data;
// Slow operations are fine here
await processInboundEmail(inbound);
});
```
# Inbound Rules and Messages API
## Inbound Rules
Block unwanted messages by email address or domain before they reach your webhook.
### API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/triggers/inboundrules` | `GET` | List all inbound rules |
| `/triggers/inboundrules` | `POST` | Create a new block rule |
| `/triggers/inboundrules/{ruleid}` | `DELETE` | Delete a rule |
### List Rules
```bash
curl "https://api.postmarkapp.com/triggers/inboundrules" \
-H "Accept: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN"
```
### Create a Block Rule
Block a specific address:
```bash
curl "https://api.postmarkapp.com/triggers/inboundrules" \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
-d '{"Rule": "[email protected]"}'
```
Block an entire domain:
```bash
curl "https://api.postmarkapp.com/triggers/inboundrules" \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
-d '{"Rule": "spamdomain.com"}'
```
Rules accept exact email addresses (`[email protected]`) or entire domains (`example.com`).
### Delete a Rule
```bash
curl "https://api.postmarkapp.com/triggers/inboundrules/{ruleid}" \
-X DELETE \
-H "Accept: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN"
```
---
## Messages API (Inbound)
Query and manage processed inbound messages.
### API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/messages/inbound` | `GET` | Search inbound messages |
| `/messages/inbound/{messageid}/details` | `GET` | Get full details for one message |
| `/messages/inbound/{messageid}/bypass` | `PUT` | Bypass block rules for a specific message |
| `/messages/inbound/{messageid}/retry` | `PUT` | Retry a failed webhook delivery |
### Search Inbound Messages
```bash
curl "https://api.postmarkapp.com/messages/inbound?count=50&offset=0" \
-H "Accept: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN"
```
Query parameters:
| Parameter | Description |
|-----------|-------------|
| `count` | Number of results (max 500) |
| `offset` | Pagination offset |
| `recipient` | Filter by recipient address |
| `fromemail` | Filter by sender address |
| `subject` | Filter by subject |
| `mailboxhash` | Filter by MailboxHash value |
| `status` | `blocked`, `processed`, `queued`, `failed`, `scheduled` |
| `fromdate` | Start date (YYYY-MM-DD) |
| `todate` | End date (YYYY-MM-DD) |
### Retry a Failed Delivery
If your webhook failed to respond with 200 and all retries were exhausted:
```bash
curl "https://api.postmarkapp.com/messages/inbound/{messageid}/retry" \
-X PUT \
-H "Accept: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN"
```
### Node.js Examples
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
// Search inbound messages
const messages = await client.getInboundMessages({
count: 50,
offset: 0,
status: 'processed'
});
messages.InboundMessages.forEach(msg => {
console.log(`${msg.From} → ${msg.Subject} (${msg.Status})`);
});
// Retry a failed delivery
await client.retryInboundMessage(messageId);
```
# Inbound Email Setup ## Option 1: MX Record (Recommended) Point your domain's MX records to Postmark to route all email for the domain through Postmark: ``` MX inbound.postmarkapp.com priority 10 ``` ### DNS Configuration Steps 1. Log in to your DNS provider (Route 53, Cloudflare, Namecheap, etc.) 2. Remove or lower the priority of any existing MX records 3. Add: `MX inbound.postmarkapp.com 10` 4. Allow up to 48 hours for DNS propagation ## Option 2: Email Forwarding Forward a specific address to your Postmark inbound address. Your server's inbound address is shown in the Postmark dashboard under **Server → Inbound**. The forwarding address looks like: `[hash]@inbound.postmarkapp.com` ### When to Use Forwarding - You already have an email provider and don't want to change MX records - You only want to process a single address (e.g., `[email protected]`) - You want a quick setup without DNS changes ### Caveats with Forwarding - Some email providers modify headers during forwarding, which can affect sender identification - DMARC policies on the sender's domain may cause issues with forwarded email - `StrippedTextReply` may not work as reliably with forwarded email ## Constraints | Constraint | Detail | |-----------|--------| | **Inbound Streams** | One per server | | **Domain** | One per Inbound Stream | | **Webhook URL** | One per Inbound Message Stream | | **Outbound compatibility** | Same server can handle both inbound and outbound | If you need to process email for multiple domains, create separate Postmark servers — one per domain. ## Setting the Webhook URL In your Postmark server dashboard: 1. Go to **Server → Settings → Inbound** 2. Set the **Inbound Webhook URL** to your endpoint (must be HTTPS) 3. Postmark will POST parsed email data to this URL for every incoming email ## Webhook Retries If your endpoint returns a non-200 status code, Postmark retries delivery: | Retry | Interval After Previous Attempt | |-------|-------------------------------| | 1 | 1 minute | | 2 | 5 minutes | | 3 | 10 minutes | | 4 | 10 minutes | | 5 | 10 minutes | | 6 | 15 minutes | | 7 | 30 minutes | | 8 | 1 hour | | 9 | 2 hours | | 10 | 6 hours | A **403 response** immediately stops all retries — Postmark interprets this as intentional rejection. After all retries are exhausted, the message is marked as "Failed" in your activity page. Manually retry via the API: ```bash curl "https://api.postmarkapp.com/messages/inbound/{messageid}/retry" \ -X PUT \ -H "Accept: application/json" \ -H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" ``` ## Testing Inbound Use the Postmark dashboard to send a test inbound message: 1. Go to **Server → Inbound → Send Test** 2. Enter a sample email payload 3. Postmark will POST to your configured webhook URL Alternatively, send an actual email to your inbound address and watch your webhook endpoint receive it.
# Inbound Webhook Payload Structure
When an email arrives, Postmark POSTs the following JSON to your webhook URL:
```json
{
"FromName": "John Doe",
"MessageStream": "inbound",
"From": "[email protected]",
"FromFull": {
"Email": "[email protected]",
"Name": "John Doe",
"MailboxHash": ""
},
"To": "[email protected]",
"ToFull": [
{
"Email": "[email protected]",
"Name": "",
"MailboxHash": "ticket-456"
}
],
"Cc": "",
"CcFull": [],
"Bcc": "",
"BccFull": [],
"OriginalRecipient": "[email protected]",
"Subject": "Re: Issue with my order",
"MessageID": "73e6d360-66eb-11e1-8e72-a8206ea7d3ea",
"ReplyTo": "",
"MailboxHash": "ticket-456",
"Date": "Thu, 5 Apr 2025 16:59:01 +0200",
"TextBody": "I still haven't received my order.",
"HtmlBody": "<p>I still haven't received my order.</p>",
"StrippedTextReply": "I still haven't received my order.",
"Tag": "",
"Headers": [
{
"Name": "Received",
"Value": "by mx.postmarkapp.com ..."
},
{
"Name": "Message-ID",
"Value": "<[email protected]>"
}
],
"Attachments": [
{
"Name": "screenshot.png",
"Content": "base64-encoded-content",
"ContentType": "image/png",
"ContentLength": 45892,
"ContentID": ""
}
]
}
```
## Field Reference
| Field | Type | Description |
|-------|------|-------------|
| `From` | string | Sender email address |
| `FromName` | string | Sender display name |
| `FromFull` | object | Full sender details including MailboxHash |
| `To` | string | Recipient address (your inbound address) |
| `ToFull` | array | Full recipient details with MailboxHash per recipient |
| `Cc` / `CcFull` | string / array | CC recipients |
| `Bcc` / `BccFull` | string / array | BCC recipients (usually empty for inbound) |
| `OriginalRecipient` | string | The address the email was originally sent to |
| `Subject` | string | Email subject line |
| `MessageID` | string | Unique Postmark message identifier |
| `ReplyTo` | string | Reply-To header, if set by the sender |
| `MailboxHash` | string | The `+` hash portion of the address — primary routing mechanism |
| `Date` | string | When the email was sent (from the email's Date header) |
| `TextBody` | string | Full plain text body |
| `HtmlBody` | string | Full HTML body |
| `StrippedTextReply` | string | Just the reply text, with quoted/forwarded content stripped |
| `Tag` | string | Tag, if any (usually empty for inbound) |
| `Headers` | array | All email headers as `[{Name, Value}]` |
| `Attachments` | array | File attachments (see below) |
## Attachment Fields
Each object in `Attachments`:
| Field | Type | Description |
|-------|------|-------------|
| `Name` | string | File name |
| `Content` | string | Base64-encoded file content |
| `ContentType` | string | MIME type (e.g., `image/png`, `application/pdf`) |
| `ContentLength` | integer | File size in bytes |
| `ContentID` | string | If set, this is an inline image embedded in HTML — not a standalone file |
## StrippedTextReply vs TextBody
Use `StrippedTextReply` when you want just the new reply text without quoted content:
| Field | Contains |
|-------|---------|
| `TextBody` | Full email body including all quoted replies |
| `StrippedTextReply` | Only the new text the sender wrote in this reply |
For support ticket systems and reply-by-email, always prefer `StrippedTextReply` to avoid storing duplicate quoted content.
## MailboxHash Routing
`MailboxHash` contains the `+` suffix from the recipient address:
```
[email protected] → MailboxHash: "ticket-456"
[email protected] → MailboxHash: "order-789"
[email protected] → MailboxHash: "user-123"
```
The `MailboxHash` appears in:
- Top-level `MailboxHash` field
- `ToFull[].MailboxHash` for each recipient
- `FromFull.MailboxHash` (if the sender address had a `+` hash)
## Headers Array
The `Headers` array contains all email headers. Useful headers for threading:
| Header Name | Use |
|------------|-----|
| `Message-ID` | Sender's original message ID |
| `In-Reply-To` | The Message-ID this email is replying to |
| `References` | Thread reference chain |
Example — find `In-Reply-To` for email threading:
```javascript
const inReplyTo = inbound.Headers.find(h => h.Name === 'In-Reply-To')?.Value;
```