Skip to content

fix(mail): skip self-echoed Gmail reconcile sends and drafts (HT-50) - #53

Merged
zaridan merged 3 commits into
mainfrom
fix/ht-50-reconcile-self-echo-skip
Jul 17, 2026
Merged

fix(mail): skip self-echoed Gmail reconcile sends and drafts (HT-50)#53
zaridan merged 3 commits into
mainfrom
fix/ht-50-reconcile-self-echo-skip

Conversation

@zaridan

@zaridan zaridan commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

First live HT-44 run exposed a real failure: Gmail's history.list surfaces the mailbox's own outbound sends as messagesAdded entries indistinguishable from genuine inbound mail, so reconcile ingested an Agent's just-sent reply as a brand-new ghost conversation.

This PR fixes that at the transport layer, before any ingest happens:

  • GmailHistoryClient.listAddedMessageIds (src/providers/adapters/gmail/history.ts) now surfaces each history record's labelIds (returns AddedGmailMessage[] instead of bare ids).
  • The reconcile handler (src/mail/gmail-reconcile.ts) skips a message that carries SENT without INBOX — before any messages.get/ingest call — so no inbound_deliveries row is ever created, and the cursor still advances past it exactly like the existing "deleted between list and get" case.
  • A self-addressed message (both SENT and INBOX) is deliberately still ingested — Gmail gives no other signal to distinguish it from a real customer message. A message-id-tracking alternative was considered and rejected: it would miss an Agent replying directly from the Gmail web UI, which the label filter catches.
  • Round-2 review finding: Gmail also autosaves an Agent's in-progress Gmail-UI reply as a new DRAFT-labeled message id on every pause, each surfacing in history.list before anything is sent. The prior filter left these to ingest as ghost/half-written "customer" messages. Fixed by also skipping DRAFT-labeled history entries in isSelfEchoMessage — safe in the drop direction with no ambiguity, since genuine inbound mail can never carry the system DRAFT label.
  • Round-2 hardening: listAddedMessageIds now also requests the labelAdded history type and folds a later labelsAdded record's label snapshot into an id already tracked via messagesAdded in the same listed window. This closes an (unverified-live, but plausible) failure mode where Gmail records a self-addressed send's SENT and INBOX labels as two separate history records instead of one messagesAdded snapshot carrying both — which could otherwise cause a silent, permanent drop (invariant HT-3: clean-room protocol doc #1: never lose an inbound message).

specs/mail/gmail-push.md documents the DRAFT skip, the rejected message-id-tracking alternative, and amends §4's cursor rule to scope "the batch" to messages actually handed to the ingest pipeline — closing a contradiction with §3's self-echo filter (previously described as cursor-neutral but never reconciled against §4's literal wording). inbound-ingestion.md is unchanged since its pipeline contract (parse/thread/ingest) is untouched — the filter runs entirely on transport metadata before any of that.

Design decisions (flagging for sign-off)

  • Self-addressed mail is still ingested. If a mailbox owner emails themselves, Gmail gives no signal distinguishing that from a real customer message carrying both SENT and INBOX labels — so it's treated as inbound. Flagging in case that's not the desired default.
  • The split-history hardening (labelsAdded folding) is unverified against a live Gmail mailbox. It defends against a plausible-but-unconfirmed API behavior (SENT and INBOX landing as two separate history records). The module doc calls this out explicitly. It doesn't depend on nor confirm the assumption — it closes the failure mode either way — but the maintainer should know live confirmation is still open.

Review — 7 adversarial findings (5 actionable), fixes applied

Two review rounds were run against this change. Round 2 findings (all addressed, see 296b755):

  • DRAFT-labeled history entries were not skipped, letting Gmail's autosaved in-progress-reply drafts ingest as ghost/half-written "customer" messages — fixed by extending isSelfEchoMessage to skip DRAFT alongside SENT-without-INBOX.
  • listAddedMessageIds was vulnerable to a split SENT/INBOX history ordering silently dropping a message — hardened by requesting labelAdded history type and folding later label snapshots into already-tracked ids.
  • specs/mail/gmail-push.md had an internal contradiction between §3 (self-echo filter framed as cursor-neutral) and §4 (literal wording of "the batch") — reconciled.
  • Two findings were non-actionable / informational (live-verification-still-open note, and confirming the message-id-tracking alternative was correctly rejected).

Verification

Ran for real, in the assigned worktree only (/Users/tjbaker/Projects/helpthread-worktrees/fix-ht-50-reconcile-self-echo-skip):

  • npm install (fresh, since node_modules wasn't present) — succeeded.
  • npm run typecheck (tsc --noEmit) — exit 0, no errors.
  • npm run lint (biome check .) — one formatting violation (a single-quoted string containing an apostrophe) auto-fixed via biome check --write; re-run exit 0, no remaining violations.
  • npx vitest run — exit 0: 44 files, 868 tests, all passing.
  • git status --short — clean tree.

Independent gate exit codes: typecheck 0, lint 0, tests 0, clean tree.

Jira: https://resonantiq.atlassian.net/browse/HT-50

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Prevented Gmail outbound self-messages and draft autosaves from being fetched/ingested during reconciliation, while still advancing the reconciliation cursor.
    • Preserved normal ingestion for self-addressed messages that are both sent and in the inbox.
    • Improved reconciliation correctness by incorporating per-message label handling from Gmail history (including de-duplication, pagination “last wins,” and label folding with missing labels).
    • Ensured skipped items do not create inbound delivery records or ingest confirmation artifacts.
  • Tests
    • Updated and expanded Gmail reconciliation and history adapter tests for the revised listing response shape and new self-echo filter scenarios.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Gmail history results now include message labels, allowing reconciliation to skip outbound self-echoes and drafts before fetching or ingesting them. History pagination folds label updates, while reconciliation tests cover filtering, cursor advancement, normal ingestion, and existing lease behavior.

Changes

Gmail self-echo filtering

Layer / File(s) Summary
History label contract
src/providers/adapters/gmail/history.ts, src/providers/adapters/gmail/history.test.ts, src/providers/adapters/gmail/index.ts
History listing returns { id, labelIds } entries, requests messageAdded and labelAdded, folds labels across pages, and validates the updated contract.
Reconciliation filter and cursor handling
src/mail/gmail-reconcile.ts, specs/mail/gmail-push.md
Reconciliation skips DRAFT and SENT-without-INBOX messages before raw fetch or ingestion, while documenting cursor and ledger behavior.
Reconciliation behavior coverage
src/mail/gmail-reconcile.test.ts
Tests cover the new result shape, self-echo filtering, fail-open handling, cursor advancement, normal ingestion, deletion, replay, and lease scenarios.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GmailHistory
  participant ReconcileOneMailbox
  participant RawMessageClient
  participant IngestPipeline
  GmailHistory->>ReconcileOneMailbox: return added messages with labelIds
  ReconcileOneMailbox->>ReconcileOneMailbox: apply isSelfEchoMessage
  ReconcileOneMailbox->>RawMessageClient: fetch non-skipped message
  RawMessageClient->>IngestPipeline: provide RawInboundMessage
  IngestPipeline-->>ReconcileOneMailbox: return ingest outcome
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: Gmail reconcile now skips self-echoed sends and drafts.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ht-50-reconcile-self-echo-skip

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: 1

🤖 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 `@src/providers/adapters/gmail/history.ts`:
- Around line 189-194: Update src/providers/adapters/gmail/history.ts:189-194 to
model labelsAdded.labelIds at the top level of each label-added record, then
update the history folding logic at
src/providers/adapters/gmail/history.ts:293-307 to merge those labels into the
already tracked entry rather than reading message.labelIds or overwriting
existing labels. Adjust the related history definitions/documentation at
src/providers/adapters/gmail/history.ts:47-92, add coverage for the top-level
label delta in src/providers/adapters/gmail/history.test.ts:139-220, and update
the reconciliation handling at src/mail/gmail-reconcile.ts:208-220 and behavior
documentation at specs/mail/gmail-push.md:101-103 as needed to reflect the
corrected merge 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bc815879-a7ac-4df3-9725-f95ac63caafc

📥 Commits

Reviewing files that changed from the base of the PR and between e5c9ccd and 296b755.

📒 Files selected for processing (6)
  • specs/mail/gmail-push.md
  • src/mail/gmail-reconcile.test.ts
  • src/mail/gmail-reconcile.ts
  • src/providers/adapters/gmail/history.test.ts
  • src/providers/adapters/gmail/history.ts
  • src/providers/adapters/gmail/index.ts

Comment thread src/providers/adapters/gmail/history.ts
zaridan and others added 3 commits July 16, 2026 20:41
Live-proven failure (first HT-44 run): history.list surfaces the mailbox's
own outbound sends as messagesAdded entries indistinguishable from genuine
inbound mail, so reconcile ingested an Agent's just-sent reply as a new
ghost conversation.

Surface each history record's labelIds through the Gmail history adapter
(GmailHistoryClient.listAddedMessageIds now returns AddedGmailMessage[]
instead of bare ids) and skip a message in the reconcile handler when it
carries SENT without INBOX, before any messages.get/ingest call — so no
inbound_deliveries row is ever created and the cursor still advances past
it exactly like the existing "deleted between list and get" case. A
self-addressed message (both SENT and INBOX) is deliberately still
ingested, since Gmail gives no other signal to distinguish it from a real
customer message. A message-id-tracking alternative was considered and
rejected: it would miss an Agent replying directly from the Gmail web UI,
which the label filter catches.

specs/mail/gmail-push.md documents the filter and the rejected alternative;
inbound-ingestion.md is unchanged since its pipeline contract (parse/thread/
ingest) is untouched — the filter runs entirely on transport metadata before
any of that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ening (HT-50)

Review round 2 findings, all addressed:

- Skip DRAFT-labeled history entries too (src/mail/gmail-reconcile.ts's
  isSelfEchoMessage), not just SENT-without-INBOX. Gmail autosaves an
  Agent's in-progress Gmail-UI reply as a new DRAFT-labeled message id on
  every pause, each surfacing in history.list before anything is sent; the
  prior filter left these to ingest as ghost/half-written "customer"
  messages. Safe in the drop direction with no ambiguity: genuine inbound
  mail can never carry the system DRAFT label.

- Harden src/providers/adapters/gmail/history.ts's listAddedMessageIds
  against the (unverified-live, but plausible) case where Gmail records a
  self-addressed send's SENT and INBOX labels as two SEPARATE history
  records instead of one messagesAdded snapshot carrying both. Now also
  requests the labelAdded history type and folds a later labelsAdded
  record's label snapshot into an id already tracked via messagesAdded in
  the same listed window, so a split SENT-then-INBOX ordering can no longer
  cause a silent permanent drop (invariant #1). Live verification against
  the dogfood mailbox remains open and is called out in the module doc as
  such — this hardening does not depend on nor confirm the assumption, it
  removes the failure mode either way.

- specs/mail/gmail-push.md: document the DRAFT skip; amend §4's cursor rule
  to scope "the batch" to messages actually handed to the ingest pipeline,
  closing the contradiction with §3's self-echo filter (which was already
  described as cursor-neutral but never reconciled against §4's literal
  wording).

- Full `npx vitest run` on this branch: 44 files, 868 tests, all passing
  (exit 0) — the previously-unattributed 18 failures from review round 1 do
  not reproduce; no comparison against origin/main was needed since this run
  itself is clean evidence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…apshot (HT-50)

CodeRabbit round 3: Gmail's HistoryLabelAdded schema carries the added
labels in the record's TOP-LEVEL labelIds field, beside `message` — the
embedded message's own labelIds is not guaranteed to be populated in
history records. The folding logic was reading message.labelIds and
OVERWRITING the tracked entry, so an omitted embedded snapshot would
clobber a real ['SENT'] down to [] (losing the label the self-echo
filter keys on and defeating the very INBOX-visibility hardening the
fold exists for).

Now merges (set union) the schema-guaranteed top-level delta into the
tracked entry and never reads the embedded snapshot: merging can only
ADD labels, so a later INBOX becomes visible without ever losing the
earlier SENT. Still fails open — an absent delta leaves the snapshot
intact, biasing toward ingesting, never toward dropping (invariant #1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zaridan
zaridan force-pushed the fix/ht-50-reconcile-self-echo-skip branch from 4fdb658 to 81802b6 Compare July 17, 2026 03:45

@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: 1

🤖 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/gmail-push.md`:
- Around line 307-311: Add an acceptance criterion near the existing self-echo
filter case documenting that a `messagesAdded` entry labeled `DRAFT` is skipped
before `messages.get`/ingest, creates no `inbound_deliveries` row, and still
advances the cursor past it.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 278e7469-08a6-45f2-8fbc-43a1a3e24521

📥 Commits

Reviewing files that changed from the base of the PR and between 4fdb658 and 81802b6.

📒 Files selected for processing (6)
  • specs/mail/gmail-push.md
  • src/mail/gmail-reconcile.test.ts
  • src/mail/gmail-reconcile.ts
  • src/providers/adapters/gmail/history.test.ts
  • src/providers/adapters/gmail/history.ts
  • src/providers/adapters/gmail/index.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/providers/adapters/gmail/index.ts
  • src/providers/adapters/gmail/history.test.ts
  • src/mail/gmail-reconcile.ts
  • src/providers/adapters/gmail/history.ts
  • src/mail/gmail-reconcile.test.ts

Comment thread specs/mail/gmail-push.md
Comment on lines +307 to +311
- A `messagesAdded` entry labeled `SENT` without `INBOX` (the mailbox's own outbound send,
or an Agent's direct Gmail-UI reply) → skipped before `messages.get`/ingest, no
`inbound_deliveries` row created, cursor still advances past it (HT-50, "the self-echo
filter" above). A self-addressed entry labeled both `SENT` and `INBOX` → still ingested
normally.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a DRAFT case to the acceptance criteria.

The filter also skips DRAFT messages, but this release-gate section only verifies SENT without INBOX. Add an acceptance case confirming that drafts avoid messages.get/ingest while the cursor still advances.

🤖 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 `@specs/mail/gmail-push.md` around lines 307 - 311, Add an acceptance criterion
near the existing self-echo filter case documenting that a `messagesAdded` entry
labeled `DRAFT` is skipped before `messages.get`/ingest, creates no
`inbound_deliveries` row, and still advances the cursor past it.

@zaridan
zaridan merged commit 53a24ba into main Jul 17, 2026
5 checks passed
@zaridan
zaridan deleted the fix/ht-50-reconcile-self-echo-skip branch August 2, 2026 19:19
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