CodeKits.shopNew developer offer

Build an honest sent-email history

Model queued, accepted, rejected and unconfirmed email attempts without confusing SMTP acceptance with delivery to the recipient's inbox.

By Jonathan · 4 min read

Published

Email history separates queued, accepted, rejected and unconfirmed states
In this article

“Sent” is an attractive label because it is short. It is also ambiguous. Did the application queue the message, did an SMTP server accept it, or did the recipient receive it? A useful email history stores evidence for each stage instead of compressing them into a green tick.

This tutorial builds the state model for a Node.js email worker. It assumes durable message and attempt records and an SMTP adapter such as Nodemailer. The pure classifier is runnable without a mail account. Real delivery, provider callbacks and inbox placement are outside the local test scope.

Separate a message from its attempts

A message is the intended communication. An attempt is one interaction with a transport. Store a stable message ID, a unique attempt ID, start and finish times, provider message ID when present, and a redacted error category. Do not overwrite the previous attempt every time a retry runs.

Track recipients separately. A single SMTP transaction can accept one recipient and reject another. Retrying the entire recipient list then risks delivering a second copy to those already accepted. Nodemailer's SMTP documentation describes transport results; the SMTP standard explains that acceptance transfers responsibility, not proof of inbox placement. See RFC 5321.

Use labels that match the evidence

Use queued when a durable job exists, sending while a worker owns its lease, accepted after a positive transport result, and rejected after an explicit refusal. A lost connection after message data has been submitted may be unconfirmed: neither successful delivery nor safe non-delivery has been established.

attempt-state.mjs
export function classifyAttempt({ accepted = [], rejected = [], error }) {
  if (error) {
    // The adapter must positively establish these classifications.
    if (error.definitelyNotSubmitted === true) return "retryable";
    if (error.definitiveRejection === true) return "rejected";
    return "unconfirmed";
  }
  if (accepted.length && rejected.length) return "partial";
  if (accepted.length) return "accepted";
  if (rejected.length) return "rejected";
  return "unconfirmed";
}

Those two error flags are your adapter's contract, not built-in Nodemailer properties. Do not infer “definitely not submitted” from every timeout. A connection failure before any submission is different from a timeout waiting for the final response. If the adapter cannot distinguish them reliably, keep the conservative unconfirmed state.

A database failure after an accepted send creates the same uncertainty. When a processing lease expires, recover the attempt as unconfirmed rather than silently treating it as a fresh queued job. A unique application message ID helps correlation but does not force every mail server to deduplicate messages.

Render actionable history

Show “Accepted by mail server” with a timestamp, not “Delivered”, unless a trusted provider delivery event supports the latter. Even a delivery callback typically means acceptance by the recipient's mail infrastructure, not that a human read the message. Open tracking is neither required nor reliable proof of reading.

For a partial result, show per-recipient states only to authorised staff. Give retries their own attempt rows. Allow an operator to choose whether to resend an unconfirmed attempt, with a warning that the recipient may already have a copy. A provider with explicit idempotency support can reduce this risk, but its time window and semantics still matter.

Test without emailing anyone

Use a fake adapter returning one accepted address, one rejected address, both together, and a thrown timeout. Verify the classifier produces accepted, rejected, partial and unconfirmed. Then simulate an error before submission and verify it is retryable only when the adapter supplies that evidence.

For MIME tests, Nodemailer's stream transport generates a message locally rather than sending it. It proves composition, not SMTP acceptance. Do not present a stream-transport test as evidence that deliverability works.

Test the worker crash window: record sending, return mock acceptance, then fail the database update. Recovery should retain the uncertain attempt. Test that a repeated provider callback is idempotent and cannot change a later definitive state back to queued. Authenticate callbacks before trusting them.

Troubleshooting and retention

If every failure is retried immediately, inspect the distinction between temporary rejection, permanent rejection and uncertainty. If the UI says sent before a worker runs, it is displaying queue state under the wrong name. If users receive duplicates, inspect lease expiry and retry policy.

Store only the delivery evidence you need. Bodies, attachments and recipient lists create a larger privacy and access-control burden than status metadata. The CodeKits email and newsletter service is relevant when the history, compose screen and worker need to operate as one workflow.