This post describes a general purpose email sending system, without tying it to any technology. What the email is (an OTP, a notice, a report alert…) is not this system’s problem — that belongs to whoever triggers it. The job here is one clear thing:
Accept the request “send an email to these people, with this template and these parameters”, and carry it out reliably.
Java, Node, Python, Go, .NET — the same principles hold whatever the stack is. There is a mapping table at the end, so you can translate it into your own.
1. Three facts that shape the problem
Sending is an external dependency
Email leaves through a provider you do not control (SMTP, or an API based service: SES, SendGrid, Mailgun…). The provider can get slow or return errors. Your system has to absorb that — the workflow that wants to send an email should not be affected, and should not have to wait.
The answer: sending is async. The producer only leaves a record and moves on.
Sending is a side effect you cannot undo
You cannot pull back an email that was already sent, and a duplicate email annoys the user. So the usual assumption “running the same job twice does no harm” does not hold here. Stopping two workers from sending the same record is at the centre of the design (claim and the danger window).
The provider will fail sometimes
The system should not give up, but it should not try forever either. Temporary errors are handled with retries — but a retry needs a ceiling and a rhythm (retry).
2. The overall flow
graph TD
W["Any workflow<br/>login · order · report · alarm"]
W -->|"in the SAME transaction<br/>as its own domain work:<br/>INSERT status = PENDING"| T[("email_messages<br/>both the work queue<br/>and the send record")]
T -->|"Model A: DB poll<br/>Model B: relay → broker"| K["Sender worker"]
K --> C["1 · claim: PENDING → SENDING"]
C --> R["2 · render the template now"]
R --> S["3 · send to the provider"]
S --> D["4 · SENDING → SENT"]
S -.->|error| RT["retry policy"]
T -.->|periodic| CL["Cleanup scheduler"]The key point: the producer never waits. The code that wants to send an email runs one INSERT and it is done. Whatever state the provider is in never touches the producing flow.
3. The data model
EmailMessage
id
to -- recipient(s); SNAPSHOT taken at enqueue time, so the record can
-- still say "where did we send it" if the user changes the address
cc -- optional list
bcc -- optional list
template -- template id (e.g. ORDER_CONFIRMED, PASSWORD_RESET)
parameters -- the values the template will be filled with (name, code, order no)
-- NOT rendered
attachments -- list: [{filename, content_type, storage_ref}]
-- a reference to the file, NOT the file itself
status -- PENDING | SENDING | SENT | FAILED
attempt_count -- retry counter
error_message -- a short note about the last error (for diagnosis)
locked_at -- when a worker claimed the job (for stuck-job detection)
created_at
sent_at
Suggested indexes:
| Index | What for |
|---|---|
(status, created_at) | the poll query — FIFO job pickup |
(status, locked_at) | the reaper scan |
created_at | retention cleanup |
Four states are enough: PENDING (waiting), SENDING (a worker took it), SENT (the provider accepted it), FAILED (attempts ran out). Late outcome information like “delivered / bounced” is deliberately out of scope.
Why `template` + `parameters` instead of one `payload` field?
A single payload field tries to do two jobs at once: carry the content, and answer the question “what kind of email was this?”. Splitting it is clearer:
template— which content. It also works as a label; it is enough on its own to filter logs and metrics, so you do not need a separatetypecolumn.parameters— the variables of that content.
This split is also what makes it possible to render at send time.
Do not put attachments in the row as blobs
If the file itself goes into the row, rows grow to kilobytes or megabytes, and both the table and the poll query suffer. The row holds only a reference (an object storage path, a file system path); the worker reads the file from there at send time.
Two practical rules:
- The file behind the reference must not change or be deleted before the email goes out. If the source file is not stable, copy it at enqueue time to a place the system owns, and reference that copy.
- Retention must delete these files together with the record — otherwise you leak “no row in the table, but an orphan file in storage”.
4. Atomic enqueue: the record must be born with the trigger
The domain operation that triggers the email (saving the order, creating the OTP) and the INSERT into email_messages must happen in the same transaction.
Domain first, email record second
If the process dies in between, the order exists but the email record does not → the user never hears anything.
This silent loss is the worst failure mode: nobody sees an error, an email simply does not arrive.
Email record first, domain second
The email goes out, but the thing it talks about does not exist yet — and maybe never will.
The user gets a confirmation for an order that was never created.
As long as the email table lives in the same database as the domain data, this guarantee is free: one transaction, two writes. The name for it in the literature is the outbox pattern — here the table already is a natural outbox.
If the trigger is not a database operation at all, a single-row INSERT is atomic by itself; the API returns “accepted” the moment the row commits.
5. Taking the job: an idempotent claim
Whatever model you pick up work with (polling or a broker), the practical guarantee is at-least-once: a job can land in front of more than one worker because of a crash, a redelivery and so on. If two workers send the same email, you lose the duplicate battle right at the start.
So an atomic “claim” before sending is a must:
UPDATE email_messages
SET status = 'SENDING', locked_at = now()
WHERE id = :id AND status = 'PENDING'
-- 1 row affected: this worker won, carry on
-- 0 rows affected: somebody else took it, back off quietly
UPDATE email_messages SET status = 'SENDING', locked_at = now()
WHERE id IN (
SELECT id FROM email_messages
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT :N
FOR UPDATE SKIP LOCKED -- competing workers do not wait for each other
)
RETURNING *
Because the update and its condition are one atomic step (compare-and-swap), only one of two competing workers wins.
6. Jobs stuck in SENDING: stuck-job recovery
The claim above leaves a gap. If a worker takes the job and then dies before finishing it (OOM, a deploy, a lost node), the record stays in SENDING, and no claim looking for status = 'PENDING' will ever pick it up again → an email stuck forever, never sent.
The answer: “a job that was claimed but did not finish in a reasonable time counts as unclaimed” — the database version of the visibility timeout in messaging systems. A scheduled reaper releases them:
UPDATE email_messages
SET status = 'PENDING', locked_at = NULL
WHERE status = 'SENDING'
AND locked_at < now() - INTERVAL 'X'
Sending an email is a short job (one API call), so X can be aggressive — one or two minutes.
Two rules that go with the reaper
Always put a client timeout on the provider call. A call without a timeout can hang forever, which breaks the reaper’s idea of “a reasonable time” and makes it release a record while the job is still running.
Know that a released job will be sent again. This ties directly into the next section.
7. The danger window: “I sent it but died before writing SENT”
sequenceDiagram
participant W as Worker
participant DB as email_messages
participant P as Provider
W->>DB: claim → SENDING
W->>P: send
P-->>W: accepted
Note over W,DB: if the worker dies right here:<br/>the email WENT OUT but the record stays SENDING
W->>DB: status = SENTLooking at a record stuck in SENDING, the reaper cannot tell: did the crash happen before the call (the email did not go out → try again), or after it (it went out → trying again means a duplicate)?
You cannot do a side effect on the outside world and your own record in one atomic step. Exactly-once does not exist. The choice is between at-least-once (a rare duplicate) and at-most-once (a rare loss).
A pragmatic default: accept at-least-once. The reaper releases the job, it is sent again, and in a very rare crash the user gets the same email twice.
For almost every kind of email, a duplicate is much less bad than a loss: a confirmation that arrives twice is odd, an email that never arrives is a fault. The important thing is that the decision is made on purpose.
You can shrink the risk:
- Some provider APIs accept an idempotency token — they swallow a second request with the same token themselves. Use the record
idas the token. - Narrow the window mechanically: write SENT the moment the call returns, with nothing else in between.
You manage the risk. You cannot remove it.
8. Retry: backoff and a max attempt count
If the provider returns an error, the job does not go straight to FAILED — but it is not retried forever either.
- A counter and a ceiling. Every failed attempt raises
attempt_count; when it reachesmax_attempts(typically 3–5) the job becomesFAILEDand the last error goes intoerror_message. FAILED records are not deleted — they stay diagnosable, and they get monitored. - Backoff and jitter. The wait between attempts should grow (say 30 s → 2 min → 10 min), with some randomness on top. Otherwise, during a short provider outage every retry piles in at the same moment and makes the outage last longer.
- A rough error split is enough.
| Error | Class | Behaviour |
|---|---|---|
| timeout, 5xx, connection error | temporary | retry makes sense |
| 4xx — bad address, malformed request, auth | permanent | FAILED on the first attempt |
Fine-grained classification (bounce types and so on) is not needed yet. Avoiding the lazy “retry every error 5 times” is enough.
An implementation note: for retry timing you either use locked_at / a separate next_attempt_at column, or — much easier — lean on the retry mechanism of whatever job library you use. Most of them give you this out of the box.
9. Rendering the template at send time
The row holds template + parameters, not rendered HTML. The template is filled in at send time, for two reasons:
- A bug in the template does not leave the queued jobs “frozen with broken content”. You fix the template, and the waiting jobs go out correctly.
- Rows stay small. HTML takes kilobytes, parameters take a few hundred bytes — and table health and poll performance depend directly on that.
A render error (a missing parameter, a template that does not exist) belongs to the permanent error class: a retry will not fix it, so it goes straight to FAILED.
10. Polling or a broker?
There are two models:
DB poll (pull)
Workers query the table periodically and pick up work with the FOR UPDATE SKIP LOCKED pattern.
No extra infrastructure; the table is the queue.
Broker (push)
A relay publishes new rows to the broker as messages, and the broker pushes them to the consumers itself.
Latency drops to milliseconds, and a consumer declares its capacity with prefetch.
For email in particular, the scales usually tip towards DB polling:
- You need the table anyway. The send record, the retry counter, the diagnostic info — all of it needs to be persisted. A broker does not replace the table, it is added on top of it. So the real question is not “table or broker”, it is “is a broker on top of the table worth it?”
- Atomic enqueue is free. In the same-database model, the guarantee from §4 is one transaction. With a broker there is a relay in between (table → relay → broker). It works, but it is a pure extra part.
- The latency is good enough. Poll latency averages half the poll interval, and an interval of a few seconds is usually more than fine for email. And even if you need it faster, a broker is not the only way: mechanisms like Postgres
LISTEN/NOTIFYturn polling into push in practice.
Where a broker really wins: when email sending becomes a shared platform service for many independent services. “Let everybody write to the same table” couples services through the database — that is an anti-pattern. A shared protocol (AMQP, or the service’s own API) is the right border.
At very high volume, broker-native parts like DLQs and delayed retries also start to pay off. In a single application — or a few services sharing the same database — none of that gives you anything back.
| Criterion | DB poll | Broker |
|---|---|---|
| Latency | Depends on the poll interval (seconds) — ms with LISTEN/NOTIFY | Native push (ms) |
| Atomic enqueue | Free (same transaction) | Needs a relay |
| Extra infrastructure | None | Running a broker and a relay |
| Many producing services | Couples them through the DB (anti-pattern) | The right border |
| Fits | One application / one DB, medium volume | Shared platform service, high volume |
11. Retention: a scheduler that cleans old records
email_messages is both a queue and a ledger, so it grows all the time, and unlimited growth slowly weighs down the table, the indexes and the poll query. The answer is a simple scheduled cleanup job:
-- e.g. once a day, at a quiet hour:
DELETE FROM email_messages
WHERE created_at < now() - INTERVAL 'N days'
AND status IN ('SENT', 'FAILED')
LIMIT :batch_size -- in chunks instead of one big DELETE (no lock/WAL pressure)
The decisions to make:
- How many days is N? As far back as you want to answer “what did we send and when”. Based on support and audit needs, typically 30–90 days.
- Only the end states are deleted — SENT and FAILED. PENDING and SENDING are never deleted; they are jobs that are still going to run, or running. And if there is a very old PENDING record, that is not a retention matter, it is a signal that something is broken.
- Attachment files have to go too. When a record is deleted, the files it references — the ones the system owns — must be deleted as well, or orphan files pile up in storage.
- Delete or archive. If you do not want to lose the records completely, move them to a cheap archive table or store instead of deleting. Same logic, different target.
Together with the reaper, this scheduler makes up the two “maintenance workers” of the system: one rescues what is stuck, the other clears what is old.
12. Deliberately out of scope for now
The following are not part of this design. It is enough to know they can be added when the need shows up — the current schema does not block any of them.
Enqueue deduplication (a dedup key)
The protections in this system (claim, the danger window) stop “an existing record from being sent twice”. They do not stop “two separate records being created for the same event”.
The second one only happens if the triggering side processes the same event twice — its own retry mechanism, a double click and so on. If the triggers are simple and fire once, this risk does not exist in practice.
If one day a trigger says “I am inserting the same event twice by mistake”, the fix is simple in shape: a column carrying the event id, plus a UNIQUE index. The trigger produces the key.
Delivery tracking / webhooks
SENT means the provider accepted it. Whether it was really delivered, or bounced, arrives later from the provider through a webhook. If you do not need it now, do not build it.
If you add it later, the skeleton is this much: store the message id the provider gives you (a provider_message_id column), and make the webhook handling idempotent and signature-verified.
Throttling / the provider's rate limit
As volume grows you may need a speed ceiling so you do not hit the provider’s sending limit. When that day comes, the simplest version is already in your hands: the LIMIT in the poll × the number of workers ÷ the interval is a natural ceiling.
TTL, priority, unsubscribe
TTL: “never send an email that expired” can be added with an
expires_atcolumn and a single check after the claim. As long as the system keeps up (the queue is not piling), it is unnecessary.Priority: if certain templates have to jump the queue, adding a priority column to the
ORDER BYor running a separate worker pool is enough.Preferences / unsubscribe and suppression lists: these are checks made at send time, and they matter once marketing-style sending arrives. Transactional email does not need them.
13. Checklist
- The producer does not wait: an email request is one INSERT, sending is async
- The email record is born in the same transaction as the triggering domain work
- Recipients (to/cc/bcc) are snapshotted at enqueue time
- Attachments are held as references in the row, not as blobs
- An atomic claim before sending — PENDING → SENDING, a conditional UPDATE
- A reaper for jobs stuck in SENDING, plus a client timeout on the provider call
- A deliberate decision about the “sent it but could not record it” window: accept at-least-once, and use the provider’s idempotency token if it has one
- Retry: max attempts plus backoff/jitter; straight to FAILED on 4xx
- The template is rendered at send time; a render error is a permanent error
- The poll-vs-broker decision was made based on the profile — one application → DB poll
- There is a retention scheduler: old SENT/FAILED records and their attachment files are cleaned in chunks
- Basic metrics: the age of the oldest PENDING, the FAILED rate, the send duration
14. Mapping table
| Concept | Java/Spring | Node.js | Python | .NET |
|---|---|---|---|---|
| DB-backed worker + retry | JobRunr | pg-boss (Postgres) / BullMQ (Redis) | Procrastinate (Postgres) / Celery | Hangfire |
| Claim + stuck recovery | Built into JobRunr | Built into pg-boss/BullMQ | Built into the library | Built into Hangfire |
| Provider client | SES SDK / JavaMail | nodemailer / SDKs | boto3 / httpx | AWS SDK / SmtpClient |
| Template engine | Thymeleaf / Freemarker | Handlebars | Jinja2 | Razor |
| Retention scheduler | Spring @Scheduled / JobRunr recurring | node-cron / pg-boss schedule | Celery beat / cron | Hangfire recurring |
Postgres-backed queues like pg-boss and Procrastinate fit this architecture especially well: the job record lives in the same database as the domain data (so the atomicity guarantee comes for free), they bring claim, reaper and retry out of the box, and thanks to LISTEN/NOTIFY the poll latency is effectively push.

Comments