Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

9 Transactional Email Mistakes That Show Up in Production

Your checkout succeeds, but the receipt never arrives. A customer clicks “resend” and gets two copies. A password reset lands twenty minutes later, after the link has expired. A successful test email tells you little ab

Your checkout succeeds, but the receipt never arrives. A customer clicks “resend” and gets two copies. A password reset lands twenty minutes later, after the link has expired.

A successful test email tells you little about how the integration behaves under failure. Production adds timeouts, retries, process crashes, DNS changes, and delivery events that arrive after your application has moved on.

Here are nine mistakes worth checking before your users discover them. The examples use SMTPfast, but the application patterns apply across email providers.

1. Treating an API success response as proof of delivery

This is an easy shortcut to ship:

await emailProvider.send(message);
await markReceiptDelivered(order.id);

The provider accepted your request. Your application immediately records that the customer received the receipt.

Those are different events.

Submission, recipient-server acceptance, inbox placement, and reading are separate stages. Even a successful SMTP handoff does not establish that a message appeared in someone’s inbox. SMTP defines responsibility for accepting and transporting a message; it does not provide a read receipt. RFC 5321

The fix: record what you actually know.

Start with an application delivery record containing:

notification_id
business_event_id
provider_email_id
submitted_at
delivered_at
bounced_at
last_error

Store the provider’s email ID when submission succeeds. Update delivery information from webhooks or reconciliation jobs.

For example, SMTPfast exposes message details and delivery events through GET /v1/emails/:id. That gives support a path from an order to the provider’s recorded outcome. Emails API

A useful support answer is: “The recipient server accepted it at 14:32.” “Our API call succeeded” leaves most of the investigation unfinished.

2. Sending email inside the HTTP request

A checkout handler often starts like this:

const order = await saveOrder(input);
await sendReceipt(order);
return response.json(order);

Now provider latency affects checkout latency.

If sending fails after the order commits, the customer may see an error even though the purchase succeeded. If the process crashes between saving the order and sending the receipt, the order exists without any durable record that email remains to be sent.

Moving the send into an untracked background promise leaves the crash problem intact.

The fix: write the business change and an outbox record in the same database transaction.

This simplified SQL illustrates the boundary:

BEGIN;

INSERT INTO orders (id, customer_id, total_cents)
VALUES (:order_id, :customer_id, :total_cents);

INSERT INTO email_outbox (
    notification_id,
    business_event_id,
    kind,
    status
)
VALUES (
    :notification_id,
    :order_id,
    'order_receipt',
    'pending'
);

COMMIT;

A worker claims pending rows and submits the emails.

Use a uniqueness constraint for the logical notification, such as (business_event_id, kind, recipient_id), so processing the same business event twice does not create two receipts.

The worker also needs a recoverable claim or lease. If it dies mid-send, another worker must eventually be able to continue.

That recovery introduces the next problem.

3. Retrying a timed-out send without an idempotency key

Consider this sequence:

Worker submits receipt
Provider accepts receipt
Response is lost
Worker records a timeout
Worker retries
Customer receives two receipts

A timeout means your application does not know the outcome. It does not prove that the provider rejected the message.

The fix: assign an idempotency key to the logical send and preserve it across attempts.

For a provider endpoint that supports it, the request might include:

Idempotency-Key: receipt:order_4821:v1

Generate and store the key before the first attempt. Reuse the same request body when retrying that operation.

This defeats the purpose:

// Every retry becomes a new operation.
const idempotencyKey = crypto.randomUUID();

SMTPfast documents a 24-hour idempotency window for its batch endpoint, with a conflict response if the same key is reused with a different body. Check the exact endpoint’s contract before relying on it. Batch sending documentation

Your database uniqueness constraint prevents duplicate jobs. Provider idempotency addresses repeated submission after an uncertain outcome. You need both boundaries.

Once the provider’s deduplication window expires, an unresolved send needs reconciliation or an explicit decision about duplicate risk.

4. Retrying until a useful email becomes a useless one

A receipt delayed by ten minutes can still help.

A password reset delivered after its token expires sends the user straight back to support.

Treating every notification as “retry until successful” ignores the lifetime of the action inside the message.

The fix: give each job a deadline and check its business state before submitting it.

For example:

kind: password_reset
created_at: 12:00
token_expires_at: 12:15
send_before: 12:10

The five-minute margin is an application choice, not a delivery guarantee. It leaves some time for downstream delivery and the user to act.

Before submission, check whether the job expired, the token was revoked, or a newer request superseded it.

Classify failures too:

Failure Worker action
Invalid payload Stop retrying; fix the request
Invalid credentials Alert; stop repeated attempts
Rate limit Reschedule using provider guidance
Temporary provider failure Retry with backoff and jitter
Timeout with unknown outcome Retry using the same idempotency key
Expired or superseded notification Mark expired or cancelled

SMTPfast supplies Retry-After on rate-limit responses. Respect it rather than letting every worker retry immediately. Rate limits documentation

An application deadline stops future submissions. It cannot recall a message already handed to the mail system.

5. Treating DNS authentication as a launch-day task

You verify the domain, send a test email, and close the ticket.

