Mail Bounce

Inbound mail bounce-handler — parse the vendor's webhook DSN / complaint / delivery payload, normalize it into one event shape, classify hard vs soft bounces, and feed an operator-supplied suppression-list hook.

Outbound mail comes back as a hard bounce (permanent — invalid address), a soft bounce (transient — mailbox full, greylisted), a spam / abuse complaint, a delivery confirmation, or a list- unsubscribe. Vendors (Postmark, AWS SES via SNS, Resend) ship the same information dressed up in three different JSON shapes. This module owns the translation so operators write a single reconciliation path regardless of vendor.

b.mailBounce.parse is the pure synchronous parser; it returns the normalized event { vendor, type, subType, recipient, messageId, reason, timestamp, raw }. b.mailBounce.handler wires that parser into an Express-style middleware that buffers the body, runs an operator verify hook (HMAC, Basic Auth, SNS-Signature), emits a system.mail.bounce audit row, and calls the operator's onBounce(event) so the suppression list can be updated before the 200 response goes back.

Generic RFC 3464 / RFC 3461 / RFC 6533 DSN is wired in as b.mailBounce.dsn.parse / b.mailBounce.dsn.build — a parser for raw multipart/report message/delivery-status MIME bounces (the shape any spec-conforming MTA returns) and a generator that builds the same shape for operators that need to issue bounces from their own MTA. Operators with bespoke vendor inflow can still supply { parser } to plug a custom normalizer onto parse / handler.

b.mailBounce.parse(payload, opts) #

stable0.5.0
{
  vendor: "postmark" | "ses" | "resend",       // required when `parser` is absent
  parser: function (payload): normalizedEvent, // alternative to `vendor`
}

Pure synchronous parser. Routes payload through the chosen vendor parser (built-ins: postmark, ses, resend) and returns the normalized event. Operators with bespoke vendors supply opts.parser — a function (payload) -> normalizedEvent that the framework runs and then validates so a misbehaving custom parser cannot emit malformed audit rows.

Throws MailBounceError (HTTP 400) on missing / unknown vendor, empty payload, or payload missing the required vendor-specific fields. Never mutates payload — the original is preserved on event.raw for downstream re-parsing.

var event = b.mailBounce.parse({
  RecordType:  "Bounce",
  Type:        "HardBounce",
  Email:       "user@example.com",
  MessageID:   "abc-123",
  Description: "550 No such mailbox",
  BouncedAt:   "2026-04-28T10:00:00Z",
}, { vendor: "postmark" });
event.type;      // → "bounce"
event.subType;   // → "hard"
event.recipient; // → "user@example.com"
event.timestamp; // → "2026-04-28T10:00:00Z"

b.mailBounce.handler(opts) #

stable0.5.0
{
  vendor:   "postmark" | "ses" | "resend",
  parser:   function (payload): normalizedEvent,    // alternative to `vendor`
  verify:   function (req, body, raw): boolean,     // optional authenticity gate
  onBounce: function (event): Promise|void,         // operator suppression hook
  audit:    boolean,                                 // default: true
  maxBytes: number,                                  // body cap; default 256 KiB
}

Returns an Express-style (req, res) middleware that buffers the inbound webhook body (capped at maxBytes), runs verify(req, body, raw) if supplied, parses via the configured vendor (or custom parser), emits one system.mail.bounce audit row, calls onBounce(event), and responds 200. Failures map to 400 (bad payload), 401 (verify rejected), 413 (body too large), 500 (onBounce threw).

audit defaults to ON; pass audit: false to suppress the system.mail.bounce row when the operator already records the normalized event in their own data store.

var bounce = b.mailBounce.handler({
  vendor:   "postmark",
  verify:   function (req) {
    return req.headers.authorization === "Basic c2VjcmV0";
  },
  onBounce: function (event) {
    suppressionList[event.recipient] = event.subType;
  },
  maxBytes: b.constants.BYTES.kib(64),
});
typeof bounce; // → "function"
bounce.length; // → 2

Last updated 2026-08-08T16:39:49.652Z by seeder.