Skip to content

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

Merged
zaridan merged 5 commits into
mainfrom
feat/mail-trash-filing
Aug 8, 2026
Merged

fix(mail): file Gmail TRASH as spam instead of live support work#182
zaridan merged 5 commits into
mainfrom
feat/mail-trash-filing

Conversation

@zaridan

@zaridan zaridan commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🟢 SAFE TO MERGE

Gates green on this head (typecheck 0, lint 0, 2018 tests / 106 files, exit 0), re-run after the rebase. No unanswered decisions — D7a and D8 were both put to the maintainer and answered on 2026-08-07. Codex (adversarial, in place of CodeRabbit — rate limited, no review ever produced on this PR): 2 findings, 0 real, both declined with reason.

Rebased onto the current main, which now includes #193's comment sweep over the same three mail files. No conflicts; both sides verified present afterwards.

What changed

spamVerdictOf mapped a TRASH-labeled Gmail message to 'clean', so ingest created it as an ordinary active conversation. Mail the operator's own 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: one condition in src/mail/gmail-reconcile.ts (SPAM or TRASH'spam') plus the doc comment explaining why an operator action and a classifier verdict are nonetheless collapsed; specs/mail/spam-classification.md (§3.1's widened definition, the 'clean' honesty gap closed in prose, §7 gains D6/D7/D7a/D8); and four cases in src/mail/gmail-reconcile.test.ts.

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 reading is safe 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 (D7a) You, 2026-08-07: "ok"
A message you delete and then restore within the same minute stays filed as junk (D8) You, 2026-08-07, asked to accept it or fix it: "Accept — file it as junk"

No one-way doors. Nothing 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. D8 is a two-way door: reversing it costs one edit and no migration.

Invariants held

  • Nothing is dropped (inbound-ingestion.md §1). A TRASH message is parsed, stored, threaded and attachment-linked exactly as before — one column differs.
  • 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 (§4.2).
  • Self-echo still wins. A SENT+TRASH message is skipped before the verdict is computed — our own deleted reply never becomes a spam conversation.
  • Gmail-only. IMAP opens INBOX only, so a server-side delete means the message is never fetched; the field stays 'unknown', which is the honest verdict.
  • 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.

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 left as a surprise.

@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 7, 2026 11:41pm
helpthread-inbox Ready Ready Preview Aug 7, 2026 11:41pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@zaridan, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 07c9a9d5-581c-469f-94f6-413f0b2fecf2

📥 Commits

Reviewing files that changed from the base of the PR and between bf6fe12 and ebab026.

📒 Files selected for processing (3)
  • specs/mail/spam-classification.md
  • src/mail/gmail-reconcile.test.ts
  • src/mail/gmail-reconcile.ts

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.

@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.

zaridan added 4 commits August 7, 2026 16:23
…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.
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.
D7a — a message carrying both SPAM and TRASH is filed as spam — was put as
its own question and answered (maintainer decision, 2026-08-07), rather than
being left to follow mechanically from D7.

§7's closing sentence enumerated the remaining INFERRED rows but omitted D8,
which this same section introduces.
@zaridan
zaridan force-pushed the feat/mail-trash-filing branch from 9cfb957 to 3b2c802 Compare August 7, 2026 23:36
@zaridan

zaridan commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 15 minutes.

@zaridan

zaridan commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Adjudication — 2 findings, both declined with reason

CodeRabbit is rate limited ("Your included review limit is currently reached") and has never produced a review on this PR — the check reads pass because it skipped, not because it reviewed. This round is therefore an adversarial Codex pass in its place, briefed on the invariants at risk: the never-dropped rule (inbound-ingestion.md §1), no-writeback (D3), create-only filing (§4.2), self-echo precedence, Gmail-only scope, spec-vs-code agreement, and rebase integrity across the three files #193 also touched.

Findings

# Finding Verdict
1 The ['SPAM','TRASH'] test passes against the old implementation too, so it does not protect the new TRASH branch. Correct, but declined. Factually right — the old code returned 'spam' for anything containing SPAM. But that case exists to pin D7a (both labels collapse to one value), not to guard the TRASH branch. It earns its place forward, not backward: if someone later splits TRASH into its own verdict, this test fails. The TRASH branch itself is covered by the two cases that do fail against the old code.
2 The ['SENT','TRASH'] test never reaches spamVerdictOf, so it does not exercise the change. Correct, and intended — declined. isSelfEchoMessage runs first by design; the case exists to prove that ordering cannot invert and that a deleted self-echo is never filed as a spam conversation. The PR body already describes it as exactly that, not as a TRASH regression test.

Both findings come from a criterion in my own brief ("every added case must fail against the old implementation") that is right for regression tests and wrong for decision-pinning tests. Neither is a defect; no code changed in response.

Invariants — all clean

  • Nothing droppedTRASH alters only providerSpamVerdict; the message is parsed, stored, threaded and attachment-linked identically. Only the new-conversation status differs.
  • No writeback (D3) — no modify/trash/untrash/batchModify anywhere on the reachable path.
  • Create-only — the verdict is read at the two createConversationInTx sites; the append branch never reads it.
  • Self-echo precedence — filtering happens before spamVerdictOf is called.
  • Gmail-only — the diff touches no IMAP file; IMAP still defaults to 'unknown'.
  • Spec matches code — §3.1's widened definition and D6/D7/D7a/D8 all check out.
  • Rebase integrity — the diff against the new main contains only this feature's three files. docs: cut the remaining module doc-comments for concision #193's comment sweep survived intact; no duplicated or orphaned block, no accidental revert.

Full gate re-run locally after the rebase, exit codes read directly: typecheck 0, lint 0, test 0 — 106 files / 2018 tests passed.

A message deleted and then restored inside one reconcile window stays filed
as spam; the label union is not corrected by reading labelRemoved (maintainer
decision, 2026-08-07). Reading labelRemoved would re-open the split-delta race
the union was introduced to close.

§7's remaining-INFERRED list returns to D0, D1a, D2, D4, D5.
@zaridan
zaridan merged commit a10ee48 into main Aug 8, 2026
10 checks passed
@zaridan
zaridan deleted the feat/mail-trash-filing branch August 8, 2026 00:01
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