# Design note — following topics, and email that actually arrives

**Dated 2026-08-05. Written before the code, per the provenance practice in
[ROADMAP.md](../../ROADMAP.md#proving-independence).**

---

## The job to be done

Two gaps, and they are the same gap seen from different sides.

**You cannot follow a thread you did not start.** Notifications fire for a
reply to your own topic and for an `@mention`, and that is all. Answer somebody
else's question, and you never hear that they replied. That is the single most
ordinary thing a forum member does.

**Nothing ever reaches you when you are not on the site.** Every notification
is in-app. A member who posts a question and closes the tab has no way to learn
it was answered, short of coming back to check — which is exactly the behaviour
a forum needs and cannot ask for.

## Decision 1 — following is a row, and it covers topics and forums

One `follows` table: who, what kind of thing, which one. Topics and forums both,
because "tell me about this thread" and "tell me about everything in Support"
are the same mechanism with a different target, and building the first without
the second means building it twice.

Each row carries **how** to be told: in-app, email, or both. Per follow rather
than one global switch, because the thread somebody is arguing in and the forum
they skim are not the same level of interest.

## Decision 2 — posting follows the thread, and that is a preference

Replying to a topic follows it. That is what somebody meant by replying, and a
forum that makes you tick a box as well is a forum where nobody gets told
anything.

It is a preference, defaulted on, because the person who does not want it wants
it off *everywhere*, not once per thread.

**Un-following is explicit and sticks.** Somebody who has deliberately left a
thread must not be dragged back in by their own next reply, so an explicit
un-follow is remembered as a `muted` row rather than a deleted one.

## Decision 3 — the notification is written in the request; the email is not

A topic with two hundred followers is two hundred rows to write and, if email
went the same way, two hundred SMTP conversations inside the request of
whoever pressed Post. That is a minute of spinner, a timeout, and a member who
thinks the forum is broken.

Worse, it is the exact failure this project has already caused once in
production elsewhere: outbound calls in a visitor's request.

So:

- **In-app notifications are written inline.** They are local `INSERT`s, they
  are what makes the bell number correct on the next page load, and a member
  expects them immediately.
- **Email goes into an outbox table** and is sent by a **command run from
  cron**. Nothing about pressing Post talks to an SMTP server.

## Decision 4 — the outbox is a table, not a queue system

`mail_outbox`: recipient, subject, template, payload, state, attempts, error.
`php tools/convoro mail:send` drains it.

Not Redis, not a job runner, not a daemon. Convoro has no dependencies and this
does not get to be the first one. A table plus a cron entry is something anybody
can run on shared hosting, inspect with SQL when it goes wrong, and reason about
without learning a second system.

It carries what a queue is actually for:

- **Attempts and a cap.** A permanently-broken address must not be retried
  forever; after enough failures the row is marked failed with its last error
  and left there to be looked at.
- **A claim.** Two overlapping cron runs must not both send the same email, so
  a row is marked as being sent before it is sent.
- **Pruning.** Sent rows are kept briefly and then removed. A log nobody trims
  is a table that only grows — the same rule the webhook delivery log follows.

## Decision 5 — one email per event, not one per notification

If a thread produces six replies before somebody reads their email, they get
six emails. That is how a forum loses a member.

**Follow-up replies to the same topic do not send another email until the
member has been back.** The first reply since their last visit sends; the rest
accumulate in-app. It is the cheapest possible digest, it needs no scheduling,
and it is the difference between useful and unsubscribed.

## Decision 6 — every email can be turned off from inside itself

An unsubscribe link, in every notification email, that works **without signing
in** — a token in the URL, scoped to that member. Somebody who wants out will
not log in to find a settings page; they will mark it as spam, and that hurts
delivery for everybody else on the site.

The link un-follows that one thing. There is also a settings page for the
broader choices.

## What this does not do

- **No digests.** "Everything from this week in one email" needs scheduling and
  a real answer about what counts as a week. The outbox makes it possible
  later; it is not this pass.
- **No watching people.** Following a member is a different feature with
  different privacy questions.
- **No push notifications.** Web push needs service workers, VAPID keys and a
  permission prompt, and deserves its own note.
- **No per-forum email frequency.** One knob per follow — in-app, email, both.
  More knobs than that is a settings page nobody finishes reading.
