Split out of #36, which is closed by #51. #51 makes every metadata surface state whether payload bytes exist (content_available) so nothing advertises a download that cannot work. It deliberately does not recover the missing bytes. This issue tracks that.
State on the live serve (measured 2026-07-24, emails.hasna.xyz, /ready version 1.2.7)
- 56,292 of 57,455 attachment rows (≈28,705 messages) are
legacy-inbound:* imports carrying filename/content_type/size with no stored content_base64. GET /v1/messages/{id}/attachments/{n} answers 409 attachment_content_unavailable for all of them.
- Post-cutover (SES-ingested) messages are unaffected and download fine — verified on
1edcf862-…, a 32 KB PDF with a matching sha256.
Why the existing repair path cannot fix it
attachment-repair-canary replays a canonical S3 object and needs bucket + object_key provenance. Legacy rows do not have one:
provider_message_id on legacy-inbound:352ff380-… is 45770487 — a Gmail history id, not an S3 key. (Migration 0007 sets provider_message_id from raw_s3_url when present, else message_id; these rows came from a Gmail sync, so there is no raw_s3_url.)
GET /v1/messages/{id}/raw returns MIME reconstructed from stored headers/body — it contains no attachment parts, so it is not a byte source either.
- The operator station's local SQLite
inbound_emails table is empty (0 rows), so the bytes are not sitting on a station either.
So there is nothing on Hasna infrastructure to replay from. Recovery means re-fetching from the source mailbox (Gmail), keyed by the retained history/message id.
What a fix would involve
- Decide the provenance contract for a Gmail-sourced recovery: which id is authoritative, and how a recovered payload is attested (sha256 + an
established_via: "gmail_replay" provenance row alongside the existing normal_ingest / canonical_replay).
- A read-only Gmail fetch worker (OAuth scope, rate limits, resumable checkpointing over ~28.7k messages) that resolves
provider_message_id → message → attachment parts.
- Reuse
replaceAttachmentPayloadsAtomically's same-shape CAS so length/order never change and the inventory's positional attachment_index stays valid mid-scan.
- Dry-run + canary first (exact ids, counts only, no customer data in logs), then a bounded apply.
- Ambiguity/failure states must stay explicit — a row that cannot be resolved keeps
content_available: false rather than acquiring a fabricated payload.
- Rollback: the CAS is per message and idempotent, so a bad batch is re-runnable, but a
--limit and a kill switch are required before touching 28.7k messages.
Rough size: a substantial feature (new worker + credential path + provenance migration + canary), not a hotfix. Owner decisions needed first: whether the Gmail credential is available and authorized for a bulk historical read, and whether recovery is wanted at all versus leaving those records honestly marked metadata-only.
Split out of #36, which is closed by #51. #51 makes every metadata surface state whether payload bytes exist (
content_available) so nothing advertises a download that cannot work. It deliberately does not recover the missing bytes. This issue tracks that.State on the live serve (measured 2026-07-24,
emails.hasna.xyz,/readyversion 1.2.7)legacy-inbound:*imports carrying filename/content_type/size with no storedcontent_base64.GET /v1/messages/{id}/attachments/{n}answers409 attachment_content_unavailablefor all of them.1edcf862-…, a 32 KB PDF with a matching sha256.Why the existing repair path cannot fix it
attachment-repair-canaryreplays a canonical S3 object and needsbucket+object_keyprovenance. Legacy rows do not have one:provider_message_idonlegacy-inbound:352ff380-…is45770487— a Gmail history id, not an S3 key. (Migration0007setsprovider_message_idfromraw_s3_urlwhen present, elsemessage_id; these rows came from a Gmail sync, so there is noraw_s3_url.)GET /v1/messages/{id}/rawreturns MIME reconstructed from stored headers/body — it contains no attachment parts, so it is not a byte source either.inbound_emailstable is empty (0 rows), so the bytes are not sitting on a station either.So there is nothing on Hasna infrastructure to replay from. Recovery means re-fetching from the source mailbox (Gmail), keyed by the retained history/message id.
What a fix would involve
established_via: "gmail_replay"provenance row alongside the existingnormal_ingest/canonical_replay).provider_message_id→ message → attachment parts.replaceAttachmentPayloadsAtomically's same-shape CAS so length/order never change and the inventory's positionalattachment_indexstays valid mid-scan.content_available: falserather than acquiring a fabricated payload.--limitand a kill switch are required before touching 28.7k messages.Rough size: a substantial feature (new worker + credential path + provenance migration + canary), not a hotfix. Owner decisions needed first: whether the Gmail credential is available and authorized for a bulk historical read, and whether recovery is wanted at all versus leaving those records honestly marked metadata-only.