PSA/docs/plans/2026-06-10-inbound-email-rules-design.md
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

12 KiB

Inbound Email Rules — Design

Problem

Inbound email processing (shared/services/email/processInboundEmailInApp.ts) matches senders to clients by exact contact email or by sender domain (client_inbound_email_domains), then creates a ticket using the inbound_ticket_defaults cascade. That covers email written by humans at a client, but not email about a client sent by a third-party service.

Two customer requests drive this feature:

  1. A monitoring service (Huntress) emails alerts where the affected customer's name appears in the subject, in parentheses. The ticket should be assigned to that client, but the sender is @huntress.com — neither contact match nor domain match can ever resolve it.
  2. Some recurring emails (status updates, notifications) should not create tickets at all.

Both are achievable today with workflows, but workflow executions are metered, and authoring a regex-parsing workflow is too complex for what competing PSAs offer as built-in configuration. Tenants with the AI add-on should additionally be able to classify email without writing patterns at all.

Solution overview

A tenant-wide, ordered list of inbound email rules, evaluated inline in processInboundEmailInApp() on the new-ticket path. Each rule has:

  • an optional mailbox filter (which email_providers it applies to)
  • a set of conditions (sender, subject, body, recipients)
  • one action: skip, extract + assign client, set destination, or AI classify (EE)
  • a non-match behavior for when the action's extraction/classification fails

Rules are configured in the email settings UI with a structured condition builder, a regex escape hatch, and a live tester that runs the production evaluator against sample input.

Deliberately not built: a generic automation framework. The evaluator is a small, email-specific module; the JSONB condition shape leaves room to generalize later if a second consumer appears. Compiling rules to hidden system workflows was rejected — in-app processing already replaced workflow-based email handling, and generated workflows are hard to debug and version.

Data model

inbound_email_rules (new)

Column Type Notes
tenant uuid composite PK (tenant, id), Citus distribution column
id uuid default gen_random_uuid()
name text e.g. "Huntress customer routing"
is_active boolean default true
position integer evaluation order within tenant
provider_ids jsonb array of email_providers.id; NULL = all mailboxes
conditions jsonb ALL-of array, see below
action_type text skip | extract_assign_client | set_destination | ai_classify
action_config jsonb per-type payload, see Actions
on_no_match text proceed | fallback_destination | skip (default proceed)
fallback_inbound_ticket_defaults_id uuid used when on_no_match = fallback_destination
created_at / updated_at timestamptz

conditions is a flat ALL-of array. An any_of wrapper can be introduced later without a migration.

[
  { "field": "from_address", "operator": "contains", "value": "@huntress.com" },
  { "field": "subject", "operator": "contains", "value": "(" }
]
  • Fields: from_address, from_domain, to_address, subject, body_text
  • Operators: equals, contains, starts_with, ends_with, matches_regex
  • All matching is case-insensitive; to_address matches if any recipient matches.

client_name_aliases (new)

Same shape as client_inbound_email_domains: (tenant, id) PK, client_id, alias (stored as typed), unique index on (tenant, lower(alias)) so an alias resolves to exactly one client. Both new tables follow the existing migration conventions (best-effort create_distributed_table, transaction: false — see server/migrations/20260213180500_create_client_inbound_email_domains.cjs).

Modified

  • email_processed_messages.processing_status check constraint gains 'skipped'; metadata records { ruleId, ruleName } for skipped email.
  • tickets.email_metadata (existing JSONB) gains appliedRuleId and clientMatchSource (rule_extraction | rule_ai | email_match | domain_match). No schema change.

Evaluation semantics

Rules run inside processInboundEmailInApp() only on the new-ticket path — after all thread-matching attempts fail, before resolveInboundTicketDefaults() and contact/client matching. Replies that thread onto existing tickets are never touched by rules, so a broad skip pattern cannot swallow a genuine customer reply. A side effect: skip rules work even for tenants with no inbound defaults configured (today such email drops as missing_defaults).

The loop:

  1. Load the tenant's active rules ordered by position; keep those whose provider_ids is NULL or contains the receiving provider.
  2. Walk the list; the first rule whose conditions all match executes its action.
  3. If the action resolves (skip decided, client assigned, destination set), stop and continue the pipeline with that outcome.
  4. If the action's extraction/classification fails to match:
    • on_no_match = proceed → continue down the rules list. This enables the intended pattern "deterministic extraction rule first, AI catch-all later" — the AI rule only spends tokens when regex fails.
    • on_no_match = skip → stop; email skipped.
    • on_no_match = fallback_destination → stop; ticket created at the referenced inbound_ticket_defaults destination for human triage.
  5. If no rule matches or resolves, the pipeline behaves exactly as today. Tenants with no rules see zero behavior change.

Precedence of a rule-assigned client

A client assigned by a rule wins over sender-based matching. The premise of the extraction case is that the sender is a service, not the client, so an accidental exact-email contact match must not override the rule. Contact attribution within the assigned client: if the sender email matches a contact in that client, use it; otherwise the client's primary contact (mirroring today's domain-match behavior in findValidClientPrimaryContactId). Destination still flows through resolveEffectiveInboundTicketDefaults() with the rule-assigned client, so a client-level inbound_ticket_defaults_id override applies as usual.

Regex safety

