Skip to content

cold email: fail toward not blocking, and stop clobbering learned patterns - #3075

Open
elie222 wants to merge 5 commits into
mainfrom
avoid-false-sender-blocks
Open

cold email: fail toward not blocking, and stop clobbering learned patterns#3075
elie222 wants to merge 5 commits into
mainfrom
avoid-false-sender-blocks

Conversation

@elie222

@elie222 elie222 commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Guiding rule

When a check that feeds the cold email decision cannot answer, default to leaving the sender alone.

The two errors are not symmetric. A wrongly blocked sender is labelled, archived out of the inbox, and on accounts with NOTIFY_SENDER enabled, auto-emailed to say their message was unsolicited outreach. A missed cold email costs the user one email in the inbox.

Fail toward not blocking

Outlook prior-contact lookup returned false on error. hasPreviousCommunicationsWithSenderOrDomain wrapped its Graph queries in a catch that returned false. But false means "no prior contact", which is exactly the input that pushes the blocker toward blocking. A transient Graph outage could therefore start blocking established contacts. It now fails toward reporting contact.

The four inner per-query .catch()es had the same shape, resolving a failed query to { value: [] } so a partial failure looked like a confirmed absence of history. They now propagate to the outer handler.

Gmail's implementation has no catch, so errors already propagate and no blocking occurs. Left as is.

A message without a date or id can no longer be classified as cold. That combination makes the prior-contact check unrunnable, and the old fallback treated "couldn't check" as "no prior contact".

Pattern writes no longer clobber

saveLearnedPattern wrote exclude and source on every upsert, including for callers that never passed them. exclude defaulted to false, so any caller omitting it would silently un-exclude a sender the user had explicitly corrected, and re-blocking begins.

Callers now overwrite only what they supply, and source keeps recording how the pattern was first learned rather than what touched it last. Two consumers depend on that: undoing a junk action, and the audit trail.

Reachable today via analyze-sender-pattern, though it has written zero cold-email patterns in production so far, so this is a latent footgun rather than a live regression. Prisma already skips undefined on update, so reason/threadId/messageId were never actually being nulled.

Skips are not failures

NOTIFY_SENDER is in ACTION_RESULT_FAILURE_TYPES, so the internal-sender guard returning { success: false } was persisted as ExecutedActionStatus.FAILED with an executionError, and pushed into actionFailures, which flips the whole rule to ERROR. A deliberate policy decision was being reported as an execution failure.

Actions can now return { skipped: true }, handled in both executors, following the ExecutedActionStatus.SKIPPED precedent already used for the automated-archive exception.

One thread read per junk

Gmail labels every message in a thread as spam, so junking an N-message thread fires N webhook events. Spam learning is thread-scoped, so all N produced the same answer, and on the conversation path nothing was written, meaning the duplicate guard never engaged and every event paid a full threads.get that downloads all N bodies.

A batch-scoped set of thread ids means only the first event does the work. Junking a 25-message thread goes from 25 thread reads to 1.

Tests

Each change was mutation-tested: reverting the fix makes its test fail.

Reverted Failing test
source back in the update block never overwrites source
skip handling removed keeps the rule APPLIED when an action skips itself on purpose
thread dedupe removed should only read the thread once when a whole thread is junked
fail-open default restored should not classify as cold when prior contact cannot be checked

Full suite: 495 files passed, 97 skipped, 0 failed. Lint clean, no new type errors.

🤖 Generated with Claude Code

Review in cubic

…terns

When a check that feeds the cold email decision cannot answer, the code now
defaults to leaving the sender alone. Blocking is compounding: a wrongly blocked
sender is labelled, archived out of the inbox, and on many accounts auto-emailed
to say their message was unsolicited. Missing a cold email costs one email in
the inbox.

- The Outlook prior-contact lookup returned false when the Graph query failed.
  False reads as "no prior contact", which pushes toward blocking, so a
  transient API error could start blocking real contacts. It now fails toward
  reporting contact. Partial failures no longer resolve to an empty result set
  for the same reason.
- A message without a date or id cannot be checked for prior contact, and is no
  longer eligible to be classified as a cold email.

Separately:

- saveLearnedPattern overwrote exclude and source on every upsert, including for
  callers that never passed them. A caller that omitted exclude would silently
  re-block a sender the user had corrected. Callers now only overwrite what they
  supply, and source keeps recording how the pattern was first learned.
- An action that declines to run on purpose is recorded as skipped rather than
  failed, so deliberate policy skips stop appearing as execution errors.
- Junking a thread fires one webhook event per message. Spam learning is
  thread-scoped, so only the first event now pays for the thread read.

Co-Authored-By: Claude <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
inbox-zero Ignored Ignored Preview Aug 5, 2026 10:56pm

The fail-safe was written three times: inside the Outlook provider's catch, and
as two hand-written ternaries at the call sites. Gmail had none, so a lookup
failure there aborted the whole rule run instead of degrading to "not cold".

Moves it to a single hasPriorContactOrAssumeYes helper that both callers use.
Providers no longer encode what a failed lookup means to a classifier: Outlook's
catch is gone and errors propagate like Gmail's always did. One place to read
the policy, one set of tests, identical behaviour on both providers.

Also applies the same rule where it was missing:

- An explicit user correction may now claim a learned pattern's source again.
  Blocking inferred writers from restating it was right, but it also stopped
  "not a cold email" from promoting the row, so a later un-junk deleted the
  user's correction and the sender became blockable again.
- isColdEmail now returns early for a sender inside the user's own
  organisation. The guard existed when learning from junk and when notifying
  the sender, but not in the step that actually decides, so a colleague could
  still be labelled and archived.

Cleanups from review:

- Junking a thread no longer fetches the sender for every message in it. The
  dedupe check now runs before that call, not after.
- The batch-scoped thread set is required rather than optional, so a caller
  cannot silently lose the dedupe, and is built at its single construction site.
- Reuse asRecord for the skipped-result check, name the ActionSkipped type, and
  drop test assertions that restated ones already made in the same file.

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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 14 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/web/utils/webhook/google/process-label-added-event.ts Outdated
Comment thread apps/web/utils/webhook/google/process-label-added-event.ts Outdated
Comment thread apps/web/utils/cold-email/is-cold-email.ts Outdated
Comment thread apps/web/utils/cold-email/has-prior-contact.ts Outdated
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