feat(email-intake): auto-create contact + reply-as-comment + intake-log audit (PMS-469) #346

Merged
YousifShkara merged 1 commit from feat/PMS-469-email-intake-follow-ups into main 2026-06-24 07:31:48 +02:00
Owner

PMS-450 phase 1 + PMS-450 phase 2 left three follow-up gaps the gateway integrators kept tripping over: an unknown sender always 422'd (so onboarding a new tenant meant pre-creating every conceivable sender contact), a reply was acknowledged with the existing ticket id but no comment landed on it (so the agent saw the original ticket but not the customer's follow-up content), and a misrouted gateway request left no audit trail at all (the admin had to recover the raw payload from the mail-gateway side, which is the wrong place for a server-side error to hide). This PR closes all three.

What landed:

  • Migration 073_email_intake_log.sql adds an audit table that captures every inbound intake's raw headers (JSONB), raw text body, raw html body, message_id, ticket_id (NULL while in-flight or on error), received_at, and error. Indexed on (tenant_id, received_at DESC) for the admin "recent intakes" view and (tenant_id, message_id) for the "find the log row for this Message-Id" lookup. The 90-day retention sweep is a separate ticket.

  • validate_setting_value("email_intake", "default_company_id") accepts UUID strings; the email-intake service reads it via settings::read_email_intake_default_company and treats Ok(None) as "preserve the Phase 1 422-on-unknown-sender posture". When set, an unknown sender lands a fresh contact under that company with email = lowercased from_email, first_name + last_name split off from_name (falling back to "Contact" when absent), then proceeds with the create.

  • service::intake now wraps the existing flow in intake_inner and bookends it with record_intake_log (top of flow) + finalise_intake_log (tail, UPDATEs the row with ticket_id or error). The log write is fail-soft on the tail: a failed UPDATE is logged via tracing but does NOT mask the original intake outcome.

  • find_by_references hits now call resolve_or_create_contact and append_reply_comment. The comment is a note_type='public' row with created_by_contact_id = matched sender contact (per the PMS-449 column) so the SPA renders Customer correctly. The note's created_by_id (NOT NULL FK on users) falls back to the tenant's first active admin/manager - email-intake has no agent identity, mirroring the create path. When the sender cannot be resolved (no contact AND no fallback company set), the response is threaded=true, comment_added=false: the gateway still gets the threading hit, just no note.

  • EmailIntakeResponse gains a comment_added: bool field; existing callers see false on every non-reply path. EmailIntakeRequest accepts an optional raw_headers: serde_json::Value (defaults to {}) so the gateway can carry verbatim headers into the audit log.

  • New route: GET /api/v1/email-intake-log/{id}. Admin-only via the existing RequireAuth + RequireAdmin pairing; tenant-scoped so a guessed UUID across tenants 404s cleanly.

Integration tests at tests/email_intake_phase2.rs:

  • auto_create_contact_under_fallback_company: unknown sender 422s when the setting is absent; same intake auto-creates a contact under the fallback company when the setting is populated; the resulting ticket references the auto-created contact.

  • reply_appends_public_comment: a References-matching intake adds a note_type='public' row attributed (via created_by_contact_id) to the matched sender contact and the response carries comment_added=true.

  • every_intake_writes_a_log_row: any intake (success or 422) writes an email_intake_log row; the happy row carries ticket_id and no error, the stranger row carries error and no ticket_id; the admin GET endpoint surfaces the row contents.

#PMS-469

