Skip to content

feat(mail): carry the provider's spam verdict through intake - #180

Merged
zaridan merged 4 commits into
mainfrom
feat/mail-spam-classification
Aug 2, 2026
Merged

feat(mail): carry the provider's spam verdict through intake#180
zaridan merged 4 commits into
mainfrom
feat/mail-spam-classification

Conversation

@zaridan

@zaridan zaridan commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🟢 SAFE TO MERGE

Gates green. Both blocking decisions answered by the maintainer and quoted below. No one-way doors. CodeRabbit: 3 findings, 3 real, 3 fixed — then rate-limited, so an adversarial GPT-5.2 pass substituted for the later heads: 3 findings, 1 partly real (fixed), 1 out of scope (tracked), 1 wrong.

Decision provenance

Decision — in plain words Source
Build the intake fix and a spec for real auto-classification, not the fix alone Maintainer decision, 2026-08-02 fix-only and (b) fix-plus-spec)
Ship the fix by pushing this branch and opening a PR Maintainer decision, 2026-08-02
Spam is never classified automatically in v1 (the rule this PR amends) Prior accepted spec, specs/api/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 classification is on by default, and an operator can switch it off Maintainer decision, 2026-08-02
D1a — That switch is per-mailbox rather than global ⚠️ INFERRED — the grain was not part of D1's answer. 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 someone already discarded that thread. A uniform rule was 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 decision, 2026-08-02
D4 — Header scoring requires one strong or two weak signals; a lone weak signal never classifies ⚠️ INFERRED — a conservative default, not a measured threshold
D5Auto-Submitted is a loop-guard concern, not a spam signal ⚠️ INFERRED

D0, D1a, D2, D4 and D5 are conservative defaults, each cheap to overturn.

One-way door: none. 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 is a two-way door: each changes a default or a threshold, and reversing any costs one edit and no migration.

D3's honest consequence, recorded in §5.3: a rescued sender can keep arriving with a 'spam' verdict, because Google's opinion never changes. Per-mailbox sender allow-listing (§5.2) is what absorbs that, which makes it load-bearing rather than optional once §5 is built.

The defect this closes

Gmail classifies every message before it reaches us, and the verdict was being discarded.

history.list is deliberately not label-filtered (src/providers/adapters/gmail/history.ts) — filtering there races label application and would drop real mail. But the only label check downstream was the self-echo filter (DRAFT, or SENT without INBOX). A message Google had already put in SPAM carries none of those, so it became an ordinary active conversation in the inbox.

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

IMAP was unaffected by construction — the client opens INBOX, so a server-side Junk move means the message is never fetched. That asymmetry between the two intake paths was accidental; it is now documented.

What changes

RawInboundMessage.providerSpamVerdict ('spam' | 'clean' | 'unknown') carries the transport's own conclusion across the provider boundary, never re-derived. spamVerdictOf maps Gmail's labels onto it. Ingest files a brand-new conversation as spam rather than active when it says so.

