# Design — Iteration 2: Email Drafting from Zammad (no CRM)

Date: 2026-06-29
Status: approved (design), pending spec review
Supersedes the data-source half of `2026-06-29-email-drafting-assistant-design.md` (Iteration 1).

## 1. Context & scope change

Iteration 1 shipped a read-only "Email Drafting Assistant" grounded in a **fake, CRM-shaped
Postgres database**. The direction has changed:

- Zammad is now used **only to retrieve emails**. It is **not** the CRM.
- Structured client info (orders, prices, project status) will live on a **separate client-side
  database that does not exist yet** → explicitly out of scope here.
- For this iteration the assistant **drafts replies only**, from the email content alone.
  No classification, no routing (the email-type taxonomy is domain context, not a task).

Engine stays **`claude-sonnet-4-6`** (dev crutch). Sovereignty rule still holds: no real client
data may reach Claude, so the Zammad instance is **seeded with fake English data** for testing.

The business (from the client's email-type sheet) is a **jewelry / precious-metals manufacturer**:
reorders, new designs for quotation/development, lead-time questions, updated quotations tied to the
current metal/silver rate, order cancellations, quality complaints, expedite requests, online-meeting
requests, new-brand info requests, etc. Some types carry routing rules (e.g. Reorder → Data dept →
Costing/PI → Marketing → customer; "prepare photos" → PD email) — **noted for the future, not built now**.

## 2. Decisions (locked during brainstorming)

| Decision | Choice | Rationale |
|----------|--------|-----------|
| How the tool reaches Zammad | **REST API + token** | Decoupled from Zammad's internal schema (stable across upgrades), no role created in the prod DB, respects Zammad's own permissions/audit. |
| Test data while engine is Claude | **Seed empty Zammad with fake EN data** | Zammad was just launched and is empty; keeps Claude away from real client data. |
| What the assistant does | **Draft only** | No classification/routing this iteration. |
| How the user picks an email | **Assistant lists recent emails**, user picks | Drives a 2-method tool surface. |
| New tool vs modify `crm_reader.py` | **New `mail_reader.py`, keep `crm_reader.py`** | Iteration 1 stays a working demo; when the separate client DB arrives the assistant composes both tools. |

## 3. Architecture

```
test/dev/mac-mini/
├─ tools/
│  ├─ crm_reader.py        # KEPT as-is; not wired now. Future "separate client DB" tool.
│  └─ mail_reader.py       # NEW: read-only HTTP client to the Zammad REST API.
├─ seed/
│  └─ seed_zammad.py       # NEW: one-shot DEV seeder (admin token) — fake EN emails into Zammad.
└─ prompts/
   └─ email-drafting-assistant.md  # REVISED: drop CRM grounding; keep drafting discipline.
```

The Open WebUI **Model**, its wiring, and the drafting discipline are unchanged in shape; only the
**data source** changes (CRM SQL → Zammad email retrieval). The "Email Drafting Assistant" model
swaps its attached tool from **CRM Reader** to **Mail Reader** and uses the revised prompt.

### 3.1 `mail_reader.py`

Single `Tools` class. `Valves(BaseModel)`:

- `ZAMMAD_URL` — base URL reachable from the Open WebUI container
  (dev: `http://host.docker.internal:8080`).
- `ZAMMAD_TOKEN` — API token, set by the admin in the tool's Valves UI; **never committed**.
- `DEFAULT_LIMIT` — default list size (10).
- `REQUEST_TIMEOUT` — HTTP timeout seconds (10).

Auth header on every request: `Authorization: Token token=<ZAMMAD_TOKEN>`.
The HTTP client implements **GET only** — no create/update/delete method exists anywhere in the file.

Public, LLM-callable methods:

- `list_recent_emails(self, limit: int = 10) -> str`
  `GET /api/v1/tickets/search?query=state.name:(new OR open)&order_by=updated_at&sort_by=desc&limit=N`.
  For each ticket, inspect the latest article; if its `sender == "Customer"` the ticket is **awaiting
  reply** (⏳), else ✓. Returns a compact list: `#<number> · <from> · <subject> · <date> · <flag>`.

- `get_email_thread(self, ticket_id: int) -> str`
  `GET /api/v1/ticket_articles/by_ticket/{ticket_id}`. Keep articles with `type == "email"` and
  `internal == false`; convert HTML bodies to clean plain text; chronological order. Returns
  `From / Date / Subject / Body` blocks. Internal notes are excluded.

Error handling mirrors `crm_reader.py`: catch network/HTTP/auth errors, return a short human message
(`"Could not reach Zammad: …"`, `"No email thread found for ticket <id>."`), **never leak the token**
or raw stack traces to the model.

### 3.2 `seed/seed_zammad.py`

One-shot **dev** seeder. Reads `ZAMMAD_URL` and a **write-capable admin token** from env
(`ZAMMAD_ADMIN_TOKEN`) — supplied at run time, never committed. Steps:

1. Ensure a group exists (default `Users`) and 2–3 organizations.
2. Ensure ~5 customer users (English names/emails) exist (search by email before create).
3. Create ~8 tickets, each with one **inbound customer email article** whose body is a realistic
   English message for a distinct email type from the sheet (reorder, quality complaint, lead-time,
   updated quotation w/ metal price, cancel order, expedite, online meeting, new-brand info).
4. Idempotent: search before create; skip existing; print a summary.

This is the only component that issues writes, and only against the **fake** dev Zammad.

### 3.3 Revised system prompt

`prompts/email-drafting-assistant.md`:

- **Remove** CRM-grounding language (`find_customer` / `get_customer_context` / customer profiles).
- **Keep**: read the email thread via the tool before drafting; never invent facts (prices, dates,
  order numbers, metal rates) that are not in the email; English only; output a **subject + body**;
  read-only (you read and draft, you never send or change anything).
- **Add** light domain context: jewelry/precious-metals manufacturer; list the common email types so
  tone and structure fit — **without** formally labeling/classifying. Unknown client data the future
  separate DB would provide → leave a clearly marked `[placeholder]` for staff to fill.

## 4. Read-only posture (honest tradeoff)

Iteration 1 enforced read-only **at the database** (a `SELECT`-only Postgres role — even a buggy tool
could not write). Zammad has **no native read-only agent permission**: any token that can read tickets
can also write them. So for this iteration read-only is enforced **in the tool**: `mail_reader.py`
implements GET requests only and exposes no write method. A test statically asserts the file contains
no `POST`/`PUT`/`DELETE` calls. Future hardening (a GET-only reverse proxy, or a Zammad permission
add-on) is out of scope for dev.

## 5. Testing

**Standalone smoke test** (`tools/test_mail_reader.py`) against the seeded dev Zammad, token via env:
1. `list_recent_emails` returns ≥1 seeded ticket with sender + subject.
2. `get_email_thread` on a seeded ticket returns its email body, excludes internal notes.
3. Static read-only check: no `POST`/`PUT`/`DELETE`/`PATCH` in `mail_reader.py`.
4. No token leak: token string never appears in any returned message (incl. error paths).

**End-to-end (Open WebUI)** with the "Email Drafting Assistant" model (Function Calling = Native):
1. *"List the recent emails."* → calls `list_recent_emails`, shows the seeded inbox.
2. *"Draft a reply to the reorder email."* → calls `get_email_thread`, drafts a grounded reply,
   uses `[placeholders]` for any client data not present in the email.
3. Unknown reference → reports it found nothing, does not invent.

## 6. Out of scope / future

- The **separate client-side database** (orders, prices, project status) and its own read tool —
  the assistant will later compose `mail_reader` (email) + that tool (client facts).
- **Classification** of incoming emails into the sheet's types, and **routing** workflows
  (Reorder → Data → Costing/PI → Marketing; "prepare photos" → PD; etc.).
- **Daily metal/silver-rate** feed referenced by quotation emails.
- **Ollama** engine swap (required before any real client data) and **write operations** (send/reply
  through Zammad).
