feat(email-intake): tenant-scoped email-to-ticket POST endpoint (PMS-450 phase 1) #337

Merged
YousifShkara merged 1 commit from feat/PMS-450-email-intake into main 2026-06-23 10:51:17 +02:00
Owner

Adds POST /api/v1/email-intake, the webhook surface external mail gateways (postfix MDA hook, Cloudron, Microsoft Graph subscription forwarder) call with a parsed inbound email to land it as a ticket inside a tenant. Three primitives ship together:

  • A tenant_intake_tokens table holds one or more bearer tokens per tenant. The plaintext is shown once at create time; only the SHA-256 hash is stored, so a database leak does not yield working tokens. kind is a discriminator so the same table can later host other webhook surfaces (rmm-intake, billing-webhook) without a schema migration.
  • A partial unique index on tickets(tenant_id, email_message_id) WHERE email_message_id IS NOT NULL enforces dedup at the DB layer in addition to the application-layer check, so a concurrent retry of the same Message-Id loses cleanly with a constraint violation that the service translates back to a deduplicated response.
  • CreateTicketRequest gains optional email_message_id + email_thread_id fields, plumbed through create_ticket's INSERT. The existing seed / portal / RMM ticket paths leave them None; only the email-intake handler populates them.

Intake flow (mirrors the portal-ticket pattern in create_portal_ticket): dedup on Message-Id, thread via the references header (returns the existing ticket id when a reply is detected; Phase 1 only returns the id, Phase 2 will append the reply as a comment), look up the contact by lowercased From: address inside the caller's tenant scope, fall back to the first admin/manager as created_by_id because email-intake has no agent identity, then call TicketService::create_ticket with source='email' set. The integration test under tests/email_intake.rs exercises every branch (happy, dedup, threading, unknown-sender 400, bad/missing bearer 401, and a final "exactly one ticket created" assertion that catches a regression in either dedup or threading from multiplying rows).

Phase 2, tracked under PMS-450:

  • tenant_settings.email_intake_default_company_id so the intake can auto-create a contact when the From: address is not already on the contacts list;
  • turn a recognised reply (matched via the references array) into a ticket comment rather than just returning the existing id;
  • email_intake_log audit table that captures every raw payload (headers + body) so a malformed sender can be debugged after the fact;
  • an admin CRUD surface for tenant_intake_tokens (currently seeded directly via SQL; operators need a UI to mint + revoke).

#PMS-450

Adds `POST /api/v1/email-intake`, the webhook surface external mail gateways (postfix MDA hook, Cloudron, Microsoft Graph subscription forwarder) call with a parsed inbound email to land it as a ticket inside a tenant. Three primitives ship together: - A `tenant_intake_tokens` table holds one or more bearer tokens per tenant. The plaintext is shown once at create time; only the SHA-256 hash is stored, so a database leak does not yield working tokens. `kind` is a discriminator so the same table can later host other webhook surfaces (rmm-intake, billing-webhook) without a schema migration. - A partial unique index on `tickets(tenant_id, email_message_id) WHERE email_message_id IS NOT NULL` enforces dedup at the DB layer in addition to the application-layer check, so a concurrent retry of the same Message-Id loses cleanly with a constraint violation that the service translates back to a deduplicated response. - `CreateTicketRequest` gains optional `email_message_id` + `email_thread_id` fields, plumbed through `create_ticket`'s INSERT. The existing seed / portal / RMM ticket paths leave them `None`; only the email-intake handler populates them. Intake flow (mirrors the portal-ticket pattern in `create_portal_ticket`): dedup on Message-Id, thread via the `references` header (returns the existing ticket id when a reply is detected; Phase 1 only returns the id, Phase 2 will append the reply as a comment), look up the contact by lowercased From: address inside the caller's tenant scope, fall back to the first admin/manager as `created_by_id` because email-intake has no agent identity, then call `TicketService::create_ticket` with `source='email'` set. The integration test under `tests/email_intake.rs` exercises every branch (happy, dedup, threading, unknown-sender 400, bad/missing bearer 401, and a final "exactly one ticket created" assertion that catches a regression in either dedup or threading from multiplying rows). Phase 2, tracked under PMS-450: - `tenant_settings.email_intake_default_company_id` so the intake can auto-create a contact when the From: address is not already on the contacts list; - turn a recognised reply (matched via the `references` array) into a ticket comment rather than just returning the existing id; - `email_intake_log` audit table that captures every raw payload (headers + body) so a malformed sender can be debugged after the fact; - an admin CRUD surface for `tenant_intake_tokens` (currently seeded directly via SQL; operators need a UI to mint + revoke). #PMS-450
YousifShkara force-pushed feat/PMS-450-email-intake from f3071b2524
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 1m1s
Check / fmt + clippy + build + tests (pull_request) Successful in 3m48s
Integration / integration tests (pull_request) Successful in 11m33s
to 5071ad9ab0
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 31s
Check / fmt + clippy + build + tests (pull_request) Successful in 1m10s
Integration / integration tests (pull_request) Successful in 3m56s
Create release / Create release from merged PR (pull_request) Successful in 4s
2026-06-23 10:38:17 +02:00
Compare
YousifShkara deleted branch feat/PMS-450-email-intake 2026-06-23 10:51:17 +02:00
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!337
No description provided.