Skip to content

feat(leads): personalised re-engagement campaign for captured PDF leads - #118

Merged
ralyodio merged 1 commit into
masterfrom
feat/lead-campaign
Jul 26, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/lead-campaign

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Emails the people who ran a scan and asked for the PDF, driving them to /hire.

Not a broadcast. Each email is built from the recipient's own most recent report — their host, their score, their top three failing findings — with a /hire link prefilled with the site and address we already hold. It's about the thing they asked us for.

Why not /api/admin/email-broadcast

That route selects from profiles, so it structurally cannot reach a lead who never registered — 51 of our 108. It also sends raw HTML via sendBulk, bypassing sendMarketingEmail, so it adds no unsubscribe footer and no List-Unsubscribe headers, and applies no unsubscribed_at filter. Nobody has unsubscribed yet so no harm has occurred, but the first person who does would keep receiving mail. Worth fixing separately.

This route uses sendMarketingEmail (footer + one-click headers) and checks suppression twice — when selecting the audience, and again immediately before each send in case someone opts out mid-run.

Safety

  • Dry run by default. Sending requires an explicit {"dryRun": false}; an accidental POST mails nobody.
  • testTo sends the entire campaign to one inbox for a real-world preview.
  • Excluded: unsubscribed (which beats a later consent record), our own domains, role accounts (postmaster@, no-reply@…), and anyone with no report to talk about.
  • Deduped per address; 400ms pacing so a burst doesn't trip the provider or look like a spam cannon.
  • Segmentable: users (existing relationship) vs leads (cold) vs all.

Audience selection is a pure module (lib/leadCampaign.ts) with 15 tests, because choosing wrongly doesn't throw an exception — it silently mails someone who opted out.

Usage

GET  /api/admin/lead-campaign?segment=all      # preview: counts, exclusions, 5 samples
POST /api/admin/lead-campaign {"segment":"all"}                    # dry run
POST /api/admin/lead-campaign {"dryRun":false,"testTo":"you@..."}  # one test send
POST /api/admin/lead-campaign {"dryRun":false,"segment":"users"}   # real send

Verification

npm run typecheck clean, 549 tests pass (15 new), npm run build exits 0. No email has been sent.

🤖 Generated with Claude Code

Adds an admin-only endpoint that emails people who ran a scan and asked for
the PDF, driving them to /hire.

Not a broadcast. Each email is built from the recipient's OWN most recent
report — their host, their score, their top three failing findings — with a
/hire link prefilled with the site and address we already have. That is the
thing they asked us for, so it converts better than a newsletter and stays
tied to the request that captured the address.

Why not /api/admin/email-broadcast: it selects from `profiles`, so it
structurally cannot reach a lead who never registered (51 of the 108 we
have). It also sends raw HTML through sendBulk with no unsubscribe footer
and no List-Unsubscribe headers, and applies no unsubscribed_at filter at
all — nobody has unsubscribed yet so no harm has occurred, but the first
person who does would keep receiving mail. This route goes through
sendMarketingEmail, which adds both, and filters the suppression list twice:
once when selecting the audience and again immediately before each send, in
case someone opts out mid-run.

Audience selection is a pure module with its own tests, because choosing
wrongly doesn't throw — it silently mails someone who opted out. Excluded:
unsubscribed (which beats a later consent record), our own domains, role
accounts, and anyone with no report to talk about. Segmentable into existing
customers vs cold leads, deduped per address.

DRY RUN BY DEFAULT — sending requires an explicit {"dryRun": false}, and a
testTo override sends the whole thing to one inbox first. Paced at 400ms
between sends.

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.

@ralyodio
ralyodio merged commit 172389a into master Jul 26, 2026
7 of 8 checks passed
@ralyodio
ralyodio deleted the feat/lead-campaign branch July 26, 2026 08:09
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