# Email Summary

Understand a long email conversation in under a minute.

```
A mail in the list
      ↓
Its whole conversation           (every message sharing the provider thread id)
      ↓
Quoted history stripped          (each reply's own words, not the thread beneath it)
      ↓
Gemini, one JSON schema
      ↓
Five sections                    Customer requirement
                                 Previous discussion
                                 Current status
                                 Pending actions
                                 Next step
      ↓
Stored per conversation          (re-opening it costs nothing; a new reply makes it stale)
```

The rule the feature is built around: **the model reports, it does not decide.**
It is told to answer only from the thread and to say when something was never
stated. A summary that invents a deadline is worse than one that admits the
thread never gave one.

## Not the same as the Summary chip

There are two summarisers in the app and they answer different questions.

| | Summary chip (toolbar) | Email Summary |
| --- | --- | --- |
| Scope | The latest 20 mails in the mailbox | One conversation, all of it |
| Question | "What has come in?" | "What is this thread about and what do I do?" |
| Output | One block per mail | Five fixed sections |
| Where | `MessageController::fetchSummary` | `ThreadSummaryController` |
| Stored in | `mail_summaries` | `thread_summaries` |

Neither replaces the other. The chip is a triage view of the inbox; this is a
briefing on a single exchange.

## Where it is

| Surface | How to get there |
| --- | --- |
| Modal | **Summary** on any row of the mail list |
| Full page | `/email-summary/{messageId}` — also the *Full page* button in the modal |

Both render the same card from the same endpoint, so they cannot drift apart.
The page carries **Regenerate**, **Open the mail** and **Reply**.

Any mail in a conversation opens the same summary: the anchor decides which
conversation, not which message is described.

## The five sections

| Section | What it answers |
| --- | --- |
| Customer requirement | What the other party wanted, and how the ask has changed |
| Previous discussion | The course of the exchange, oldest first, repetition merged |
| Current status | Where it stands after the newest message |
| Pending actions | What is outstanding, each with an owner (Us / Customer / a named person) |
| Next step | The single most useful thing to do next |

Alongside them the card shows a status label — **Open**, **Waiting on us**,
**Waiting on customer**, **Resolved** or **No action needed** — the message
count, the other party, and when the summary was written. Anything the model
returns outside that vocabulary falls back to *Open*.

## How a conversation is assembled

`App\Services\ThreadSummary\ThreadCollector` rebuilds the exchange from
`messages`. Three things it exists to get right:

**Grouping.** Messages sharing `threadId` are one conversation — the provider's
own grouping, which is also what the ingest pipeline uses. When a message has no
thread id, the collector falls back to the normalised subject, the same way the
pipeline stitches Outlook replies together.

**Sides.** A message is *ours* when the mailbox sent it (`is_sent_mail`), when it
came from another mailbox on the same account, or when the sender shares the
mailbox's domain. That last rule covers colleagues who are copied into a
customer thread but have no login here — without it the summary attributes our
own commitments to the customer. It is skipped for public domains (gmail.com and
friends), where a shared domain means nothing.

**Quoted history.** Every reply carries the whole thread beneath it, so raw
bodies would send the same text thirty times and bury the newest message. Bodies
are cut at the first reply marker (`On … wrote:`, `-----Original Message-----`,
Outlook's `From:`/`Sent:` block, `>` lines). Cutting early is the safe failure:
what is cut is, by definition, already in the thread as its own message.

In practice this takes a 32-message thread from ~800 KB of raw bodies to about
22,000 characters — roughly 8,000 input tokens.

## Cost and staleness

A generated summary is stored in `thread_summaries`, keyed on
`(user_id, thread_key)`. Opening the same conversation again is a database read,
not a Gemini call.

The stored row remembers how many messages it was built from and the newest
`messages.id` it saw. When either has moved on, the summary is **stale** and the
next view regenerates it automatically. **Regenerate** forces a rebuild even
when nothing has changed.

If a regenerate fails and a stored summary exists, the stored one is shown with
a warning rather than an error page — and it is not overwritten.

## Limits

Set in `config/thread_summary.php`, all overridable by environment variable:

| Setting | Default | What it does |
| --- | --- | --- |
| `THREAD_SUMMARY_MAX_MESSAGES` | 40 | Beyond this the oldest messages are dropped |
| `THREAD_SUMMARY_MAX_BODY_CHARS` | 2500 | Per-message body cap after de-quoting |
| `THREAD_SUMMARY_MAX_TOTAL_CHARS` | 60000 | Whole-thread budget; older mails fall back to their stored one-line `mail_summary` |
| `THREAD_SUMMARY_GEMINI_TIMEOUT` | 120 | Seconds per attempt |

When either cap bites, the card says so — the summary never claims to describe a
conversation it only partly read.

The oldest messages are the ones dropped, not the newest: an opening request is
usually restated in the reply chain, whereas current status exists nowhere but
the tail.

## Failure behaviour

| What happened | What the user sees |
| --- | --- |
| No API key configured | "The summary service is not configured yet." |
| Flash model overloaded (503) or timed out | Silently retried on `gemini-pro-latest` |
| Rate limited (429) | The wait Gemini asked for, when it gave one |
| Answer was not usable JSON | "The summary came back in a form this screen could not read." |
| Any failure, with a stored summary | The stored summary, plus a warning toast |

Every decision is appended to `storage/logs/thread_summary.log`; token counts go
to `storage/logs/Gemini_token_error.log` alongside the rest of the pipeline.

## Files

| File | What it is |
| --- | --- |
| `app/Http/Controllers/ThreadSummaryController.php` | Page, JSON endpoint, regenerate, mailbox access rules |
| `app/Services/ThreadSummary/ThreadCollector.php` | Rebuilds the conversation from `messages` |
| `app/Services/ThreadSummary/ThreadSummaryService.php` | Prompt, Gemini call, validation, storage |
| `app/Models/ThreadSummary.php` | The stored summary; the section list lives here |
| `resources/views/mail/thread_summary.blade.php` | The page |
| `resources/views/mail/_thread_summary_card.blade.php` | The five sections, shared by page and modal |
| `resources/views/partials/thread-summary-modal.blade.php` | The mail-list modal |
| `config/thread_summary.php` | Endpoint, key, timeouts, size limits |
| `database/migrations/2026_09_02_000600_create_thread_summaries_table.php` | `thread_summaries` |

The controller is deliberately independent of `MailController`: it resolves its
own mailbox, reads `messages` directly and renders its own views, so summarising
keeps working regardless of what happens to the mail screens around it.

## Access

A mailbox owner sees their own conversations. Beyond that the rules match the
rest of the app: account administrators (`user_level` 0) may open any mailbox on
the account, managers (2) only the employees under them, department heads (1)
only their department. A request for a mailbox outside those bounds is refused
with a 403 — it does not fall back to the signed-in user's own mail.

## Safety

The model returns **data**, not markup. The five sections are validated against
a fixed schema and rendered by Blade with normal escaping, so nothing Gemini
returns can inject anything into the page. Every card carries a line saying the
summary was written by AI and should be checked against the mail before acting
on it.

## Setup

Nothing to configure. `GEMINI_API_KEY` is already set for the rest of the app,
and the table is created by:

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

(The plain `php artisan migrate` fails on this database — the older tables
predate the `migrations` table, so a full run tries to recreate `users`.)
