# Scratchpad — Inbound Email Sender Routing to Boards

- Plan slug: `inbound-email-sender-routing-to-boards`
- Created: `2026-02-25`

## What This Is

Keep a lightweight, continuously-updated log of discoveries and decisions made while implementing this plan.

Prefer short bullets. Append new entries as you learn things, and also update earlier notes when a decision changes or an open question is resolved.

## Decisions

- (2026-02-25) Revised plan ownership from provider-level sender-rule table to client/contact-owned routing configuration.
- (2026-02-25) Destination target stays as `inbound_ticket_defaults_id` (profile-level), not board-only override.
- (2026-02-25) Precedence for new-ticket creation: contact override -> client destination -> provider default.
- (2026-02-25) Existing-ticket reply flows remain unchanged; no rerouting for threaded replies.
- (2026-02-25) `F001` completed as a plan-model checkpoint: implementation work proceeds only with client/contact-owned routing and does not introduce provider-level sender-rule tables.
- (2026-02-25) Shared resolver `resolveEffectiveInboundTicketDefaults` is the single precedence source for contact override / contact-client default / provider fallback used by both in-app and runtime context paths.
- (2026-02-25) Domain-matched client routing now contributes to destination resolution via the same shared resolver (`client_default_from_domain` before provider fallback).
- (2026-02-25) Destination IDs from contacts/clients are runtime-validated against `inbound_ticket_defaults` with `(tenant, id, is_active=true)` before use; invalid/inactive mappings now warn and fallback.

## Discoveries / Constraints

- (2026-02-25) Existing sender-domain-to-client mapping already exists and is explicit via `client_inbound_email_domains`.
- (2026-02-25) `processInboundEmailInApp` currently sets ticket board from provider defaults (`defaults.board_id`) and does not vary by sender.
- (2026-02-25) `resolve_inbound_ticket_context` currently resolves target client/contact/location only; it does not yet compute sender-based destination defaults.
- (2026-02-25) `ClientDetails` already includes inbound-domain and default-contact controls, making client-level destination a natural extension.
- (2026-02-25) `clients` currently had no dedicated inbound destination field; persisted client destination now starts with nullable `clients.inbound_ticket_defaults_id`.
- (2026-02-25) `contacts` currently had no dedicated inbound destination override; persisted contact override now starts with nullable `contacts.inbound_ticket_defaults_id`.
- (2026-02-25) Added explicit tenant-scoped indexes for lookup safety/perf:
  - `idx_clients_tenant_inbound_ticket_defaults`
  - `idx_contacts_tenant_inbound_ticket_defaults`
- (2026-02-25) Local worktree currently has no installed npm dependencies (`npm ls vitest --depth=0` is empty), so targeted Vitest runs fail at startup before test execution.

## Commands / Runbooks

- (2026-02-25) Read current inbound processing and context resolution:
  - `rg -n "processInboundEmailInApp|resolve_inbound_ticket_context|resolveInboundTicketDefaults" shared/services/email shared/workflow`
- (2026-02-25) Read existing domain-match plans and tests:
  - `ee/docs/plans/2026-02-13-inbound-email-domain-matching-default-contact/`
  - `server/src/test/integration/inboundEmailInApp.webhooks.integration.test.ts`
  - `server/src/test/integration/resolveInboundTicketContext.domainFallback.integration.test.ts`
- (2026-02-25) Scaffold and validate plan:
  - `python3 /Users/roberisaacs/.codex/skills/alga-plan/scripts/scaffold_plan.py "Inbound Email Sender Routing to Boards" --slug inbound-email-sender-routing-to-boards`
  - `python3 /Users/roberisaacs/.codex/skills/alga-plan/scripts/validate_plan.py ee/docs/plans/2026-02-25-inbound-email-sender-routing-to-boards`
- (2026-02-25) Add client destination schema migration:
  - `server/migrations/20260225120000_add_client_inbound_ticket_defaults_id.cjs`
