Skip to content

Historical (legacy-inbound) attachment payloads are unrecoverable from stored state — needs a source-mailbox re-sync #52

Description

@andrei-hasna

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

  1. 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).
  2. A read-only Gmail fetch worker (OAuth scope, rate limits, resumable checkpointing over ~28.7k messages) that resolves provider_message_id → message → attachment parts.
  3. Reuse replaceAttachmentPayloadsAtomically's same-shape CAS so length/order never change and the inventory's positional attachment_index stays valid mid-scan.
  4. Dry-run + canary first (exact ids, counts only, no customer data in logs), then a bounded apply.
  5. Ambiguity/failure states must stay explicit — a row that cannot be resolved keeps content_available: false rather than acquiring a fabricated payload.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions