# Auto Reply

Automatic acknowledgement of incoming mail — for approved categories only.

```
Incoming mail
      ↓
AI categorisation          (Gemini, against the admin's own category list)
      ↓
Category + confidence
      ↓
Auto-reply configuration   (defined? active? enabled? confident enough?)
      ↓
Approved template?
      ↓
Send                       (Gmail API / Microsoft Graph, in-thread)
      ↓
Log the decision
```

The rule the whole feature is built around: **the AI only names a category.
Whether that category may be answered is business configuration.** A code the
AI returns that has no active, enabled category behind it is never answered, no
matter how confident the model is.

## Screens

All under **Auto Reply** in the sidebar (`/auto-reply`).

| Screen | Route | What it is for |
| --- | --- | --- |
| Settings | `/auto-reply` | Master switch, confidence threshold, safety limits, template values |
| Categories | `/auto-reply/categories` | The category vocabulary and which of them may reply |
| Templates | `/auto-reply/templates` | Writing and approving the acknowledgement wording |
| Reply Log | `/auto-reply/logs` | Every decision, sent or not, with the reason |

Configuration belongs to the account admin (`users.auth_id = 0`); employee
mailboxes read the same configuration. Members can view the screens; only the
admin can change anything.

## Getting started

1. Open **Auto Reply → Categories** and press *Add the six standard categories*
   (Product Enquiry, Pricing Enquiry, Demo Request, Support Request, Complaint,
   General Enquiry). They arrive active but with auto-reply switched off.
2. Open **Templates**, write one per category, read it, then **Approve** it.
3. Back on **Categories**, assign the approved template to its category and
   switch auto-reply on.
4. On **Settings**, fill in the company name and signature, then turn the master
   switch on. The status card tells you how many categories are actually ready.

Or seed everything at once (draft templates, categories off, feature off):

```bash
php artisan db:seed --class=AutoReplyDefaultsSeeder
```

## Template variables

Written as `{{name}}`. Values are HTML-escaped when rendered; unknown names
collapse to nothing and block approval.

| Variable | Value |
| --- | --- |
| `{{customer_name}}` | Sender's display name, or "there" |
| `{{customer_email}}` | Sender's address |
| `{{product_name}}` | Default product from Settings, else the category name |
| `{{company_name}}` | Company name from Settings |
| `{{category_name}}` | Category the mail was classified as |
| `{{original_subject}}` | Subject of the mail being acknowledged |
| `{{signature_name}}` | Signature from Settings, else the mailbox owner |
| `{{received_date}}` | Date the mail arrived |

Keeping `Re: {{original_subject}}` as the subject is what makes the reply thread
under the customer's own mail.

### Why editing an approved template un-approves it

Approval is of the wording that was read, not of the row. Changing the subject
or body of an approved template drops it back to draft, and its categories stop
replying until it is approved again. The Templates screen tells you which
categories that affects before you edit.

## The rules, in the order they are applied

Everything from step 4 on is written to the log, so a customer who heard nothing
back can always be explained.

1. Feature enabled — `config('auto_reply.enabled')` and the account's master switch.
2. Not an outbound mail (`is_sent_mail`).
3. Not already evaluated — a unique key on `(user_id, message_ref)` means two
   concurrent workers cannot both claim the same mail.
4. Recipient is usable: a valid address, not the mailbox itself, not a
   `no-reply`-style address, not a colleague on the same account.
5. Not a promotion.
6. The conversation has not already been acknowledged (when *reply once per
   thread* is on) → `ALREADY_SENT`.
7. At least one active category exists.
8. **AI categorisation** returns a code and a confidence.
9. The code matches an active category, by code or by one of its legacy aliases.
   No match → `SKIPPED`.
10. That category has auto-reply enabled.
11. Confidence ≥ the category's threshold, else the account's → `MANUAL_REVIEW`
    (or `SKIPPED` when manual review is off).
12. The category's template exists, belongs to the account, and is **approved**.
13. The daily per-recipient limit has not been reached.
14. Render, send, record `SENT` — or `FAILED` with the provider's error.

