Short answer: Choose the transactional email API that preserves a small SaaS team's delivery invariants, then compare its total operating burden; don't choose from the lowest headline price.
A welcome message is part of account creation, so retry behavior, suppression handling, consent evidence, and provider replacement matter more than a tiny difference in unit cost.
This is the decision: put a narrow, asynchronous mail boundary behind the Node.js application, keep policy and audit data in the application domain, and test Postmark, Resend, and Mailgun with the same acceptance cases before signing a contract. No public comparison can declare one of them universally cheapest because volume, retained data, support needs, and engineering time vary. The correct answer is conditional.
Record the decision and its limits
The application owns the fact that a welcome email is due. The delivery provider owns an attempt to transmit it. Those are different responsibilities, and collapsing them makes signup availability depend on an external delivery call.
The proposed boundary is an outbox record written with the user-creation transaction, followed by a worker that renders and submits the message. The worker records an opaque provider message identifier and normalized delivery state, while the application retains the business reason, template revision, recipient policy, and consent evidence it actually needs. SPF is part of sender authorization, not proof that an individual message reached an inbox; RFC 7208 defines SPF in terms of whether a sending host is authorized to use a domain in SMTP identities.
Keep that distinction sharp.
This ADR does not select a universal winner. It isn't suitable when the application sends only a handful of low-consequence internal messages and a synchronous call is an acceptable failure boundary. It also doesn't settle legal roles or retention periods: those depend on the actual deployment, contract, and processing context, which must be reviewed rather than inferred from an API landing page.
Name the invariants and failure boundaries
Start with invariants that survive a vendor change. A signup creates no more than one logical welcome-message intent. Retrying an uncertain submission must not create a second intent. A suppressed or ineligible address must stop before rendering. Every attempt must be traceable to a template revision without storing more message content than the team has decided to retain. Provider callbacks are untrusted input until authenticated, parsed, and matched to a known message.
Then draw the failure boundary around signup. The account transaction should succeed or fail based on account rules, not on mail-provider latency. After commit, a worker may retry a temporary outcome with bounded backoff. A permanent rejection closes the attempt and creates an operational signal; it should not spin forever. Delivery events can arrive late or out of order, so the state transition code must be monotonic and idempotent. This is where welcome mail and OTP mail diverge. A delayed welcome message is annoying. A delayed OTP can lock someone out while its replacement arrives first, so its expiry, issuance sequence, and retry policy need a separate design. I've dealt with delivery gaps and rate limits in these flows, and combining both message classes behind one retry policy hides exactly the edge cases operators need to see — the mail transport looks shared, but the product consequences are not.
Deliverability also needs its own evidence. Authentication configuration, suppression behavior, bounce classification, complaint handling, and content changes belong in the rollout checklist. Postmark's transactional email guide is useful background for those operational concerns, but the architecture should express them without importing a provider's vocabulary into the domain model.
How should a small EU SaaS choose a transactional welcome email API for Node.js?
Run the same acceptance suite against Postmark, Resend, and Mailgun. The shortlist names came from the purchasing question; they aren't a ranking. Ask each provider the same questions using its current contract and documentation, because I'm not sure a static article can resolve the deployment-specific data-processing and retention terms a particular EU company will receive.
Compare architectural options first:
| Option | Signup failure boundary | Duplicate control | Operational cost | Best fit | Main limitation |
|---|---|---|---|---|---|
| Direct call during signup | Includes provider submission | Often tied to request retries | Low initial code, higher incident coupling | Low-consequence internal tools | Provider delay can delay account creation |
| Transactional outbox plus worker | Ends at the local transaction | Stable logical message key | Worker, queue polling, and reconciliation | Customer-facing SaaS with reliable welcome delivery | More components to operate |
| Managed workflow outside the app | Ends at an emitted domain event | Depends on workflow identity rules | Less application code, more external configuration | Teams already operating a governed event platform | Policy and audit context can become split across systems |
For the vendor exercise, use pass/fail gates before price. Verify domain authentication instructions against the DNS change process. Confirm how suppression, bounce, and complaint events are represented. Test idempotent submission behavior, webhook authentication, event reordering, export and deletion workflows, access controls, data location commitments, contract terms, and support escalation. Finally, estimate total cost from the team's own send volume and operating model. Do this once; don't let a promotional unit rate become the architecture.
The cheapest acceptable choice is the candidate that passes every mandatory gate and has the lowest modeled total burden. If two pass, prefer the one that makes replacement cheap: a small adapter, portable templates, exportable event history, and no provider-specific state in signup logic. Your mileage may vary because support load and compliance review time are local costs, not universal constants.
Put the critical path behind one port
Even in a Node.js service, the interface matters more than the SDK shape. The following Python model is intentionally small enough to translate into a TypeScript port. It shows the ownership boundary: domain code creates an intent; an adapter submits a rendered envelope; event handling advances normalized state.
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
class DeliveryState(Enum):
QUEUED = "queued"
SUBMITTED = "submitted"
DELIVERED = "delivered"
PERMANENTLY_REJECTED = "permanently_rejected"
@dataclass(frozen=True)
class Envelope:
logical_id: str
recipient: str
template_revision: str
subject: str
plain_body: str
html_body: str
@dataclass(frozen=True)
class Submission:
provider_message_id: str
class TransactionalMailer(Protocol):
def submit(self, envelope: Envelope) -> Submission:
...
def dispatch_welcome(
intent: dict,
mailer: TransactionalMailer,
repository,
) -> None:
if repository.is_suppressed(intent["recipient"]):
repository.close_as_ineligible(intent["logical_id"])
return
if repository.has_submission(intent["logical_id"]):
return
envelope = Envelope(
logical_id=intent["logical_id"],
recipient=intent["recipient"],
template_revision=intent["template_revision"],
subject=intent["subject"],
plain_body=intent["plain_body"],
html_body=intent["html_body"],
)
submission = mailer.submit(envelope)
repository.record_submission(
logical_id=envelope.logical_id,
provider_message_id=submission.provider_message_id,
state=DeliveryState.SUBMITTED,
)
There is a subtle race here: checking has_submission and later recording the submission is not enough if two workers can claim the same intent. The repository therefore needs an atomic claim or lease before this function runs, plus a stable logical identifier passed through the adapter when the selected API supports that concept. If an outcome is uncertain, reconciliation should inspect recorded provider events before another submission. Blind retries are how a pleasant welcome turns into three identical messages.
The test suite should force concurrent claims, duplicated callbacks, reversed callback order, suppression before dispatch, template rendering failure, and adapter timeout. It should also verify that logs omit message bodies and recipient data unless the documented operational policy requires them. These cases are far more revealing than a happy-path SDK snippet.
Why reject a provider-specific signup call?
The rejected option is importing one provider client directly into the account-creation handler and waiting for submission. It has a valid use case: a small internal tool where occasional delayed signup is tolerable, the message has no compliance-sensitive workflow, and the team values minimal machinery over isolation. Stick with that simpler design while those conditions remain true.
For a customer-facing SaaS, the catch is coupling. Request retries can become message retries, provider terminology leaks into account code, and a future migration touches the most sensitive path in the application. An outbox and adapter don't guarantee inbox placement, but they make failure visible and replacement bounded. That is the durable reason to use them.
Revisit the ADR when volume, message criticality, jurisdictions, or team operations change. Re-run the same acceptance suite, review current contracts, and preserve the invariants. The provider is replaceable; the delivery policy is part of the product.
Top comments (0)