feat(email-intake): auto-create contact + reply-as-comment + intake-log audit (PMS-469) #346
No reviewers
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
psa-systems/mokosh-server!346
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/PMS-469-email-intake-follow-ups"
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?
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.sqladds 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 viasettings::read_email_intake_default_companyand treatsOk(None)as "preserve the Phase 1 422-on-unknown-sender posture". When set, an unknown sender lands a fresh contact under that company withemail = lowercased from_email,first_name + last_namesplit offfrom_name(falling back to "Contact" when absent), then proceeds with the create.service::intakenow wraps the existing flow inintake_innerand bookends it withrecord_intake_log(top of flow) +finalise_intake_log(tail, UPDATEs the row withticket_idorerror). 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_referenceshits now callresolve_or_create_contactandappend_reply_comment. The comment is anote_type='public'row withcreated_by_contact_id = matched sender contact(per the PMS-449 column) so the SPA renders Customer correctly. The note'screated_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 isthreaded=true, comment_added=false: the gateway still gets the threading hit, just no note.EmailIntakeResponsegains acomment_added: boolfield; existing callers seefalseon every non-reply path.EmailIntakeRequestaccepts an optionalraw_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 existingRequireAuth + RequireAdminpairing; 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 anote_type='public'row attributed (viacreated_by_contact_id) to the matched sender contact and the response carriescomment_added=true.every_intake_writes_a_log_row: any intake (success or 422) writes anemail_intake_logrow; the happy row carriesticket_idand no error, the stranger row carrieserrorand 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