Skip to content

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.

Koltrix Team5 min read
A computer screen showing a bar chart
Photo by 1981 Digital on Unsplash
On this page(10 sections)
  1. Why a status column is not enough
  2. Two tables: messages and events
  3. Messages
  4. Events
  5. Deriving status from events
  6. Ingesting events safely
  7. Answering real questions
  8. Engagement events need skepticism
  9. Handling multiple providers and migrations
  10. Retention and privacy
  11. Checklist
  12. Key takeaways

"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 queued for 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.

SharePost on XLinkedIn