Skip to content

feat: count mail per domain (domain pages, phases 1 and 2) - #70

Merged
Otitodev merged 2 commits into
mainfrom
feat/domain-workspace-data
Nov 6, 2026
Merged

Otitodev merged 2 commits into
mainfrom
feat/domain-workspace-data

Conversation

@Otitodev

@Otitodev Otitodev commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

What and why

Phases 1 and 2 of plans/domain-workspace.md: a page per domain, so someone managing several domains can open one and see only its mail.

Phase 1. emails.from_domain, derived from the address whenever it is set, indexed by project, domain and id, and filled in for existing rows by the migration. domain_of is in its own module because the model needs it and putting it beside bare_address closed an import cycle that only a fresh interpreter sees; a test now guards that.

Phase 2. compute_metrics and activity_series take an optional DomainScope, so a domain's counts, rates and chart are its own, divided by its own sends. A verified domain covers its subdomains unless a more specific identity of the same project exists; an address identity covers only itself. The rule is written in Python and in SQL, and a test requires the two to agree on lookalike domains and a LIKE wildcard.

Nothing in the dashboard uses this yet. The pages are phase 3.

Checks

  • Migrations: the drift test compares the migration with the model, and a new test builds the schema one revision back and checks the backfill. Neither has run locally; they need Postgres, so CI covers them.
  • ruff, ruff format --check, mypy . and the tests that need no database pass locally. The SQL half of the scope rule and the scoped metrics are database tests and have not run locally either.

🤖 Generated with Claude Code

@Otitodev
Otitodev force-pushed the feat/domain-workspace-data branch from 16e9caf to cc8cb9d Compare November 6, 2026 07:12
@Otitodev
Otitodev changed the base branch from feat/custom-mail-from to main November 6, 2026 07:12
A domain's page has to list and count what was sent from it, and the domain was
only ever inside `from_address`, a string that may carry a display name. Finding
one domain's mail meant parsing every row. `emails.from_domain` stores it, with
an index on project, domain and id so the newest-first listing a page needs is
one index scan.

It is derived and never set by hand: assigning `from_address` fills it in, via a
validator, so the constructor, a test that builds an `Email` directly and a
later code path all agree without each remembering to. The reduction is
`domain_of`, which takes the mailbox with `parseaddr` first so a display name
containing an `@` is not taken for it, and uses the domain after the last `@`
because a quoted local part may hold one. An address with no `@` has no domain,
and the first draft of this returned the whole string for it; the test for that
case is what caught it.

`domain_of` is in a module of its own, `seskit_core.addressing`, that imports
nothing from SESKit. It first sat beside `bare_address` in `email.message`, and
the model importing that closed a cycle (message, security, models, message)
that raised an ImportError when `seskit_core.services` was imported first. No
test noticed, because every test happens to import the models first, so a
fresh-interpreter test now guards it.

Text rather than a reference to the identity, for the reason `InboundEmail`
does the same: removing a domain stops future mail, and must not erase or
orphan what was already sent. The column is nullable for an address with
nothing to store.

The migration fills existing rows in batches by primary key, with the reduction
written out in the migration rather than imported, so it keeps meaning what it
meant on the day it ran. `test_migrations.py` builds the schema one revision
back, writes mail the way the old code did, runs the migration and checks the
result, since a column that is NULL for every old row would leave a domain's
page empty until it was sent from again.

This is phase 1 of plans/domain-workspace.md. Nothing reads the column yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@Otitodev
Otitodev force-pushed the feat/domain-workspace-data branch from cc8cb9d to b48b93d Compare November 6, 2026 07:17
A domain's page shows that domain's counts, rates and chart. The Overview
numbers were computed for the whole project, so a domain that sends cleanly
would have shown the bounce rate of a noisy neighbour.

`compute_metrics` and `activity_series` take an optional `DomainScope`. With one,
every count and every rate is that domain's own: the rates divide by its own
sends, not the project's. Without one they behave exactly as before.

Which mail belongs to a domain is one rule, in `services/domain_scope.py`. A
verified domain covers its subdomains, as SES does, unless a more specific
identity of the same project exists: mail from `a@mail.example.com` belongs to
`mail.example.com` if that was added and to `example.com` otherwise, and nothing
is counted under two domains of one project. An address identity covers that
address and nothing else. Another project's domains are not looked at.

The rule is written twice, in Python for a test to read and in SQL for a page to
afford, and a test runs the same senders through both and requires the answers
to match. The senders include lookalikes (`notexample.com`,
`example.com.evil.test`) and a LIKE wildcard (`ex_mple.com` must not match
`exAmple.com`), which are the ways a suffix match goes wrong.

This is phase 2 of plans/domain-workspace.md. The email list and the status counts
live in the route and move with the pages in phase 3.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@Otitodev Otitodev changed the title feat: record the domain an email was sent from (domain pages, phase 1) feat: count mail per domain (domain pages, phases 1 and 2) Nov 6, 2026
@Otitodev
Otitodev merged commit 3cd4ef1 into main Nov 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant