# Automatic Email Assignment

Whose job each mail is.

```
A mail the classifier has already typed     messages.is_category_value = "SUPPORT_REQUEST"
      |
      v
The account's routing rule                  email_categories.assign_department_id = 36
      |
      v
That team                                   departments #36 "Support"
      |
      v
Its head becomes the named owner            users.is_department_head = 1 -> users.id 28
      |
      v
Stamped on the mail                         messages.assigned_department_id = 36
                                            messages.assigned_user_id      = 28
                                            messages.assignment_source     = "rule"
                                            messages.assigned_at           = now
      |
      v
Read back by four surfaces                  Assigned column on the mail list
                                            Assigned chip and its menu
                                            /mails/assigned_* listings
                                            Assigned-to tile on the mail itself
```

The rule the feature is built around: **the AI is not asked who owns a mail.**
The classifier already answers the only language question involved — what kind
of mail is this — and it answers it once, for every mail, with a confidence
attached. Which team owns a pricing enquiry is not a language question; it is
this business's org chart, which the model cannot see and would have to be told
in the prompt anyway.

So no prompt changed to ship this. That is what makes the feature free: no extra
Gemini call, no new field that can come back malformed, and it works on mail
classified months ago — which is what `mail:assign-existing` relies on.

## What the user sees

The mail list gains an **Assigned** column, team first and the person beside it:

| Sender | Subject | Assigned |
| --- | --- | --- |
| Vallabh Enterprises | Rate card for 250 seats | **Team** Sales · Rahul Desai |
| Team Twilio | Invoice 40119 is overdue | **Team** Finance · Nisha Rao |
| Vallabh Enterprises | Portal throws a 500 on export | **Owner** Support · Pankaj Yadav |
| Ami Mehta | Details about your inventory module | **Team** Sales · Rahul Desai |
| Some newsletter | Half price this week only | **Team** — |

Rows three and five are the interesting ones. Row three says **Owner** rather
than **Team**: somebody assigned that mail by hand, and a hand-assigned mail is
never reassigned automatically again. Row five has no owner at all, and never
will — it is a promotion, and nobody is meant to own a newsletter.

The **Assigned** chip splits into the questions people actually arrive with:

```
Assigned  12
--------------------------
     12   Assigned to me
-- By team ---------------
     34   Sales
     18   Support
      7   Finance · no head
      0   Purchase
--------------------------
     59   Assigned to anyone
    204   Unassigned
--------------------------
          Routing rules…
```

The chip's own number is **assigned to me**, not the total assigned. A number
counting the whole account's assigned mail would read the same for everybody
looking at it, and so would tell nobody anything.

That row is also the one listing that deliberately **crosses mailboxes**, and it
has to. An employee's work does not arrive in their own mailbox: mail addressed
to `info@` or `sales@` is synced under that mailbox's `user_id`, and routing it
to Support does not move it. A version of this listing scoped to the signed-in
user's own mailbox would be permanently empty for exactly the people the feature
exists to serve. The team rows are *not* cross-mailbox — they are opened from a
mailbox somebody is already looking at, and answer "what of this inbox is
Support's", which is what the number beside them counts.

`Finance · no head` is worth reading as a feature. A team with nobody made head
still receives mail; it just belongs to the queue rather than to a desk. Saying
so in the menu is how that gets noticed and fixed.

Opening a mail, **AI Insights** gains an **Assigned to** tile that links to the
team's listing, a reassignment control, and — only when it is not obvious — one
sentence saying why the mail is where it is:

> **Assigned by hand:** the routing rule sends Support Request to Support, but
> Ami Mehta assigned this one to Pankaj Yadav. A hand-assigned mail is never
> reassigned automatically.

> **Nobody owns this:** Payment or Billing is not routed to a team. Map it on
> the Mail Routing screen and new mail of this kind is assigned as it arrives.

Three cases get a sentence and the rest get none: a manual override where the
rule disagrees, an unassigned mail whose category *is* routed (something is
wrong), and an unassigned mail whose category is not (nothing is wrong, but
somebody has to act). A mail sitting where the rule put it needs no explanation
beyond the pill.

## Stored on the mail, unlike a priority band

This is the opposite of the choice [lead priority](lead-priority.md) made, and
the one thing to understand before changing any of it.

A priority band is a **display ranking**. Nothing has happened to the mail, so
deriving it on every read costs nothing and means an admin's edit re-ranks the
whole inbox instantly. An assignment is not a ranking — somebody has been told
the mail is theirs and may already be drafting a reply. So:

