# Next Action Recommendation

Work the inbox from the list, without opening every mail.

```
A mail arrives
      ↓
The classification prompt          (one Gemini call, the same one that already
                                    returns sentiment, category and summary)
      ↓
One action from the vocabulary     "escalate_manager"
      ↓
Stored on the mail                 messages.next_action + next_action_source
      ↓
Read back by three surfaces        Next Action column
                                   Next Action toolbar chip (counts)
                                   /mails/action_* listings (filter)
```

The rule the feature is built around: **one mail, one action.** Not a list of
suggestions, not a paragraph of advice — the single most useful thing to do
next, in a word the app can count and filter by.

## What the user sees

The mail list gains a **Next Action** column, immediately after Sent Date:

| Sender | Subject | Sent Date | Next Action |
| --- | --- | --- | --- |
| Pankaj Yadav | Extremely Dissatisfied With Your Service | 03 Sep 2026 | ● Escalate to Manager |
| Ami Mehta | Immediate Attention Required – Unacceptable Delay | 03 Sep 2026 | ● Respond Immediately |
| Neeta Shah | Revised AMC quote? | 03 Sep 2026 | ● Send Quotation |

The dot takes the action's colour, so the column reads as a workload at a
glance: red is now, amber is on the list, green is nothing to do. Sorting the
column sorts by urgency — the vocabulary order — not alphabetically.

The **Next Action** chip in the toolbar is the same list as a count panel:

```
Next Action  238
──────────────────────────
●   0   Respond Immediately
●   0   Escalate to Manager
●   0   Escalate to Technical Team
●   0   Call the Customer
●  88   Send Quotation
●   0   Schedule a Demo
● 150   Follow Up
● 387   No Action Required
```

Each row opens that action's mail listing. The number on the chip is
**outstanding work** — everything except *No Action Required* — which is why it
reads 238 rather than 625.

## The vocabulary is data, not code

The actions are rows in `next_actions`, managed at **Next Actions** in the
sidebar. Adding "Raise a credit note" is an admin action: the new row goes into
the AI prompt, the column, the chip and the filters at once.

The list ships with eight actions:

| Action | Code | When |
| --- | --- | --- |
| Respond Immediately | `respond_immediately` | A written reply is owed within hours |
| Escalate to Manager | `escalate_manager` | Someone with authority has to decide |
| Escalate to Technical Team | `escalate_technical` | The answer is a diagnosis, not a decision |
| Call the Customer | `call_customer` | The sender asked for a conversation |
| Send Quotation | `send_quotation` | The reply they want is a price |
| Schedule a Demo | `schedule_demo` | The next step is an appointment |
| Follow Up | `follow_up` | Outstanding, but not pressing |
| No Action Required | `no_action` | Nothing is being asked of the reader |

**Order is the urgency ranking.** It is the order the actions are shown to the
model, and the prompt tells it to prefer the action *nearest the top* when more
than one fits. That is the opposite of the sentiment vocabulary, where the
tie-break prefers the most specific label and so the specific ones sit at the
bottom — worth knowing before reordering either list.

Nothing has to be configured for the feature to work. With no rows, the eight
above are in force as the built-in list; the seed button on the admin screen
turns them into rows so they can be reworded, reordered or removed. Seeding
changes no classification — it only makes the same list editable.

An action is deleted only while no mail recommends it. Otherwise it is
deactivated: it leaves the prompt and the filter menu, and the mail already
carrying it keeps its pill.

## Where the word comes from

`messages.next_action_source` records that, and the column shows it:

| Source | Pill | Meaning |
| --- | --- | --- |
| `ai` | solid | The classifier recommended this action for this mail |
| `inferred` | dashed | Nobody asked the classifier; it was worked out from the mail's other labels |

Inference exists because of history. Every mail that arrived before this
feature shipped has never been near the next-action prompt, and a column of
dashes over thousands of rows is worth nothing — so those rows were backfilled
from what the pipeline already knew about them:

| The mail already had | Inferred action |
| --- | --- |
| Promotion, or a provider spam label | No Action Required |
| `action_required` = 0 | No Action Required |
| Sentiment *angry* or *urgent* | Respond Immediately |
| Sentiment *complaint* | Escalate to Manager |
| Category *sales* | Send Quotation |
| Category *amc* | Follow Up |
| Anything else needing a reply | Follow Up |

The same rules run for a new mail the model gave no usable answer for, so the
column is never empty on a mail that went through the pipeline. They live twice
on purpose — once as SQL in the migration that backfills history, once as
`NextAction::inferFrom()` for a single new mail. Change one and change the
other.

A dashed pill is a guess from labels; a solid one is a reading of the mail. The
distinction is why the source is stored rather than thrown away.

## How it is classified

No extra Gemini call. The word comes back from the classification prompt the
pipeline already sends per mail, which now asks for six fields instead of five:
`action_required`, `sentiment`, `category`, `mass_mail`, **`next_action`** and
`summary`. The next-action section of that prompt is generated from the active
vocabulary by `NextAction::promptSection()`, so the model is only ever offered
words the app can store.

A word the vocabulary does not claim is **not** stored. It resolves through
codes first, then each action's *Also match* aliases; anything left over falls
to inference, because a stored nonsense word would be counted and filtered as
if it meant something.

Both fetch paths carry it: `MailController` (the interactive sync, Gmail and
Outlook) and `ProcessEmailQueue` (the cron, which reads its vocabulary from the
per-client database).

## Counting and filtering

One grouped query per mailbox, keyed by filter slug, and one predicate shared by
the chip and the listing — so a chip reading 150 opens a list of 150 mails.

Read mail is counted, unlike the sentiment chips: an action is still outstanding
after you have read the mail that asked for it. Promotions and provider spam are
left out, because mass mail asks nothing of anyone.

Sentiment and next-action filters share the `/mails/{type}` route, and sentiment
slugs were bookmarkable first, so a sentiment slug is matched before an action
slug. The admin screen refuses a slug that a built-in filter or a sentiment
already owns.

## Files

| File | What it is |
| --- | --- |
| `app/Models/NextAction.php` | The vocabulary: defaults, prompt section, resolution, inference rules |
| `app/Http/Controllers/NextActionController.php` | The admin screen — add, edit, reorder, toggle, delete, seed |
| `app/Http/Controllers/MailController.php` | Prompt section, ingest storage, counts, the `action_*` filters |
| `app/Console/Commands/ProcessEmailQueue.php` | The same, for the cron |
| `resources/views/next_actions/index.blade.php` | The admin screen |
| `resources/views/next_actions/_fields.blade.php` | The form, shared by add and edit |
| `resources/views/mail/index.blade.php` | The Next Action column |
| `resources/views/partials/mail-toolbar.blade.php` | The chip and its count panel |
| `database/migrations/2026_09_03_000200_create_next_actions_table.php` | `next_actions` |
| `database/migrations/2026_09_03_000300_add_next_action_to_messages_table.php` | The two columns, and the backfill |

## Access

Any account administrator (`users.auth_id` = 0) may edit the list; employees may
look. The list is installation-wide rather than per account, for the same reason
the sentiment vocabulary is: the code is written onto mail rows and used as a URL
segment, and the screens reading those back do not know whose vocabulary wrote
them.

## Setup

Nothing to configure — `GEMINI_API_KEY` is already set for the rest of the app.

```bash
php artisan migrate --path=database/migrations/2026_09_03_000200_create_next_actions_table.php
php artisan migrate --path=database/migrations/2026_09_03_000300_add_next_action_to_messages_table.php
```

The second one backfills every existing mail, so the column is populated the
first time the list is opened.
