Skip to content

feat: mirror inbound media into chat-media so attachments survive Meta's 30-day expiry - #496

Merged
ArnasDon merged 2 commits into
mainfrom
feat/466-mirror-inbound-media
Aug 12, 2026
Merged

feat: mirror inbound media into chat-media so attachments survive Meta's 30-day expiry#496
ArnasDon merged 2 commits into
mainfrom
feat/466-mirror-inbound-media

Conversation

@ArnasDon

Copy link
Copy Markdown
Owner

Summary

Inbound WhatsApp media is now copied into the chat-media bucket when it arrives, so attachments stay viewable after Meta deletes its own copy (~30 days). Previously the webhook stored only a pointer — /api/whatsapp/media/<id> — and the proxy behind it re-fetched from Meta on every view, so every inbound photo, voice note and document quietly became "Photo unavailable" a month later. Closes #466.

What changed

The mirrorsrc/lib/whatsapp/mirror-inbound-media.ts (new). After a successful verify, parseMessageContent downloads the bytes once and uploads them to chat-media under the existing account-scoped path convention, storing that public URL in media_url. It is strictly best-effort: it never throws, and on any failure the caller keeps the proxy URL, which still serves rows written before this change.

Object paths are keyed on Meta's media id and carry no wall-clock stamp (account-<id>/inbound/<mediaId>-<name>.<ext>), so a Meta redelivery rewrites one object instead of orphaning a second copy. A document keeps the sender's own filename, which is what a later download gets named.