## Statuses

| Status | Meaning |
| --- | --- |
| `PENDING` | Claimed, decision in progress |
| `SENT` | Acknowledgement delivered |
| `FAILED` | Transient problem (AI outage, provider error) — retryable |
| `SKIPPED` | A business rule said no |
| `MANUAL_REVIEW` | Below the confidence threshold; a person should answer |
| `ALREADY_SENT` | The conversation was acknowledged earlier |

## Retries

`FAILED` entries are transport problems, not business decisions, so they are
worth retrying. A retry re-runs the *whole* pipeline on the same log row rather
than replaying the send — a category switched off in the meantime is skipped,
not delivered late — and the attempt count carries over so a doomed mail is not
retried forever.

```bash
php artisan app:retry-auto-replies                 # this database
php artisan app:retry-auto-replies --all-clients   # every tenant
php artisan app:retry-auto-replies --client=3 --limit=50 --max-attempts=5
```

Scheduled hourly in `app/Console/Kernel.php`. A single entry can also be retried
from its detail page in the Reply Log.

## Where it hooks into mail processing

Right after a message row is stored, in both fetch paths:

- `app/Console/Commands/ProcessEmailQueue.php` — the cron, bound to the
  `clientdb` tenant connection (Gmail and Outlook branches).
- `app/Http/Controllers/MailController.php` — the browser-triggered fetch, on
  the default connection.

Both call a local `dispatchAutoReply()` which delegates to
`AutoReplyService::handleSafely()`. That method swallows every throwable by
design: storage and categorisation have already succeeded by that point, and a
missed acknowledgement must never cost a synced mail.

## Configuration

`config/auto_reply.php`. Everything has a working default; these env vars
override:

| Key | Purpose |
| --- | --- |
| `AUTO_REPLY_ENABLED` | Global kill switch — set `false` on a staging copy of production data |
| `AUTO_REPLY_CONFIDENCE` | Default threshold for accounts with no settings row |
| `AUTO_REPLY_DAILY_LIMIT` | Default per-recipient daily cap |
| `GEMINI_API_KEY` | Shared with the existing classifier |
| `GMAIL_SERVICE_ACCOUNT_FILE` | Service account with domain-wide delegation |
| `OUTLOOK_TENANT_ID` / `OUTLOOK_CLIENT_ID` / `OUTLOOK_CLIENT_SECRET` | Graph app registration |

Decisions are appended to `storage/logs/auto_reply.log`; classifier token usage
joins the existing `storage/logs/Gemini_token_error.log`.

## Loop protection

Two auto-responders answering each other is the classic failure of a feature
like this. Four things prevent it:

- Outgoing mail carries `Auto-Submitted: auto-replied` and
  `X-Auto-Response-Suppress: All`, which well-behaved servers do not reply to.
- `no-reply`-style senders are refused.
- One acknowledgement per conversation (configurable).
- A hard daily cap per recipient address.

## Schema

| Table | Holds |
| --- | --- |
| `email_categories` | Name, code, description, aliases, auto-reply switch, template, threshold, active flag |
| `email_templates` | Name, category, subject, body, `draft`/`approved`/`inactive`, who approved and when |
| `auto_reply_settings` | One row per admin: master switch, threshold, limits, template values |
| `auto_reply_logs` | One row per evaluated mail: category, confidence, raw AI response, template, recipient, subject, body, status, sent time, failure reason, attempts |

Migrations are `2026_09_02_0001..0004`. They guard on `Schema::hasTable`, so
re-running them is safe. This database has migrations recorded out of sync with
its schema, so run them by path:

```bash
php artisan migrate --force \
  --path=database/migrations/2026_09_02_000100_create_email_templates_table.php \
  --path=database/migrations/2026_09_02_000200_create_email_categories_table.php \
  --path=database/migrations/2026_09_02_000300_create_auto_reply_settings_table.php \
  --path=database/migrations/2026_09_02_000400_create_auto_reply_logs_table.php
```

No existing table was altered.
