Skip to content

fix(mail): file Gmail TRASH as spam instead of live support work - #182

Open
zaridan wants to merge 3 commits into
feat/mail-spam-classificationfrom
feat/mail-trash-filing
Open

fix(mail): file Gmail TRASH as spam instead of live support work#182
zaridan wants to merge 3 commits into
feat/mail-spam-classificationfrom
feat/mail-trash-filing

Conversation

@zaridan

@zaridan zaridan commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🟡 NEEDS YOUR DECISION

One derived rule is INFERRED (D7a, below). No review bot has run yet — this opens 🟡 by protocol and moves to 🟢 only after a bot review lands on the current head and its findings are adjudicated.

Stacked on #180 (base is feat/mail-spam-classification, not main). Retargets to main once #180 merges. Review bots skip non-main base branches, so the required review pass will be an adversarial substitute run and disclosed as such.

Decision provenance

Decision — in plain words Source
Mail that the operator's own Gmail already deleted gets filed as junk, instead of opening a new support conversation You, 2026-08-02 — chose "file as spam" from three options (separate signal filed as closed / file as spam / leave as-is)
Junk and deleted mail share one folder, so the Spam folder now mixes "Google thinks this is junk" with "I threw this away" Follows from the choice above; the tradeoff was stated in the options as "the Spam folder mixes junk with merely-deleted mail"
The code that decides "Gmail didn't call this spam" stays as it is; the spec now explains why that's a safe reading rather than the code changing You, 2026-08-02 — chose "Keep code, tighten §3.1" over narrowing the code
A message that is both junk-flagged and deleted is filed as junk ⚠️ INFERRED — follows mechanically from mapping both labels to one value, but the combination was never put to you as its own question

No one-way doors. Nothing here writes to the operator's mailbox (D3 is "no writeback"), no schema change, no public promise altered. Filing is reversible in the UI and self-heals on reply.

What changed

spamVerdictOf mapped a TRASH-labeled Gmail message to 'clean', so ingest created it as an ordinary active conversation. Mail the operator's mailbox had already discarded — by a delete-on-arrival filter, or by hand before the reconcile window ran — became live support work.

TRASH reaches the handler for the same reason SPAM does: history.list is an unfiltered delta stream, and the history client set-unions labelsAdded without ever removing a label. So both ['TRASH'] and ['INBOX','TRASH'] arrive intact.