- (2026-02-25) Add contact override schema migration:
  - `server/migrations/20260225120500_add_contact_inbound_ticket_defaults_id.cjs`
- (2026-02-25) Add lookup index migration:
  - `server/migrations/20260225121000_add_inbound_ticket_defaults_lookup_indexes.cjs`
- (2026-02-25) Targeted unit tests attempted for shared/runtime email routing changes:
  - `npx vitest run shared/services/email/__tests__/processInboundEmailInApp.test.ts shared/services/email/__tests__/processInboundEmailInApp.additionalPaths.test.ts shared/workflow/runtime/actions/__tests__/registerEmailWorkflowActions.contactAuthorship.test.ts`
  - Blocker in local env: Vitest startup fails because `dotenv` package is missing from active node_modules resolution path.
- (2026-02-25) Targeted migration integration test attempted:
  - `cd server && npx vitest run src/test/integration/inboundTicketDestinationMigrations.integration.test.ts --reporter=dot`
  - Blocker in local env: Vitest startup fails because `vitest` package is not installed in this worktree.
- (2026-02-25) Targeted in-app routing integration test attempted:
  - `cd server && npx vitest run src/test/integration/inboundEmailInApp.webhooks.integration.test.ts --reporter=dot`
  - Blocker in local env: Vitest startup fails because `vitest` package is not installed in this worktree.
- (2026-02-25) Targeted workflow destination routing integration test attempted:
  - `cd server && npx vitest run src/test/integration/resolveInboundTicketContext.destinationRouting.integration.test.ts --reporter=dot`
  - Blocker in local env: Vitest startup fails because `vitest` package is not installed in this worktree.
- (2026-02-25) Targeted resolver unit test attempted:
  - `npx vitest run shared/workflow/actions/__tests__/emailWorkflowActions.destinationResolver.test.ts --reporter=dot`
  - Blocker in local env: Vitest startup fails because `dotenv` package is missing from active root node_modules resolution path.
- (2026-02-25) Targeted clients actions unit test attempted:
  - `npx vitest run packages/clients/src/actions/inboundTicketDestinationActions.test.ts --reporter=dot`
  - Blocker in local env: Vitest startup fails because `dotenv` package is missing from active root node_modules resolution path.
- (2026-02-25) Targeted ClientDetails inbound destination wiring test attempted:
  - `npx vitest run packages/clients/src/components/clients/ClientDetails.inboundDestination.wiring.test.ts --reporter=dot`
  - Blocker in local env: Vitest startup fails because `dotenv` package is missing from active root node_modules resolution path.
- (2026-02-25) Targeted ContactDetails inbound destination wiring test attempted:
  - `npx vitest run packages/clients/src/components/contacts/ContactDetails.inboundDestination.wiring.test.ts --reporter=dot`
  - Blocker in local env: Vitest startup fails because `dotenv` package is missing from active root node_modules resolution path.

## Links / References

- In-app inbound pipeline:
  - `shared/services/email/processInboundEmailInApp.ts`
- Workflow action registration and context resolution:
  - `shared/workflow/runtime/actions/registerEmailWorkflowActions.ts`
- Shared email workflow actions:
  - `shared/workflow/actions/emailWorkflowActions.ts`
- Existing client inbound domain actions:
  - `packages/clients/src/actions/clientInboundEmailDomainActions.ts`
- New inbound destination actions:
  - `packages/clients/src/actions/inboundTicketDestinationActions.ts`
- Client details UI:
  - `packages/clients/src/components/clients/ClientDetails.tsx`
- Contact UI surfaces:
  - `packages/clients/src/components/contacts/*`

## Open Questions

- Should client/contact destination be global, or optionally provider-specific for tenants with multiple inbound mailboxes?
- Should UI block selecting inactive defaults profiles, or allow save and rely on runtime fallback?
- Should contact override be shown only when contact has a valid email value?

## Implementation Log

