Skip to content

feat: store received mail (inbound Phase B) - #61

Merged
Otitodev merged 6 commits into
mainfrom
feat/inbound-storage
Oct 5, 2026
Merged

Otitodev merged 6 commits into
mainfrom
feat/inbound-storage

Conversation

@Otitodev

@Otitodev Otitodev commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

What and why

Follows #60, which is merged; this is rebased onto main.

SESKit can send mail and report what happened to it, but cannot receive. #60 added the SES half. This adds everything that happens once a message has arrived: storing it, parsing it, and a worker job that reads the queue. Nothing turns receiving on yet, since that is the routes and dashboard in a later PR, so no user-visible behaviour changes. What is new is the path from "SES stored a message" to "a parsed row in the database".

Six commits, one concern each:

  • An ID prefix and a retention setting. Messages read inbound_01J.... INBOUND_RETENTION_DAYS (default 30, bounded 1 to 3650) is how long the raw message and attachments stay in S3; the parsed message stays in the database regardless.
  • A parser for received MIME. It never raises, nothing it returns can make a Postgres insert fail (NUL bytes, lone surrogates), and it is bounded on every axis.
  • Storage. The inbound_emails table, the columns that record the plumbing, and a partial unique index so one domain can receive in only one place.
  • The service layer. Set up and remove receiving, with a refcount across projects sharing an account and region. Removing a domain or disconnecting AWS now removes receipt rules first.
  • Ingest. SES notification plus raw message into a row, scoped to the projects the queue speaks for.
  • The worker job. poll_inbound, every minute.

What to review hardest:

  • The refcount. Two projects in one account and region share the bucket, topic and queue. Removing them when the first project's last domain goes would stop the second project's mail with nothing on either screen to say why. test_plumbing_stays_while_another_project_in_the_account_still_receives is the guard.
  • AWS first, the row second on every removal, so a failure never leaves a row saying "not receiving" while the rule is still in somebody's account.
  • Receiving is claimed before anything is built, by the unique index inside a savepoint, so a lost race fails before it has created a rule in somebody's account.
  • Scope is the lock on ingest. Nothing in an SES notification is secret, so an announcement naming a domain or bucket that is not this queue's is dropped before anything is downloaded.
  • One failing message does not stall the batch. This differs from the event poller on purpose: the step that can fail is a download, and a missing s3:GetObject fails identically on every message.
  • A verdict SES omitted is stored as NULL, never PASS.

remove_identity and disconnect_aws gained an optional factory and raise ValueError when something receives and none is given. Every project today receives nothing and behaves as before; the two routes that call them pass one.

The 22 service tests, 19 ingest tests and 12 model tests run against Postgres and ran in CI. Locally I could only run the static gate and the tests that need no database, since I did not start Docker.

Not yet verified against a real AWS account: that 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. The ingest code deliberately does not depend on the second.

Checks

  • Migrations: tests/test_migrations.py passes in CI. I generated the SQL offline and checked it; I did not run alembic upgrade head against a database myself.
  • Docs: updated, and uv run --group docs mkdocs build --strict is clean

🤖 Generated with Claude Code

@Otitodev Otitodev changed the title feat(core): read received mail, and its settings (inbound Phase B, 1 of 2) feat: store received mail (inbound Phase B) Oct 5, 2026
Two names settled before the code that uses them, so the model, the
service and the worker do not each coin their own.

The prefix is "inbound", so a received message reads inbound_01J... in
the API. Like every prefix it is permanent once shipped.

INBOUND_RETENTION_DAYS (default 30) is how long the raw message and its
attachments stay in the project's bucket. The parsed message stays in the
database either way, so this bounds only what costs storage and holds
somebody's files. It is bounded to 1..3650: zero would have S3 expire mail
the moment it lands, and no upper bound turns a typo into "never". Refused
at startup, where the error names the setting, rather than at provisioning
where AWS would say something about a lifecycle rule. A change applies the
next time receiving is set up; an object already stored keeps the clock it
had.

Polling reuses the EVENT_POLL_* settings rather than adding a second set
that would have to be kept in step with them.
The inverse of message.py, and a different problem. That module builds
mail SESKit wrote, so a declared charset is a real one and a multipart is
well formed. This reads mail somebody else wrote, and none of that holds.

It never raises. A message that cannot be read is still a message
somebody sent, and a worker that raises on it leaves it on the queue to
be retried for ever. The worst case is parse_failed set on a result that
is stored and shown.

Nothing leaves it that the database would refuse. Postgres cannot store a
NUL in text and the driver cannot encode a lone surrogate, which 8-bit
header bytes produce; either turns one odd message into an insert that
fails on every retry. _clean is the one place that removes them, and the
tests were run with it disabled to confirm they fail without it.

Bounded on every axis - parts, nesting depth, headers, body length - and
a cap sets `truncated` rather than failing, because a 40 MB message is
legitimate and a 40 MB message of nothing but nested multiparts is an
attack that looks identical until something counts. A nesting bomb three
thousand deep returns a result.

Attachments are described, not carried. The bytes stay in the stored
message, where they already are; read_attachment finds one by index by
walking the same parts in the same order, so an index means the same
thing in both places. A message/rfc822 part is one attachment: descending
into it would take a forwarded message's body for this one's.

Filenames are reduced to a basename with control characters removed,
because the sender chose the string and a download will echo it in a
header. A backslash path inside a plain quoted filename is consumed as an
escape by RFC 2045 before this module sees it, which looks like a bug and
is the reason it is safe; a test pins that. The real route for one is
RFC 2231 percent-encoding, which is tested.

Only the first text/plain and first text/html are bodies. Any further
text part stays visible as an attachment: nothing is silently dropped.
The table the messages go in, the columns that record the plumbing built
to bring them, and the column that records which domain's mail arrives
through it. Nothing reads or writes them yet.

inbound_emails mirrors Email in name only. Email records something SESKit
was asked to do, written before it happens; this records something that
already happened to somebody else's message. The parsed message is stored
whole and stays as long as the row does; the original stays in the bucket
until retention removes it, and raw_expires_at says when. So a message can
still be read after its attachments can no longer be downloaded, which is
the right way round - the row costs almost nothing and the files cost
storage.

Two uniqueness rules, because each catches what the other cannot. SNS is
at-least-once, so the same notification arrives twice and its message id
says so. SES can also announce one stored object again under a different
SNS id, which only the storage location notices. A message holds its
domain as text and not a reference to the identity: removing a domain
stops future mail and must not take what was already received with it.

One receiving identity per domain is enforced by a partial unique index
on identities.value, not left to the service layer. A domain's MX record
names a single endpoint in a single region, so two identities both
receiving for it cannot both be right, and mail would belong to whichever
was looked up first. Partial because most identities receive nothing and
any number may share a value; a check-then-insert in a service cannot make
that promise under two concurrent requests, an index can.

The rule name and rule set are recorded on the identity rather than
derived, so teardown removes the rule that was created, from the set it
was put in. That is whichever set the account had active at the time and
no name can reconstruct it. inbound_rule_set_created records whether
SESKit made the set, because only then may it remove it.

The connection keeps inbound_bucket after the topic and queue are gone if
the bucket still holds mail, so a later setup finds it again. inbound_enabled
therefore asks for the topic and queue, not the bucket: a kept bucket does
not mean anything is listening.

The references column is reference_ids: references is reserved in Postgres
and a column that must be quoted in every hand-written query is a trap.

The migration is written by hand, since autogenerate needs a database.
Checked offline instead: alembic emits the expected SQL in both
directions, there is one head, and the model DDL compiles. The check that
runs it against a real database and diffs it against the models is
tests/test_migrations.py, in CI.
The service layer over the provider: decide whether to create or remove
anything, and record what happened. Nothing calls setup yet - the routes
that turn it on are a later phase - but removal is wired in now, because
that is the half that must never be missed.

Two halves, owned differently. The bucket, topic and queue belong to an
AWS account and region and are recorded on each project's connection; a
receipt rule belongs to one domain and is recorded on its identity. A
second domain adds its rule and leaves the first's plumbing alone.

The refcount is the reason for most of this file, and the same trap
services.events documents. Two projects in one account and region share
the plumbing, so removing it when the first project's last domain goes
stops the second project's mail, with nothing on either screen to say
why. Teardown counts the other users first, matched on account and
region rather than on a stored ARN. A bucket that still holds mail stays,
and stays recorded, so a later setup finds it and the name is not lost
with the row that knew it.

Every removal touches AWS first and the row second. The other way round,
a failure halfway leaves a row saying "not receiving" while the rule is
still in somebody's account and nothing left to name it. A test makes the
removal fail and asserts the row still says it receives.

The domain is claimed before anything is built. The name is flushed first,
inside a savepoint, so the unique index decides a race between two
requests enabling the same domain: the loser fails before it has created
a rule in somebody's account, and does not roll back the request that
lost. The friendly pre-check beside it is only courtesy; a check followed
by an insert cannot hold under concurrency, an index can. The refusal does
not say whose domain it is.

remove_identity and disconnect_aws now take an optional inbound factory
and raise ValueError when something receives and none is given. Deleting
the row would strand the rule, since nothing could then say which receipt
rule in the account was ours; disconnecting would drop the only key that
could remove it. A project that receives nothing, which is every project
today, needs no factory and behaves as before. The two routes that call
them pass one.

Core cannot import the adapter, so the rule-name prefix is repeated here
with a test that holds it equal to the adapter's. A mismatch would let SES
accept a rule whose writes the bucket policy then refuses.
The counterpart of ingest_event, for mail that arrives instead of mail
that was sent. Nothing calls it yet; the worker job is next.

Split into steps because the worker has to do something between them that
this module should not know about: fetch the message. is_recorded is its
own step so a redelivery of a 40 MB message costs a database lookup and
not a download.

Whose message it is comes from the recipient's domain, not the object key.
SES says the key equals the message id and is silent on whether a rule's
key prefix is included, so nothing here parses it; the envelope recipients
are what the rule matched. A rule for example.com also matches
sub.example.com, so a recipient's parent domains are candidates too, most
specific first.

That match is the lock, for the reason ingest_event's message-id scope is.
Nothing in a notification is secret - the SES message id is in the headers
of every message it delivers - so an announcement naming a domain that
belongs to a project the queue does not speak for, or a bucket that is not
this queue's, is dropped. Every way of being wrong returns the same None,
so a probe learns nothing from the difference. An empty scope matches
nothing.

A verdict the notification omits is stored as NULL, never as PASS. Saying a
message was authenticated when nothing checked it is the one thing this
column must not do. Verdicts are recorded and not acted on.

Losing a race to record the same notification ends as a duplicate and not
an error, inside a savepoint, so the loser does not roll back whatever the
caller has pending; a test records another message straight afterwards in
the same session to prove it.

The parser's cleaning is proved here, where it can be: real NUL bytes and
invalid UTF-8 going into real Postgres, which is what refuses them. And a
message nobody can read is still recorded, with the original in storage,
instead of left on the queue to fail for ever.

Logs carry ids and sizes only. Who a message is from, who it is to and what
it says are not a reason to put any of it in a log.
poll_inbound drains each inbound queue, fetches the message an
announcement points at, and records it. It runs every minute, offset from
the event poll so the two long polls do not start together. The last piece
of storing received mail: nothing receives yet, since nothing turns
receiving on, but everything below that line now works end to end.

The order puts the expensive step last. Whose message it is and whether it
is already stored are database lookups; the download is up to 40 MB. A
redelivery, a message for somebody else's domain, and a notification that
is not mail at all never reach it, and tests assert the store was not asked.
The download holds no database connection, so the lookups, the fetch and
the write are separate short sessions.

What acknowledges a message follows events.py: deleted only once settled -
recorded, already recorded, not ours, not mail, or expired. An expired
object is settled, because it will not come back and a queue that keeps
redelivering it makes no progress on the rest. A download that failed for a
reason that may pass - a permission being added, a network returning - is
left on the queue, because deleting it would turn a fixable setup mistake
into mail that is gone.

The one deliberate difference from events.py is isolation. There an
unexpected exception ends the pass; here the step that can fail is a
download, and the commonest cause is a missing s3:GetObject, which fails
identically on every message. If one failure ended the batch, the unreadable
message at the front would stall everything behind it indefinitely. Each
message is isolated instead: the failure is logged by type and the queue's
own id - never its text, since an exception raised while handling mail can
carry a fragment of it - the message is left for the next pass, and the rest
of the batch is handled. A test fails the first of three downloads and
asserts the other two are recorded.

The original is never deleted from the bucket here; retention does that. A
bounded pass and a single request on an empty queue, as before.
@Otitodev
Otitodev force-pushed the feat/inbound-storage branch from b8c98ae to a453cec Compare October 5, 2026 04:45
@Otitodev
Otitodev changed the base branch from feat/inbound-email to main October 5, 2026 04:45
@Otitodev Otitodev closed this Oct 5, 2026
@Otitodev Otitodev reopened this Oct 5, 2026
@Otitodev
Otitodev merged commit 67d14ad into main Oct 5, 2026
12 of 15 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