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.

On this page(9 sections)
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_atplusidas a tie-breaker, nevercreated_atalone, because many messages share a timestamp. - Return
next_cursor(null on the last page) and optionallyprev_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:
- Accepted by your system, with the idempotency key if there was one. See idempotency keys for email sends.
- Handed to the provider.
- Delivered, deferred (with the reason and retry time), bounced (with the code) or complained.
- 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
400with 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.


