Modeling email events in your database
Store email delivery events in an append-only table, derive message status from it, and keep the history you need to answer support questions.

On this page(10 sections)
"Did the customer get the email?" is one of the most common support questions in any SaaS company, and one of the hardest to answer if your database only stores a sent_at column. A small, well-designed event model turns that question into a single query.
Why a status column is not enough
The first version of email tracking in most applications looks like this:
ALTER TABLE invoices ADD COLUMN email_sent_at timestamptz;
It answers whether your code attempted to send. It cannot answer whether the receiving server accepted the message, whether it bounced two hours later, whether the recipient complained, or whether a retry sent it twice. Each new question adds another column, and soon a dozen tables each have their own partial, inconsistent email state.
Email delivery is a sequence of events that arrive over time, often out of order, from systems you do not control. Model it that way.
Two tables: messages and events
Messages
One row per logical message your application sends:
CREATE TABLE email_messages (
id uuid PRIMARY KEY,
provider_id text UNIQUE, -- ID returned by your email provider
idempotency_key text UNIQUE,
template text NOT NULL,
template_version text,
recipient text NOT NULL,
subject text NOT NULL,
related_type text, -- e.g. 'invoice'
related_id uuid, -- e.g. the invoice ID
status text NOT NULL, -- derived; see below
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
If a single send has several recipients, either create one row per recipient or add a recipients table. Per-recipient rows are usually simpler, because bounces, complaints and opens are per recipient.
Events
One row per thing that happened to a message:
CREATE TABLE email_events (
id bigserial PRIMARY KEY,
message_id uuid NOT NULL REFERENCES email_messages(id),
event_type text NOT NULL, -- queued, sent, delivered, bounced, complained, opened, clicked...
occurred_at timestamptz NOT NULL, -- when the provider says it happened
received_at timestamptz NOT NULL DEFAULT now(),
provider_event_id text UNIQUE, -- for deduplication
detail jsonb -- bounce reason, SMTP response, clicked URL...
);
CREATE INDEX ON email_events (message_id, occurred_at);
The events table is append-only. You never update or delete rows in normal operation. That gives you a complete history for support, debugging and audits.
Deriving status from events
The status column on messages is a cache of the most meaningful event so far, recalculated whenever a new event arrives. Define a precedence order, because events arrive out of order:
| Precedence | Status | Meaning |
|---|---|---|
| 1 (highest) | complained | Recipient marked it as spam |
| 2 | bounced | Permanently rejected |
| 3 | clicked | Recipient clicked a link |
| 4 | opened | Tracking pixel loaded (unreliable) |
| 5 | delivered | Receiving server accepted it |
| 6 | sent | Handed to the provider or first SMTP hop |
| 7 (lowest) | queued | Accepted by your system |
When an event arrives, update the status only if the new event ranks higher than the current one:
UPDATE email_messages
SET status = $2, updated_at = now()
WHERE id = $1
AND array_position(ARRAY['queued','sent','delivered','opened','clicked','bounced','complained'], status)
< array_position(ARRAY['queued','sent','delivered','opened','clicked','bounced','complained'], $2);
A late "delivered" event can never overwrite "bounced," and a duplicate "opened" event changes nothing. Adjust the order to your product's needs; some teams prefer to keep engagement events out of status entirely and track them in separate counters.
Ingesting events safely
Events come from your own code (queued, sent) and from provider webhooks (delivered, bounced, complained, opened, clicked). Webhooks need care:
- Verify signatures before trusting the payload.
- Deduplicate on the provider's event ID; webhook systems retry, so duplicates are normal.
- Respond quickly and process asynchronously, so slow database work does not cause provider timeouts and more retries.
- Store the raw payload in
detail, so you can reprocess if your mapping logic changes. - Handle unknown message IDs gracefully. An event can arrive before your own transaction that created the message commits. Retry such events later rather than dropping them.
Answering real questions
With this model, support and engineering questions become straightforward queries:
-- Everything that happened to emails about one invoice
SELECT m.template, m.recipient, e.event_type, e.occurred_at, e.detail->>'reason' AS reason
FROM email_messages m
JOIN email_events e ON e.message_id = m.id
WHERE m.related_type = 'invoice' AND m.related_id = $1
ORDER BY e.occurred_at;
Other useful views:
- Bounce rate per template per day, to catch a bad deploy.
- Recipients with a bounce or complaint in the last N days, to feed suppression.
- Messages stuck in
queuedfor more than a few minutes, to detect pipeline problems. - Duplicate sends: more than one message with the same template and related object within a short window.
Engagement events need skepticism
Opens and clicks are the noisiest events you will store. Several mail clients and privacy features load images automatically through proxies, which registers an "open" whether or not a person read the message. Security gateways at many companies follow links in incoming mail to scan them, which registers "clicks" seconds after delivery. If your product makes decisions on these events, such as marking an invoice as "viewed" or suppressing a reminder, those decisions will sometimes be wrong.
Some practical defenses:
- Store the user agent and IP information your provider reports, and the provider's own flag if it marks an event as a likely machine or prefetch hit.
- Treat clicks that arrive within a few seconds of delivery, or on every link at once, as probable scanner activity.
- Avoid showing "opened" as a reliable fact in customer-facing UI. "Delivered" is a far more trustworthy state.
- Keep engagement out of the derived status if you use status for anything important, and expose it as separate counters instead.
Handling multiple providers and migrations
If you ever run two email providers at once, for failover or during a migration, the event model pays off again. Add a provider column to messages, keep provider-specific IDs unique per provider, and map each provider's event names to your own vocabulary in the ingestion layer. The rest of your application, including support views and suppression logic, never needs to know which provider carried a given message.
Retention and privacy
Email events contain personal data: addresses, sometimes IP addresses and user agents from opens and clicks. Decide on retention deliberately:
- Keep message metadata and delivery events long enough to answer disputes and support questions, which for many businesses means months to a couple of years.
- Consider dropping or aggregating engagement events (opens, clicks) sooner.
- Avoid storing full message bodies unless you have a clear need; store the template name, version and the data used instead, so you can re-render on demand.
- Partition the events table by month so old data can be dropped cheaply.
Checklist
- A messages table with one row per recipient, linked to the business object it concerns.
- An append-only events table with provider event IDs for deduplication.
- Status derived from events using an explicit precedence order.
- Webhook ingestion that verifies, deduplicates, acknowledges fast and stores raw payloads.
- Indexed queries for support lookups, bounce rates and stuck messages.
- A documented retention policy.
Key takeaways
- Email delivery is a series of events, so store events, not just a sent timestamp.
- Keep events append-only and derive the current status with a precedence order that tolerates disorder.
- Deduplicate webhook events by provider ID and process them asynchronously.
- Link messages to business objects so support questions become one query.
- Treat event data as personal data with a deliberate retention period.
Start with Koltrix
Your domain, one inbox, and an API that sends.
A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.