matches_regex patterns are length-capped and compiled in try/catch (invalid pattern = condition false, logged once per rule per process). Inputs are length-bounded (body sliced to ~100 KB). Rules load with one indexed query per email.

Actions

skip

No config beyond an optional note. Writes the email_processed_messages row with processing_status = 'skipped' and rule metadata. No ticket, comment, or attachment processing.

extract_assign_client

{
  "source": "subject",
  "extraction": {
    "type": "between",
    "start": "(", "end": ")",
    "occurrence": "first",
    "regex": "..."
  }
}
  • source: subject | body_text
  • extraction.type: between | after | before | regex. The friendly templates compile to the same internal extractor as raw regex — one code path. regex uses capture group 1.
  • occurrence: first | last, for repeated delimiters.

The extracted value is normalized (trim, collapse whitespace, lowercase) and matched first against clients.client_name (normalized), then client_name_aliases. Inactive clients are excluded. No match → the rule's on_no_match.

set_destination

{ "inbound_ticket_defaults_id": "..." } — applies the referenced defaults set at the top of the destination cascade (above contact override). Normal sender matching still runs for client/contact attribution. Reuses the existing defaults entity rather than inventing a parallel destination shape.

ai_classify (EE)

{ "instruction": "...", "allowed_outcomes": ["skip", "assign_client"] }

The model never picks a client_id directly — it extracts, the deterministic matcher resolves. The EE module receives subject/from/body-excerpt plus the tenant's instruction and returns constrained JSON:

{ "decision": "skip" | "assign_client" | "no_decision", "extracted_client_name": "..." }

An assign_client decision runs the extracted name through the same exact+alias matcher as regex extraction, so AI and regex rules share identical matching and audit semantics, no client list is sent in the prompt, and hallucinated assignment is impossible.

Wiring follows the inboundReplyAcknowledgementDecider pattern (shared/services/email/inboundReplyAcknowledgementDecider.ts): OSS ships a stub returning no_decision; EE dynamically loads the real module. Any AI failure (timeout, error, missing add-on) is a non-match → on_no_match. Ticket creation never blocks on AI availability. Token usage is logged through the AI module's existing usage tracking so metering can be added later without schema changes.

UI

A new Inbound Rules section in email settings, alongside the provider list and InboundTicketDefaultsManager (packages/integrations/src/components/email/). Same RBAC as email provider administration.

  • Rules list: ordered table — drag to reorder (persists position), name, human-readable summary ("From contains @huntress.com → assign client from subject"), mailbox-filter chips, active toggle, edit/delete.
  • Rule editor (drawer): name, active, mailbox multi-select; condition rows of field + operator + value (matches regex is just another operator — the escape hatch needs no separate mode); action picker; per-action config (extraction source + template inputs, destination picker, AI instruction + allowed-outcome checkboxes); non-match behavior select with fallback destination picker. The AI action is always visible; without EE + AI add-on it renders disabled with an upsell hint.
  • Live tester: paste sample From/Subject/Body; a server action runs the actual shared evaluator against the draft rule and shows each condition's pass/fail, the extracted value, the resolved client (or no match), and the final outcome. There is no separate test implementation to drift from production.
  • Alias management: a "Matching aliases" list on the client record next to the inbound email domains UI, plus a shortcut in the tester — when extraction succeeds but no client matches, offer "Add as an alias of…" with a client picker.

Error handling and observability

  • Rule evaluation is wrapped so an engine error (bad JSONB shape, regex compile failure, alias query error) never kills email processing — it logs a warning with the rule id and falls through to the unmodified pipeline. A misconfigured rule degrades to "no rules", not to dropped email.
  • Dangling references (deleted defaults set or client behind an alias) → treated as non-match/proceed with a warning. UI pickers filter to active records.
  • Skipped email is answerable from existing diagnostics: the email_processed_messages row carries the rule id/name.
  • Created tickets carry appliedRuleId/clientMatchSource in email_metadata, and the existing INBOUND_EMAIL_RECEIVED activity-log row includes the rule name.
  • The engine emits one structured log line per evaluated email: rules considered, rule matched, outcome.

Packaging

The rules engine (skip, extraction, destination, aliases, UI) ships in CE. Only the ai_classify rule type is gated on EE + the AI add-on, matching how AI ack suppression is packaged.

Testing

  • Unit (bulk of coverage): the evaluator is a pure function — condition matching per field/operator, extraction templates and edge cases (missing delimiters, repeated delimiters, unicode, empty capture), normalization, alias resolution, first-match/proceed chaining, regex-safety guards.
  • Integration: full processInboundEmailInApp() runs against seeded rules — skip outcome, extract+assign end-to-end including precedence over a sender contact match, non-match fallback destination, no-rules regression (pipeline unchanged), OSS AI-stub behavior.
  • Manual smoke: UI flow — create a rule via the builder, exercise the live tester, reorder rules, alias quick-add — against the dev email tooling (MailHog).

Out of scope (future)

  • any_of condition groups (JSONB shape already accommodates them)
  • Per-rule "also apply to replies" flag
  • Fuzzy client-name matching (rejected for v1: a wrong match assigns a ticket to the wrong client)
  • A pre-seeded global AI fallback rule
  • AI token metering (usage is already logged; billing integration comes later)