Short answer: send each welcome message once, poll delivery events outside the signup request, and turn every permanent bounce or complaint into a suppression decision before another send can be queued. For fintech onboarding, retain the event, the decision, and the message correlation ID as separate evidence. A dashboard saying "bounced" helps operations; it is weak evidence by itself.
The least complex design uses four durable records: recipient state, outbound intent, raw provider event, and suppression decision. The application writes an intent without waiting on an SMTP conversation. A worker submits it through an email API, stores the returned message ID, and a poller later imports events. A reducer updates recipient state transactionally. This keeps a slow delivery channel away from account creation while preserving a trail an investigator can replay.
Start with the evidence you need
A welcome email can help a user begin onboarding and establish that the system attempted a communication. Do not collapse those jobs into sent = true. Acceptance by a delivery service, arrival at a recipient server, inbox placement, and a person reading the message are different claims. Store only the claim an event supports. Use an internal communicationId as the stable join key. A provider message ID is an attribute, not your primary identity, because retries can produce multiple submission attempts. Record the template revision, recipient reference, purpose, creation time, and idempotency key. Keep sensitive onboarding data out of event logs; evidence needs identifiers and decisions, not a second copy of a customer's financial profile. For each imported event, preserve its ID, message ID, type, provider timestamp, ingestion timestamp, and a digest of the raw payload. Then record what address was suppressed, why, which event caused it, which rule version ran, and when the decision took effect. A later policy change can be evaluated against the old input without rewriting history.
Claims need boundaries.
A small TypeScript implementation
This example uses generic ports. It assumes a transport exposes a cursor-based event feed. Production stores must be durable, but the state transition remains testable without a commercial SDK.
type DeliveryEvent = {
id: string;
messageId: string;
kind: "accepted" | "delivered" | "temporary_bounce" |
"permanent_bounce" | "complaint";
occurredAt: string;
recipient: string;
rawPayload: unknown;
};
type Decision = {
recipient: string;
reason: "permanent_bounce" | "complaint";
sourceEventId: string;
ruleVersion: "suppression-v1";
effectiveAt: string;
payloadSha256: string;
};
interface EventSource {
list(after: string | null, limit: number): Promise<{
events: DeliveryEvent[];
nextCursor: string | null;
}>;
}
interface EvidenceStore {
hasEvent(id: string): Promise<boolean>;
commit(input: {
event: DeliveryEvent;
decision: Decision | null;
nextCursor: string;
}): Promise<void>;
}
const { createHash } = await import("node:crypto");
function decide(event: DeliveryEvent): Decision | null {
if (event.kind !== "permanent_bounce" && event.kind !== "complaint") {
return null;
}
return {
recipient: event.recipient.trim().toLowerCase(),
reason: event.kind,
sourceEventId: event.id,
ruleVersion: "suppression-v1",
effectiveAt: new Date().toISOString(),
payloadSha256: createHash("sha256")
.update(JSON.stringify(event.rawPayload))
.digest("hex"),
};
}
async function importPage(
source: EventSource,
store: EvidenceStore,
cursor: string | null,
): Promise<string | null> {
const page = await source.list(cursor, 100);
for (const event of page.events) {
if (await store.hasEvent(event.id)) continue;
await store.commit({
event,
decision: decide(event),
nextCursor: page.nextCursor ?? event.id,
});
}
return page.nextCursor;
}
commit is the boundary to take seriously. In a relational database, inserting the unique event, inserting the optional decision, updating suppression, and advancing the cursor should share one transaction. If the process stops before commit, request the page again. The unique event ID makes replay harmless.
The digest has a caveat: JSON.stringify hashes the representation received by this process, not universal canonical JSON. If an auditor must verify exact payloads, retain and hash the original response bytes or adopt a documented canonicalization scheme. Make the choice explicit.
Why do transactional welcome emails have deliverability problems?
Transport acceptance is not inbox placement. A receiving system can evaluate sender authentication, domain alignment, sending history, content, and recipient behavior after a submission API accepts the request. A successful response closes the submission step. Nothing more.
No retry fixes that distinction.
SPF lets a domain publish which systems may send for it. DKIM adds a cryptographic signature tied to a signing domain. DMARC publishes policy and reporting expectations based on aligned SPF or DKIM results. These controls do different work, and passing them does not promise inbox placement.
Authenticate the organizational domain deliberately, align the visible From domain with the chosen policy, and test the production path. A staging domain with different DNS records proves little about production. Keep security conclusions separate too: an email event can support a communication audit, but it does not prove that a person completed identity authentication. NIST treats authenticator assurance as a separate concern.
Spam placement creates a failure bounce processing cannot repair. The address may remain valid, so no permanent bounce arrives and suppression would be wrong. Compare onboarding completion with communication events through pseudonymous internal identifiers. If accepted messages correlate with unfinished onboarding, investigate authentication reports, content changes, volume shifts, and recipient-domain patterns before adding retries.
Suppression is a state machine, not a list
Treat a temporary bounce as evidence of one failed attempt, not immediate proof that an address is invalid. A permanent bounce can feed a durable invalid-recipient rule. A complaint needs its own reason because its policy meaning differs even when the send-time result is the same. Version these mappings and test them with documented event fixtures.
The worker must read suppression state immediately before submission. Checking only when the job is created leaves a race: an address can be suppressed after enqueue and before execution. The portable invariant is blunt: no worker submits unless it observed an eligible recipient state for that attempt.
Do not silently remove suppression when a user edits a profile field back to the same value. Require a new, attributable action and preserve the old decision. Normalize case and surrounding whitespace, but avoid provider-specific rewriting of the local part that could merge distinct addresses.
Retention belongs here as well. Keep the minimum event metadata for the approved period, restrict access by role, and make deletion actions auditable. Legal requirements vary, so engineering should encode an approved policy instead of inventing one from email mechanics.
Operate the poller without hiding failure
Poll with bounded pages, persist the cursor with imported events, and alert on event age rather than polling errors alone. A poll can succeed while returning stale data. Watch the newest-event age, oldest unprocessed event, duplicate rate, unmatched message IDs, and delay between a permanent bounce and effective suppression. Each signal maps to a broken invariant.
Backfill tests should include duplicates, out-of-order events, a temporary bounce followed by delivery, and a permanent bounce for an unknown message ID. Keep unknown events for investigation, but never let them mutate a recipient automatically.
Run a new importer in shadow mode first: write raw events and proposed decisions, compare outcomes, then enable mutation. It costs storage and engineering time. The trade is justified because a false negative keeps contacting a bad recipient while a false positive blocks legitimate onboarding. Faster polling fixes neither.
Before release, walk one synthetic recipient through intent creation, submission, event import, and suppression. Retrieve the full chain by communicationId. Verify that secrets and onboarding attributes are absent from logs, duplicate import changes nothing, the cursor survives restart, and a suppressed recipient cannot pass the worker's final check. Assign owners to stale-feed alerts and evidence-access reviews. An ownerless alert is decoration.
Further reading
- Amazon Simple Email Service Developer Guide: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- NIST SP 800-63B, Digital Identity Guidelines: https://pages.nist.gov/800-63-3/sp800-63b.html
- RFC 7208, Sender Policy Framework: https://www.rfc-editor.org/rfc/rfc7208
- RFC 6376, DomainKeys Identified Mail: https://www.rfc-editor.org/rfc/rfc6376
- RFC 7489, DMARC: https://www.rfc-editor.org/rfc/rfc7489
Top comments (0)