- **Deriving it would rewrite history.** An admin remapping Support Request from
  IT to Operations would silently empty one person's queue and fill another's,
  including mail already being worked. A remap has to affect what arrives next.
- **A manual reassignment has to survive.** Derived, there is nowhere to record
  that a human overruled the rule, so the next page load would undo them.
- **"Assigned to me" spans mailboxes.** Derived, that listing would be a join
  through the owner's category vocabulary on every read rather than a `WHERE` on
  one indexed column.

The cost is that a mapping changed today does nothing to mail already in the
inbox. That is deliberate, and it is what `mail:assign-existing` is for:
applying the current mapping to mail nobody has yet, as an explicit act with a
report, rather than as a silent side effect of saving a form.

Five columns rather than one, because each part has to be answerable on screen:

| Column | Why it exists |
| --- | --- |
| `assigned_department_id` | The team. What the chip counts and the filters read. |
| `assigned_user_id` | The named person, or null for a team-level assignment. |
| `assignment_source` | `rule` or `manual`. This is what makes reassignment safe. |
| `assigned_at` | A queue that cannot be aged is a queue nobody trusts. |
| `assigned_by` | Who did it, for a manual assignment. Null for the rule. |

`assigned_by` is null for a rule-assigned mail on purpose: the configuration is
not a person, and naming the admin who once saved the mapping would be a lie
about who made this decision about this mail.

## A rule is a category with a team against it

There is no rules table. `email_categories.assign_department_id` is one nullable
column on the per-account vocabulary that already holds a category's business
decisions — `auto_reply_enabled` and `template_id` are its neighbours, and "may
this be answered automatically, and with what" sits naturally beside "and whose
job is it".

Every category starts **unrouted**, and the feature assigns nothing until an
admin maps it. That is the whole install story, and it is not laziness: a
default mapping cannot be guessed. This installation's departments are Business,
Operation, HR, SEO and IT — none of them the Sales/Support/Finance/Purchase that
routing examples assume — and quietly assigning a customer's complaint to
whichever team sorted first is worse than assigning nothing.

The screen edits the whole table in one submit rather than a control per row,
because routing is a set of decisions read together: an admin sending Support
Request to Support usually wants to place Complaint at the same moment, and a
per-row save turns that into six page loads and six chances to leave the table
half mapped.

An account still running on the built-in categories has no rows to hang a
mapping on. Saving one persists the vocabulary first — mapping a category is a
decision to have that category — and the screen says so before the submit.

## The named person is the head

`users.is_department_head`, which the schema already holds and the Assign
Department screen already enforces at one per department. No new state, no new
screen.

Deliberately not a round-robin. That would need a stored cursor, would make two
identical mails land on different desks, and would keep assigning to whoever is
on leave. A team with no head is not an error: the mail is assigned to the team,
shows as such, and anyone in it can pick it up.

Reassigning a single mail is open to **anyone in the account**, not just the
admin — the person who knows a mail landed in the wrong queue is usually
whoever it landed on. Changing the *rule* stays an admin action: one mail is not
a policy.

Naming a person is enough to reassign; their department is taken from their own
membership, because a mail whose named owner is in Support but whose team says
Sales is a row no screen could explain. Somebody whose `users.group_by` points
at no team of this account's is refused with a message naming the real cause —
some rows in this installation carry a department id belonging to a different
account, and "that team does not exist" reads as a bug when the person is the
thing that needs fixing.

## What is never assigned

- **Promotions and provider-flagged spam.** Nobody is meant to own a newsletter,
  and filling a team's queue with them on the first run is the fastest way to
  have the feature switched off. They are also left out of the Unassigned count,
  which would otherwise bury the mail that really has no owner.
- **Mail that already has an owner**, whatever the source. Rule-assigned mail is
  left alone because somebody may be working it; manually assigned mail is left
  alone because a person overruled the rule. The only way back into scope is for
  somebody to unassign it — which is exactly what unassigning means.
- **Mail with no category.** `mail:backfill-gmail` deliberately stores none so
  summaries generate lazily on view, so there is nothing to route by until
  something classifies it. Once something has, `mail:assign-existing` picks it
  up.
- **A category mapped to a team that no longer exists**, or to one belonging to
  another account. `Department::find_for()` is the gate every read goes through,
  and it resolves to null — which reads as unrouted, rather than as mail in a
  queue with no way to open it.

