Skip to content

feat(leads): track opens, and read replies out of the mailbox - #152

Merged
ralyodio merged 1 commit into
masterfrom
feat/outreach-opens-and-replies
Jul 28, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/outreach-opens-and-replies

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Everything past "contacted" waited on somebody remembering to mark it, so a funnel left alone reported a reply rate of structurally zero — a number that reads as a verdict on the campaign rather than on the bookkeeping.

Opens: per send, and honest about the error bar

Per send, not per campaign — a campaign-level pixel only says somebody opened something, which nobody can act on.

Privacy proxies fetch every image the instant mail arrives. Those loads are counted separately and kept out of the total:

GoogleImageProxy            → prefetch
opened 2s after send        → prefetch
no user-agent at all        → prefetch
Slackbot / facebookexternalhit → prefetch
Safari, 4h later            → open

The number is a floor, and the UI says so. Open rate divides by tracked sends, not every send ever — otherwise a healthy campaign appears to decline as its pre-tracking history piles up behind it.

Replies: two findings from the live mailbox

The mailbox connected in #126 has had IMAP settings stored all along and was never read. Running it for real found two bugs that no unit test would have.

1. A cap on an oldest-first walk never reaches today.

before: scanned 200, matched 0     ← budget spent on two-week-old mail
after:  scanned 199, matched 1     ← same window, newest-first

2. That one match was wrong. A Huntress marketing blast, from a contacted domain, from a plausible address, marked the prospect replied. Its own headers say what it was:

List-Unsubscribe: <mailto:...@bf02x.hubspotemail.net>
Feedback-ID:      aecl4ke:aig3kzw8:aib1s:HubSpot
In-Reply-To:      (absent)
Subject:          Here's what hackers can expect     ← no Re:

Three independent signals. So: bulk fingerprints now count as automatic, and the domain fallback requires evidence of being a response at all. Sends also record their own Message-Id, so a reply naming it in In-Reply-To matches that exact send instead of being inferred from a domain.

Verified against the live mailbox

mailbox: anthony@profullstack.com via imap.forwardemail.net:993
scanned: 200   matched: 0   autoReplies: 3   error: null

The newsletter no longer matches. The three that do all carry Auto-Submitted: auto-replied (helpdesk acknowledgements) — recorded, flagged, kept out of the reply rate. The bad row and the prospect status it changed have been reverted.

Both of those also reference our real sent Message-Ids, confirming the threading path works.

Checks

  • tsc --noEmit clean · 1,014 tests pass (65 new) · build compiles
  • Migrations applied to prod; crawlproof-outreach-replies scheduled and active
  • imapflow added, externalised in next.config.ts (raw TLS sockets, must not be bundled)

🤖 Generated with Claude Code

The funnel could count sends and nothing else, because a send is
something this application does and a reply is something that happens
elsewhere. Everything past "contacted" waited on somebody remembering to
mark it, so a funnel left alone reported a reply rate of structurally
zero — which reads as a verdict on the campaign rather than on the
bookkeeping.

Opens are tracked per send rather than per campaign. A campaign-level
pixel can only say that somebody opened something, which is not a fact
anybody can act on; per send it says which person opened which email,
which is what decides who to chase. The honest caveat is built in rather
than written on a help page: privacy proxies fetch every image the moment
mail arrives, so loads carrying a proxy fingerprint or arriving
implausibly fast are counted separately and kept out of the total. The
number is a floor. The UI says so, and the open rate divides by tracked
sends rather than by every send ever, so a healthy campaign does not
appear to decline as its pre-tracking history accumulates behind it.

Replies come from the mailbox connected in #126, which has stored IMAP
settings all along and never been read. Two findings from running it
against a real inbox, both now fixed and pinned:

A cap on an oldest-first walk reads two-week-old mail forever and never
reaches today. The first run scanned 200 messages and matched nothing;
searching first and taking the newest slice found a match in the same
window.

And that match was wrong. A Huntress marketing blast came from a
contacted domain, from a plausible address, and was counted as a reply —
marking the prospect replied. Its headers say plainly what it was:
List-Unsubscribe, Feedback-ID, no In-Reply-To, no Re:. So bulk
fingerprints now count as automatic, and the domain fallback requires
some evidence of being a response at all. Sends record their own
Message-Id, so a reply that names it in In-Reply-To matches the exact
send rather than being inferred from a domain.

Auto-replies are recorded and flagged, never counted. Verified against
the live mailbox: the newsletter no longer matches, and three helpdesk
acknowledgements carrying Auto-Submitted: auto-replied are kept out of
the reply rate. The bad row and the prospect status it changed have been
reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedimapflow@​1.6.1891009896100

View full report

@socket-security

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
License policy violation: npm @zone-eu/mailsplit under EUPL-1.2

License: EUPL-1.2 - The applicable license policy does not permit this license (5) (package/LICENSE.EUPL-1.2)

License: unrecognized license - This license was not allowed or given any lesser classification by the applicable policy (package/LICENSE.EUPL-1.2)

License: EUPL-1.1+ - This license classifier is not allowed by the applicable policy (package/package.json)

From: package-lock.json → npm/imapflow@1.6.1 → npm/@zone-eu/mailsplit@5.4.14

ℹ Read more on: This package | This alert | What is a license policy violation?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Find a package that does not violate your license policy or adjust your policy to allow this package's license.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@zone-eu/mailsplit@5.4.14. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@ralyodio
ralyodio merged commit 77b61a9 into master Jul 28, 2026
8 checks passed
@ralyodio
ralyodio deleted the feat/outreach-opens-and-replies branch July 28, 2026 12:06
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