Months later, someone migrates DNS or removes an unfamiliar record. Authentication starts failing while the application continues submitting messages successfully.

The fix: make email DNS part of your infrastructure inventory and change review.

Keep these roles clear:

  • SPF authorizes sending infrastructure for the envelope sender domain.
  • DKIM signs messages using a signing domain.
  • DMARC checks whether passing SPF or DKIM aligns with the visible From domain and publishes a handling policy.

A passing SPF check for an unrelated domain does not, by itself, establish DMARC alignment. Google’s sender guidelines describe the alignment requirement for direct bulk email. Gmail sender guidelines

After a DNS migration, check the actual records and inspect authentication results on a received message.

Useful checks include:

dig +short TXT bounce.your-domain.com
dig +short TXT _dmarc.your-domain.com
dig +short CNAME your-selector._domainkey.your-domain.com

Replace those names with the hostnames your provider supplies.

Do not add a second SPF record at a hostname that already has one. Update the existing policy carefully. SMTPfast’s domain documentation covers this and the separate records used for its bounce subdomain. Domains documentation

6. Logging bounces and complaints without acting on them

A webhook arrives:

{
  "type": "email.bounced",
  "data": {
    "id": "email_abc123"
  }
}

Your handler writes a log line and returns success.

Tomorrow, the application tries the same address again.

The fix: make delivery feedback change application behavior.

A permanent bounce can mark an address as undeliverable and prompt the customer to correct it through another channel. A complaint should prevent continued sending according to your provider’s suppression policy.

Temporary delivery problems need different handling. Avoid treating every delayed message as a permanently invalid address.

SMTPfast automatically adds hard bounces and complaints to its suppression list and checks that list before sending. Your application should also use the outcome to stop creating futile jobs and explain the problem to users. Suppressions documentation

Do not automatically remove a suppression because a customer clicked “resend.” That button supplies no evidence that the underlying problem changed.

7. Applying the same unsubscribe rules to every email

A customer unsubscribes from a newsletter. Your application flips:

email_enabled = false

Later, they request a password reset. Your worker sees the flag and discards it.

One boolean has combined several unrelated decisions.

The fix: model marketing preferences separately from delivery eligibility.

For example:

marketing_opted_out
address_undeliverable
provider_suppressed

Then classify each notification by its actual purpose.

A newsletter opt-out should stop newsletters. A hard bounce still makes the address unusable for a receipt. Calling a promotion “transactional” does not make it an account notification.

With SMTPfast, unlabelled sends default to marketing. Explicitly set the category for genuine transactional messages:

{
  "category": "transactional"
}

Marketing-only suppressions allow those messages through; hard-bounce and complaint suppressions block both categories. Email categories, suppression scopes

Test this distinction directly: unsubscribe a test contact from marketing, then verify that an eligible transactional notification still follows the intended path.

8. Assuming webhook delivery happens exactly once

Your webhook handler updates the database, but the response never reaches the provider.

The provider retries. Your handler applies the same side effect again.

There is another failure window: the handler returns success, then crashes before saving the event. The provider considers delivery complete, but your application lost it.

The fix: authenticate the request, persist it durably, and make processing safe to repeat.

A useful sequence is:

Receive raw request
→ Verify signature
→ Persist event with a deduplication key
→ Return success
→ Process asynchronously

For SMTPfast, signature verification uses an HMAC over the raw request body. Parsing JSON and serializing it again can change the bytes being verified. Webhook verification documentation

Use a stable event identifier when the provider supplies one. Keep delivery-attempt identifiers separate. If the payload lacks an event ID, derive a deduplication strategy from the provider’s documented retry behavior.

Also avoid replacing the entire message state with whichever event arrived last. A delayed event should not erase a later delivery outcome.

SMTPfast retries unsuccessful webhook deliveries, so repeated requests are an expected integration path. Webhook retry documentation

9. Testing the template while skipping the failure paths

The preview looks good. The button works. The test message reaches your inbox.

That covers rendering and one successful send.

The worker, retry policy, and webhook handler need their own tests.

The fix: exercise the boundaries where work can be lost or repeated.

Test Expected behavior
Worker crashes before submission Job becomes eligible for processing again
Provider accepts; response is lost Retry preserves the idempotency key and body
Provider returns 429 Worker reschedules according to retry guidance
Same webhook arrives twice Side effects happen once
Database is unavailable during webhook receipt Handler does not acknowledge an unpersisted event
Reset job passes its deadline Worker marks it expired
Recipient has a hard-bounce suppression Worker does not keep resubmitting
Older event arrives late It does not overwrite newer delivery information

Use a fake provider for deterministic failure tests. Reserve real test sends for integration checks with addresses you control.

Give support a practical lookup path too: business event → notification → provider email ID → delivery history. Record the template version and timestamps, while keeping reset tokens and full message bodies out of routine logs.

For password resets and sign-in links, review click tracking as well. SMTPfast documents that tracked links pass through a redirect and their URLs are recorded; disable click tracking for links containing secrets. Tracking documentation

The next time you test transactional email, let one request time out after acceptance. Deliver a webhook twice. Stop the worker between submission and saving the response.

Those tests tell you whether the integration can survive the failures your users will eventually encounter.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.