Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions specs/api/agent-inbox-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,24 @@ interface AttachmentView {
```

**Status semantics (v1.1).** `active` is the working state β€” inbound mail creates
conversations `active`, and v1.0's `open` rows migrate to `active`. `pending` is an Agent
conversations `active` (with one exception, below), and v1.0's `open` rows migrate to
`active`. `pending` is an Agent
statement that the conversation is parked awaiting something outside the inbox (a
customer, a third party, a release); nothing sets it automatically in v1, and it still
counts as open work (Β§3a). `closed` is resolved. `spam` is junk an Agent has thrown out
of the inbox; nothing classifies spam automatically in v1. Status pills in the UI:
of the inbox β€” **or, since 2026-08-02, a brand-new conversation filed as junk at intake
because the transport's own classifier already called it spam**
([spam-classification.md](../mail/spam-classification.md) Β§4). Status pills in the UI:
Active = accent, Pending = warn, Closed = dim, Spam = critical.

That intake classification is narrow, and two boundaries matter. It applies **only when
the conversation is created** β€” it is never read for a message that threads onto an
existing conversation, so it can neither re-file nor rescue one. And it never decides
whether a message is *stored*: junk is filed under a different status, never dropped
(inbound-ingestion.md Β§1). The reopen rule below is unaffected by it and still governs
existing conversations β€” including one filed as `spam` at intake, which a reply reopens
to `active` like any other. See spam-classification.md Β§4.2 for the full interaction.

**Snooze exception to "pending is never cleared automatically" (v1.1).** A snooze
is a TIMED `pending` β€” `pending` plus a `snoozedUntil` timestamp (Β§4b) β€” and it is the
ONE case where `pending` clears itself: a periodic wake pass flips a snoozed conversation
Expand Down
5 changes: 5 additions & 0 deletions specs/mail/inbound-ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ knows:
- `providerMessageId` β€” the transport's own stable id for the message (for Gmail, the
Gmail message id). This is the idempotency authority (Β§4), *not* the RFC `Message-ID`.
- `receivedAt` β€” when the transport recorded delivery (not a header-parsed `Date`).
- `providerSpamVerdict` β€” optional; what the transport's own spam classifier already
concluded (`'spam' | 'clean' | 'unknown'`), carried through verbatim and never
re-derived here. It decides the status a *newly created* conversation is filed under
and nothing else β€” never whether the message is stored, which is invariant #3's to
answer, not a classifier's. See [spam-classification.md](./spam-classification.md).

> **Correction.** The interface as first drafted returns a `NormalizedInboundEmail`
> β€” headers and body already parsed, attachments already blob-referenced. That is wrong
Expand Down
256 changes: 256 additions & 0 deletions specs/mail/spam-classification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
# Spam classification

**Status: PARTIALLY BUILT.** Β§3.1 and Β§4 are implemented and tested (the provider
verdict, Gmail's half of it, and the status decision at ingest). Β§3.2 (header-derived
signals), Β§5 (the reclassification loop) and Β§6 (operator controls) are specified but
**not built**. Every blocking decision is now made β€” Β§5.3 (no writeback to the operator's
mailbox) and Β§6 (on by default, with an off switch) were answered by the maintainer on
2026-08-02 and are quoted in Β§7's ledger. What remains unbuilt is implementation work,
not open questions.

Companion to [inbound-ingestion.md](./inbound-ingestion.md), whose three invariants this
spec is subordinate to, and [threading.md](./threading.md), whose reply-token decision
always outranks anything here. Status vocabulary is
[agent-inbox-v1.md](../api/agent-inbox-v1.md) Β§3a.

## 1. The problem this closes

Before this spec, `spam` was a status an Agent set by hand and nothing else β€” as
agent-inbox-v1.md Β§3a put it: "nothing classifies spam automatically in v1."

That was a deliberate v1 deferral, but it left a defect underneath it. The two intake
paths behaved differently, and neither behaviour was chosen:

- **IMAP** opens `INBOX` only (`src/providers/adapters/imap/client.ts`). Whatever the
mail server filed as Junk was never fetched. Spam filtering by side effect.
- **Gmail** calls `history.list` with no `labelId` filter
(`src/providers/adapters/gmail/history.ts`) β€” correctly, because filtering there would
race the label application and drop real mail. But the only label check downstream was
the self-echo filter. **A message Google had already put in `SPAM` became an ordinary
`active` conversation in the inbox.** Google's verdict was computed, delivered to us,
and thrown away.

An operator who connects a Gmail mailbox reasonably expects Gmail's spam filtering to
still apply. It did not.

## 2. What this is not

Helpthread does not build a spam classifier. Scoring message content is a large,
adversarial, permanently-maintained problem, and every mailbox we intake from is already
behind one. This spec is about **not discarding verdicts that already exist**, plus a small,
conservative set of header-derived signals for transports that supply no verdict.

Three constraints bound everything below:

1. **Nothing is ever dropped.** inbound-ingestion.md Β§1's third invariant is not
negotiable. A spam decision changes one column on one row; the message is parsed,
stored, threaded and attachment-linked identically either way. A false positive is
always visible in the Spam folder and always recoverable.
2. **Our own reply token always wins.** If `decideThreading` matched a valid token, the
message belongs to that conversation and the conversation's status is not this spec's
business (Β§4.2).
3. **An Agent's judgment is never silently overruled.** Automatic classification decides
where a *brand-new* conversation is filed. It never re-files a conversation a human
already placed.

## 3. Signals

### 3.1 The provider verdict (BUILT)

`RawInboundMessage.providerSpamVerdict` (`src/providers/inbound-email.ts`) carries the
transport's own conclusion across the provider boundary. Three states:

| Value | Meaning |
|---|---|
| `'spam'` | The provider affirmatively classified the message as junk. |
| `'clean'` | The provider classified it and did not call it junk. |
| `'unknown'` | No verdict available β€” the provider does not classify, or omitted it on this delivery. |

Omitted entirely by a transport with no concept of a verdict; ingest reads an absent
field exactly as `'unknown'`.

`'clean'` and `'unknown'` produce the same outcome today (Β§4.1) but are kept distinct on
the wire: "we asked and it said no" is evidence, "we have no idea" is not, and Β§3.2's
header signals will need to tell them apart β€” header scoring should defer to an explicit
`'clean'` and should not defer to silence.

**Per transport:**

- **Gmail** (built, `spamVerdictOf` in `src/mail/gmail-reconcile.ts`): the system `SPAM`
label β‡’ `'spam'`; any other non-empty label set β‡’ `'clean'`; an empty/absent `labelIds`
β‡’ `'unknown'`, because the history client documents that Gmail does not guarantee the
field is populated. This runs **after** the self-echo filter, so our own outbound reply
that Gmail happened to file as junk is skipped entirely rather than filed as a spam
conversation.

**This is best-effort, not complete, and the boundary is worth stating precisely.**
`history.list` is a delta stream, not a snapshot. The history client requests
`labelAdded` alongside `messageAdded` and set-unions the label deltas
(`src/providers/adapters/gmail/history.ts`), so a message that arrives labeled `INBOX`
and is classified `SPAM` moments later β€” **within the same reconcile window** β€” is
correctly seen as spam. What is *not* covered: a `SPAM` label applied **after** the
window's `history.list` snapshot. That message has already been ingested as `active`,
and the next reconcile deliberately ignores a `labelsAdded` record for an id it did not
itself newly add (otherwise any re-labeling of any old message would manufacture an
ingest). So it stays `active`.

This is not a regression β€” before this spec, *every* spam message stayed `active` β€” but
it means the feature must not be described as "Gmail's spam filtering now applies."
It applies to mail Gmail had classified by the time we read the history window, which
is the common case and not all cases. Closing the remainder is Β§5's reclassification
problem, not something the intake path can solve.
- **IMAP** (built by omission): the client opens `INBOX`, so a server-side Junk move
means the message is never fetched. The field is not supplied β‡’ `'unknown'`. This is
the correct verdict: we genuinely do not know, because we never saw the message at all.
Recorded here so the asymmetry with Gmail is documented rather than accidental.
- **A future forwarding-address transport**: whatever the receiving service reports. A
transport that reports nothing supplies nothing.

### 3.2 Header-derived signals (NOT BUILT)

For a `'unknown'` verdict on a message we did receive, a small conservative set of
RFC-defined headers, evaluated at ingest after `parseInboundEmail`:

| Signal | Source | Weight |
|---|---|---|
| DMARC `fail` | `Authentication-Results` (RFC 7601), our own receiving hop only | Strong |
| SPF `fail` + DKIM `fail`, both | `Authentication-Results` | Strong |
| `Precedence: bulk` / `list` | RFC 2076 | Weak |
| `List-Unsubscribe` present **and** no prior conversation with this sender | RFC 8058 | Weak |
| `Auto-Submitted:` other than `no` | RFC 3834 | Weak β€” see below |

Two rules make this safe:

- **Only the receiving hop's `Authentication-Results` is trusted.** The header is
sender-forgeable; every hop before ours is attacker-controlled. A message with no
parseable `Authentication-Results` from our own hop contributes nothing, never a
failure.
- **One strong signal, or two weak ones, classifies as spam.** A single weak signal never
does. Legitimate mail is routinely `Precedence: bulk` (every receipt, every
notification), and a customer who genuinely writes from a mailing list address is not
junk.

`Auto-Submitted` deserves a carve-out rather than a weight: an auto-reply is not spam,
it is a bounce or an out-of-office. Suppressing those is
inbound-ingestion.md Β§5's loop-guard problem, not this spec's, and the two must not be
conflated. Listed here only to record that it was considered and routed elsewhere.

## 4. The decision (BUILT)

### 4.1 New conversations

At ingest, a brand-new conversation is created with `status = 'spam'` when the verdict is
`'spam'`, and `status = 'active'` otherwise (`'clean'`, `'unknown'`, absent). This is the
whole effect. `NewConversation.status` (`src/store/conversations.ts`) is narrowed to
exactly those two values: a conversation is never born `closed`, `pending`, or `deleted`.

Both creation sites apply it β€” a genuine `new` threading decision, and the
deleted/not-found fallback where a valid token named a conversation that is gone. The
fallback case is flagged as an open decision (Β§7, D2).

`conversation.created` and `conversation.message_received` fire for a spam conversation
exactly as for any other. A consumer that wants to ignore junk reads the status; events
are not silently withheld, because a webhook consumer that never learns a message
arrived cannot audit what we filed.

### 4.2 Replies to existing conversations

**The verdict has no effect on an existing conversation, ever.** A message that threads
onto one by valid reply token contributes its thread and nothing else; the verdict is not
read. Two reasons, either sufficient: our token is the stronger signal (we minted it, and
it proves we wrote to this address first), and the target's current status may be an
Agent's own deliberate placement.

That is a statement about **this spec's** input only, and it must not be read as "the
status cannot change." It can, by a rule that predates this spec and is untouched by it:
agent-inbox-v1.md Β§4a's reopen, which moves a `closed` or `spam` conversation to `active`
on any genuinely-new inbound thread (`appendThreadInTx`, `src/store/conversations.ts`).

Spelled out, because the composition is the part that is easy to get wrong:

| Target's status | Verdict on the arriving reply | Resulting status | Decided by |
|---|---|---|---|
| `active` | `'spam'` | `active` β€” unchanged | This spec: verdict not read on append |
| `active` | `'clean'` / `'unknown'` | `active` β€” unchanged | This spec: verdict not read on append |
| `spam` | `'spam'` | **`active` β€” reopened** | Β§4a's reopen rule, not this spec |
| `spam` | `'clean'` / `'unknown'` | **`active` β€” reopened** | Β§4a's reopen rule, not this spec |

The third row is the counter-intuitive one and it is deliberate: a message the provider
called junk, replying to a conversation we ourselves filed as junk, still reopens it. The
reply token proves we wrote to that address first, and a customer who answers is a
customer whatever a classifier thought of either message. Both `spam` rows are covered by
tests in `src/mail/ingest.test.ts`.

The practical consequence for an operator: a false positive self-heals the moment the
sender replies, and never needs the Spam folder to be checked for it to do so.

## 5. Reclassification (NOT BUILT)

### 5.1 The Agent's correction is the ground truth

An Agent moving a conversation out of `spam` is the only correction signal that matters.
It is already recorded by `setStatus`.

### 5.2 Sender allow-listing

A sender an Agent has rescued from spam should not be re-classified on their next
message. The intended mechanism is per-mailbox sender state, not a global list.

### 5.3 Feeding corrections back to the provider β€” DECIDED: no

Gmail's API can remove the `SPAM` label, which would train the operator's own filter.
**Helpthread does not do this** (maintainer, 2026-08-02: "no writeback"). Corrections stay
inside Helpthread's own database.

This is a boundary, not a deferral: Helpthread reads the operator's mailbox and does not
write classification state back into it. A correction made here changes what Helpthread
believes, never what the operator's mail provider believes. An operator who wants Gmail
retrained does it in Gmail, where they can see it.

Consequence to be honest about: the same sender can keep arriving with a `'spam'` verdict
after being rescued, because Google's opinion is unchanged. Β§5.2's per-mailbox sender
state is what absorbs that, and it is why allow-listing is not optional once Β§5 is built.

## 6. Operator controls (NOT BUILT)

**On by default, with an off switch** (maintainer, 2026-08-02: "on by default with an off
switch"). Automatic classification applies to a newly connected mailbox with no setup, and
an operator who does not want it can turn it off.

Three consequences follow, and none are built yet:

- **The switch is per-mailbox, not global.** Mailboxes are connected independently and an
operator may trust one provider's filtering and not another's. (INFERRED β€” the grain was
not specified in the decision above; per-mailbox is the author's reading, and it is a
two-way door.)
- **Off means the verdict is not read at all**, not that it is read and ignored. A message
arriving at a mailbox with classification off is filed `active` regardless of its label,
exactly as before this spec existed.
- **The Spam folder distinguishes "filed automatically" from "an Agent filed this."**
Without that, an operator cannot audit what the classifier did, and cannot tell a
mistake of theirs from a mistake of ours. The storage for this does not exist yet β€”
`status` alone does not record who set it.

## 7. Decision ledger

Per the repo's provenance discipline, every normative choice above is marked with its
source. Anything marked **INFERRED** is the author's judgment and has not been approved.

| # | Decision β€” in plain words | Source |
|---|---|---|
| β€” | Build the fix plus a spec for real auto-classification, rather than the fix alone | Maintainer, 2026-08-02: "b" (in reply to the (a) fix-only / (b) fix-plus-spec choice) |
| β€” | Spam is never auto-classified in v1 | Prior accepted spec, agent-inbox-v1.md Β§3a |
| D0 | Junk mail is stored and filed as `spam`, never dropped at intake | ⚠️ INFERRED β€” follows from inbound-ingestion.md Β§1's never-dropped invariant, but applying it to junk specifically is the author's reading |
| D1 | Automatic spam classification is **on by default**, and an operator can **switch it off** | Maintainer, 2026-08-02: "on by default with an off switch" |
| D1a | That switch is **per-mailbox** rather than global | ⚠️ INFERRED β€” the grain was not part of D1's answer. A two-way door |
| D2 | A spam-verdict message whose reply token names a *deleted* conversation is filed as spam | ⚠️ INFERRED β€” the valid token argues it is a real customer; the deletion argues an Agent already discarded that thread. Uniform rule chosen for simplicity, not because the edge was decided |
| D3 | An Agent's "not spam" correction is **never** written back to the operator's Gmail | Maintainer, 2026-08-02: "no writeback" |
| D4 | Header scoring requires one strong or two weak signals; a lone weak signal never classifies | ⚠️ INFERRED β€” a conservative default, not a measured threshold |
| D5 | `Auto-Submitted` is a loop-guard concern, not a spam signal | ⚠️ INFERRED |

**One-way door:** none, in what is built or specified. The only candidate was writing
classification state back into the operator's own mailbox, and D3 closed that door rather
than walking through it β€” Helpthread reads the operator's mail and does not write its own
opinions into it. Every remaining INFERRED row above (D0, D1a, D2, D4, D5) is a two-way
door: each changes a default or a threshold, and reversing any of them costs one edit and
no migration.
Loading