# Ticket Category Routing and Background Email Design

**Goal:** When the chatbot raises a support ticket, route a notification email to
the right team inbox based on the ticket's category, confirm receipt to the
customer who raised it, and stop making the customer wait in chat for any
outbound email (ticket notification or the existing `send_email` tool) to
actually finish sending.

**Architecture:** Extend the existing ticket tool and settings system with a
category concept and a per-tenant, per-category business-email list; fire
outbound email sends as background tasks instead of awaiting them inside the
chat tool loop.

**Tech Stack:** FastAPI, PostgreSQL (per-tenant schema), the existing
`app/services/chat/tools_settings.py` settings store, `app/services/messaging/
email.py` send path (SMTP/Mailchimp, already tenant-selectable), `asyncio`.

## Context

Confirmed by reading the code before this spec was written:

- `create_or_update_ticket` (`app/services/chat/tools.py`) has no category
  field in its tool schema, and the ticket flow sends no email today at all.
- `strategist_tickets` (`app/services/infra/database.py`) has no `category`
  column.
- `tools_settings.py`'s `ticket_management` provider is a single fixed option
  (`galaxiq_ticketmanagement`) — there is no business-email list concept
  anywhere in the codebase yet.
- `send_email()` (built earlier this work) already resolves SMTP vs. a
  tenant's connected Mailchimp account correctly; this spec does not change
  that resolution, only how it's invoked from tool executors.
- Every tool executor that calls `send_email()` today does so with `await
  anyio.to_thread.run_sync(...)`, which blocks the chat tool loop
  (`app/services/chat/chat.py`) until the SMTP/Mandrill round-trip
  completes — the model cannot give its next reply until that finishes.
- The frontend's "Business Emails" screen (see the two screenshots supplied
  in conversation) has no existing wired backend contract in this repo — the
  contract below is being defined here, and the frontend will be pointed at
  it.

## Decisions (settled via clarifying questions)

1. **Contract location:** business emails are saved through the existing
   `POST /tools-settings` endpoint, as a new top-level field, the same way
   `providers`/`features`/`email` already work — no new endpoint.
2. **No-match behavior:** if a ticket's category has no configured business
   email, no team notification is sent. Nothing falls back to "notify
   everyone."
3. **Category source:** the model classifies the ticket via a new required
   enum parameter on the tool schema, the same mechanism already used for
   `priority`. No keyword/regex classification in backend code.
4. **Background failures:** if the background send fails, it is logged
   server-side only. No dashboard event, no customer-facing signal. This
   keeps the first iteration small; a dashboard-visible failure signal can
   be added later without changing the public contract.

## Data Model

```python
TICKET_CATEGORIES = ("General enquiries", "Sales", "Business")
```

New settings key, stored in the same tenant `strategist_settings` row
`tools_settings.py` already manages, alongside `features`/`email`/
`providers`:

```python
"ticket_emails": [
    {"email": "sales@acme.com", "category": "Sales"},
    {"email": "support@acme.com", "category": "General enquiries"},
]
```

Unlike `providers` (a dict merged key-by-key), `ticket_emails` is a list.
Save semantics are **full replace**: a `POST /tools-settings` call that
includes `ticket_emails` replaces the tenant's entire stored list with what
was sent, matching the UI's "add several rows locally, then Save Ticket
Settings once" flow. A call that omits the key leaves the stored list
untouched (same "absent means don't touch" rule the rest of this endpoint
already follows).

Validation, enforced in `save_tool_settings`:
- Each entry must have `email` (must look like a real address, reusing
  `is_valid_email` from `app/services/messaging/email.py`) and `category`
  (must be one of `TICKET_CATEGORIES`).
- Duplicate `{email, category}` pairs are not rejected — harmless, and
  rejecting them adds validation complexity nothing in this design needs.

New DB column:

```sql
ALTER TABLE {tenant_schema}.strategist_tickets ADD COLUMN IF NOT EXISTS category TEXT;
```

Applied the same way other per-tenant schema changes in this codebase are
applied — inside the tenant bootstrap/DDL path in `database.py`, guarded
with `IF NOT EXISTS` so it's safe to run against an already-provisioned
tenant.

## Tool Schema Change

`create_or_update_ticket`'s parameters gain:

```python
"category": {
    "type": "string",
    "enum": list(TICKET_CATEGORIES),
    "description": "Which team should handle this ticket.",
}
```

Added to the `required` list alongside `heading`, `content`, `priority`.
`execute_create_or_update_ticket` passes it straight into `ticket_data` for
`create_ticket`/`update_ticket` to persist. An update to an existing ticket
may change its category (e.g. a ticket that started as General enquiries
turns out to be a Sales question) — the notification logic below only fires
on the *create* path, not on update, so changing a category on an existing
ticket does not re-notify anyone.

## Notification Flow

Inside `execute_create_or_update_ticket`, only on the branch that creates a
brand-new ticket (not the update/consolidation branch):

1. Look up `get_tool_settings(tenant_id)["ticket_emails"]`, filter to
   entries whose `category` matches the ticket's category.
2. If the filtered list is non-empty, compose one notification email (ticket
   ID, heading, content, priority, customer's `email`/`contact_no` if given)
   and send it to every matching address.
3. If the ticket has a customer `email`, compose a separate, shorter
   confirmation email to that address ("we've received your ticket, ID:
   TICK-XXXX, we'll get back to you").
4. Both emails go through the existing `send_email()` (tenant-branded,
   respects that tenant's SMTP/Mailchimp choice) — no new sending
   infrastructure, only new call sites and new email content.
5. Both sends are fired via the background mechanism below, not awaited
   inline.

Step 2 and step 3 are independent — a ticket with no matching business email
can still get a customer confirmation, and vice versa (no email given).

## Background Execution

Today: `execute_send_email` does
```python
result = await anyio.to_thread.run_sync(lambda: send_email(...))
```
which blocks the surrounding tool-call loop until the send finishes.

New pattern, applied to `execute_send_email` and the two new ticket-email
call sites:
```python
asyncio.create_task(anyio.to_thread.run_sync(lambda: send_email(...)))
```
with the task's result awaited nowhere on the request path. The executor
returns immediately (e.g. `{"status": "queued"}` for `execute_send_email`;
the ticket executor's existing return value is unaffected since ticket
creation itself is still synchronous — only the notification emails become
background work).

This mirrors the existing fire-and-forget idiom already used in
`app/api/catalog.py` (`asyncio.create_task(_run(...))`) and
`app/api/websocket.py`, so no new execution pattern is introduced to the
codebase.

A failed background send is caught inside the task (never propagates,
never crashes the event loop) and logged via `logger.error`, per the
"log only" decision above.

**System prompt note:** the assistant-facing tool description for
`create_or_update_ticket` and `send_email` should be worded so the model
tells the user the action is happening/queued ("I've logged your ticket and
notified the team") rather than implying a synchronous, already-confirmed
delivery ("I've emailed the team"). This is a prompt wording change, not a
schema change — flagged here so the implementation plan includes it.

## Testing

- Unit tests for `save_tool_settings`'s `ticket_emails` validation (valid
  list accepted, invalid email rejected, invalid category rejected, full
  replace semantics, absent key leaves existing list untouched).
- Unit tests for the category-filter + notification-composition logic in
  `execute_create_or_update_ticket`, with `send_email`/the background task
  mechanism mocked — no real network or DB calls.
- Unit tests confirming `execute_send_email` returns before its background
  task's `send_email()` call has been awaited to completion (i.e. the
  fire-and-forget behavior itself, not just that it eventually sends).
- No change to the existing SMTP/Mailchimp routing tests from the prior
  work — this design only changes *how* and *when* `send_email()` is
  invoked, not its own internal routing logic.

## Out of Scope

- Any dashboard-visible signal for a failed background send (deferred; see
  Decision 4).
- Any change to `update_ticket`'s consolidation flow beyond persisting
  `category` if supplied.
- Any change to how `send_email()` itself resolves SMTP vs. Mailchimp.