Three properties are load-bearing, each with tests:

  1. Nothing is ever dropped. A 'spam' verdict changes one column. The message is parsed, stored, threaded and attachment-linked identically either way (inbound-ingestion.md §1 invariant HT-7: mail-behavior acceptance fixtures #3).
  2. An existing conversation is never re-filed. A message that threads onto its target by valid reply token leaves that target's status untouched, however the provider classified it.
  3. Only 'spam' is evidence. 'clean', 'unknown' and an absent field all mean active.

Ordering: the verdict is read only 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. Asserted, including that no raw fetch is wasted on it.

NewConversation.status is narrowed to 'active' | 'spam' — a conversation is never born closed, pending, or deleted.

The reopen interaction, spelled out

Review surfaced a real composition nobody had written down. §4.2 now carries it as a table; the counter-intuitive row is the third:

Target's status Verdict on the arriving reply Resulting status Decided by
active 'spam' active — unchanged This spec: verdict not read on append
spam 'spam' active — reopened §4a's pre-existing reopen rule, not this spec

A message the provider called junk, replying to a conversation we filed as junk, still reopens it — the reply token proves we wrote to that address first. Both spam rows are covered by tests. The practical consequence: a false positive self-heals the moment the sender replies, with nobody opening the Spam folder.

What this does NOT do

Stated because the feature must not be oversold. history.list is a delta stream. A SPAM label applied after a reconcile window's snapshot never reaches us — that message is already ingested as active, and the next reconcile deliberately ignores label deltas for ids it did not itself newly add. So it stays active.

Not a regression (before this, every spam message stayed active), but this is not "Gmail's spam filtering now applies." It applies to mail Gmail had classified by the time we read the history window — the common case, not all cases. Closing the remainder is §5's reclassification problem, which is specified and not built.

Mail semantics

This touches the charter's conversation-integrity invariant, so, explicitly: no message's storage, parsing, or threading outcome changes. The threading decision is decideThreading's alone and is not read by any code in this diff. The only behavioural delta is the status column on rows this pipeline creates, plus one new optional field on the provider seam. Existing ingest and reconcile suites pass unchanged.

Review — CodeRabbit substituted, disclosed

CodeRabbit did not review this PR. Its check reads pass, but on the first head it reported Review rate limited (citing the Fair Usage Limits Policy) and on subsequent heads Review skipped: incremental reviews are disabled. Green is not a review.

It did produce one real review on the original head (2420ea9) before hitting its limit — 3 findings, all 3 real, all 3 fixed in f8cf328: a test that asserted too little to prove its own ordering claim, an Actor/Agent vocabulary slip, and the status-contract ambiguity that turned out to be hiding the reopen interaction documented above.

For the later heads, per the repo's protocol, an adversarial GPT-5.2 pass (via OpenRouter) stood in — prompted with this repo's sacred invariants and directed at seven specific hunting grounds including Gmail label semantics, delta-stream ordering, and vacuous tests. 3 findings — 1 partly real (fixed), 1 real but out of scope (tracked), 1 wrong (name clarified). Full adjudication is in a PR comment; its most valuable finding is what produced the "What this does NOT do" section above.

The two commits after that pass (49598e0, 29b7d82) are prose plus one test-name string — zero executable logic — so every line of code that runs on this branch has been through an adversarial pass. Verifiable: git diff f8cf328..HEAD -- src/ is a single renamed test title.

Tests

  • src/mail/gmail-reconcile.test.ts — 4 new: SPAM carries 'spam'; INBOX carries 'clean'; an empty label set carries 'unknown'; a SPAM+SENT self-echo is still skipped with no raw fetch.
  • src/mail/ingest.test.ts — 6 new, against a real migrated database: each verdict's resulting status, an omitted field, a spam-verdict reply that must not re-file its target, and a spam-verdict reply onto an already-spam conversation that must still reopen it.

Unrelated findings, tracked not folded in

  • src/store/gmail-watch-state.test.ts is flaky on clean main — 3 of 4 runs failed in an untouched checkout, in the lease block. A gate that fails half the time will eventually mask a real regression.
  • Gmail TRASH-labeled mail files as active. Real, pre-existing, and a normative decision in its own right.

🤖 Generated with Claude Code

Gmail classifies every message before it reaches us, and we were throwing
that verdict away. `history.list` is deliberately not label-filtered
(filtering there races label application and would drop real mail), and
the only label check downstream was the self-echo filter — so a message
Google had already put in SPAM became an ordinary `active` conversation
in the inbox. An operator who connects a Gmail mailbox reasonably expects
Gmail's spam filtering to still apply. It did not.

`RawInboundMessage.providerSpamVerdict` ('spam' | 'clean' | 'unknown')
carries the transport's own conclusion across the provider boundary;
`spamVerdictOf` derives it from Gmail's labels; ingest files a brand-new
conversation as `spam` instead of `active` when it says so.

Three properties are load-bearing and tested:

- Nothing is dropped. A spam verdict changes one column. The message is
  parsed, stored, threaded and attachment-linked identically either way
  (inbound-ingestion.md §1 invariant #3), so a false positive is visible
  in the Spam folder and a reply reopens it.
- An existing conversation is never re-filed. A reply that matched a
  valid reply token threads onto its target and leaves that target's
  status alone, however the provider classified it. Our token is the
  stronger signal, and an Actor's own placement is not overruled.
- Only 'spam' is evidence. 'clean', 'unknown' and an absent field all
  mean active — the two are kept distinct on the wire because header
  scoring will need to tell "it said no" from "we don't know".

Ordering note: the verdict is read only after the self-echo filter, so
our own outbound reply that Gmail filed as junk is skipped entirely
rather than filed as a spam conversation.

IMAP is unaffected by construction — it opens INBOX, so a server-side
Junk move means we never fetch the message. The asymmetry is now
documented rather than accidental.

specs/mail/spam-classification.md is the new spec. §3.1 and §4 are built
here; header-derived signals, reclassification, and operator controls are
specified but not built, and carry open decisions in its §7 ledger.

Gates: tsc 0, biome 190 files clean, vitest 1757 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@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 9:34pm
helpthread-inbox Ready Ready Preview Aug 2, 2026 9:34pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

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: 3d6230d2-fba6-4911-8c19-eedca6c1fa03

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
📝 Walkthrough

Walkthrough

Gmail labels now produce provider spam verdicts. Inbound ingestion uses these verdicts to set the status of new conversations while preserving existing conversation status and storing all messages.

Changes

Provider Spam Verdict Ingestion

Layer / File(s) Summary
Provider contract and conversation status
src/providers/inbound-email.ts, src/providers/index.ts, src/store/conversations.ts
RawInboundMessage carries an optional ProviderSpamVerdict. New conversations accept active or spam status.
Gmail verdict mapping
src/mail/gmail-reconcile.ts, src/mail/gmail-reconcile.test.ts
Gmail maps empty labels to unknown, SPAM to spam, and other labels to clean. Self-echoes remain excluded before raw-message fetching.
Ingestion status assignment
src/mail/ingest.ts, src/mail/ingest.test.ts
New conversations use spam only for a spam verdict. Replies preserve existing status, and messages remain stored.
Classification specifications
specs/api/agent-inbox-v1.md, specs/mail/inbound-ingestion.md, specs/mail/spam-classification.md
The specifications define verdict propagation, status semantics, existing invariants, planned classification work, and unresolved decisions.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Gmail
  participant GmailReconcile
  participant InboundIngest
  participant ConversationStore
  Gmail->>GmailReconcile: Message labels
  GmailReconcile->>InboundIngest: RawInboundMessage with providerSpamVerdict
  InboundIngest->>ConversationStore: Create new conversation with active or spam status
  InboundIngest->>ConversationStore: Append message without changing existing status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes carrying the provider's spam verdict through mail intake, which is the pull request's main change.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mail-spam-classification

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/mail/gmail-reconcile.test.ts (1)

737-759: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen the self-echo-and-spam test to assert getRawMessage was never called.

This test spies on getRawMessage but only checks that ingest was not called. Add an assertion that getRawMessage was not called. This directly verifies the ordering claim in the module doc: the self-echo filter runs before messages.get.

✅ Proposed test strengthening
       expect(result).toEqual({ kind: 'ack' })
       expect(ingest).not.toHaveBeenCalled()
+      expect(getRawMessage).not.toHaveBeenCalled()
       expect(setCalls).toEqual([{ mailboxId: MAILBOX_ID, historyId: 'cursor-2' }])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/gmail-reconcile.test.ts` around lines 737 - 759, Strengthen the test
for the self-echo and SPAM message by asserting that the existing getRawMessage
spy was never called, alongside the ingest assertion. Keep the test’s current
result and watch-state expectations unchanged.
src/mail/ingest.ts (1)

439-463: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider logging the spam verdict on stored outcomes.

raw.providerSpamVerdict ?? 'unknown' correctly defaults an absent verdict. The logIngestEvent call right after does not include the verdict or the resulting conversation status. Add it to make spam-verdict distribution and misclassifications observable without a database query.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/ingest.ts` around lines 439 - 463, The stored-outcome log emitted
after storeAndMarkDelivered should include the normalized spam verdict and
resulting conversation status. Reuse raw.providerSpamVerdict ?? 'unknown' and
the status returned by storeAndMarkDelivered when constructing the
logIngestEvent payload, preserving existing fields and fallback behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@specs/mail/spam-classification.md`:
- Around line 16-17: Update the human support staff references in the
spam-classification specification, including the cited sections around the
status-setting and sender-rescue flows, from Actor to Agent. Reserve Assistant
for AI actors and ensure the terminology is consistent throughout the affected
prose.
- Around line 137-147: Clarify the status contract in
specs/mail/spam-classification.md lines 137-147 and specs/api/agent-inbox-v1.md
lines 114-119: provider verdicts must not alter an existing conversation’s
status, except that the normal reopen rule may change spam to active for valid
replies; newly created conversations must be active unless the provider verdict
is spam. Ensure both sections use consistent wording.

---

Nitpick comments:
In `@src/mail/gmail-reconcile.test.ts`:
- Around line 737-759: Strengthen the test for the self-echo and SPAM message by
asserting that the existing getRawMessage spy was never called, alongside the
ingest assertion. Keep the test’s current result and watch-state expectations
unchanged.

In `@src/mail/ingest.ts`:
- Around line 439-463: The stored-outcome log emitted after
storeAndMarkDelivered should include the normalized spam verdict and resulting
conversation status. Reuse raw.providerSpamVerdict ?? 'unknown' and the status
returned by storeAndMarkDelivered when constructing the logIngestEvent payload,
preserving existing fields and fallback behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 74ee2011-eae1-4af2-abc1-05ab75958cd7

📥 Commits

Reviewing files that changed from the base of the PR and between 78f1069 and 2420ea9.

📒 Files selected for processing (10)
  • specs/api/agent-inbox-v1.md
  • specs/mail/inbound-ingestion.md
  • specs/mail/spam-classification.md
  • src/mail/gmail-reconcile.test.ts
  • src/mail/gmail-reconcile.ts
  • src/mail/ingest.test.ts
  • src/mail/ingest.ts
  • src/providers/inbound-email.ts
  • src/providers/index.ts
  • src/store/conversations.ts

Comment thread specs/mail/spam-classification.md Outdated
Comment thread specs/mail/spam-classification.md Outdated
Three findings, all real.

1. The self-echo-plus-SPAM regression test asserted that ingest was never
   called but not that getRawMessage was never called — so it did not
   actually verify the ordering claim it exists to protect (the self-echo
   filter runs before messages.get). Asserted now.

2. Vocabulary: the new spec and ingest's doc comments said "Actor" where
   the repo's checked-in vocabulary says "Agent" for human support staff.
   Aligned with the repo as it stands. (A rename of this vocabulary has
   been discussed but has not landed in the repo; this change follows what
   is actually written here rather than pre-empting it.)

3. The status contract read as self-contradictory across the two specs,
   and the ambiguity hid a real interaction nobody had written down: a
   reply that threads onto a conversation ALREADY filed as spam reopens it
   to active, even when the provider also called that reply junk. That is
   agent-inbox-v1.md §4a's pre-existing reopen rule composing with this
   spec's intake classification, and it is correct — the reply token
   proves we wrote to that address first — but it was nowhere stated.

   Verified against the code before documenting it: appendThreadInTx's
   reopen policy is `closed OR spam → active` on any genuinely-new inbound
   thread. §4.2 now carries the full four-row interaction table, both spam
   rows covered by new tests, and agent-inbox-v1.md §3a is tightened so
   "nothing re-files an existing conversation" can no longer be misread as
   denying the reopen rule.

The practical consequence, now stated: a false positive self-heals the
moment the sender replies, without anyone opening the Spam folder.

Gates: tsc 0, biome 190 clean, src/mail 399 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zaridan

zaridan commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


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 39 minutes.

From an adversarial GPT-5.2 pass standing in for CodeRabbit, which hit its
fair-usage limit and produced a green check with no review.

Its headline finding — that the verdict is computed from messageAdded
deltas only, so a later SPAM label is never seen — is wrong as stated: the
history client also requests `labelAdded` and set-unions the deltas
(src/providers/adapters/gmail/history.ts), so a message that arrives as
INBOX and is classified SPAM moments later, within the same reconcile
window, IS seen as spam.

But the weaker version of it is real and was not written down anywhere: a
SPAM label applied AFTER the window's history.list snapshot never reaches
us. That message is already ingested as active, and the next reconcile
deliberately ignores a labelsAdded record for an id it did not itself
newly add — otherwise re-labeling any old message would manufacture an
ingest. So it stays active.

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 — the common case, not all cases. §3.1 now says so, and
points at §5 as where the remainder would be closed.

Also renamed a test whose name overclaimed: it passes `[]`, and `[]` is
exactly what the adapter normalizes an omitted labelIds to, so there is no
separate undefined shape at this seam. The name now says that instead of
implying an untested case.

TRASH-labeled mail filing as active is real but pre-existing and out of
scope here; tracked separately rather than folded in.

Gates: tsc 0, biome 190 clean, src/mail 399 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zaridan

zaridan commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Review substitution: adversarial Codex-class pass in place of CodeRabbit

CodeRabbit's check reads pass on this PR, but it produced no review on the current head — it reported Review rate limited and its own comment cites the Fair Usage Limits Policy. Per the repo's review protocol, an adversarial different-vendor pass substitutes for it, and the substitution is disclosed rather than left to look like a CodeRabbit approval.

Adversarial pass: GPT-5.2 (via OpenRouter), prompted with this repo's sacred invariants (never drop mail; threading decided only by our own signed token; parse exactly once; an Agent's placement is never silently overruled) and directed at seven specific hunting grounds including Gmail label semantics, delta-stream ordering, and vacuous tests.

3 findings — 1 partly real (fixed), 1 real but out of scope (tracked), 1 wrong (name clarified).

# Severity claimed Verdict Action
1 CRITICAL — verdict computed from messagesAdded deltas only, so a later SPAM label is never seen Partly real. Wrong as stated: the history client also requests labelAdded and set-unions the deltas (src/providers/adapters/gmail/history.ts), so a SPAM label applied within the same reconcile window is seen. The reviewer did not read that file — it is outside the diff. But the weaker form is real: a SPAM label applied after the window's history.list snapshot never reaches us, and the message stays active. Documented in §3.1. No code change — closing it would require ingesting on labelsAdded for untracked ids, which the history client deliberately refuses. (49598e0)
2 HIGH — TRASH-only messages map to 'clean' and become active conversations Real, but pre-existing and not a regression. Before this PR every ingested message became active regardless of labels; this PR changes nothing about TRASH. Folding a fix in would be scope creep on a mail-semantics path. Tracked separately for a maintainer decision, not fixed here
3 MEDIUM — the "no labelIds" test is vacuous because it passes [] rather than an absent field Wrong at this seam. The adapter normalizes an omitted labelIds to [] before this handler sees it, so [] is the omission case and ListedMessage.labelIds is always present. The test name overclaimed, though. Renamed to say what it actually covers (49598e0)

Finding 1 is the valuable one, and it changed what this PR claims: the feature is now described as best-effort with a stated boundary, not as "Gmail's spam filtering now applies."

Gates on 49598e0: tsc 0, biome 190 files clean, src/mail 399 pass.

Both open decisions in the spam-classification spec are now made, quoted
rather than paraphrased.

D1 — automatic classification is on by default with an off switch
("on by default with an off switch"). §6 records the three consequences
that follow: off means the verdict is not read at all rather than read and
ignored; the Spam folder must distinguish "filed automatically" from "an
Agent filed this", which nothing currently stores; and the switch's grain
is per-mailbox. That last one is still INFERRED and marked so — it was not
part of the answer, it is the author's reading, and it is a two-way door.

D3 — no writeback ("no writeback"). This is a boundary rather than a
deferral, so §5.3 states it as one: Helpthread reads the operator's
mailbox and does not write classification state back into it. A correction
changes what Helpthread believes, never what Google believes.

The honest consequence is recorded with it: a rescued sender can keep
arriving with a 'spam' verdict, because Google's opinion never changes.
Per-mailbox sender allow-listing (§5.2) is what absorbs that, which makes
it load-bearing rather than optional once §5 is built.

With D3 answered "no", the spec no longer contains a one-way door in
either its built or its specified surface — the only candidate was writing
into the operator's own mailbox, and that door is now closed rather than
walked through. The remaining INFERRED rows are all two-way.

No code change; gates re-run anyway: tsc 0, biome 190 clean, src/mail 399
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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