# Follow-up Reminder

Make sure the mail that matters is not missed.

```
A mail arrives
      ↓
The classification prompt          (no new AI call — the same one that already
                                    returns sentiment, category, summary and
                                    the recommended next action)
      ↓
The recommended next action        "send_quotation"
      ↓
The window for that action         48 hours, from when the mail was sent
      ↓
A follow-up task                   follow_ups: due_at, remind_at, priority
      ↓
A reminder, before it is late      in the app (bell, toast, desktop popup) and
                                   by mail, one digest per mailbox, chased again
      ↓
Closed                             somebody ticks it off — or a reply is seen
                                   on the conversation and it closes itself
```

The rule the feature is built around: **a task, not a flag.** A mail that needs
an answer was already a row in a list, and a list is something you look at, not
something that comes and finds you. A task has a due time and a reminder, so
the mail arrives twice — once when it is sent, and once when it is about to be
late.

## What the user sees

**Follow-ups** in the sidebar, carrying the overdue count — the one number on
that menu that is a problem rather than a place. The queue itself is seven tabs:

```
Overdue (3)   Due today (5)   Upcoming (12)   All open (20)   Done (44)   Dismissed (2)   Everything (66)
```

| Due | From | Subject | What is needed | Reminders | Status |
| --- | --- | --- | --- | --- | --- |
| 1 day late | Pankaj Yadav | Extremely Dissatisfied With Your Service | ● Respond Immediately | 2 | overdue |
| in 4 hours | Neeta Shah | Revised AMC quote? | ● Send Quotation | 0 | open |
| in 2 days | Hari Patel | Unable to log in to the app | ● Follow Up | 0 | open |

Each row says **why it is there** underneath the subject — *"Recommended action
'Send Quotation', which is due within 2 days."* A queue nobody trusts is a queue
nobody works, and "because the classifier said so" is an answer a person can
check.

Four things can be done to a task, and each is one click: **answer it** (straight
into the reply screen), **done**, **later** (10 minutes, 4 hours, tomorrow,
3 days, next week), **dismiss**.

The ten-minute offset is there for testing, and is labelled as such. Everything
else about the feature is measured in hours, because those are business
decisions; ten minutes exists only so the whole chase — raised, reminded,
overdue — can be watched in one sitting rather than over a working day.

On the mail list itself, the actions column gains a follow-up control. A mail
already covered shows when it is due — in red once late — and links to the
queue. A mail with no task offers to raise one, because the mail somebody just
read and knew mattered is exactly what no vocabulary was ever going to catch.

## Detection costs no AI call

Every mail already goes through one classification prompt that returns a
recommended next action. That recommendation **is** the detection. Asking a
second model the same question in different words would cost a call per mail to
produce a worse answer.

So the only decision left is how long a recommendation may go unactioned:

| Recommended action | Due within |
| --- | --- |
| Respond Immediately | 4 hours |
| Escalate to Manager | 8 hours |
| Escalate to Technical Team | 24 hours |
| Call the Customer | 24 hours |
| Send Quotation | 48 hours |
| Schedule a Demo | 48 hours |
| Follow Up | 72 hours |
| No Action Required | never |

Counted from when the mail was **sent**, not from when the task was raised —
otherwise a three-day-old mail would be due in three days, which is the one
thing a follow-up queue must not do.

Every window is editable per account at **Follow-ups → Settings**, one row per
active next action, built from the live vocabulary — so an action an admin adds
at *Next Actions* appears there with its effective window straight away rather
than being invisible until somebody saves the form. An empty box means *never
chase this one*, which is a decision and is stored as one.

Four kinds of mail are never raised: promotions, provider spam, mail the mailbox
itself sent, and a conversation that has already been replied to.

Mail that arrived **before next actions existed** is still caught, through the
reply flag the pipeline has always set: `action_required = 1` means the mail
asked for something. Those get the account's default window (48 hours out of the
box). A follow-up queue that silently excluded half the mailbox would be worse
than none.

## The reminder

The reminder, not the task, is the point. One digest per mailbox per run, never
one mail per task — four reminders in a minute is what teaches somebody to
filter them. Overdue work is listed first and is the only thing coloured, so the
mail can be triaged from the preview pane.

| Setting | Default | What it does |
| --- | --- | --- |
| Send reminder mails | on | The digest above. |
| Notify inside the app | on | The bell, the toast and the desktop notification below. |
| First reminder | due − 2h | **Before** the due time. Being told a mail is late is worth much less than being told in time to still answer it. |
| Chase again every | 24h | While the task stays open past its due time. |
| Stop after | 4 reminders | The task stays open — it is still owed, it just stops shouting. |
| Copy the administrator in | overdue by 48h | Once per task, not daily. |
| Send every reminder to | (the mailbox) | An address here sends every mailbox's reminders to one shared desk instead. |

