Skip to content

feat: turn receiving on, and read it in the dashboard (inbound Phase D) - #63

Merged
Otitodev merged 11 commits into
mainfrom
feat/inbound-ui
Oct 13, 2026
Merged

Otitodev merged 11 commits into
mainfrom
feat/inbound-ui

Conversation

@Otitodev

@Otitodev Otitodev commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

What and why

Phase D, the last of receiving mail: a way to turn it on, and a place to read it. Phases A to C (#60, #61, #62) built the plumbing, stored received messages and exposed them through the API, a webhook and the SDK, but nothing could switch receiving on. This does, and adds the dashboard Inbox.

  • Receiving on the Domains page. A verified domain gets a Receiving row and a Start receiving button. Before it is pressed the card says what it will create in the user's AWS account and that SESKit never replaces an existing receipt rule set. Once on, it shows the MX record with a Copy on each value, and the warning that matters most: an MX record sends all of a domain's mail to SES and replaces wherever it is received now. It suggests a subdomain.
  • The Inbox. A list with a domain filter, and a detail page: from, to, who it was actually delivered to, what SES concluded, text, attachments, headers and the original.
  • A sandboxed HTML preview, served as a separate document with its own policy and framed with an empty sandbox attribute.
  • receiving on GET /v1/domains, read-only, with the MX record withheld until receiving is on.
  • A user guide and the IAM policy (a third optional one, sixteen actions).

What to review hardest:

  • Everything on these pages was written by a stranger. Each sender-controlled field is fed a script tag in the tests and asserted never to reach a response as markup. The HTML is never written into the message page: a test asserts exactly one iframe exists, ours, with sandbox="" and no allow- token, and the sender's own iframe survives only as escaped text. The document has sandbox, default-src 'none' (which also blocks tracking pixels and remote images) and inline styles only.
  • The security-headers middleware had a bug that feat: tell applications about received mail (inbound Phase C) #62 fixed for one header and this fixes for two more. It assigned its own header to every response, silently overwriting a route's. feat: tell applications about received mail (inbound Phase C) #62 fixed Content-Security-Policy, without which the download's sandbox policy never reached the client. This PR fixes X-Frame-Options and Referrer-Policy, which the preview needs to be framed at all. Each has a guard that fails against the old code.
  • The server refuses for every reason the button is disabled. A form can be posted by hand, so the same check runs in the route, and tests post to blocked domains directly.
  • An AWS refusal must not leave the page saying "Receiving is on". setup_receiving claimed the domain before calling AWS and the routes do not roll back, so a refusal would have rendered exactly that. It now gives the domain back, and a test asserts it in the HTML the user is shown.
  • Removing a domain, or disconnecting AWS, removes the receipt rule first, tested through the real routes, so neither can strand a rule in someone's account.
  • A verdict SES did not give reads "Not checked", styled unlike a pass, and the page says SESKit shows verdicts and acts on none.
  • The download code is one module shared by /v1 and the dashboard, so a header added to one cannot be forgotten on the other.

Two things CI caught that I got wrong, both fixed: tests that expected 403 where the Domains page deliberately answers 400 for AWS permission problems, and an HTMX filter with no loading state, which the polish test exists to catch.

Not done, and worth knowing:

  • I have not looked at any of this in a browser. It needs Postgres and I did not start Docker. The templates were rendered offline through the real Jinja environment, including with hostile input, and the design-system and polish tests pass, but the layout, the spacing of the new card and how the preview frame looks in the dark theme have not been seen. Please look at the Domains page, the Inbox and a message before merging.
  • Nothing has run against real AWS. Two things only a real account can answer: whether SES accepts a rule publishing to our topic without a topic policy (moto does), and whether a rule's key prefix appears in the reported objectKey. A real-mail test needs a domain with an MX record you can point at SES and a key with the new policy.
  • The IAM guide grants the SES receipt-rule actions on *, and says why: whether they can be narrowed was not checked.
  • No prices in the guide. Whether new accounts are billed for classic receipt rules under the newer SES plans, and whether classic receipt rules have any end-of-support notice, were never established.
  • MX cannot be checked from inside SESKit; the guide gives a dig command.
  • A link clicked inside the preview opens inside the frame, still without script. Noted on the card.

Checks

  • Docs: updated, and uv run --group docs mkdocs build --strict is clean

🤖 Generated with Claude Code

setup_receiving claims the domain before it calls AWS, so that the unique
index can decide a race between two requests enabling the same domain. If
AWS then refused - a missing permission is the likeliest - the claim stayed
on the identity, and the identity said it received mail when nothing did.

That is worse than it sounds because of how the routes that call this are
written. They deliberately do not roll back on an APIError: a rollback expires
every loaded object and the page then 500s instead of showing the message, which
was seen on a real host for the event routes. So the same render would have
shown "Receiving: on" beside the very reason it was not.

On failure the identity's rule fields are now put back to what they were before
the attempt, and flushed. "What they were" and not "blank": a repair run on a
domain that already receives must not leave it blank because the second attempt
failed - it was receiving, and it still is. The record of the bucket, topic and
queue is kept when only the rule step fails, because they really are in the
account and a row that forgot them is one teardown could never find.

Tests cover a refusal before anything is built, a refusal after the plumbing
exists, and a failed repair. They need the database, so they run in CI; the
first would fail on the old code on `receives_mail is False`.
The dashboard inbox needs the same downloads as /v1, with session auth in place
of an API key. Both serve the same hostile thing from the same origin, so the
part that makes that safe is written once: the header set, the filename
handling, the order of refusal, and building the response.

A header added to one surface and forgotten on the other is the failure this
prevents, and it would look like nothing until somebody pointed a browser at the
wrong route. The previous change in this series was that exact shape - a policy
set on a route and silently discarded - so this is not a hypothetical.

What stays in the routes is what genuinely differs: how a caller is authenticated
and how a refusal is shown. An API key and a JSON envelope on one side, a session
and a page on the other.

No behaviour change. The regenerated OpenAPI document is identical, and the
download tests are unchanged apart from where they import the shared names from.
A verified domain's card gains a Receiving row and a panel to start it. Starting
creates the bucket, topic and queue in the user's AWS account and adds the
domain's rule; the card then shows the MX record to add, with a Copy on each value
like the DNS records, and a Stop button.

Before it is pressed the card says exactly what it will create in the user's own
account and that SESKit never replaces a rule set they already have - the same
rule the event reporting panel follows, because both put resources somewhere that
then appears on somebody's bill. The confirmation names them again.

The warning that matters most is about the MX record, and it is on the card and
not in the docs. An MX record sends all of a domain's mail to Amazon SES, so a
domain that receives mail somewhere else today stops receiving it there. The card
says so and points at a subdomain, which works because the rule is per identity:
add mail.example.com as its own domain.

Every reason receiving is unavailable is stated and none is hidden - an
unverified domain, no AWS connection, a domain in a different region from the
connection, a region SES cannot receive in, an instance reading events over HTTPS
only, a domain another project already receives for. A button that is simply absent
leaves somebody wondering whether the feature exists. The refusal for the last one
does not say whose domain it is.

The server refuses for every reason the page disables the button for. The
button is a courtesy and a form can be posted by hand; the same check runs again
in the route, and tests post to a blocked domain directly.

The most important test is a regression guard from earlier in this series. The
claim on a domain is written before AWS is called, and these routes do not roll
back on an error, so an AWS refusal would have rendered "Receiving is on" beside
the reason it was not. The test makes AWS refuse and asserts on the HTML the user is
shown. A refused stop leaves it on and says why, for the converse reason: AWS first,
the row second.

The page is rendered offline against the real templates in all three states
before the tests were written - on, available, blocked with the button actually
disabled - which caught nothing, and is how it is known the card still renders for
callers that never pass the new variable. Removing a receiving domain and
disconnecting AWS are tested through their real routes, so a user pressing either
cannot strand a rule in their account.
A new Inbox page lists what the project received, newest first, with a domain
filter that swaps the table over HTMX like the Emails page, and a detail page
for one message: who it is from and to, who it was actually delivered to, what
Amazon SES concluded, the text body, the HTML as source, the attachments, the
headers, and the original.

Everything on these pages was written by a stranger, and that is what most of
the tests are about. Each sender-controlled field - subject, display name, both
bodies, every address list, the message id, an attachment's filename, a header's
name and value - is given a script tag and an onerror handler, and the assertion
is that none reaches the response as markup. The page holds a signed-in person's
session; a stranger's HTML does not belong beside it. The HTML body is shown as
source here, and a test sends an iframe and a paragraph and asserts neither exists
in the page while both survive as visible text. The templates were rendered
offline with the same hostile input before any test was written.

A verdict SES did not give reads "Not checked" and is deliberately not styled
like a pass; a test asserts five of them on a message with none. The macro that
does this lives in ui.html with the other components. The page says plainly that
SESKit shows the verdicts and acts on none.

An attachment's declared content type is labelled "declared", because the
download is opaque bytes whatever it says. Downloads use the shared module, so
they carry the same headers as /v1, and a test asserts the sandbox CSP on the
response: the middleware keeps a route's own policy now, which is what an earlier
commit in this series had to fix to make this true.

A failed download lands on the message page and says why, instead of a bare JSON
envelope in a browser tab - the person clicked a link, and an error object tells
them nothing. A message past retention, a missing attachment index and a
permission failure each render a page, and the first two fetch nothing. As on the
other dashboard pages a permission problem answers 400, not 403: it is the user's
AWS configuration and not their session.

The id of another project's message and one that never existed give the same
answer on every route, so a stranger cannot probe for real ids. Older mail is
reached by cursor and not by offset, for the reason /v1/inbound is. The
raw_available rule moves onto the model so the API and the dashboard cannot
disagree about whether to offer a link.
CI found three failures in the Domains receiving tests, all in the tests and
none in the feature.

Two expected 403 where the page answers 400. That is deliberate and was already
the convention on the AWS and Domains pages: a credential or permission problem
is the user's AWS configuration and not their SESKit session, so answering 403
would read as "you are not signed in". The tests asserted what I assumed and not
what the page is documented to do; they now assert 400 and say why. The CSRF test
correctly stays at 403.

One searched for a sentence the template wraps across a line, so it failed on the
whitespace and not on the words. It compares the words now.
The same shape as the Content-Security-Policy fix two commits back, for two more
headers. SecurityHeadersMiddleware assigned X-Frame-Options: DENY and
Referrer-Policy: same-origin to every response, so a route that set either was
silently overwritten.

The preview of a received message's HTML is a document of its own, framed by the
page that shows it. Forced to DENY it could never be framed, and the feature
would have failed with nothing to say why. Forced to same-origin, a document that
deliberately sends no Referer at all would have been loosened to sending one.

Both now use setdefault, so a route's own value wins and everything else is
unchanged. A test registers a route that sets both and asserts they survive, and
that the rest of the headers are still applied; a second asserts every other
route is still DENY and same-origin, so that letting a route override cannot leave
the default anywhere else. The first was run against the previous middleware and
fails there.
The message page now renders the HTML body in a frame, so a message can be read
the way its sender intended without a stranger's markup ever sharing a page with
the signed-in person's session. The source is still there, in a collapsed card.

It is the one place in SESKit that shows hostile markup, so the containment is
layered and each layer is asserted on its own.

The HTML is served as a document of its own at /inbox/{id}/html, never written
into the message page - not inlined, not in a srcdoc - and a test sends a script,
a form and an iframe of the sender's own and asserts the page contains exactly
one frame, ours, with the sender's elements present only as escaped text. That
document carries a policy that allows as little as still shows a message:
`sandbox` with no tokens, so an opaque origin with no script, no forms and no
navigation; default-src 'none', so nothing loads from the network, which also
blocks every tracking pixel and remote image; inline styles, because without them
nearly every message is unreadable; images only as data:. It is served exactly as
sent. A sanitiser would be one more thing to get wrong, and a test that the
bytes arrive unaltered is also a test that nobody quietly added one.

The frame's own sandbox attribute is empty. allow-scripts together with
allow-same-origin lets a frame remove its own sandbox, so neither, and nothing
else, may ever be granted; the template says so beside the tag and a test
asserts no allow- token appears. The two layers are the same on both sides, so
the document is just as contained opened directly in a tab.

Only the message page may frame it: SAMEORIGIN and frame-ancestors 'self'. The page
that holds the frame is the one holding the session, so a test asserts it is still
DENY, still has its own script policy, and carries no sandbox directive - adding
the frame did not loosen it. The two headers are the ones the previous commit
stopped the middleware overwriting, which this could not have worked without.

Remote images do not show, and the card says so rather than leaving a message
looking broken for no stated reason. A link a person clicks opens inside the
frame, still without script; that is a rough edge, noted on the card and not
engineered around.

The frame is a component. The stylesheet gains one token, a white canvas defined
once in the root block so the dark theme inherits it - email is written for a white
page, and a message that sets dark text and no background is unreadable on a dark
one - and one .frame rule composed from existing tokens. The design-system tests
that forbid hardcoded colours and per-page CSS pass.
CI's polish test failed on the inbox filter: every HTMX swap has to say it is
working, and mine did not. That test reads the templates and not a rendered
page, so that the third swap added later by someone who has not read it is the
one that fails - and this was that swap, added by me.

The two filter links now carry hx-indicator and the table gets the same hidden
spinner the Emails page's filter has, so a click shows something while the
fragment loads.

Also fixes a verdict test that counted the phrase "Not checked" and found six,
because the page's own explanation uses it once. It counts the verdict rows, which
is what it meant to.
Each domain gains a read-only `receiving` object: whether a receipt rule exists
for it, and the MX record to publish. An application managing its own sending
infrastructure can then see which of its domains receive mail and what DNS they
need, without scraping the dashboard. Read-only because domains are managed in the
dashboard - they cannot be created through the API either - so there is nothing
here to switch; it reports what was set up.

The MX record is null until receiving is on. Published earlier, it would send
senders' mail to Amazon SES with no rule to take it, and the mail would be
refused. The description says so, and that publishing it replaces wherever the
domain receives mail now, since that is the mistake most worth stopping.

It names the SES inbound endpoint for the domain's own region, and a test uses
one outside us-east-1: mail is received where the domain is verified, and a record
naming any other would deliver nowhere without an error.

The rule name, the rule set and the bucket are not in the response - a test greps
for them. A customer cannot act on them and putting them in a response would make
them a contract. The attachment is done in the route and not read off the row:
the MX host is Amazon SES's and the row is core's, which knows nothing about a
provider.

Additive, so a client written before the field existed still reads the rest, and
a test asserts the existing fields are unchanged. Another project's receiving
domain is never listed. The OpenAPI document is regenerated, and the docs tests
that require every field to be described pass.
The guide is the first thing a user reads for a feature that creates storage in
their own AWS account and edits how the account receives mail, so it leads with
what that means and not with how to switch it on: what is created and where, that
SESKit never replaces an existing receipt rule set, and that AWS bills it. It
gives no prices. Whether new accounts are billed for classic receipt rules under
the newer SES plans was never established, and a figure that turned out wrong
would be worse than none; it points at AWS's pricing pages and says SESKit does
not estimate.

The MX record gets the warning that matters most, at length and in an admonition:
it sends all of a domain's mail to SES and replaces wherever it is received now.
The guide recommends a subdomain and explains why that works - the rule is per
domain, so the subdomain is added as its own - and says plainly that neither SESKit
nor the page can check the record is live, with a dig command to do it.

A section on treating everything as hostile is addressed to the reader's own code,
not only to SESKit's: the API returns HTML exactly as sent, so the guide says not to
render it where it could reach their credentials. Retention says what expires and
what does not, and turning it off says what is kept - mail already received, and a
bucket that still holds mail - and that removing a domain or disconnecting does the
same first, so neither can leave a rule behind.

The third IAM policy is sixteen actions: seven bucket-level S3 actions scoped to
seskit-*, GetObject on its objects, and eight SES receipt-rule actions. It says
why s3:ListBucket is worth keeping - without it an ordinary expiry reads as a
permission failure, because S3 answers a missing key with access denied - and that
the receipt-rule actions are granted on `*` because whether they can be narrowed
was never checked, and a policy claiming a scope AWS does not enforce is worse than
one that says it has none. The SNS and SQS statements of the second policy already
cover the topic and queue, since they share the prefix.

The test that counts the IAM guide's policy sections now expects three. Both the
guide and the SDK, events and IAM descriptions are added to the agent index, which
a test requires for every page in the nav.
@Otitodev Otitodev changed the title feat: turn receiving on, and read it in the dashboard (inbound Phase D, in progress) feat: turn receiving on, and read it in the dashboard (inbound Phase D) Oct 5, 2026
@Otitodev
Otitodev marked this pull request as ready for review October 5, 2026 08:18
@Otitodev
Otitodev merged commit d08339d into main Oct 13, 2026
7 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