A startup's practical email deliverability stack has one awkward constraint: an e-commerce attachment is generated inside a job, but delivery failures arrive later. Optimizing only the per-message rate leaves you owning the expensive part — state, retries, and support.
Short answer: the cheapest practical stack is a small Node.js sending worker, a dedicated sending subdomain, a database-backed suppression table, and one normalized event path for bounces and complaints, with polling only as a reconciliation job. Choose a delivery service by the engineering needed to maintain that path in the US and EU, not by the lowest headline price.
This is deliberately a stack decision, not a vendor ranking. For a one-person SaaS shipping weekly, the useful question is which responsibilities can be outsourced without giving up the application state needed to prevent a second bad send.
The failure happens after API acceptance
Treat sending and deliverability as two connected workflows. The foreground workflow creates the report, stores a send attempt, asks a provider to accept the message, and records the provider message ID. The background workflow consumes delivery events and decides whether that recipient can be mailed again. A scheduled poll then reconciles gaps; it must not be the primary feedback loop unless the selected service offers no push events.
The dedicated domain in this design is a sending subdomain such as reports.example.com. Keep its DNS configuration and verification state separate from the root domain used by employees. Domain verification belongs in deployment readiness, not in every send request: publish the records required by the selected sender, wait for its verification result, and block production traffic until the status is verified. Don't interpret an API's acceptance response as proof that DNS authentication or inbox delivery succeeded. It proves only what that API documents.
Use one internal event vocabulary even if the external service uses different labels. At minimum, distinguish a permanent bounce, a transient bounce, a complaint, a delivery, and an unknown event. A 550 response and a network timeout are not the same decision. Permanent failures and complaints should close the gate before the next job runs; transient failures belong to a bounded retry policy. Unknown input should be retained for inspection rather than silently translated into a permanent suppression.
Fast feedback matters. Perfection doesn't.
Polling is still useful because webhooks and consumers are distributed systems. Query from a durable cursor or time window, overlap the previous window, and deduplicate on the provider's event identifier. If an API has no stable event ID, derive a key from the documented immutable fields and keep the raw payload. I'm not sure any universal polling interval is defensible: message volume, provider limits, and the business cost of a repeat send determine it. A test with real staging traffic resolves that choice better than a generic number.
The Node.js report ledger
The application should know about mail intent, not provider payload shapes. A narrow TypeScript interface keeps the report job testable and makes the expensive state transitions visible. This is the part worth owning.
type SendReport = {
attemptId: string;
recipient: string;
reportName: string;
pdf: Uint8Array;
};
type AcceptedMessage = {
providerMessageId: string;
acceptedAt: Date;
};
type DeliveryEvent = {
eventId: string;
providerMessageId: string;
recipient: string;
kind: "delivered" | "transient_bounce" | "permanent_bounce" | "complaint" | "unknown";
occurredAt: Date;
raw: unknown;
};
interface MailTransport {
verifyDomain(domain: string): Promise<"pending" | "verified" | "failed">;
sendReport(message: SendReport): Promise<AcceptedMessage>;
pollEvents(cursor?: string): Promise<{ events: DeliveryEvent[]; nextCursor?: string }>;
}
MailTransport is an application-owned adapter, not a claim that every service exposes those exact operations. The adapter may receive pushed events, call a documented polling API, or support both. It is also the only place that should translate external event names. That stops a provider-specific string from leaking into checkout, report generation, or customer support tools.
Before uploading the attachment, the worker checks the normalized recipient against a suppression table. Normalize conservatively: trim surrounding whitespace and apply domain handling that the relevant standards support, but don't invent mailbox equivalence rules such as removing dots or plus tags for every domain. Store the address needed for operations with restricted access and a retention policy appropriate to the business; a hash can support lookups, but it does not automatically make the underlying personal data anonymous. Regional legal and retention decisions need qualified review, especially when US and EU customer data share infrastructure.
Then make the send attempt idempotent. The report job's stable attemptId gets a unique database constraint. If a queue redelivers the job after the process loses its connection, the worker reads the existing attempt before deciding whether another external send is allowed. Provider idempotency support can help when it is documented, but the application database remains the source of truth for the business action.
async function deliverReport(input: SendReport, deps: {
db: ReportMailStore;
mail: MailTransport;
}): Promise<void> {
if (await deps.db.isSuppressed(input.recipient)) return;
const attempt = await deps.db.beginOnce(input.attemptId, input.recipient);
if (attempt.status !== "ready") return;
const accepted = await deps.mail.sendReport(input);
await deps.db.markAccepted(
input.attemptId,
accepted.providerMessageId,
accepted.acceptedAt,
);
}
There is an uncomfortable edge here. If the external sender accepts the message and the database write fails, blindly retrying can duplicate the report. The practical answer is a reconciliation state, a deterministic message key when the provider documents one, and an operator-visible queue for ambiguous attempts. Exactly-once delivery across an HTTP boundary isn't a promise this worker can make by itself. For a weekly shipping cadence, a small review queue beats a complex transaction scheme that still cannot atomically commit in two systems.
Attachment handling deserves the same restraint. Generate the PDF once, calculate and store its digest, set an explicit content type and filename, and enforce a product-level size limit below the selected sender's documented ceiling. Don't log attachment bytes or full recipient data. Tests should cover a zero-byte file, a filename with spaces, duplicate job delivery, a recipient suppressed between generation and send, and an event delivered twice.
How can a startup reconcile transactional email bounce, complaint, and suppression events?
A suppression record needs a reason, source event, event time, and audit trail. A single Boolean loses too much information: a complaint has a different remediation path from a typo corrected by the customer, while a transient bounce should not silently become permanent because a retry counter happened to reach zero. Keep provider suppression features enabled when they protect reputation, but mirror the decision your application needs so changing an adapter doesn't erase policy.
The event consumer should process each external event once and update by rule. Complaints and documented permanent bounces add a suppression. Deliveries close an attempt but do not erase an earlier complaint. Transient bounces increment attempt state and enter a capped retry schedule. Unrecognized events are stored, counted, and reviewed. The raw payload is evidence; the normalized row is policy.
This is where cheap implementations often become costly. A cron task that downloads a list and replaces a local CSV looks fine at ten messages, then loses ordering when a late event arrives. A webhook that updates rows without event deduplication looks fast, then increments the same bounce twice after redelivery. Neither failure requires exotic infrastructure. A relational table with unique event IDs, transactions around event application, and a dead-letter path is enough for the first version.
Observe decisions rather than vanity totals. Useful signals include event-consumer lag, unclassified event count, permanent-bounce rate by sending subdomain, complaint count, poll cursor age, duplicate event count, and ambiguous send attempts. Alert on a change from the service's own baseline and investigate by campaign type or report template. Cross-provider benchmark claims are weak unless message mix, recipient population, authentication, and measurement windows match.
Test recurring work, not brochure price
Evaluate candidates with a short proof, using the same report attachment and the same test harness. The point is not to crown a winner. It is to expose work that a pricing page cannot show.
| Decision area | Evidence to collect | Cost if missing |
|---|---|---|
| Domain setup | Documented DNS records, verification status, and automation boundary | Manual deployment checks |
| Event flow | Push delivery, polling semantics, stable IDs, retention window | Reconciliation code and operational lag |
| Suppression | Account-level behavior, reason detail, export and lookup controls | More policy state in the application |
| Attachments | Documented size and encoding rules | Preflight and storage work |
| Operations | Request IDs, event timestamps, logs, and test mode | Longer support investigations |
| Data governance | Processing locations, retention controls, and contractual terms | Legal and migration work |
Build one adapter spike per serious candidate. Measure implementation hours, required services, failure recovery steps, and the number of manual dashboard actions. Unit price belongs in the sheet once, based on expected volume and the current published terms, but it should not decide the architecture. A nominally inexpensive sender that requires a custom event bridge, manual domain checks, and a weekly suppression import consumes the resource a solo founder has least of: focused engineering time.
The catch is that the thin managed stack is not suitable when mail is the product, when the business needs control over mail transfer infrastructure, or when a regulated workflow demands a deployment and audit model the service cannot provide. In those cases, choose a specialist platform or operate more of the mail system, accepting the extra on-call and compliance work. A provider with no polling API can still fit when its push event contract and replay facilities meet the recovery plan; a polling-first integration can fit low-volume internal reports when delayed suppression is explicitly acceptable.
No magic.
Do the math in revenue-per-hour terms. I'd outsource reputation handling and transport for ordinary order reports, while owning intent, suppression policy, and reconciliation. That boundary preserves weekly shipping speed without pretending the sender can own business correctness.
Scale only the slow boundary
Keep the interface. Change the machinery behind it.
Move PDF generation and delivery onto separate queues so a slow report does not block accepted mail. Store attachment objects with short retention and pass references between workers rather than copying large buffers through queue payloads. Partition event consumption only after ordering and deduplication rules are explicit. Add per-tenant quotas, controlled concurrency, and a circuit that pauses new sends when event lag crosses the business threshold.
A second delivery adapter may become worthwhile for contractual resilience or regional requirements, but failover is not a switch you flip after an error. Domains need prior authentication, templates must render the same way, event types need equivalent mappings, and suppression policy must remain shared. Without rehearsal, automatic failover can turn one uncertain send into two accepted messages. Start with a documented manual decision and a tested migration path; automate it only when the incident model justifies the complexity.
The final selection rule stays plain: pick the option that proves domain readiness, preserves recipient safety through a local suppression state machine, supplies recoverable delivery events, and takes the fewest recurring founder-hours to operate. Recheck that result when volume, geography, or compliance obligations change.
References
- https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- https://www.rfc-editor.org/rfc/rfc5321
- https://www.rfc-editor.org/rfc/rfc3463
- https://www.rfc-editor.org/rfc/rfc7489
- https://datatracker.ietf.org/doc/html/rfc8058
- https://www.ctia.org/the-wireless-industry/industry-commitments/messaging-interoperability-sms-mms
Top comments (0)