Inbound

Receive replies via webhook

When mail arrives at support@yourdomain.com (or any address at a domain you own), SendBolt can POST the raw RFC822 message to your application with an HMAC-signed header so you can verify origin.

Set up

  1. Confirm MX @ 10 mail.rahstack.dev is published (see DNS)
  2. Configure the webhook URL via the settings endpoint or UI
  3. Implement an HMAC-verifying receiver in your app

Configure

curl -X PATCH "$SENDBOLT_API_URL/api/v1/settings/inbound-webhook" \
  -H "Authorization: Bearer $SENDBOLT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://acme.com/api/inbound-mail",
    "enabled": true
  }'

# Response — copy "secret" once, never shown again:
# {
#   "webhook_url": "https://acme.com/api/inbound-mail",
#   "enabled": true,
#   "secret": "ihw_a1b2c3d4...",
#   "secret_revealed_once": true
# }

Store the secret in your app's env (e.g. MP_INBOUND_SECRET). If you lose it, rotate via ?rotate_secret=true on the PATCH.

Receive

// app/api/inbound-mail/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { simpleParser } from "mailparser";

export async function POST(req: Request) {
  const sig = req.headers.get("x-mp-inbound-sig") ?? "";
  const tenant = req.headers.get("x-mp-inbound-tenant");
  const recipient = req.headers.get("x-mp-inbound-recipient");
  const body = await req.text();

  // Verify HMAC
  const expected = "hmac-sha256=" + createHmac("sha256", process.env.MP_INBOUND_SECRET!)
    .update(body)
    .digest("hex");
  if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return new Response("invalid signature", { status: 401 });
  }

  // Parse RFC822
  const mail = await simpleParser(body);

  // Route to your helpdesk / queue / DB
  await db.supportTickets.insert({
    fromEmail: mail.from?.value[0]?.address,
    fromName:  mail.from?.value[0]?.name,
    toAddress: recipient,
    tenantId:  tenant,
    subject:   mail.subject,
    bodyText:  mail.text,
    bodyHTML:  mail.html,
    receivedAt: new Date(),
  });

  return new Response("ok");
}

HTTP contract

  • Method: POST
  • Content-Type: message/rfc822
  • Body: raw RFC822 bytes (the full email including headers)
  • Headers:
    • X-MP-Inbound-Sig: hmac-sha256=<hex> — HMAC of the body using your shared secret
    • X-MP-Inbound-Tenant: <tenant_id>
    • X-MP-Inbound-Recipient: <the-to-address-on-the-envelope>

Response expectations

  • Return 2xx within 10 seconds — SendBolt accepts on queue
  • Return 4xx (non-429) — message dead-lettered immediately, no retry
  • Return 429 + Retry-After — SendBolt defers per your header
  • Return 5xx or timeout — retried with backoff 30s→24h across 7 attempts before dead-lettering

Dead-lettered messages are visible at GET /api/v1/admin/tenants/{id}/inbound-webhook-dlq for super-admin review (W133-D).

Test it

curl -X POST "$SENDBOLT_API_URL/api/v1/settings/inbound-webhook/test-send" \
  -H "Authorization: Bearer $SENDBOLT_API_KEY"

# This sends a synthetic "Hello from SendBolt" message to your configured
# webhook URL. If your endpoint returns 2xx, the test passes and you'll see
# the round-trip status + your HTTP response code echoed back.

Common pitfalls

  • Don't parse the recipient from the email body — use the X-MP-Inbound-Recipientheader. BCC'd recipients aren't in the visible To/Cc headers
  • Don't auto-reply to every inbound mail — bounce loops + vacation auto-responders create message storms. Detect Auto-Submitted: auto-replied headers and skip those
  • Don't trust the From: header alone — receiver-side SPF/DKIM/DMARC are NOT enforced by the inbound parser as of today (see backlog TICKET-852-C). Treat inbound mail as untrusted user input

Alternative: if you want a full Gmail-like inbox UI for replies rather than a webhook, see Workspace mailboxes.