Skip to content
Koltrix

Pagination and filtering for an email logs API

Support asks 'did the receipt go out?' a hundred times a day. Design an email logs endpoint with cursor pagination, useful filters and safe defaults.

Koltrix Team5 min read
Close-up of server cooling fans in a data center
Photo by Winston Chen on Unsplash
On this page(9 sections)
  1. What the list should return
  2. Pagination: use cursors
  3. Filters people actually use
  4. Indexes that make it fast
  5. Retention and privacy
  6. Detail view: the story of one message
  7. Errors and limits
  8. Document it with examples
  9. Key takeaways

Sooner or later someone asks, "Did the password reset email go out to this customer?" If the only way to answer is to query the database by hand or open a provider dashboard, your support team will be asking engineers all week.

An email logs endpoint in your own product, backed by the events you already collect, fixes that. The hard part is not storing the rows. It is making a list that stays fast with millions of entries, that people can filter to the one message they need and that does not leak anything it should not. This guide covers the design choices that matter.

If you have not yet decided what an email event looks like, start with an email events data model and come back.

What the list should return

One row per message, not per event. A row summarises the message's current state, and a detail view shows the events behind it.

A useful list row has:

  • id, a stable identifier;
  • to (or a count of recipients and the first one);
  • from and subject;
  • status, such as queued, sent, delivered, deferred, bounced, complained, rejected;
  • created_at and last_event_at;
  • template or category, so people can tell receipts from resets;
  • your own reference, such as a user id or order id, if the sender supplied one.

Keep the list row small. Put bodies, headers, attachment names and raw provider responses in the detail view.

Pagination: use cursors

Offset pagination (?page=500&per_page=50) is easy and breaks in two ways at scale.

  • It gets slower as the offset grows, because the database must skip all earlier rows.
  • It shows duplicates or skips rows when new messages arrive between page loads, which is constant on a logs screen.

Cursor pagination avoids both. The response includes an opaque cursor that encodes the position of the last row, and the client sends it back to get the next page.

A good design:

  • Order by a stable, indexed key. Use created_at plus id as a tie-breaker, never created_at alone, because many messages share a timestamp.
  • Return next_cursor (null on the last page) and optionally prev_cursor.
  • Make the cursor opaque, such as a signed or encoded string, so clients do not build their own and you can change the internals.
  • Fix the page size with a default (say 50) and a maximum (say 200).
  • State clearly that cursors expire if you prune old data.

You can offer a total count as an option, but counting millions of rows on every request is expensive. Return an approximate count or none by default.

Filters people actually use

Start from the questions support asks, not from every column.

Filter Example Why
Recipient [email protected] "Did this person get it?"
Status status=bounced Finding problems
Time range from=2026-10-01T00:00:00Z&to=... Narrowing a day or an incident
Template or category category=password_reset One kind of mail
Reference reference=order_1042 Joining to your own records
Message id id=... Direct lookup

Design rules:

  • Combine filters with AND. Keep OR logic out of the first version.
  • Require a time range, or default to a recent window such as the last 7 days. An unbounded scan of everything is the most common cause of slow log screens.
  • Support exact matching first. Prefix and substring search on addresses need special indexes and are easy to get wrong.
  • Normalise addresses by lower-casing the domain part and trimming whitespace before comparing.
  • Reject unknown filters with a clear error instead of ignoring them silently. A typo should not return everything.

Indexes that make it fast

The filters you offer should map to indexes you actually have.

  • A composite index on the time column and id for the default ordering.
  • An index on recipient (or a recipient table, if one message has many) plus time.
  • An index on status plus time for problem screens.
  • An index on your reference field if you support it.

Test with realistic volumes. A query that is fast on 10,000 rows can take seconds on 50 million. Check the query plan for the default list and for each filter.

Retention and privacy

Logs contain personal data: addresses, names in subjects and sometimes content.

  • Retention. Decide how long to keep rows, and delete or archive after that. Say so in the documentation.
  • Content. Do not store full bodies unless you need them. Store a hash or a redacted preview. Never put one-time codes or reset links in logs that many people can search.
  • Access. Limit who can see logs and what they see. Support may need status and recipient but not bodies.
  • Audit. Record who looked up which message, especially for sensitive categories.
  • Tenants. In a multi-tenant product, every query must be scoped to the caller's workspace, and cursors must not be usable across tenants.

Detail view: the story of one message

The detail view should answer "what happened?" in order:

  1. Accepted by your system, with the idempotency key if there was one. See idempotency keys for email sends.
  2. Handed to the provider.
  3. Delivered, deferred (with the reason and retry time), bounced (with the code) or complained.
  4. Opened or clicked, if you track them, with the caveat that opens are unreliable.

Show raw reasons from the receiving server. For example, an enhanced status code and text turn "bounced" into something a person can act on. SMTP enhanced status codes helps you present them. For an end-to-end troubleshooting flow, see email not received: debugging.

Errors and limits

  • Return 400 with a field-level message for invalid filters or cursors.
  • Rate limit the endpoint and document the limits. See handling 429 and rate limits.
  • Make the same request return the same data, so you can cache and debug.
  • Keep response times predictable: if a filter would be too expensive, ask for a narrower time range instead of timing out.

Document it with examples

Show real requests: the first page, the next page using a cursor, a filter by recipient and a filter by status within a time window. Show what an empty result looks like. Mention how to link a log entry to a webhook event; webhook consumers and ordering covers that side.

Key takeaways

  • Return one summary row per message, with details on request.
  • Use cursor pagination with a stable order and an opaque cursor.
  • Offer a few filters tied to support's questions, require a time window and back them with indexes.
  • Treat logs as personal data: limit retention, content and access, and scope by tenant.
  • Show the full story of a message in the detail view, with the receiving server's reasons.

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