feat(email-intake): store inbound email attachments on the ticket (PMS-450) #379
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/PMS-450-email-intake-attachments"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes the last open PMS-450 acceptance criterion ("Attachments are stored"). The email-intake webhook already created tickets on a fresh Message-Id and appended replies as public notes on a threading hit, but the request DTO had no attachments field, so MIME parts decoded by the gateway were silently dropped.
The gateway now ships each attachment as a base64 part on the
attachmentsarray of thePOST /api/v1/email-intakebody. On the create path the blobs are stored against the new ticket (note_id NULL); on the threading path they hang off the reply note so the SPA renders them in the reply. Both attribute authorship to the sender contact viacreated_by_contact_id, mirroring the portal upload posture.Storage reuses
AttachmentService(same env-drivenATTACHMENT_DIR+ATTACHMENT_MAX_BYTEScap and per-tenant uuid blob layout as the agent/portal upload routes) rather than a second blob path.AttachmentService::createwas refactored to delegate to a new sharedinsert_blob, and a publicstore_email_attachmentexposes the email origin (NULL note_id allowed, contact-attributed). Per-part storage is best-effort: a part whose base64 fails to decode or whose blob exceeds the size cap is logged and skipped rather than failing the whole intake, and the response carriesattachments_storedso the gateway sees how many landed.Integration test drives the real HTTP surface: a create-path intake with one valid and one undecodable part stores exactly one row (note_id NULL, contact-attributed, filename/mime/size preserved), and a threading-path reply stores its attachment against the new public note.
#PMS-450
Closes the last open PMS-450 acceptance criterion ("Attachments are stored"). The email-intake webhook already created tickets on a fresh Message-Id and appended replies as public notes on a threading hit, but the request DTO had no attachments field, so MIME parts decoded by the gateway were silently dropped. The gateway now ships each attachment as a base64 part on the `attachments` array of the `POST /api/v1/email-intake` body. On the create path the blobs are stored against the new ticket (note_id NULL); on the threading path they hang off the reply note so the SPA renders them in the reply. Both attribute authorship to the sender contact via `created_by_contact_id`, mirroring the portal upload posture. Storage reuses `AttachmentService` (same env-driven `ATTACHMENT_DIR` + `ATTACHMENT_MAX_BYTES` cap and per-tenant uuid blob layout as the agent/portal upload routes) rather than a second blob path. `AttachmentService::create` was refactored to delegate to a new shared `insert_blob`, and a public `store_email_attachment` exposes the email origin (NULL note_id allowed, contact-attributed). Per-part storage is best-effort: a part whose base64 fails to decode or whose blob exceeds the size cap is logged and skipped rather than failing the whole intake, and the response carries `attachments_stored` so the gateway sees how many landed. Integration test drives the real HTTP surface: a create-path intake with one valid and one undecodable part stores exactly one row (note_id NULL, contact-attributed, filename/mime/size preserved), and a threading-path reply stores its attachment against the new public note. #PMS-450