`remind_at` is what the sending command reads, and nulling it is what ends the
chase — which is why closing a task nulls it, and why a send **failure** does
not touch it. A reminder nobody received must not consume the chase budget.

## The notification in the app

A mailed digest only reaches somebody who is reading their mail — which is
exactly the state this feature exists to compensate for. So the reminder also
arrives where the person actually is, in three escalating degrees:

| | What it is | When |
| --- | --- | --- |
| **The bell** | A count on the topbar, red when anything is overdue, amber when it is merely due. Its panel lists what is waiting, with a reply button per row. Its first paint is rendered server-side, so the number is right immediately. | Always, on every screen |
| **A toast** | `emiToast`, and it stays until dismissed — a reminder that vanishes while you are looking at another window has not reminded anyone. | A task comes due while the tab is open |
| **A desktop notification** | The browser's own popup, so it lands even when the tab is not focused. | The same moment, once the viewer has allowed it |

The bell polls `/follow-ups/notifications` once a minute, and its horizon is the
account's own lead time — so the bell and the digest never disagree about what
is coming. The bell is always on the topbar — an
account with follow-ups switched off gets a panel that says so and offers the
switch, rather than a control that only exists once something is wrong. Polling
stops while the tab is hidden, and stops for good on a signed-out
session (which answers `204`, not `403`, so a tab left open overnight goes quiet
instead of logging a wall of failures).

Two things worth knowing about how it behaves:

**Announcing is the browser's business.** The endpoint only ever reads.
`reminder_count` is the record of what was *mailed*, and a toast is not a send —
so which task has already been shouted about is remembered in `localStorage`,
keyed on the task's id *and* its due time. A snoozed task therefore announces
itself again at its new time without everything else re-announcing.

**It never nags on arrival.** Work already overdue when a page loads is counted
on the bell but not announced. Opening a screen is not the moment to be shouted
at about a mail from yesterday; only a task that comes due while you are there
is announced.

The desktop notification needs the viewer's permission, and browsers only grant
it from a real click — so it is asked for by a **Notify on my desktop** button
inside the bell panel, never on page load. Turning **Notify inside the app** off
in settings silences the toast and the popup but keeps the bell, because a
number is not an interruption.

## Closing itself

A person who answers a mail from their own mail client does not then come to a
task list and tick something off. So a queue that only shrinks when clicked is a
queue full of work already done, and reminders about that work are exactly the
noise that gets a feature switched off.

Every pass therefore closes any open task whose conversation has since been
answered, using the pipeline's own reply signal — the sync writes the replying
mail's id onto the message it answers, which is the same signal the *Replied
Mail* listing uses. Those tasks are marked `completion_source = reply` rather
than `manual`, because "seems to have been handled" and "handled" are different
claims and the screen should not confuse them.

## One task per message, one open task per conversation

The `follow_ups` row **is** the idempotency, exactly as `auto_reply_logs` is for
auto-reply: a row means this mail has been considered, whatever the outcome. So
nothing is raised twice, and a mail somebody dismissed is never raised again.

Separately, a conversation only ever holds one *open* task. A long chase
generates a mail a day, and a queue with the same conversation in it six times
is a queue nobody works. Nothing is recorded against those later mails, so the
conversation is reconsidered once the open task closes.

## Opt-in, per account

No settings row means the feature was never configured, which reads as **off** —
no tasks raised, no reminders sent. The same contract auto-reply has, so neither
automation can surprise an account that never asked for it.

Turning it on is one click — **Turn follow-ups on**, on the queue banner or in
the bell panel — which writes the settings row with the shipped defaults and
sweeps in the same request, so the queue is populated by the time the page
reloads. (It has to act rather than navigate: a button that merely opened the
settings form left the "switched off" banner standing and looked as though it
had ignored the click.) The settings screen is for adjusting the windows
afterwards.

Turning it on later loses nothing: detection reads the `messages` table, not a
live feed, so recent unanswered mail is picked up on the first pass. **Check
now** on the queue re-runs that pass on demand, because a feature whose first
result arrives on the next cron tick reads as broken.

Tasks belong to mailboxes; settings belong to the account. An admin sees every
mailbox in their account and can narrow to one; an employee sees their own.
Closing, snoozing and dismissing are open to whoever can see the task — they are
the person doing the work. Only the admin changes the settings.

## Two commands

