feat(email-intake): store inbound email attachments on the ticket (PMS-450) #379

Merged
Claude-Run merged 1 commit from feat/PMS-450-email-intake-attachments into main 2026-06-26 12:54:59 +02:00
Member

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

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
feat(email-intake): store inbound email attachments on the ticket (PMS-450)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 26s
Check / fmt + clippy + build + tests (pull_request) Successful in 2m33s
Integration / integration tests (pull_request) Successful in 7m1s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
8a5cf9a916
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
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!379
No description provided.