Email Receiving
Parse inbound email from provider webhooks (Postmark, Mailgun) or poll any mailbox over IMAP. Every source normalizes to a canonicalParsedInboundMail, so the same application code handles mail regardless of origin. For sending mail, see Email Sending.
Quick start
import { Router } from '@strav/http'
import { PostmarkInboundParser } from '@strav/signal'
const parser = new PostmarkInboundParser()
Router.post('/inbound/email/postmark', async ctx => {
const mail = await parser.parse({
body: await ctx.request.text(),
headers: Object.fromEntries(ctx.request.headers.entries()),
})
if (mail.isAutoGenerated) {
return ctx.json({ ok: true, skipped: 'auto-generated' })
}
await processInboundMail(mail)
return ctx.json({ ok: true })
})
Sources
Each parser verifies the provider's authentication scheme (where one exists) and normalizes the payload to ParsedInboundMail.
Available today:
| Source | Parser / Driver | Authentication |
|---|---|---|
| Postmark Inbound | PostmarkInboundParser |
Configure at HTTP layer — Postmark does not sign inbound webhooks |
| Mailgun Routes | MailgunInboundParser |
HMAC-SHA256 over timestamp + token with webhook signing key |
| IMAP (any mailbox) | ImapInboundDriver + mail.poll(...) |
Password auth (OAuth2 XOAUTH2 field available; refresh flow pending) |
Pending:
- SendGrid Inbound Parse, SES inbound (each with HMAC signature verification).
- OAuth2 refresh helpers for Gmail / Microsoft 365 IMAP (the driver accepts access tokens today; refresh is your responsibility).
The canonical shape
Every inbound parser produces a ParsedInboundMail:
interface ParsedInboundMail {
from: { address: string; name?: string }
to: { address: string; name?: string }[]
cc: { address: string; name?: string }[]
bcc: { address: string; name?: string }[]
replyTo?: { address: string; name?: string }
subject: string
text?: string
html?: string
date?: Date
/** Lowercased header name → value. */
headers: Record
attachments: {
filename: string
contentType: string
content: Buffer
size: number
cid?: string // Content-ID for inline images, angle brackets stripped
}[]
/** RFC 5322 Message-ID, angle brackets stripped. Use for threading. */
messageId?: string
/** In-Reply-To header, angle brackets stripped. */
inReplyTo?: string
/** References header, parsed list, angle brackets stripped. */
references: string[]
/** True if auto-reply / vacation / bulk / list — do NOT auto-respond. */
isAutoGenerated: boolean
/** Provider's own identifier (e.g. Postmark's MessageID field). */
providerMessageId?: string
}
Two identifier fields deserve attention:
messageIdis the RFC 5322Message-IDheader value — the id your reply threads against. Extracted from the message headers regardless of what the provider calls its own id.providerMessageIdis the transport's internal id (e.g. Postmark'sMessageID). Useful for debugging and delivery tracking — never for threading.
Postmark webhook
import { Router } from '@strav/http'
import { PostmarkInboundParser } from '@strav/signal'
const parser = new PostmarkInboundParser()
Router.post('/inbound/email/postmark', async ctx => {
const mail = await parser.parse({
body: await ctx.request.text(),
headers: Object.fromEntries(ctx.request.headers.entries()),
})
if (mail.isAutoGenerated) {
// Auto-reply, vacation responder, mailing list — accept but do not reply.
return ctx.json({ ok: true, skipped: 'auto-generated' })
}
// Hand off to your application: ticket creation, threading lookup, etc.
await processInboundMail(mail)
return ctx.json({ ok: true })
})
Authentication. Postmark does not sign inbound webhooks with HMAC. Authenticate at the HTTP layer — either put Basic Auth credentials in the webhook URL you configure in the Postmark dashboard (https://user:pass@your-host/inbound/email/postmark) or restrict the route to Postmark's published IP range. Both is better than either.
Return 2xx fast. Return 200 from the route before doing any heavy lifting — Postmark retries on non-2xx responses. Hand the parsed message to a queue job rather than processing inline.
Mailgun webhook
Mailgun POSTs multipart form data to your route URL and signs each request with HMAC-SHA256. Configure a Mailgun Route whose action is forward("https://your-host/inbound/email/mailgun"), grab the webhook signing key from the Mailgun dashboard, and wire it up:
import { Router } from '@strav/http'
import { MailgunInboundParser } from '@strav/signal'
const parser = new MailgunInboundParser({
webhookSigningKey: env('MAILGUN_WEBHOOK_SIGNING_KEY'),
maxAgeSeconds: 300, // optional, defaults to 5 minutes
})
Router.post('/inbound/email/mailgun', async ctx => {
const mail = await parser.parse({
body: Buffer.from(await ctx.request.arrayBuffer()),
headers: Object.fromEntries(ctx.request.headers.entries()),
})
if (mail.isAutoGenerated) {
return ctx.json({ ok: true, skipped: 'auto-generated' })
}
await processInboundMail(mail)
return ctx.json({ ok: true })
})
The parser:
- Verifies HMAC-SHA256 of
timestamp + tokenwith the webhook signing key in constant time. ThrowsAuthenticationErroron mismatch. - Rejects requests whose
timestampis more thanmaxAgeSecondsold (replay protection). - Parses the multipart body via Bun's native
Response.formData()— no extra multipart dependency. - Extracts threading headers (Message-ID, In-Reply-To, References) from Mailgun's
message-headersJSON field. - Decodes all
attachment-Nfields intoBuffer.
Pass the raw body. Signature verification hashes the exact bytes, so read the body as bytes (arrayBuffer() → Buffer), not as a parsed object. If your HTTP layer auto-parses multipart before you see it, the signature will fail.
Webhook signing key, not API key. Mailgun's webhook signing key is distinct from the sending API key. You'll find it under Webhooks → HTTP webhook signing key in the dashboard. Rotate both separately.
IMAP polling
For mailboxes without a provider, poll over IMAP on a cron schedule. One poll cycle = one connect / SEARCH UNSEEN / fetch / parse / mark \Seen loop.
import { mail } from '@strav/signal'
mail.poll(
'mailbox:support',
{
host: 'imap.example.com',
auth: { user: 'support@example.com', pass: env('IMAP_PASSWORD') },
},
async inbound => {
if (inbound.isAutoGenerated) return
await processInboundMail(inbound)
}
).everyMinute().withoutOverlapping()
mail.poll(name, config, handler) returns a Schedule, so you chain the standard @strav/queue scheduler methods: .everyMinute(), .everyFiveMinutes(), .cron('*/2 * * * *'), .withoutOverlapping(), .runImmediately().
Configuration
interface ImapInboundConfig {
host: string
port?: number // default 993 (IMAPS)
secure?: boolean // default true
auth: { user: string; pass: string }
| { user: string; accessToken: string } // OAuth2 XOAUTH2
mailbox?: string // default 'INBOX'
batchSize?: number // max messages per cycle; default 50
dryRun?: boolean // parse but do not mark \Seen; default false
tls?: { rejectUnauthorized?: boolean }
}
Error semantics per cycle
The driver is designed to make repeated polling safe:
- Connection or auth failure —
poll()throws. The scheduler logs and retries on the next tick; nothing is marked\Seen, so no messages are lost. - Fetch fails for one message — counted as
skipped, left UNSEEN, next cycle retries it. - Handler throws for one message — counted as
failed, left UNSEEN, next cycle retries it. Make handlers idempotent (e.g., check whether a ticket already exists for thismessageIdbefore creating one). - Handler succeeds —
\Seenis set before moving to the next message.
poll() returns a PollResult with { processed, failed, skipped } counts for logging and alerting.
OAuth2 (Gmail / M365)
Modern Google and Microsoft accounts refuse password auth. Pass a short-lived OAuth2 access token via auth.accessToken — the driver uses XOAUTH2:
mail.poll('mailbox:gmail', {
host: 'imap.gmail.com',
auth: { user: 'support@company.com', accessToken: await getAccessToken() },
}, handleMail).everyMinute()
Refreshing the token before it expires is your responsibility until OAuth2 helpers ship. One pattern: wrap the config in a closure that refreshes via @strav/oauth2 before each cycle, and pass an ImapInboundDriver built fresh each tick.
Using the driver directly
When you need to poll on demand (a CLI command, a warm-up job, tests), skip the scheduler helper and call the driver:
import { ImapInboundDriver } from '@strav/signal'
const driver = new ImapInboundDriver(config)
const result = await driver.poll(async mail => { /* ... */ })
console.log(`processed=${result.processed} failed=${result.failed}`)
For unit tests, pass a custom client factory as the second constructor argument to swap in a fake IMAP client that implements ImapClientLike — the framework's own tests use this pattern to cover the full state machine without a real server.
Threading replies
To thread an inbound reply back to the original outgoing message, match the messageId, inReplyTo, and references fields against the Message-ID values you recorded when sending:
async function findOriginalThread(mail: ParsedInboundMail) {
const candidates = [mail.inReplyTo, ...mail.references].filter(Boolean)
if (candidates.length === 0) return null
return db.sql`
SELECT * FROM "outgoing_message"
WHERE "message_id" = ANY(${candidates})
ORDER BY "created_at" DESC
LIMIT 1
`
}
When sending, generate and persist the Message-ID you set on outgoing mail so inbound replies can find their parent — this is the threading primitive on both sides.
Loop guard
The isAutoGenerated flag is true when the message looks like an auto-reply, mailing list, or bulk delivery. Applications that send auto-responders (first-contact acknowledgements, receipt confirmations) must check this flag before replying — otherwise two auto-responders talking to each other create an infinite mail loop.
The check follows RFC 3834 and common practice:
Auto-Submittedheader set to anything other thanno.Precedence: bulk | junk | list.X-Auto-Response-Suppressheader present.
You can use the same helper directly on arbitrary header bags:
import { isAutoGeneratedMessage } from '@strav/signal'
if (isAutoGeneratedMessage(mail.headers)) {
// Skip the auto-responder.
}
Set Auto-Submitted: auto-replied on your own outbound auto-responder messages so other servers honor the same contract.
Custom parsers
For a provider we don't ship, implement InboundWebhookParser:
import type {
InboundWebhookInput,
InboundWebhookParser,
ParsedInboundMail,
} from '@strav/signal'
import { isAutoGeneratedMessage } from '@strav/signal'
class CustomProviderParser implements InboundWebhookParser {
async parse(input: InboundWebhookInput): Promise {
// 1. Verify provider signature from input.headers against input.body (HMAC etc.)
// 2. Parse input.body (JSON, multipart, etc.)
// 3. Normalize to ParsedInboundMail
// 4. Extract messageId / inReplyTo / references from the RFC 5322 headers
// 5. Set isAutoGenerated: isAutoGeneratedMessage(headers)
throw new Error('not implemented')
}
}
Keep parsers free of @strav/http types — a parser should be callable from an HTTP route, a queue job (for IMAP bytes once that ships), or a unit test, so the interface takes raw bytes and headers rather than a request object.