Three files:

  • src/mail/gmail-reconcile.tsSPAM or TRASH'spam'. One condition; the rest is the doc comment explaining that TRASH is an operator action rather than a classifier verdict, and why they are nonetheless collapsed.
  • specs/mail/spam-classification.md — §3.1's 'spam' definition widens to match what the value now asserts, with the two-meanings consequence stated for §3.2's unbuilt header scoring; §1 and the delta-window caveat pick up TRASH; §7 gains D6, D7, D7a. Separately, the 'clean' honesty gap (§3.1) is closed in prose: each transport must now earn 'clean', and Gmail's licence for reading bare absence-of-SPAM that way is stated as the Gmail-specific fact it is.
  • src/mail/gmail-reconcile.test.ts — four cases: ['TRASH'], ['INBOX','TRASH'] (proving INBOX doesn't out-vote it — the case that regressed), ['SPAM','TRASH'] (D7a), and ['SENT','TRASH'] still skipped by the self-echo filter with no raw fetch.

Invariants held

  • Nothing is dropped (inbound-ingestion.md §1, invariant HT-7: mail-behavior acceptance fixtures #3). A TRASH message is parsed, stored, threaded and attachment-linked exactly as before — one column differs. It is readable in the Spam folder and a reply reopens it to active (agent-inbox-v1.md §4a), so a message trashed in error self-heals.
  • No existing conversation is re-filed. The verdict is read only at the two createConversationInTx sites; a reply threading onto an existing conversation never reads it (spam-classification.md §4.2).
  • Not a regression from feat(mail): carry the provider's spam verdict through intake #180. Before that work, every ingested message became active regardless of labels.
  • Scope is limited to Gmail. IMAP opens INBOX only, so a server-side delete means the message is never fetched — the field stays 'unknown', which is the honest verdict.

Known limit, unchanged

A TRASH label applied after the reconcile window's history.list snapshot is not covered — that message is already ingested as active, and the next reconcile deliberately ignores labelsAdded for an id it did not itself newly add. Deleting a message you have already seen arrive in Helpthread does not retroactively file it. That is §5's reclassification problem, recorded in the spec rather than silently left as a surprise.

Gates

Run locally on this head, exit codes captured directly:

  • npm run typecheck — exit 0
  • biome check . — exit 0, 318 files checked
  • npm test — exit 0, 87 files / 1762 tests passed

Review

Pending — no bot has run. Verdict stays 🟡 until a review lands on the current head and every finding gets an individual verdict.

zaridan added 2 commits August 2, 2026 14:42
…rits it

The verdict table defines 'clean' as an affirmative judgment, but the Gmail
implementation derives it from a bare absence of the SPAM label. Those are
different claims, and the gap is invisible today only because §4.1 files
'clean' and 'unknown' identically.

They collapse for Gmail because Gmail classifies every message it accepts,
so absence of the junk marker IS the verdict. Record that as the specific
licence it is, bind future transports to earning 'clean' the same way, and
note that §3.2's header scoring is what makes the distinction load-bearing.

No behaviour change.

Decision D6 added to the §7 ledger.
A Gmail message carrying TRASH fell through spamVerdictOf to 'clean' and
was created as an ordinary `active` conversation. Mail the operator's own
mailbox had already thrown away — by a delete-on-arrival filter, or by hand
before the reconcile window ran — became visible support work in the inbox.

TRASH reaches the handler for the same reason SPAM does: history.list is an
unfiltered delta stream and the client set-unions labelsAdded without ever
REMOVING a label, so both ['TRASH'] and ['INBOX','TRASH'] arrive intact.

spamVerdictOf now maps either label to 'spam'. This widens what that value
asserts — from 'a classifier called this junk' to 'the source mailbox has
already discarded this as unwanted' — so §3.1's definition widens with it
rather than letting the field lie. Nothing downstream branches on which of
the two produced the verdict; the only thing either may change is the
status a brand-new conversation is born at.

Nothing is dropped (inbound-ingestion.md §1 invariant #3): the message is
parsed, stored, threaded and attachment-linked identically, sits readable
in the Spam folder, and a reply reopens it to `active` (§4a) — so a
message trashed in error self-heals.

Not a regression from the spam-verdict work; before it, every ingested
message became `active` regardless of labels.

Decisions D7 and D7a added to the §7 ledger.
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
helpthread Ready Ready Preview Aug 2, 2026 10:01pm
helpthread-inbox Ready Ready Preview Aug 2, 2026 10:01pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fefc16d0-a13c-4aeb-a1a5-db08b5082d0f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Adversarial review of the TRASH mapping surfaced a real one: the history
client requests messageAdded and labelAdded, never labelRemoved, so
labelIds is the set of labels a message has EVER carried in the window
rather than its state now. Delete a just-arrived message, hit undo, and
TRASH stays in the union — the conversation is filed spam while sitting in
the operator's Inbox.

The overapproximation is not new (mark-as-junk then not-junk has always had
it), but TRASH extends it to a one-click action Gmail offers to undo, so
the exposure is materially larger and should not go unrecorded.

Accepted rather than fixed: reading labelRemoved would re-open the
SENT/INBOX split-delta race the union was introduced to close, and the
misfile is bounded and self-correcting — fully stored, readable in the Spam
folder, and reopened to active by any reply.

Decision D8 added to the §7 ledger, marked INFERRED.
@zaridan

zaridan commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Adversarial review pass — adjudicated

Substitution disclosed: CodeRabbit was not used. This PR's base is feat/mail-spam-classification, not main, and review bots skip non-main base branches. Per the PR verdict protocol an adversarial pass by a different vendor (OpenRouter openai/gpt-5.2) stands in. It was given the sacred invariants explicitly and six named failure modes to hunt, not a generic "review this diff."

6 findings — 1 real and fixed, 1 rejected, 4 confirmed non-defects.

# Finding Verdict
1 TRASH removed inside the window still files as spam — delete-then-undo misfiles a live message REAL, fixed (docs) — see below
2 Two new tests assert only the verdict, not ack/cursor advance Rejected — the handler never reads the verdict; terminality comes from the stubbed ingest outcome, so the described regression cannot occur. The assertion would be theater, and the terse shape matches the existing INBOX/empty-label tests directly above it. The sibling ['TRASH'] test does assert both
3 Steady-state TRASH misfiling legitimate mail Confirmed non-defect
4 Self-echo protection intact: ['SENT','TRASH'] and ['DRAFT','TRASH'] skipped; ['SENT','INBOX','TRASH'] is the pre-existing intentional ambiguity, unchanged Confirmed non-defect — and asserted by a new test
5 Reply-token threading unaffected Confirmed non-defect
6 includes('SPAM') || includes('TRASH') correctness Confirmed non-defect

Finding 1 — verified independently, then accepted with a record

I checked the mechanism rather than taking the reviewer's word: src/providers/adapters/gmail/history.ts requests messageAdded and labelAdded and never labelRemoved. So labelIds is the set of labels a message has ever carried in the window, not its current state. Delete a just-arrived message, hit Gmail's Undo, and TRASH stays unioned in — the conversation is filed spam while sitting in the operator's Inbox.

The overapproximation is not new (mark-as-junk-then-not-junk has always had it), but TRASH extends it to a one-click action Gmail actively offers to undo, so the exposure is materially larger.

Accepted, not fixed in code (9cfb957): reading labelRemoved would re-open the SENT/INBOX split-delta race the union was introduced to close. The misfile is bounded and self-correcting — fully stored, readable in the Spam folder, reopened to active by any reply. Recorded in §3.1 beside the existing delta-window limit, and as D8 (INFERRED) in the §7 ledger.

This adds a second INFERRED row, so the verdict stays 🟡. D8 was never put to the maintainer as its own question.

Gates re-run on 9cfb957: typecheck exit 0, biome check . exit 0, npm test exit 0 — 87 files / 1762 tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant