Repository navigation
feat: turn receiving on, and read it in the dashboard (inbound Phase D) - #63
Merged
Merged
Conversation
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.
Otitodev
force-pushed
the
feat/inbound-ui
branch
from
October 5, 2026 07:59
cffe88c to
e9adcea
Compare
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
marked this pull request as ready for review
October 5, 2026 08:18
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
sandboxattribute.receivingonGET /v1/domains, read-only, with the MX record withheld until receiving is on.What to review hardest:
sandbox=""and noallow-token, and the sender's own iframe survives only as escaped text. The document hassandbox,default-src 'none'(which also blocks tracking pixels and remote images) and inline styles only.Content-Security-Policy, without which the download'ssandboxpolicy never reached the client. This PR fixesX-Frame-OptionsandReferrer-Policy, which the preview needs to be framed at all. Each has a guard that fails against the old code.setup_receivingclaimed 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./v1and 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:
objectKey. A real-mail test needs a domain with an MX record you can point at SES and a key with the new policy.*, and says why: whether they can be narrowed was not checked.digcommand.Checks
uv run --group docs mkdocs build --strictis clean🤖 Generated with Claude Code