ADR-0004: Persist inbound email attachments in the webhook handler
ACCEPTED
Context
Phase 2 adds bidirectional attachment support to the email channel. Inbound email arrives via the anymail webhook handler (ADR-0001) and is routed through the Slack-style priority chain (ADR-0002), which previously ran in a Celery task behind a best-effort webhook pre-filter.
Attachments raise the question of where the bytes get persisted. Persisting in the Celery task sends raw blobs through the Redis broker, forces team context to be re-derived, and saves files for emails that route to no channel. The records must reach the pipeline and LLM as Attachment objects with session-scoped download links.
Decision
We will persist inbound attachments synchronously in the webhook handler, promoting it to the full router:
- The handler resolves channel and session, sets team context, and calls
File.create(), so the Celery payload carries only File IDs. - A generic
BaseMessage.attachment_file_idsfield carries those IDs. - A generic
AttachmentHydrationStage, inserted into the default pipeline after session resolution and before chat-message creation, converts the IDs intoAttachmentobjects with session-scoped download links.
Consequences
- No blobs through the Celery broker; payloads stay small and team context is known at persistence time.
- No orphaned files — if routing finds no channel, nothing is saved.
attachment_file_idsandAttachmentHydrationStageare channel-agnostic, so any future channel that pre-persists inbound files reuses the same plumbing.- Hydrating after session resolution guarantees download links point at a real session.
- Negative: the handler now routes and runs
File.create()synchronously before returning200to the ESP, raising webhook latency. - Negative:
handle_email_messagegainschannel_id/session_idarguments, requiring a one-release-cycle legacy fallback for in-flight pre-deploy tasks.
Alternatives considered
- Persist in the Celery task → rejected: raw bytes through the broker, team-context re-derivation, and orphaned files on no-match.
- Email-specific hydration instead of a generic field + stage → rejected: other v2-migrated channels will want the same pre-persist pattern.
- A new
FilePurposefor email attachments → rejected: they share the lifecycle of all channel media, which the existingMESSAGE_MEDIApurpose already fits.