Migration 039 — adds messages.media_type and whatsapp_config.mirror_inbound_media, and widens the chat-media MIME allow-list with the inbound-only types Meta can send (GIF, QuickTime, bare Opus, Meta's video/3gp spelling). That last part matters: the bucket enforces its allow-list against the service role too, so without it those uploads would be refused and silently fall back to the expiring proxy.

The four things the issue left open:

  • Storage cost → per-account opt-out (mirror_inbound_media), surfaced as a switch in Settings → WhatsApp and admin-gated to match the table's existing RLS. Defaults to ON, because the defect is silent data loss — an account that never finds the setting should be the one that keeps its attachments.
  • Failure handling → the webhook cannot start failing because a mirror upload did. Covered by tests for a refused upload and a throwing download.
  • Size limitsgetMediaUrl now returns Meta's file_size, so media past the bucket's 16 MB ceiling is skipped before the transfer. The downloaded length is re-checked, since that field is advisory.
  • No backfill → documented in the migration header. Existing rows keep their proxy URL.

MIME type — the webhook used to discard it outright (void mediaType); it's now persisted and preferred over blob.type when deriving a download filename, since a blob with no usable Content-Type otherwise resolves to a useless .bin.

buildMediaPath grew two optional params (suppress the timestamp, nest a subfolder). Both default to today's behaviour, so the composer and Flows builder are unaffected.

Test plan

  • npm run typecheck clean.
  • npm run lint — 0 errors, 37 warnings (below main's 39; none added).
  • npm run build succeeds.
  • npm test — 825 pass across 79 files, 26 of them new: 15 unit tests for the mirror helper and its naming rules, 8 webhook-route tests (durable URL stored, fallback on refused upload, fallback on failed download, oversized skipped without downloading, document filename preserved, opt-out honoured, absent column treated as on, text messages untouched), and 3 for buildMediaPath / mediaFilename.
  • Not manually exercised in the browser — the path needs a live Meta webhook delivery and migration 039 applied. vitest runs environment: "node" with no jsdom, so the settings switch has no component test either; it's a Switch wired to a direct whatsapp_config update.

Migration 039 has not been applied anywhere — there's no local Postgres in this repo. It's structurally checked only (5 statements, quotes and parens balanced). Worth a real apply before merge.

Related

Closes #466. The viewer/download UI it splits from shipped in #373.

🤖 Generated with Claude Code

ArnasDon and others added 2 commits August 12, 2026 09:29
…a's expiry (#466)

Inbound media was never persisted. The webhook verified the Meta media
id and stored a POINTER — `/api/whatsapp/media/<id>` — and that proxy
re-streamed from Meta on every view. Meta deletes media roughly 30 days
after receipt, so every inbound photo, voice note and document quietly
became "Photo unavailable" a month later, with no way to recover it.
Outbound media never had the problem: the composer uploads to the public
`chat-media` bucket and stores a durable URL.

Inbound now takes the same route. After a successful verify the bytes
are downloaded once and uploaded to `chat-media` under the existing
account-scoped path convention, and that public URL is stored in
`media_url`.

Decisions behind the four open questions in the issue:

- Storage cost. Per-account opt-out on `whatsapp_config`
  (`mirror_inbound_media`), surfaced as a switch in Settings → WhatsApp
  and gated to admins by the table's existing RLS. It defaults to ON:
  the defect is silent data loss, so an account that never finds the
  setting should be the one that keeps its attachments.
- Failure handling. `mirrorInboundMedia` swallows its own failures and
  returns null; the caller falls back to the proxy URL. A throw here
  would fail the webhook and have Meta redeliver the whole message.
- Size limits. Meta's `file_size` is now returned by `getMediaUrl`, so
  media past the bucket's 16 MB ceiling is skipped before the transfer
  rather than after. The downloaded length is re-checked, since that
  field is advisory.
- No backfill. Media Meta has already expired is gone; existing rows
  keep their proxy URL and the proxy route still serves them.

Object paths are keyed on the Meta media id and carry no wall-clock
stamp, so a redelivery rewrites one object instead of orphaning a
second copy. A document keeps the sender's own filename, which is what
a later download is named.

Also persists the MIME type the webhook used to discard outright
(`void mediaType`) as `messages.media_type`, and prefers it over
`blob.type` when deriving a download filename — a blob with no usable
Content-Type otherwise resolves to a useless `.bin`.

Migration 039 adds both columns and widens the `chat-media` MIME
allow-list with the inbound-only types Meta can send (GIF, QuickTime,
bare Opus, and Meta's `video/3gp` spelling); the bucket enforces that
list against the service role too, so without it those uploads would
be refused and fall back to the expiring proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mirror grows a self-hosted Supabase project's storage with inbound
volume, which is exactly the kind of runtime behaviour change a forker
should not have to discover from a quota alert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabase Bot commented Aug 12, 2026

Copy link
Copy Markdown

Updates to Preview Branch (feat/466-mirror-inbound-media) ↗︎

Deployments Status Updated
Database ⏸️ Wed, 12 Aug 2026 08:19:00 UTC
Services ⏸️ Wed, 12 Aug 2026 08:19:00 UTC
APIs ⏸️ Wed, 12 Aug 2026 08:19:00 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations ⏸️ Wed, 12 Aug 2026 08:19:00 UTC
Migrations ⏸️ Wed, 12 Aug 2026 08:19:00 UTC
Seeding ⏸️ Wed, 12 Aug 2026 08:19:00 UTC
Edge Functions ⏸️ Wed, 12 Aug 2026 08:19:00 UTC

❌ Branch Error • Wed, 12 Aug 2026 08:19:04 UTC

unexpected status 404: {"message":"Postgres config not found"}

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

ArnasDon added a commit that referenced this pull request Aug 12, 2026
Migration 039 belongs to PR #496, not here. It applied cleanly to a
clean Postgres 17 and produced the columns and widened bucket
allow-list it claims (commit 65c99d8), which was the open risk on that
PR. Removing it leaves this branch to main's 38 migrations.
@ArnasDon
ArnasDon merged commit 6ed9191 into main Aug 12, 2026
1 of 2 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.

[feat] Mirror inbound media into chat-media so attachments survive Meta's 30-day expiry

1 participant