The previous commit stops bodies being filed as attachments. It does nothing
for the mail already in the mailbox, which stays blank — and there was nowhere
for that kind of work to live.
WHY NOT THE FOUR PLACES IT COULD HAVE GONE. A migration runs exactly once on
update and is the right home for schema, but this repair needs the attachment
store and the body sanitizer, and a migration reaching into the container
breaks the day one of them moves — halfway through an update, with the schema
half applied. The entrypoint is downtime: it runs before the server accepts a
request, so a walk over every message put there means an install that updates
at eight in the evening is unreachable until it finishes. BackfillTaskInterface
is deliberately manual, which is right for "regenerate the categories because
the classifier changed" and wrong for damage nobody should read a changelog to
discover. BackgroundJob is per-user, and this is not owned by a user.
So: a ledger, in the same shape as Doctrine's own migration_versions and
readable the same way when the application will not start. A task claims its
row BEFORE running, so a container killed mid-walk leaves evidence rather than
looking like one that never started; three attempts and it stops rather than
taking the worker down every ten minutes forever. The runner re-reads the row
afterwards because tasks clear the EntityManager between batches and the
claimed instance is detached by then — writing through it would flush nothing
and the task would run again for ever.
Scheduled every ten minutes, and what runs every ten minutes is the QUESTION,
not the work: one indexed lookup per registered task against a table with
single-digit rows. What it buys is the gap between an update landing and the
repair happening.
The repair is the first task and implements both interfaces — automatic for
everybody, and app:backfill misfiled-bodies for anyone who wants it now. It
needs no mail server: persistAttachment() stored the bytes on the way past, so
the body has been on disk the whole time it was missing from the screen. It
does lose the declared charset, which MessagePart does not keep, so
CharsetHelper works it out from the bytes — exact for UTF-8 and a reasoned
guess otherwise, against a round trip per message to a server with nothing else
to say.
EXISTS rather than a join in the lookup, and not as a preference: a
multipart/alternative can have both halves misfiled, and SELECT DISTINCT cannot
run at all here — Message carries json columns and Postgres has no equality
operator for json. The repository test caught that on its first run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>