- (2026-02-25) Marked `F001` implemented in `features.json` and committed as the first checklist checkpoint.
- (2026-02-25) Completed `F002` by adding migration `20260225120000_add_client_inbound_ticket_defaults_id.cjs` to persist `clients.inbound_ticket_defaults_id`.
- (2026-02-25) Completed `F003` by adding migration `20260225120500_add_contact_inbound_ticket_defaults_id.cjs` to persist `contacts.inbound_ticket_defaults_id`.
- (2026-02-25) Completed `F004` by adding migration `20260225121000_add_inbound_ticket_defaults_lookup_indexes.cjs` with safe create/drop index behavior.
- (2026-02-25) Completed `F005` by adding shared resolver logic in `shared/workflow/actions/emailWorkflowActions.ts` and wiring it into:
  - `shared/services/email/processInboundEmailInApp.ts`
  - `shared/workflow/runtime/actions/registerEmailWorkflowActions.ts`
- (2026-02-25) Completed `F006` by passing domain-matched client context into the shared resolver in both in-app and workflow-runtime paths.
- (2026-02-25) Completed `F007` by enforcing tenant+active validation on contact/client destination IDs before applying defaults.
- (2026-02-25) Completed `F008` by using shared destination resolution in `processInboundEmailInApp` before new-ticket create, while preserving existing reply-token/thread branches.
- (2026-02-25) Completed `F009` by using the same shared destination resolver in `resolve_inbound_ticket_context` (runtime v2 action registry) for parity with in-app processing.
- (2026-02-25) Completed `F010` by keeping reply-token/thread-header branches untouched; only new-ticket destination selection path was changed.
- (2026-02-25) Completed `F011` by retaining existing sender identity matching semantics:
  - exact contact remains the only source for comment authorship
  - domain matching remains explicit via `client_inbound_email_domains`
- (2026-02-25) Completed `F012` by adding server actions for inbound destination option reads and client destination updates with explicit client permission checks.
- (2026-02-25) Completed `F013` by adding contact destination update action with explicit contact permission checks and tenant-scoped destination validation.
- (2026-02-25) Completed `F014` by adding an inbound ticket destination selector to `ClientDetails` with clear-to-provider-default behavior.
- (2026-02-25) Completed `F015` by adding optional contact override selectors to:
  - `packages/clients/src/components/contacts/ContactDetails.tsx`
  - `packages/clients/src/components/contacts/ContactDetailsEdit.tsx`
- (2026-02-25) Completed `F016` by adding explicit precedence helper text in both client and contact destination controls:
  - Contact override -> Client destination -> Provider default
- (2026-02-25) Completed `F017` by emitting structured resolution logs:
  - destination source (`contact_override`, `client_default_from_contact`, `client_default_from_domain`, `provider_default`)
  - fallback warnings with configured invalid destination IDs and fallback reason
- (2026-02-25) Completed `F018` by adding in-app integration cases in `server/src/test/integration/inboundEmailInApp.webhooks.integration.test.ts` for:
  - exact contact override destination routing
  - exact contact using client destination
  - domain-matched client destination
  - unmatched sender using provider destination
- (2026-02-25) Completed `F019` by adding workflow-runtime integration coverage in:
  - `server/src/test/integration/resolveInboundTicketContext.destinationRouting.integration.test.ts`
  - includes direct parity assertion against `processInboundEmailInApp` for identical sender/provider input
- (2026-02-25) Completed `F020` by extending regression coverage:
  - explicit board-unchanged assertions on reply-token and thread-header reply flows
  - existing idempotency tests for duplicate reply/new-email processing remain in place and continue to validate dedupe behavior
- (2026-02-25) Completed `T001` by adding migration integration coverage in `server/src/test/integration/inboundTicketDestinationMigrations.integration.test.ts` to assert `clients.inbound_ticket_defaults_id` exists, is nullable, and is UUID-typed.
- (2026-02-25) Completed `T002` by extending the same migration integration suite to assert `contacts.inbound_ticket_defaults_id` exists, is nullable, and is UUID-typed.
- (2026-02-25) Completed `T003` by adding index coverage in the same migration integration suite, asserting:
  - `idx_clients_tenant_inbound_ticket_defaults` on `clients(tenant, inbound_ticket_defaults_id)`
  - `idx_contacts_tenant_inbound_ticket_defaults` on `contacts(tenant, inbound_ticket_defaults_id)`
- (2026-02-25) Completed `T004` by adding rollback-path coverage in the same migration integration suite:
  - executes migration `down` functions inside a transaction and asserts both columns/indexes are removed
  - executes migration `up` functions inside the same transaction and asserts both columns/indexes are restored
  - rolls back transaction to avoid mutating shared integration DB state
- (2026-02-25) Completed `T005` by adding shared resolver unit coverage in `shared/workflow/actions/__tests__/emailWorkflowActions.destinationResolver.test.ts` for precedence path:
  - exact sender contact + contact override resolves `source=contact_override`
  - returns contact override defaults and does not consult client defaults
- (2026-02-25) Completed `T006` by extending shared resolver unit coverage for precedence path:
  - exact sender contact with no override resolves `source=client_default_from_contact`
  - uses the matched contact's client `inbound_ticket_defaults_id`
- (2026-02-25) Completed `T007` by extending shared resolver unit coverage for domain path:
  - when no exact contact is provided and domain client is matched, resolves `source=client_default_from_domain`
  - returns domain client's active defaults
- (2026-02-25) Completed `T008` by extending shared resolver unit coverage for fallback path:
  - when neither exact-contact nor domain destination applies, resolves `source=provider_default`
  - returns provider defaults unchanged with no fallback reason
- (2026-02-25) Completed `T009` by extending shared resolver unit coverage for invalid override safety:
  - invalid/inactive contact override destination falls back to `source=provider_default`
  - emits `fallbackReason=invalid_or_inactive_contact_override`
- (2026-02-25) Completed `T010` by extending shared resolver unit coverage for invalid client destination safety:
  - invalid/inactive client destination (from exact-contact client path) falls back to `source=provider_default`
  - emits `fallbackReason=invalid_or_inactive_client_default_from_contact`
- (2026-02-25) Completed `T011` by confirming in-app integration coverage exists in `server/src/test/integration/inboundEmailInApp.webhooks.integration.test.ts`:
  - test case `Routing destination: exact sender contact override uses contact override defaults board` asserts board/client/contact routing from contact override defaults.
- (2026-02-25) Completed `T012` by confirming in-app integration coverage exists in `server/src/test/integration/inboundEmailInApp.webhooks.integration.test.ts`:
  - test case `Routing destination: exact sender without contact override uses contact's client destination defaults` asserts exact-contact fallback to client destination defaults.
- (2026-02-25) Completed `T013` by confirming in-app integration coverage exists in `server/src/test/integration/inboundEmailInApp.webhooks.integration.test.ts`:
  - test case `Routing destination: unknown sender + domain-matched client uses domain client destination defaults` asserts domain-matched unknown-contact routing.
- (2026-02-25) Completed `T014` by confirming in-app integration coverage exists in `server/src/test/integration/inboundEmailInApp.webhooks.integration.test.ts`:
  - test case `Unmatched sender: system follows the defined behavior without throwing` asserts provider-default board/client fallback for unmatched routing.
- (2026-02-25) Completed `T015` by confirming workflow integration coverage exists in `server/src/test/integration/resolveInboundTicketContext.destinationRouting.integration.test.ts`:
  - test case `returns contact override destination outcome for exact sender` asserts `resolve_inbound_ticket_context` returns contact-override destination.
- (2026-02-25) Completed `T016` by confirming workflow integration coverage exists in `server/src/test/integration/resolveInboundTicketContext.destinationRouting.integration.test.ts`:
  - test case `returns client's destination outcome when exact sender has no contact override` asserts workflow action returns client-default destination.
- (2026-02-25) Completed `T017` by confirming workflow integration coverage exists in `server/src/test/integration/resolveInboundTicketContext.destinationRouting.integration.test.ts`:
  - test case `returns domain-matched client destination outcome when sender is unknown contact` asserts domain-matched destination resolution.
- (2026-02-25) Completed `T018` by confirming workflow/in-app parity coverage exists in `server/src/test/integration/resolveInboundTicketContext.destinationRouting.integration.test.ts`:
  - test case `matches in-app destination selection for the same sender/provider input` compares runtime action output with `processInboundEmailInApp` ticket results.
- (2026-02-25) Completed `T019` by confirming reply-token regression coverage exists in `server/src/test/integration/inboundEmailInApp.webhooks.integration.test.ts`:
  - test case `Reply threading: reply token resolves ticket and creates exactly 1 new comment` now asserts ticket board remains unchanged after reply threading.
- (2026-02-25) Completed `T020` by confirming thread-header regression coverage exists in `server/src/test/integration/inboundEmailInApp.webhooks.integration.test.ts`:
  - test case `Reply threading: thread headers resolve ticket and create exactly 1 new comment` now asserts ticket board remains unchanged after thread-header matching.
- (2026-02-25) Completed `T021` by confirming exact-contact matching regression coverage exists in `server/src/test/integration/inboundEmailInApp.webhooks.integration.test.ts`:
  - test case `Contact match: sender email is normalized from display-name format` asserts contact/client IDs still resolve from exact sender match semantics.
- (2026-02-25) Completed `T022` by confirming explicit-domain matching regression coverage exists in `server/src/test/integration/inboundEmailInApp.webhooks.integration.test.ts`:
  - test case `Domain fallback: does not match by domain unless the domain is explicitly configured` asserts no inferred domain ownership from contact records.
- (2026-02-25) Completed `T023` by adding `packages/clients/src/actions/inboundTicketDestinationActions.test.ts` coverage for client destination actions:
  - rejects updates without `client:update` permission
  - rejects cross-tenant destination IDs via tenant-scoped `inbound_ticket_defaults` validation
- (2026-02-25) Completed `T024` by extending `packages/clients/src/actions/inboundTicketDestinationActions.test.ts` coverage for contact destination actions:
  - rejects updates without `contact:update` permission
  - rejects cross-tenant destination IDs via tenant-scoped `inbound_ticket_defaults` validation
- (2026-02-25) Completed `T025` by adding `packages/clients/src/components/clients/ClientDetails.inboundDestination.wiring.test.ts`:
  - verifies client UI destination select wiring (`value`, `allowClear`, `onValueChange`)
  - verifies persistence path calls `updateClient(...)` and that server-side client update normalizes cleared empty strings to `null`
- (2026-02-25) Completed `T026` by:
  - extending `packages/clients/src/actions/contact-actions/contactActions.tsx` to persist `inbound_ticket_defaults_id` updates, normalize clears to `null`, and tenant-validate destination IDs
  - adding `packages/clients/src/components/contacts/ContactDetails.inboundDestination.wiring.test.ts` to verify contact UI set/clear wiring and save-path persistence through `updateContact(...)`
- (2026-02-25) Completed `T027` by extending `shared/workflow/actions/__tests__/emailWorkflowActions.destinationResolver.test.ts`:
  - asserts `console.warn` metadata includes `source=contact_override` and `fallback=provider_default` for invalid override fallbacks
  - asserts terminal `console.debug` metadata includes `source=provider_default` with `fallbackReason=invalid_or_inactive_contact_override`
- (2026-02-25) Completed `T028` by extending `server/src/test/integration/inboundEmailInApp.webhooks.integration.test.ts`:
  - added routed idempotency case `Idempotency: replay same routed contact-override email does not create duplicate routed tickets`
  - asserts first run creates routed ticket on override board and second run dedupes, preserving single ticket/comment under sender-based routing
