Postmark, SendGrid, and Mailgun are all plausible API-only transactional email candidates, but a payment-settled event can be delivered more than once while an order receipt must represent one durable business fact. That operational constraint changes the setup decision: choose who owns the templates before comparing deliverability claims.
Short answer: keep the receipt contract, version, render inputs, and idempotency key under application-team control. Postmark, SendGrid, and Mailgun can all be candidates for the delivery boundary, but the safest beginner setup is the one that lets a team reproduce the exact message after a retry, review a template change like code, and switch delivery adapters without redefining what the customer was told. Deliverability starts with authenticated identity and honest event handling; a friendly editor cannot compensate for an ambiguous send contract.
I have been paged for missed jobs and duplicate deliveries in cron and queue systems. The useful lesson was not that queues are unreliable. It was that delivery is normally at least once at the boundary that matters, so a handler pretending an event occurs exactly once creates the incident. For an order receipt, the invariant is tighter: one settled payment maps to one logical receipt, even if workers crash, acknowledgments disappear, or a provider request is retried.
What should beginners test in API-only transactional email templates?
A beginner usually sees template ownership as a convenience choice: edit HTML in a dashboard or keep it in the repository. In production, it is an incident-ownership choice. The owner decides which version was sent, how substitutions are validated, who approves changes, and whether an old event can still be rendered after the current design moves on.
For a payment receipt, I would define a small message contract in the commerce service and give every revision an immutable identifier such as order-receipt-v3. The event should contain stable business data or references to a stable snapshot: order ID, payment settlement ID, recipient, currency, line items, tax, and total. Do not let the delivery worker infer the paid total from a mutable shopping cart.
A provider-hosted template can still fit this model if the application pins an immutable template version and deployment records connect that version to a reviewed change. A repository-owned template makes code review and local rendering straightforward, but the application team then owns escaping, multipart output, compatibility testing, and the deployment path. A hybrid can work too: the repository owns the typed contract and fixture set, while an external renderer owns presentation. The dangerous option is unversioned mutable content addressed by a friendly name. A retry tomorrow may then say something different from the first attempt today.
This is where the three named candidates belong in the evaluation. Treat Postmark, SendGrid, and Mailgun as separate adapters and run the same acceptance test against each current API and template workflow. Record objective results for template immutability, version selection, test rendering, webhook authentication, suppression behavior, and exportability. Those results can change, so they belong in a dated decision record backed by each service's current documentation, not in a timeless ranking. None of the names changes the receipt invariant.
One receipt needs one durable state transition
The worker needs a durable claim before it performs the external side effect. A practical design writes an outbox record in the same database transaction that records payment settlement. A dispatcher then claims that record, renders a pinned version, and calls a narrow delivery interface. If the process loses the response, it retries with the same logical key.
Here is the shape I want reviewers to see. Storage details are intentionally behind an interface because atomic claim semantics depend on the database, but the state transitions are explicit.
package receipt
import (
"context"
"errors"
"fmt"
)
type Job struct {
OrderID string
SettlementID string
Recipient string
Template string
RenderedMIME []byte
}
type Store interface {
Claim(ctx context.Context, settlementID string) (claimed bool, err error)
MarkDelivered(ctx context.Context, settlementID, providerMessageID string) error
ReleaseForRetry(ctx context.Context, settlementID string) error
}
type Sender interface {
Send(ctx context.Context, recipient string, mime []byte, idempotencyKey string) (string, error)
}
func Deliver(ctx context.Context, store Store, sender Sender, job Job) error {
if job.Template == "" || len(job.RenderedMIME) == 0 {
return errors.New("receipt must be rendered from a pinned template")
}
claimed, err := store.Claim(ctx, job.SettlementID)
if err != nil {
return fmt.Errorf("claim receipt: %w", err)
}
if !claimed {
return nil
}
messageID, err := sender.Send(
ctx,
job.Recipient,
job.RenderedMIME,
"receipt:"+job.SettlementID,
)
if err != nil {
if releaseErr := store.ReleaseForRetry(ctx, job.SettlementID); releaseErr != nil {
return fmt.Errorf("send failed: %v; release failed: %w", err, releaseErr)
}
return fmt.Errorf("send receipt: %w", err)
}
if err := store.MarkDelivered(ctx, job.SettlementID, messageID); err != nil {
return fmt.Errorf("record delivery: %w", err)
}
return nil
}
The code does not manufacture exactly-once delivery. It makes the duplicate boundary visible. If a provider honors an idempotency key for the relevant operation, the adapter passes the stable settlement-derived key through according to that provider's documented semantics. If it does not, the system may still face an ambiguous outcome after a timeout. The runbook must say so, and the reconciliation path should search durable delivery events before an operator replays the job.
Keep the rendered MIME or a content hash with the job when audit requirements demand proof of what was sent. Do not log the full body by default. Receipts contain personal and purchase data, and observability does not require copying that data into every log index.
Open pixels are not paging signals
Domain authentication is a protocol concern, not a dashboard checkbox. DMARC publishes a policy for messages that fail the identifier-alignment checks described by the standard and supports aggregate reporting. Before sending production receipts, verify the authenticated domain, alignment, DNS ownership, and rollback procedure. Use a dedicated sending identity whose operational purpose is clear, then monitor rejection and complaint signals through authenticated event ingestion.
Open events are weak evidence. Apple Mail Privacy Protection can download remote content in the background and prevent senders from learning whether a recipient opened a message. A receipt pipeline should therefore measure accepted submissions, provider outcomes, bounces, complaints, and support reports; it should not page an operator because an open pixel stayed quiet.
A delivery webhook is also an input from outside the trust boundary. Authenticate it using the documented mechanism, retain the provider event identifier, deduplicate before applying state, and preserve the raw timestamp separately from processing time. Unknown event types should be observable and quarantined rather than silently treated as success.
The key dashboard is small: oldest pending receipt, pending count, terminal failure count, retry count, and the age of the last successfully processed delivery event. Alert on customer impact and stalled progress. Raw request volume alone is noise.
Run the recovery drill before choosing
Do not start the comparison by timing how quickly a sample message reaches your own inbox. That test skips the ownership questions that become expensive later. Build one fixture for a settled order with two line items, tax, a discount, and a non-ASCII customer name. Then evaluate every candidate through the same deployment and recovery drill.
- Render the pinned template and inspect both HTML and plain-text parts.
- Submit the same logical receipt twice with the same key, then document the observed and documented duplicate behavior.
- Rotate the template to a new version while an old job remains pending; verify that the old job keeps its original content.
- Feed the event consumer a duplicate and an unknown event type. Confirm that neither corrupts receipt state.
- Revoke a credential and rotate it using the runbook. Confirm that queued work remains recoverable.
The resulting table should contain evidence, not adjectives. Useful columns are contract owner, immutable version selector, local test path, duplicate boundary, webhook verification method, data retention control, export path, and operational owner. Fill it from a hands-on test and dated primary documentation for each candidate. “Easy” then has a defensible meaning: the smallest system the team can operate correctly during a retry and a template rollback.
Commercial terms belong in the decision record, but they are not the main argument. Model normal volume, retry amplification, retention, support needs, and engineering ownership. A nominal send price cannot tell you how much an unreviewed receipt change or an ambiguous replay will cost.
The boundary that fails the replay test
Repository ownership is not universal. Its main limitation is that the application team must operate rendering, preview, compatibility testing, and deployment. A small team with frequent copy changes and no safe internal preview tooling may rationally choose a hosted editor, provided it can pin versions, constrain publishing access, and retain a review trail. A regulated workflow may require a content archive and approval controls that make a managed rendering boundary preferable. That trade-off should be decided by the team that will answer the next incident, not by whichever setup wizard has fewer screens.
There is another exception: if the message is a non-transactional campaign whose audience, schedule, consent, and experimentation live in a specialized system, forcing application engineers to own every template can create the wrong control plane. An order receipt after payment settles is different. Its content is coupled to a business event and must remain reconcilable with that event.
Use the narrowest ownership boundary that preserves that coupling. Then rehearse the ugly path: a worker timed out, the status is unknown, the template changed, and support wants to resend. If the on-call engineer can identify the intended content, determine prior outcome, and replay without inventing a new receipt, the setup is ready.
Boring wins.
Sources
References:
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
- Apple, Use Mail Privacy Protection: https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
Top comments (0)