Short answer: for an e-commerce contact form, pick the email API that makes delivery state observable without making your support queue depend on a webhook. A custom sending domain, DKIM alignment, suppression handling, and a durable polling cursor matter more than a glossy template editor. Treat the provider as a transport boundary, then keep routing and retry policy in your own service.
The choice matrix I use before writing an adapter
The job is concrete: a shopper submits a form, and the message must land in the right support queue. “Sent” is not the same as “delivered,” and “delivered” does not mean an agent has acknowledged it. I score candidates against the states I can audit.
| Check | Pass condition | Failure you will see |
|---|---|---|
| Domain identity | SPF, DKIM, and DMARC records are explicit and testable | Welcome mail lands in spam or fails alignment |
| Suppression | API exposes list, check, and add operations | A hard bounce gets retried forever |
| Event access | Pull endpoint has stable IDs, timestamps, and a cursor strategy | Polling replays or skips events |
| Queue handoff | Message ID is stored with the support ticket | Agents cannot trace a complaint to a send |
| Regional policy | US and EU data paths and retention are documented | Legal review arrives after launch |
| Failure semantics | Timeouts, rate limits, and idempotency are documented | Retries create duplicate welcome messages |
My recommendation is deliberately boring: require a passing score on the first three rows, then run a replay test with production-shaped data. A provider that wins on price but cannot explain event ordering is not a reliable choice for a welcome flow.
What should a custom-domain DKIM suppression and polling design prove?
Start with identity. Publish the provider's SPF include only where your DNS policy allows it. Generate a DKIM selector per environment, and make DMARC alignment part of a pre-production test. Do not treat a green DNS check as proof of inbox placement; send to controlled US and EU mailboxes and inspect the authentication results.
Suppression is a safety rail, not a reporting feature. Store the recipient, reason, source, and first-seen time. Before a send, check your local copy. If the provider is authoritative, reconcile it on a schedule and record deletions as state transitions rather than erasing history. A support form is user input, so normalize case and Unicode domains before comparing addresses.
Polling needs the same discipline as a database consumer. Use a monotonic cursor when the API offers one. Otherwise, poll an overlapping time window and deduplicate by provider event ID. Keep the last successful cursor separate from the last attempted cursor. That one distinction prevents a transient timeout from advancing your checkpoint. I benchmark this loop with a 60-second outage injected between every page: the expected result is a replay with no duplicate queue action, followed by a clean cursor advance. If your metrics cannot show that sequence, the poller is not ready for a welcome flow, no matter how polished the dashboard looks. The same test catches clock skew, pages returned out of order, and a provider that silently truncates an event window. Record the raw cursor and response timestamp in a small audit table; it gives an on-call engineer enough evidence to reproduce a missed handoff without searching application logs across two regions.
Here is the smallest adapter shape I keep in a service. It does not assume webhooks, a vendor SDK, or a particular route naming scheme.
type MailEvent = {
id: string;
type: "queued" | "delivered" | "bounced" | "complained";
occurredAt: string;
recipient: string;
};
type EventPage = {
events: MailEvent[];
nextCursor?: string;
};
export async function drainEvents(
fetchPage: (cursor?: string) => Promise<EventPage>,
loadCursor: () => Promise<string | undefined>,
saveCursor: (cursor: string) => Promise<void>,
mark: (event: MailEvent) => Promise<void>,
): Promise<number> {
let cursor = await loadCursor();
let processed = 0;
for (;;) {
const page = await fetchPage(cursor);
for (const event of page.events) {
// The database constraint on event.id makes this operation idempotent.
await mark(event);
processed += 1;
}
if (!page.nextCursor) return processed;
await saveCursor(page.nextCursor);
cursor = page.nextCursor;
}
}
The important bit is outside the function: mark must be transactional with the queue update. If a process dies after marking an event but before advancing the cursor, the next poll safely sees the duplicate and ignores it. Exactly-once delivery is a useful database property, not a promise to expect from an email API.
How do US/EU SaaS teams handle no-webhook event polling?
Run a poller in each region that owns a queue. Keep the schedule short enough for your support SLA, then add jitter so a fleet does not synchronize on the minute. Capture response latency, page size, cursor age, and the count of unknown event types. Those four metrics reveal a stuck consumer before a support manager does.
For a welcome email, separate the user-facing request from delivery work. The contact form handler validates consent and returns a ticket ID quickly. A job writes the email request with an idempotency key derived from that ticket ID. A worker sends it, while the poller later attaches delivery events. This means a provider timeout cannot make the shopper submit the form twice.
EU traffic deserves explicit boundaries. Document where message bodies, recipient addresses, and event logs are stored; set retention for each; and redact message content from ordinary application logs. US traffic often has different disclosure and opt-out expectations. The engineering move is the same in both regions: make policy a versioned configuration value, and test it in CI.
I once assumed a provider's “event history” was a log I could query forever. That assumption is dangerous. Many systems retain events for a bounded period, and a poller outage can outlive it. Your recovery plan should include a daily reconciliation of provider message status against your own send ledger, plus an alert when the cursor age crosses the business SLA.
Three words: measure the gap. Don't trust a green send response.
Testing delivery reliability with ugly data
Use fixtures that look like real commerce traffic: duplicate submissions, plus-addresses, internationalized domains, a mailbox that hard-bounces, and a complaint arriving before the welcome message is read. Assert state transitions, not just HTTP success. A queued event followed by bounced must remove the address from the sendable set even if the support ticket is already open.
Chaos tests should stop the worker between every pair of side effects. Replay the same event page. Return an empty page with a non-empty cursor. Delay a page beyond the poll interval. Your adapter should remain boring under all three cases. Boring is measurable here: no duplicate ticket, no cursor leap, and no retry to a suppressed address.
If you compare services, compare these behaviors across at least three real categories: SendGrid-style all-in-one platforms, Postmark-style transactional specialists, and Amazon SES-style infrastructure primitives. The first may bundle broad dashboards and policy controls; the second often narrows the product around transactional streams; the third gives low-level building blocks that leave more queueing, analytics, and compliance work to your team. Those are engineering trade-offs, not a ranking. Verify current retention, regional processing, and event APIs in each service's documentation before signing a contract.
When the runner-up is the better tool
The catch is that a pull-based design is a poor fit when your SLA requires sub-second reaction to a bounce or complaint. Choose a provider with signed webhooks, or run a managed relay that can fan events into your queue, when that latency is non-negotiable. Webhooks add their own work: signature verification, replay protection, and a dead-letter path.
Do not use a broad email API as your marketing automation engine. If you need visual campaigns, audience segmentation, and consent workflows, a campaign platform is the right boundary. If you need strict data residency that a service cannot contractually provide, keep the transport layer in a regional deployment or select a provider with matching controls. Your mileage may vary because mailbox reputation and local regulation change faster than an adapter does.
The decision rule is simple: select the smallest transport surface that proves identity, suppression, and event continuity for your support queue. Keep templates, routing, and audit history in your codebase. Re-run the matrix whenever your regions, retention policy, or support SLA changes.
Top comments (0)