Email Receiving

Parse inbound email from provider webhooks (Postmark, Mailgun) or poll any mailbox over IMAP. Every source normalizes to a canonical ParsedInboundMail, 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:

  • messageId is the RFC 5322 Message-ID header value — the id your reply threads against. Extracted from the message headers regardless of what the provider calls its own id.
  • providerMessageId is the transport's internal id (e.g. Postmark's MessageID). 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 + token with the webhook signing key in constant time. Throws AuthenticationError on mismatch.
  • Rejects requests whose timestamp is more than maxAgeSeconds old (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-headers JSON field.
  • Decodes all attachment-N fields into Buffer.

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 failurepoll() 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 this messageId before creating one).
  • Handler succeeds\Seen is 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-Submitted header set to anything other than no.
  • Precedence: bulk | junk | list.
  • X-Auto-Response-Suppress header 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.