## Filter slugs

The listings arrive through the same `{type}` route parameter as every other
filter and are resolved in `Department::applyAssignmentFilter()`, tried **last**
of the five so no slug that was bookmarkable before this feature can be
shadowed.

A team's listing is keyed by id — `assigned_dept_36` — not by a slugified name.
Unlike the four AI vocabularies these rows are not a controlled list: an admin
types the name, two accounts may both have a "Sales", and a rename must not
break a bookmark.

Because `assigned_dept_<id>` cannot be enumerated in advance, the whole
`assigned_` **prefix** is reserved rather than the individual slugs — in all
four vocabulary controllers, so no category, lead type, sentiment or next action
can be given a slug in this shape. `tools/assignment-smoke.php` checks that no
configured slug has claimed it.

## Files

| File | What it does |
| --- | --- |
| `database/migrations/2026_09_07_000200_add_assignment_to_email_categories_table.php` | The routing column, and why there is no default mapping |
| `database/migrations/2026_09_07_000300_add_assignment_to_messages_table.php` | The five columns, their indexes, and why they are stored |
| `app/Models/Department.php` | `decide()`, `routeFor()`, `headFor()`, `manualAssignment()`, `applyAssignmentFilter()`, `isCrossMailboxSlug()`, `mailboxIdsFor()` |
| `app/Http/Controllers/AssignmentController.php` | The routing screen, the whole-table save, and the per-mail override |
| `app/Http/Controllers/MailController.php` | `assignmentFor()` at five storing sites, `assignmentCounts()`, `assignmentChipDefinitions()`, the filter in the chain, the cross-mailbox scoping |
| `app/Console/Commands/ProcessEmailQueue.php` | `assignmentFor()` at three storing sites |
| `app/Console/Commands/AssignExistingMail.php` | `mail:assign-existing` — the mapping applied to mail already in the inbox |
| `resources/views/assignment/index.blade.php` | The Mail Routing screen |
| `resources/views/mail/index.blade.php` | The Assigned column, its pills and its sort |
| `resources/views/mail/single.blade.php` | The Assigned-to tile, the explanation sentence and the reassign form |
| `resources/views/partials/mail-toolbar.blade.php` | The Assigned chip and its menu |
| `resources/views/partials/sidebar.blade.php` | The Mail Routing entry |
| `resources/views/layouts/app.blade.php` | Refreshes the assignment counts with the rest of the chips |
| `routes/web.php` | The `mail-routing` prefix, and why the listings need no routes |
| `public/css/emi-ui.css` | The chip's icon tint |
| `tools/assignment-smoke.php` | Checks the listings and the counts agree, and the invariants nothing else enforces |
| `tools/assignment-shot.php` | Renders the three surfaces from real mail inside a rolled-back transaction |

The four vocabulary controllers — `EmailCategoryController`,
`LeadTypeController`, `SentimentController`, `NextActionController` — each gained
the reserved-prefix rule and its message, and nothing else.

Unchanged, and worth noting: the classifier prompt, at all eight sites that
build one. Automatic assignment asks the AI nothing.

## Access

Reading an assignment needs nothing — any user sees who owns the mail in a
mailbox they can already open. Reassigning one mail needs only to be in the
account, and the mailbox is checked server-side rather than trusted from the
form. Changing the routing rules needs an administrator (`users.auth_id = 0`),
like the rest of the classification screens.

The rules are per **account**, not installation-wide, unlike sentiments, next
actions and lead types. They have to be: one business's org chart is not
another's, and the column lives on `email_categories`, which is already scoped
that way.

## Setup

```
php artisan migrate
php tools/assignment-smoke.php
```

The migrations add the columns and nothing else — no backfill, because at
install time every category is unrouted and there is nothing a backfill could do
but assign several thousand mails to a team nobody nominated.

Then, in the app:

1. **Departments** — create the teams, if they do not exist yet.
2. **Assign Department** — give each team a head and its members. The head is
   who mail is handed to by name.
3. **Mail Routing** — point each category at a team. New mail is assigned from
   the next sync.
4. `php artisan mail:assign-existing --dry-run` — see what the new mapping would
   do to mail already in the inbox; drop `--dry-run` to write it.

Nothing is reclassified and no mail is rewritten by the migrations, so there is
nothing to undo: `migrate:rollback` drops the columns, the mail keeps its
categories, and the app behaves as it did before.