PMS-450 phase 1 + PMS-450 phase 2 left three follow-up gaps the gateway integrators kept tripping over: an unknown sender always 422'd (so onboarding a new tenant meant pre-creating every conceivable sender contact), a reply was acknowledged with the existing ticket id but no comment landed on it (so the agent saw the original ticket but not the customer's follow-up content), and a misrouted gateway request left no audit trail at all (the admin had to recover the raw payload from the mail-gateway side, which is the wrong place for a server-side error to hide). This PR closes all three. What landed: * Migration `073_email_intake_log.sql` adds an audit table that captures every inbound intake's raw headers (JSONB), raw text body, raw html body, message_id, ticket_id (NULL while in-flight or on error), received_at, and error. Indexed on (tenant_id, received_at DESC) for the admin "recent intakes" view and (tenant_id, message_id) for the "find the log row for this Message-Id" lookup. The 90-day retention sweep is a separate ticket. * `validate_setting_value("email_intake", "default_company_id")` accepts UUID strings; the email-intake service reads it via `settings::read_email_intake_default_company` and treats `Ok(None)` as "preserve the Phase 1 422-on-unknown-sender posture". When set, an unknown sender lands a fresh contact under that company with `email = lowercased from_email`, `first_name + last_name` split off `from_name` (falling back to "Contact" when absent), then proceeds with the create. * `service::intake` now wraps the existing flow in `intake_inner` and bookends it with `record_intake_log` (top of flow) + `finalise_intake_log` (tail, UPDATEs the row with `ticket_id` or `error`). The log write is fail-soft on the tail: a failed UPDATE is logged via tracing but does NOT mask the original intake outcome. * `find_by_references` hits now call `resolve_or_create_contact` and `append_reply_comment`. The comment is a `note_type='public'` row with `created_by_contact_id = matched sender contact` (per the PMS-449 column) so the SPA renders Customer correctly. The note's `created_by_id` (NOT NULL FK on users) falls back to the tenant's first active admin/manager - email-intake has no agent identity, mirroring the create path. When the sender cannot be resolved (no contact AND no fallback company set), the response is `threaded=true, comment_added=false`: the gateway still gets the threading hit, just no note. * `EmailIntakeResponse` gains a `comment_added: bool` field; existing callers see `false` on every non-reply path. `EmailIntakeRequest` accepts an optional `raw_headers: serde_json::Value` (defaults to `{}`) so the gateway can carry verbatim headers into the audit log. * New route: `GET /api/v1/email-intake-log/{id}`. Admin-only via the existing `RequireAuth + RequireAdmin` pairing; tenant-scoped so a guessed UUID across tenants 404s cleanly. Integration tests at `tests/email_intake_phase2.rs`: * `auto_create_contact_under_fallback_company`: unknown sender 422s when the setting is absent; same intake auto-creates a contact under the fallback company when the setting is populated; the resulting ticket references the auto-created contact. * `reply_appends_public_comment`: a References-matching intake adds a `note_type='public'` row attributed (via `created_by_contact_id`) to the matched sender contact and the response carries `comment_added=true`. * `every_intake_writes_a_log_row`: any intake (success or 422) writes an `email_intake_log` row; the happy row carries `ticket_id` and no error, the stranger row carries `error` and no ticket_id; the admin GET endpoint surfaces the row contents. #PMS-469
feat(email-intake): auto-create contact + reply-as-comment + intake-log audit (PMS-469)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 51s
Check / fmt + clippy + build + tests (pull_request) Successful in 2m46s
Integration / integration tests (pull_request) Successful in 8m38s
Create release / Create release from merged PR (pull_request) Successful in 1s
660e4d7e30
PMS-450 phase 1 + PMS-450 phase 2 left three follow-up gaps the gateway integrators kept tripping over: an unknown sender always 422'd (so onboarding a new tenant meant pre-creating every conceivable sender contact), a reply was acknowledged with the existing ticket id but no comment landed on it (so the agent saw the original ticket but not the customer's follow-up content), and a misrouted gateway request left no audit trail at all (the admin had to recover the raw payload from the mail-gateway side, which is the wrong place for a server-side error to hide). This PR closes all three.

What landed:

* Migration `073_email_intake_log.sql` adds an audit table that captures every inbound intake's raw headers (JSONB), raw text body, raw html body, message_id, ticket_id (NULL while in-flight or on error), received_at, and error. Indexed on (tenant_id, received_at DESC) for the admin "recent intakes" view and (tenant_id, message_id) for the "find the log row for this Message-Id" lookup. The 90-day retention sweep is a separate ticket.

* `validate_setting_value("email_intake", "default_company_id")` accepts UUID strings; the email-intake service reads it via `settings::read_email_intake_default_company` and treats `Ok(None)` as "preserve the Phase 1 422-on-unknown-sender posture". When set, an unknown sender lands a fresh contact under that company with `email = lowercased from_email`, `first_name + last_name` split off `from_name` (falling back to "Contact" when absent), then proceeds with the create.

* `service::intake` now wraps the existing flow in `intake_inner` and bookends it with `record_intake_log` (top of flow) + `finalise_intake_log` (tail, UPDATEs the row with `ticket_id` or `error`). The log write is fail-soft on the tail: a failed UPDATE is logged via tracing but does NOT mask the original intake outcome.

* `find_by_references` hits now call `resolve_or_create_contact` and `append_reply_comment`. The comment is a `note_type='public'` row with `created_by_contact_id = matched sender contact` (per the PMS-449 column) so the SPA renders Customer correctly. The note's `created_by_id` (NOT NULL FK on users) falls back to the tenant's first active admin/manager - email-intake has no agent identity, mirroring the create path. When the sender cannot be resolved (no contact AND no fallback company set), the response is `threaded=true, comment_added=false`: the gateway still gets the threading hit, just no note.

* `EmailIntakeResponse` gains a `comment_added: bool` field; existing callers see `false` on every non-reply path. `EmailIntakeRequest` accepts an optional `raw_headers: serde_json::Value` (defaults to `{}`) so the gateway can carry verbatim headers into the audit log.

* New route: `GET /api/v1/email-intake-log/{id}`. Admin-only via the existing `RequireAuth + RequireAdmin` pairing; tenant-scoped so a guessed UUID across tenants 404s cleanly.

Integration tests at `tests/email_intake_phase2.rs`:

* `auto_create_contact_under_fallback_company`: unknown sender 422s when the setting is absent; same intake auto-creates a contact under the fallback company when the setting is populated; the resulting ticket references the auto-created contact.

* `reply_appends_public_comment`: a References-matching intake adds a `note_type='public'` row attributed (via `created_by_contact_id`) to the matched sender contact and the response carries `comment_added=true`.

* `every_intake_writes_a_log_row`: any intake (success or 422) writes an `email_intake_log` row; the happy row carries `ticket_id` and no error, the stranger row carries `error` and no ticket_id; the admin GET endpoint surfaces the row contents.

#PMS-469
YousifShkara deleted branch feat/PMS-469-email-intake-follow-ups 2026-06-24 07:31:48 +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!346
No description provided.