```bash
# Raise what is owed, close what has been answered. Every 15 minutes.
php artisan app:process-follow-ups --all-clients

# Send the digests that are due. Hourly.
php artisan app:send-follow-up-reminders --all-clients
```

Both walk the tenant databases the way the mail sync does, take `--owner=` for
one account and `--dry-run` to show what would happen without writing or
sending. `app:process-follow-ups` also takes `--days=` and `--limit=`.

The sweep's lookback window is short by default (7 days) and that is a safety
limit, not a nicety: without it, a first run against an established mailbox
would raise a task for every unanswered mail in its history, all of them already
late, and the first reminder would be a thousand lines long. There is also a cap
of 300 open tasks per mailbox, at which point the sweep stops creating rather
than burying the real queue.

## Trying it out in ten minutes

1. Press **Turn follow-ups on** — on the queue banner or in the bell panel. For
   a ten-minute test also set *First reminder* to `0` hours in **Settings**, so
   the reminder is not held back behind a two-hour lead.
2. On any mail in the list, open **Follow up → in 10 minutes (test)**. The row
   immediately shows `in 10 minutes`, and the task appears under *Due today*.
3. Watch the topbar bell: within a minute it turns amber and then red with the
   count, a toast appears the moment the task comes due, and **Notify on my
   desktop** in the bell panel adds the browser popup.
4. Send the mailed digest now rather than waiting for the hourly schedule:

   ```bash
   php artisan app:send-follow-up-reminders --owner=<admin users.id> --dry-run
   php artisan app:send-follow-up-reminders --owner=<admin users.id>
   ```

   The first prints what would go and to whom; the second sends it and bumps
   `reminder_count`.
5. Ten minutes later the task moves to **Overdue** and the mail-list control
   turns red. Reply to the mail from anywhere and the next
   `app:process-follow-ups` closes it as `reply`.

Set *First reminder* back to something sensible afterwards — a lead of 0 means
every reminder arrives exactly when the work is already due.

## Files

| File | What it is |
| --- | --- |
| `app/Models/FollowUp.php` | The task: statuses, priorities, due and reminder arithmetic, the state changes |
| `app/Models/FollowUpSetting.php` | Account settings, and the window ladder — override, shipped, default |
| `app/Services/FollowUp/FollowUpDetector.php` | Whether a mail needs following up, and by when |
| `app/Services/FollowUp/FollowUpDecision.php` | What the detector decided, and why — including the negatives |
| `app/Services/FollowUp/FollowUpService.php` | Raising, the candidate query, and closing what has been answered |
| `app/Console/Commands/ProcessFollowUps.php` | The scheduled sweep |
| `app/Console/Commands/SendFollowUpReminders.php` | The scheduled chase |
| `app/Mail/FollowUpReminderMail.php` | The mailed digest |
| `app/Http/Controllers/FollowUpController.php` | The queue, the actions, the settings, `Turn on`, `Check now`, and the bell endpoint |
| `resources/views/follow_ups/index.blade.php` | The queue |
| `resources/views/follow_ups/settings.blade.php` | The settings screen |
| `resources/views/follow_ups/_tabs.blade.php` | The header both screens share |
| `resources/views/emails/follow_up_reminder.blade.php` | The digest template |
| `resources/views/partials/follow-up-bell.blade.php` | The topbar bell and its panel |
| `resources/views/partials/follow-up-notifications.blade.php` | The poller: bell count, toast, desktop notification |
| `resources/views/mail/index.blade.php` | The follow-up control on the mail list |
| `app/Http/Controllers/MailController.php` | `followUpsFor()` — one query per page of mail |
| `config/follow_up.php` | Kill switch, shipped windows, safety limits |
| `database/migrations/2026_09_03_000400_create_follow_up_settings_table.php` | `follow_up_settings` |
| `database/migrations/2026_09_03_000500_create_follow_ups_table.php` | `follow_ups` |
| `database/migrations/2026_09_03_000600_add_in_app_notifications_to_follow_up_settings.php` | `notify_in_app` |
| `tools/follow-up-smoke.php` | Routes, tables and the detector against real mail, writing nothing |

## Setup

Nothing to configure — no API key, no credentials. The reminder uses the mailer
already configured in `.env`.

```bash
php artisan migrate --path=database/migrations/2026_09_03_000400_create_follow_up_settings_table.php
php artisan migrate --path=database/migrations/2026_09_03_000500_create_follow_ups_table.php
php artisan migrate --path=database/migrations/2026_09_03_000600_add_in_app_notifications_to_follow_up_settings.php
```

Then switch it on at **Follow-ups → Settings** and press **Check now**. Neither
migration writes to `messages` and neither backfills anything: the first sweep
is what fills the queue, and it only looks a week back.
