Commit graph

789 commits

Author SHA1 Message Date
justin
14357a0223 fix(nginx): unblock public API routes powering lead tools/flows (HC sales killer)
api.performancewest.net uses an explicit per-path allowlist; everything else
falls through to a trusted-IP-only catch-all that returns 403. Six browser-
facing routes had no location block, so they 403'd for every public visitor:

  /api/v1/npi/                 <- THE healthcare sales killer. The 'Free NPI
                                  Compliance Check' tool (top of the HC funnel,
                                  where every HC campaign sends traffic) fetches
                                  /api/v1/npi/lookup. It 403'd -> CORS error in
                                  the browser -> the tool never rendered results
                                  or the upsell CTAs (Revalidation $399 / NPPES
                                  $149 / Bundle $899) -> 0 HC sales despite 17
                                  sessions reaching it in 30d and 0 HC orders
                                  EVER created in the compliance DB.
  /api/v1/cdr/                 telecom CDR profile tool
  /api/v1/icc/                 intrastate/ICC profile tool
  /api/v1/corp/                corporate foreign-qual check
  /api/v1/foreign-qualification/   foreign qualification quote/jurisdictions
  /api/v1/lnpa-regions         LNPA region lookup

Added explicit proxy_pass blocks (mirroring the existing entities/identity
pattern) before the catch-all. Verified live: all six now reach the app with
proper CORS; the NPI tool renders results + order CTAs end-to-end via a real
browser; npi-revalidation order page -> Stripe confirmed.

The live /etc/nginx/sites-enabled/pw-api.conf was hand-edited and untracked;
committing the current state here so it is version-controlled. (Live backup:
/root/pw-api.conf.bak_20260623.)
2026-06-23 15:51:30 -05:00
justin
a90cdc9066 fix(trucking-email): route order CTAs to the correct service page (not $399 catch-all)
Two routing bugs that sent carriers to wrong/dead order pages:

1. MCS-150 + Inactive campaigns linked to /order/dot-full-compliance ($399)
   instead of their actual service: build_lp_link()/lp_slug_for() fell through
   to the dot-full-compliance catch-all for any campaign_type not in
   DEFICIENCY_SEGMENTS, ignoring the existing PRICE_SLUG_BY_CAMPAIGN map. So
   MCS-150 carriers (should be mcs150-update $79) and Inactive carriers (should
   be usdot-reactivation $149) were both quoted a 5x-priced bundle they never
   asked for — a severe conversion killer on the two highest-volume segments.
   Fix: lp_slug_for() now checks PRICE_SLUG_BY_CAMPAIGN first; build_lp_link()
   delegates to it (single source of truth).

2. IFTA-quarterly + UCR-annual builders set lp_link to a BARE path when no
   coupon was active (LP_LINK with no query). The body appends '&utm_source=...'
   so the CTA rendered as /order/ifta-quarterly&utm... (no '?') = 404. Fix:
   both now always emit a leading '?' query carrying ?dot= (and ?code= when a
   coupon is on), mirroring the main builder's lp_link_with_coupon().

Audited every campaign_type: all 14 order slugs now resolve 200 and match the
intended service/price. Compliance-check secondary links (/tools/dot-compliance-
check) verified correct and intentionally kept where a 'check status' CTA fits.
2026-06-23 15:19:23 -05:00
justin
e3f439221a fix(trucking-email): kill recurring @TrackLink 404 at the source-clone boundary
Root cause of the order-CTA 404s recurring after the prior live fix: the
builder clones email bodies from STORED Listmonk source campaigns (ids
186/188/271-274/309/310/469/473), not from the edited source files. Those
stored bodies still carried @TrackLink on the per-subscriber order CTA, so
every nightly build re-registered a single static /order/<slug>&utm... link
(no '?') that 404s for every recipient. This morning's 3,000 real sends AND
the owner spot-check both went out with dead order links.

Two durable guards:
1. get_base_campaign() now strips @TrackLink from any cloned body (with a
   warning), so a stale/re-edited source campaign can never reach recipients
   broken again. Human clicks are already attributed via Umami.
2. The owner test-send now builds the CTA via lp_link_with_coupon(dot=...)
   (leading '?') instead of build_lp_link() (bare path).

Also fixed live: stripped @TrackLink from the 10 stored source campaign
bodies; rewrote the 12 already-registered broken links. Backups in listmonk:
pw_source_tracklink_bak_20260623 + pw_links_tracklink_bak_20260623.
2026-06-23 15:02:05 -05:00
justin
60d2572f19 fix(intake): HC checkout 400 — resolve customer email/name from intake_data
The NPI/healthcare intake step persists provider email + name only into
intake_data (not the top-level state.email/state.name that the DOT/?dot=
flow sets). ReviewStep's order-create POST therefore sent empty
customer_email/customer_name -> API 400 'service_slug, customer_email, and
customer_name are required', blocking EVERY healthcare checkout at the
review step (explains 0 HC sales despite 13,425 sends).

ReviewStep now falls back to intake_data.{email,provider_name,
organization_name,legal_name,entity_name}; the Wizard cold-visitor create
path also now recognizes provider_name/organization_name. Verified the
trucking path is unaffected (it already populated top-level state).
2026-06-23 13:40:19 -05:00
justin
f773718e4d fix(intake): send application/json Content-Type on ReviewStep validate
The cold-visitor review-step path POSTed /validate with no Content-Type, so
the API returned 415 and validation silently failed — the user could create
the order but never advance from review to payment (the last blocker in the
trucking/HC checkout funnel). The Wizard's own validate call already set the
header; ReviewStep now matches. Completes the checkout repair in 5546c58.
2026-06-23 13:11:35 -05:00
justin
5546c58bf0 fix(intake): repair order wizard — checkout was fully broken on trucking/HC
Diagnosed via live browser E2E why campaign clicks (25 checkout-page-views,
36h) produced 0 conversions. Four bugs, all blocking checkout:

1. DOTIntakeStep: a missing `});` (DFWP hydration block, commit 9718ab9
   Jun 2) left the pw:step-shown listener unclosed -> 'missing ) after
   argument list' SYNTAX ERROR killed the whole DOT intake script. Effect:
   ?dot= prefill silently failed for ~3 weeks (exactly the campaign window),
   so every carrier had to re-type all their details.

2. ReviewStep: service slug read from `.pw-step[data-slug]` (first match),
   which on trucking/HC is the INTAKE step's slug ('dot-intake'/'npi-intake'),
   not the order. The cold-visitor order-create POST sent
   service_slug='dot-intake' -> API 501/400 -> 'Could not validate order',
   blocking checkout at the review step on EVERY multi-step vertical. Now
   reads `.pw-wizard[data-service]` (authoritative). Confirmed against prod:
   bad slug=400, correct slug=201.

3. Shared-bundle null derefs: every step's <script> is bundled onto every
   order page, so steps whose anchor element is absent threw at top level and
   could abort siblings:
     - ClassificationWizard: top-level renderQuestion(0) -> appendChild on
       null (errored on 47/67 order pages)
     - BDCDataStep: (querySelector as HTMLElement).getAttribute on null
     - STIRShakenStep / EarthStationStep: top-level addEventListener on null
     - ForeignQualStep: many top-level getElementById(...)! lookups
   Each now guarded to no-op when its step isn't present.

Verified by browser E2E: full flow dot-intake -> review -> payment ->
live Stripe Checkout session, and a 67-page scan now reports 0 JS errors
(was 47 pages erroring). Real human clicks are tracked via Umami; these
were pure functional breakages of the conversion path.
2026-06-23 13:08:41 -05:00
justin
3325259af7 fix(email): drop @TrackLink from per-subscriber CTAs (404 + collapse bug)
Listmonk @TrackLink registers ONE static URL per tracked link and points
every recipient's /link/<uuid> redirect at it. On per-subscriber hrefs
({{ lp_link }}, ?dot=, ?npi=, ?clia=) this is doubly broken:
 - the registered links.url was captured before the {{ lp_link }} token
   rendered, yielding /order/slug&utm_source=... (first &, no ?) -> 404
 - even when valid it collapses every carrier/provider onto the first
   subscriber's dot/npi/clia value

Real human clicks are already tracked via Umami campaign-click (bot
filtered), so Listmonk link tracking here is redundant and destructive.

Stripped @TrackLink from per-subscriber CTAs:
 - scripts/create_deficiency_source_campaigns.py (_cta, _dot_check_cta)
 - data/trucking_campaigns/{ucr,ifta}_*.html
 - data/hc_campaigns/*.html (10 templates)

Static CTAs (e.g. CRTC ?code= order link) keep @TrackLink (safe).
Live fix to the 10 broken registered links.url rows applied separately
(first & -> ?), backup in listmonk.pw_links_dkim_fix_bak_20260622.

Docs: new runbook incident section + corrected the disproven
'use @TrackLink on all CTAs' guidance in fmcsa/hc plans.
2026-06-22 17:01:39 -05:00
justin
1e9dcfcfd1 mail(rampcap): step trucking cap back up to 400/h (day 19-20), 500/h ceiling
The day-9 Gmail block that forced the 200/h hold is resolved: per-MX throttling
shipped, Google is excluded entirely (MAIN_EXCLUDE_OPERATORS=google), and the
OpenDKIM signing bug is fixed. With Google out of the mix, 400/h (~4k/day) is
within the envelope these IPs cleanly sustained at 68-76% delivery with zero
blocks. Lets the post-DKIM re-send backlog drain in ~1 day instead of ~3.
2026-06-22 12:49:54 -05:00
justin
62292b96af docs(deliverability): document Jun 22 re-send of never-delivered DKIM-window audience
Records the MAIN_EXCLUDE_OPERATORS=google override, the resend_dkim_backup_20260622
rollback table, the past-send_at HTTP 400 gotcha (use --send-hour for same-day
re-runs), and the exact revert SQL. 6461-row backup; ~2999 re-sent Jun 22, rest
drain on subsequent daily runs (Gmail excluded, Microsoft/Hotmail included).
2026-06-22 11:59:29 -05:00
justin
5a3063ecb3 campaigns: MAIN_EXCLUDE_OPERATORS override + Gmail-only exclusion for post-DKIM re-send
After the Jun 2026 no-DKIM incident (campaign mail went out unsigned ->
junked/blocked, ~23% delivery), DKIM is fixed and we must re-send to the
now-signed audience. The builder previously held Google AND Microsoft AND
consumer-MX out until warmup day 30; that blocks the re-send of the Microsoft-
hosted business domains that are most of the list.

Add MAIN_EXCLUDE_OPERATORS (comma-separated mx_provider labels) to override
WARMUP_EXCLUDE_OPERATORS. Set it to 'google' in the workers env so we send to
everything EXCEPT Google's consumer inboxes (still recovering reputation),
including Microsoft/Hotmail. Drives both the SQL exclude and the per-operator
daily cap consistently. Unset => prior default; '' => exclude nobody.
2026-06-22 07:35:22 -05:00
justin
2d220a273d ops(carbonio): add noreply@ mailbox auto-purge + daily cron
Server-side classifier for the noreply@performancewest.net Carbonio mailbox
(35,337 msgs, ~98.6% machine noise). Deletes bounces/auto-replies/ticket
auto-acks, keeps genuine human Re: replies + unsubscribes (move to Trash,
reversible).

Classifier precedence: unsubscribe guard > RFC3834 Auto-Submitted header >
machine From-address (localpart/strong-token/display-bot) > STRONG auto
subjects (deletes deceptive Re: auto-acks) > human Re: keep > broad auto-ack
subjects > default keep. Subjects RFC2047 MIME-decoded first.

Three-phase execution: Phase1 fast MAILER-DAEMON search-delete, Phase1.5 fast
search-delete of common auto classes (guarded against Re:/unsub), Phase2
header-classify the small remainder with KEEP-caching.

Validated 23/23 against hand-labelled live sample. Initial backfill reduced
35,337 -> 68 (67 human replies + 1 unsubscribe). Daily cron installed in root
crontab: 17 4 * * * --apply --days 3.
2026-06-21 04:55:50 -05:00
justin
e414ec4a5f fix(email): correct Reply-To header shape for listmonk (was silently dropped)
Listmonk applies campaign headers as `for hdr,val := range set { h.Add(hdr,val) }`
(internal/manager/manager.go v6.1.0): each map's KEY is the literal header name.
The trucking/CRTC/deficiency builders wrote {"name":"Reply-To","value":..} (and
{"key":..,"value":..}), which emits junk `name:`/`value:` headers and NO real
Reply-To, so replies fell back to the From address (noreply@send.performancewest.net)
instead of info@performancewest.net. HC builder already used the correct
{"Reply-To": value} shape; match it everywhere. Verified against listmonk source.

Impact: outbound only; no customer replies were lost (noreply@ is a real mailbox),
but reply UX pointed at a no-reply address. Live campaign headers re-patched separately.
2026-06-21 01:03:07 -05:00
justin
297db74fee trucking: support full-price control arm in coupon A/B (pct 0 = no code)
CAMPAIGN_COUPON_AB_PCTS="20,30,0" now means 20% / 30% / full-price. The 0 arm
mints no code; pick_coupon_for_email returns ("","") so it renders identically
to a normal-price send, while carriers are still deterministically hash-bucketed
into it (re-hash a converter's email to recover their arm). Even ~33/33/33 split
incl. the control verified over 30k. Adds test_full_price_control_arm; 8/8 pass.
2026-06-21 00:12:30 -05:00
justin
2f0753f00e trucking: add idempotent patcher for main-campaign coupon blocks (186/188 computed prices) 2026-06-20 17:43:46 -05:00
justin
579919197d trucking: compute coupon discounted prices on the fly (true per A/B arm) + fix CTA URL bug
Two correctness fixes that gate enabling the coupon test:

1. On-the-fly pricing. The coupon block hardcoded '$79 $47' (only true at 40%
   off) — a false claim on the 20/30% arms. Now build_trucking_campaigns.py
   reads api/src/service-catalog.ts (same source checkout uses) and computes
   coupon_price_full / coupon_price_deal per recipient as full - round(full*pct/100),
   exactly matching the server. Service-fee-only; non-discountable services
   (boc3-filing passthrough) get NO price and fall back to percent-only copy.
   Quotes the service the email is ABOUT (mcs150 $79, reactivation $149), not the
   bundle the CTA happens to link to. service-catalog.ts now ships in the worker
   image; helper degrades to percent-only if it can't be read.

2. CTA URL bug (likely a big driver of the zero-click problem). Main campaign
   CTAs render '/order/slug&utm_source=...' (no '?') -> HTTP 404, verified live.
   Deficiency CTAs would double-'?' once a coupon added '?code='. lp_link now
   owns the query (?dot=...&code=...) so every template appends with a leading
   '&' and is valid in all 4 states (main/deficiency x coupon on/off), verified
   against live URLs returning 200.

Deficiency _deal_box now shows real was/now prices (percent-only for boc3).
Tests: 7/7 pass (adds URL-wellformed + price-matches-checkout cases).
2026-06-20 17:43:11 -05:00
justin
6fce3ec9eb trucking: A/B/C coupon price test (20/30/40% off) + SpamAssassin harness
- CAMPAIGN_COUPON_AB_PCTS="20,30,40" mints one daily code per arm; each
  carrier is bucketed by a stable sha256(email) hash so the split is even
  (~33/33/33 verified over 30k) and stable across re-sends (no arm-hopping).
- Each arm's code stores its own percent in discount_codes, so the advertised
  discount always matches what checkout applies; redemptions are countable per
  code (marker campaign-daily:<date>:<pct>).
- Empty/unset keeps legacy single-arm behavior (COUPON_PCT, legacy marker).
- coupon_attribs() now takes per-recipient pct.
- Tests: scripts/tests/test_coupon_ab.py (5 pass). SpamAssassin: both main
  campaigns (186/188) score 0.0 HAM across all 3 arms, coupon block renders
  clean; harness saved for re-runs.
2026-06-20 16:41:47 -05:00
justin
1acae2f20c healthcare: fix 4 bugs in segment-assignment + free-check email
Found during a bug-review pass of the one-email-per-provider work:

1. assign_all overwrite bug: an email on MULTIPLE rows (shared practice inbox /
   multiple NPIs -- 2,592 such emails, 299 with mixed status) was assigned by
   the LAST row, so a less-urgent row could clobber an urgent one (overdue ->
   free check). Now keeps the most-urgent (lowest-priority) assignment.

2. warm_segment double-import + wrong-row render: all of an email's rows passed
   the candidate filter, so it could be imported twice (over-counting the slice)
   and attribs_for could render a sibling row's blank due-date in the overdue
   email. Now requires row_matches(seg) for the specific row AND dedupes by
   email (one row per email).

3. free-check email rendered broken text ('last updated on  -- about  years
   ago', 'Last updated  . ~ yrs ago') for any provider whose NPPES date isn't
   cached yet (the free check goes to everyone, and the fill is gradual). Wrapped
   the example sentence + official-record card in listmonk {{ if
   .nppes_last_updated }}...{{ else }}...{{ end }}; added a date-free else
   branch. altbody keeps the conditionals (listmonk evaluates body+altbody), and
   the test/preview renderer gained a minimal {{ if/else/end }} evaluator so
   previews match real sends. Verified both branches render with zero unfilled
   tokens.

4. cross-cron double-send: pw-hc-campaign (warmup file) and pw-hc-nppes (63k
   file) share state but tracked imports per-segment; 312 emails overlap both
   files, so a provider could get an urgent email from one cron AND the free
   check from the other. Added load_all_imported() global guard (union of all
   segment state) so each provider gets exactly one healthcare email overall.

All verified: assignment regression test (10 cases) + new dup-email/guard checks
pass; all 6 templates render clean.
2026-06-20 16:14:44 -05:00
justin
0320dc17ba healthcare: one-email-per-provider by urgency priority + free check as default
Make the free NPI compliance check the catch-all for ALL verified institutional
providers, but route anyone with a more important/time-sensitive issue to THAT
email instead -- each provider gets exactly one email, their most urgent.

- SEGMENTS gain a 'priority' (lower=more urgent): reactivation 10, revalidation
  overdue 20, due-soon 30, bundle 45, free-NPI-check 100 (catch-all).
- assign_segment()/assign_all(): route each provider to the single
  highest-priority active segment whose selector matches; warm_segment() takes
  the assignment map and only claims its assigned providers (disjoint pools, no
  double-mailing). main() now splits the daily slice by priority order, serving
  urgent segments fully before the broad free-check consumes the remainder.
- nppes_outdated selector -> 'institutional_default' (every verified, non-
  deactivated row), since the free check's value no longer depends on staleness;
  list/campaign renamed 'HC Warmup - Free NPI Check'.
- FIX latent bug: reactivation selector treated 'not on CMS reval list' as
  deactivated -- false for org NPIs (would mis-tell active practices they're
  deactivated). Now uses the REAL nppes_deactivated flag (or OIG/SAM exclusion).
- Drop blanket oig_screening from the default rotation: it matched every row and
  would starve the catch-all, and the free check already screens OIG/SAM and
  routes to the paid fix on a hit. Still runnable via --segments.
- Add scripts/test_segment_assignment.py (10 cases incl. 'overdue AND stale ->
  overdue wins'); all pass.
2026-06-20 16:01:23 -05:00
justin
4ed1498ef3 healthcare: reframe NPPES email as a FREE NPI compliance check
Pivot the weakest healthcare email from an 'your record is out of date -> buy an
update' sell into a free, value-first compliance check (the funnel already
exists: /tools/npi-compliance-check + /api/v1/npi/lookup run 5 live gov checks --
NPI status, Medicare revalidation, OIG/SAM exclusions, NPPES freshness -- and
deep-link to the right paid fix).

- Subject: 'A free compliance check for your NPI' (was 'may be out of date').
- Header: 'Free NPI Compliance Check' covering NPPES/revalidation/exclusions/NPI.
- Body: keep the REAL last_updated date as a credibility hook ('we pulled your
  public records'), but frame it honestly ('that's usually fine') and pivot to
  the broader free check. Adds a 4-item 'your free check covers' card.
- CTA now -> /tools/npi-compliance-check?npi={npi} (prefills + auto-runs their
  own check on landing) with @TrackLink + UTM; dropped the straight-to-order
  NPPES CTA and the redundant 'look up on NPPES' button.
- Reassurance reframed to free-first ('the check is completely free; a fix is
  optional, flat-fee'). cta_path updated in the segment registry.
- Verified: render + plaintext + headless screenshot, CTA tracked, no stray
  order link, zero unfilled tokens.
2026-06-20 15:46:26 -05:00
justin
a430d5e329 healthcare: honest NPPES framing -- stale != out of business / no auto-penalty
An old NPPES last_updated date does NOT mean the practice closed or that CMS
penalizes them: an NPI never expires and there is no NPPES login schedule. Many
records are stale precisely because nothing changed. Removed the overclaim that
an old record 'has almost certainly drifted' and the false 'attest periodically'
duty. Now states the real rule (correct NPPES within 30 days of a change) and
makes the harm conditional ('if anything has changed since then, your record is
now out of date'). Keeps NPPES distinct from Medicare revalidation/PECOS, which
is the separate segment that actually carries deactivation stakes.
2026-06-20 15:30:03 -05:00
justin
744f0a89cf healthcare: bound NPPES-stale window [3,10]yr + restore verify_ok gate
- Add NPPES_STALE_MAX_YEARS (default 10): a record untouched for many years is
  a stronger signal the practice closed/moved, and a bounce burns the warming
  IP. Observed institutional distribution clusters 3-7yrs with ~0 beyond 8, so
  10 is a safe ceiling that mails the whole real pool while excluding any
  outlier ancient record. MIN stays 3 (keeps the 'out of date' claim credible).
- Restore the SMTP-verification gate (verify_ok) that the shared
  institutional_verified selector had -- the swap to nppes_stale dropped it; we
  only mail inboxes we already proved live.
- enrich: process the re-fetch queue STALEST-FIRST so a bounded (--limit) or
  --max-age refresh spends its budget on the most-overdue cache entries (and new
  NPIs) first, never starving them behind merely-aging ones.
- Selector unit-tested (10 cases incl. window edges, verify gate, deactivated).
2026-06-20 15:28:12 -05:00
justin
9e155d214c healthcare: cite REAL NPPES last_updated date in 'outdated' email
The NPPES 'may be out of date' email previously asserted staleness with no
per-record evidence (softened earlier to a generic 'periodic review required').
NPPES is fully public and every record carries basic.last_updated, so we now
cite the actual government date the provider can verify on the registry.

- enrich_nppes_last_updated.py: joins real basic.last_updated /
  enumeration_date / deactivated onto the institutional list via a cached,
  resumable per-NPI crawl (no batch endpoint exists). Adds nppes_last_updated,
  nppes_enumeration, nppes_years_stale, nppes_deactivated.
- cron: new 'nppes_stale' selector mails ONLY records >= 3yrs stale (env
  HC_NPPES_STALE_MIN_YEARS) and excludes deactivated NPIs; empty date => no
  match, so we never claim staleness without the government date to back it.
- template: headline + official-record card now show the real last_updated
  date and ~N-years-ago, sourced to npiregistry.cms.hhs.gov.
- attribs + test SAMPLE expose the new fields; verified render + plaintext.
2026-06-20 15:21:15 -05:00
justin
d8e3e40dda healthcare emails: remove prices, fix click tracking, de-risk claims
Diagnosing zero healthcare sales (11k sent, 5479 opens, 0 clicks, 0 orders).
Root cause of clicks=0: Listmonk only registers a link for tracking when the
href ends with the literal @TrackLink marker; all 10 hc templates lacked it
(trucking/CRTC have it). So the entire funnel was unmeasurable below 'open'.

Changes:
- Click tracking: append @TrackLink + UTM to every /order/ CTA across all 10
  templates (external gov self-verify links left untracked on purpose).
- Remove all service prices from emails (99/49/49/99yr/9mo). Price is
  now revealed on the order page after value is established; catalog
  (api/src/service-catalog.ts) stays source of truth. Kept the 0,000 OIG
  penalty stat (regulatory fact, not our price). Added a neutral 'flat fee shown
  up front' reassurance block where the fee table used to be.
- Compliance/honesty: the nppes_outdated email asserted a per-record
  'FLAGGED OUT OF DATE / detected' status, but its selector only checks
  deliverability and the data has no NPPES last-updated field -> unsubstantiated
  for every recipient. Reframed to a generally-true periodic-attestation message
  ('PERIODIC REVIEW REQUIRED', 'most practices drift out of date'). Same hedging
  applied to npi_reactivation ('may be deactivated ... confirm on official
  sources'). Substantiated reval 'past due' claims (backed by the public CMS
  Revalidation list) were kept.
- Fixed stale $299 OIG metadata in build script -> $79/mo (reference only).

Docs: docs/healthcare-competitive-pricing.md (benchmark research) and
docs/healthcare-email-compliance-review.md (CAN-SPAM / FTC / impersonation pass;
flags SOC2/HIPAA/PCI badge claims for owner confirmation).

Verified headless: all 10 render with 0 JS errors, exactly 1 tracked CTA each,
no price leaks.
2026-06-20 09:37:02 -05:00
justin
9a9b0b9130 Add Ontario alongside BC on CRTC page
- New 'Choose your province: BC or Ontario' comparison card (entity name,
  registered office city, fees, annual return, portal, area codes, corp tax)
  inserted above the carriers banner. Previously Ontario was only mentioned
  in a buried FAQ; BC outnumbered ON 53:12.
- Tax-comparison H2 + collapse-menu label now read 'British Columbia / Ontario'
  and the key-takeaway notes ON is ~12.2% (within ~1pt of BC).
- Made hero chip, 'what we deliver' (registered office + file corporation),
  and banking copy province-aware (BC or Ontario) instead of BC-only.
- Verified headless: province card renders, H2 visible (not auto-collapsed),
  13 accordions + proof expander intact, 28 Ontario mentions, no new JS errors.
2026-06-20 06:40:18 -05:00
justin
b1629160d5 crtc collapse: skip card-wrapped headings whose body isn't a sibling (fixes empty US-wholesale + banking expanders; vendor directory left inline) 2026-06-20 01:30:26 -05:00
justin
345c22e561 crtc page: add 'is this real?' proof expander (public RMD/499 stats + named CA operators, defamation-safe); keep standalone expanders out of auto-collapse 2026-06-20 01:12:10 -05:00
justin
0562fd2bd3 crtc page: add 5-min-read TL;DR + collapsible deep-detail sections (non-destructive JS/CSS enhancement) 2026-06-20 01:08:41 -05:00
justin
4f52d12629 docs: mark MX-exclusion plan complete (all 3 fixes shipped)
Fix 2 (untagged NULL bucket cap) shipped in bc93d93; default is no-starve.
Plan fully implemented.
2026-06-20 00:21:52 -05:00
justin
bc93d93c5b mail: Fix 2 — bound the untagged (NULL mx_provider) bucket in the selector
Completes the MX-exclusion plan. Untagged carriers can't be excluded (the big-MX
gate is MX-based, so an unresolved Google/Yahoo domain would slip through), and
were previously UNCAPPED in select_sendable_carriers -- a flood of freshly-imported,
never-resolved domains could dominate a run before pw-mx-tag resolves them.

Added a single shared untagged_cap (env MAIN_UNTAGGED_MX_CAP, default max(quota,200))
so untagged sends are bounded without starving the pool: at the default the bucket
can still fill an entire run's quota (no behavior change today), but the cap can be
tightened to a fraction once pw-mx-tag has drained the backlog -- which is fast,
since only ~3,035 distinct *verified-sendable* untagged domains remain (< one
20k/day tag run). Tagged carriers keep their per-operator caps unchanged.

Verified: compiles; cap logic never starves at default, enforces the limit when
set lower.
2026-06-20 00:19:14 -05:00
justin
b7cce370d7 docs: mark MX-exclusion plan Fix 1 + Fix 3 shipped
Fix 1 (consumer mx: exclusion) and Fix 3 (pw-mx-tag cron) live as of 9eeed47.
Verified: warmup pool 353,909 after fix (not starved), mx:yahoodns.net cap=0
during warmup, cron tags idempotently. Fix 2 (NULL bucket cap) deferred.
2026-06-20 00:06:56 -05:00
justin
9eeed47c4b mail: close MX-exclusion gaps — exclude consumer mx: operators + add mx-tag cron
Fix 1 (build_trucking_campaigns.py): the warmup big-MX exclusion only covered the
clean-label operators (google/microsoft/proofpoint/...). Consumer mailbox
operators that mx_tag_carriers.py labels with an "mx:" prefix slipped BOTH the
exclusion and the per-MX throttle -- notably mx:yahoodns.net (283k sendable
carriers = Yahoo Small Business/AOL custom domains) and mx:icloud.com (25k), plus
comcast/charter/centurylink/windstream/tds/earthlink. These are custom domains
whose MX points at a consumer provider, invisible to the literal-domain blocklist.
Added CONSUMER_MX_OPERATORS, folded into WARMUP_EXCLUDE_OPERATORS used by both the
fetch_carriers() exclusion SQL and mx_daily_caps() (same day-30 ramp). Behind the
existing MAIN_SKIP_BIG_MX switch.

Validated read-only: after the fix the warmup-eligible pool is 353,909 carriers
(315,892 untagged + ~38k genuinely small/self-hosted operators), so the long tail
still sustains the daily quota -- not starved -- while 0 consumer-MX carriers are
selected during warmup.

Fix 3 (infra/cron/pw-mx-tag): mx_tag_carriers.py was on no cron, so the untagged
(NULL) backlog (~316k) never drained and new FMCSA imports stayed untagged,
slowly re-opening the gap. Added a daily 05:45 UTC cron (--only-unsent
--limit-domains 20000), before the 08:00 builder. Idempotent/bounded (only tags
mx_provider IS NULL). Verified live: a 200-domain test run tagged 216 domains.

(Fix 2 -- bounding the NULL bucket cap -- deferred; the cron will drain it.)
2026-06-20 00:03:47 -05:00
justin
285a4a087c docs: plan to close MX-exclusion gaps in trucking warmup
Analysis-only plan (no code shipped). The trucking builder's warmup excludes
big receiving operators (Google/MS/Proofpoint/...) by mx_provider, but three
holes let throttling/consumer MX through during the day<=30 window:

1. Consumer operators tagged with the "mx:" prefix (mx:yahoodns.net = 283,113
   sendable carriers, mx:icloud.com = 24,985, comcast/charter/centurylink/...)
   are NOT in BIG_MX_OPERATORS, so they slip both the exclusion and the throttle.
   These are custom domains whose MX points at Yahoo/iCloud -- invisible to the
   literal-domain blocklist, only catchable via MX tagging. Biggest hole.
2. 315,892 untagged (NULL) sendable carriers are sent to unvetted (kept by design
   for anti-starvation, but uncapped).
3. mx_tag_carriers.py is on no cron, so the NULL backlog never drains and new
   FMCSA imports stay untagged -- slowly re-opening gaps 1 and 2.

Plan proposes: CONSUMER_MX_OPERATORS set folded into exclusion+throttle (behind
the existing MAIN_SKIP_BIG_MX switch), a bounded cap on the NULL bucket, and a
daily pw-mx-tag cron. Includes live numbers, validation steps (dry-run selector
diff, no sends), and open decisions (re-introduction ramp, permanent vs warmup-
only exclusion for Yahoo/iCloud custom domains).
2026-06-19 23:55:15 -05:00
justin
98364009b0 docs: international compliance expansion plan (UK/AU/IE/NZ) + vertical portability matrix 2026-06-19 10:44:11 -05:00
justin
433b10ce98 mail: CRTC/USF campaign — add @TrackLink + UTM to CTAs (was recording 0 clicks)
Campaign 509 (CRTC USF Q3, 4,156 sent) shipped with raw <a href> URLs, so
Listmonk never registered the links and recorded ZERO clicks -- even though
Umami logged the real order-page visits AND a carrier phoned in after clicking.
Same mistake docs/fmcsa-trucking-plan.md already flagged ("Use @TrackLink on all
CTAs"); the trucking campaigns do it, the CRTC one didn't.

Listmonk only tracks a link when its href ends with the literal @TrackLink marker
(it strips it and rewrites through lists.performancewest.net/link/). Added a
_track() helper that appends UTM params (so Umami attributes the visit too) +
@TrackLink, applied to both the primary order CTA and the guide-PDF download.

The running campaign 509's body was also patched live in the DB (same two links)
so its remaining sends record clicks. Future CRTC campaigns get it from source.
2026-06-19 10:05:01 -05:00
justin
707d538847 mail: DMARC parser — classify whole 207.174.124.0/24 as ours (warmup pool)
First live ingest (28 reports) showed our warmup rotation pool (.91-.109, out0x)
mislabeled EXTERNAL because OUR_IPS only listed 4 specific IPs -- every one was
100% DMARC-passing, clearly ours, and would have generated false spoofing alerts.
Replace the literal-IP set with an ipaddress subnet check on 207.174.124.0/24
(our whole block). The only genuinely-external failing sender is 35.174.145.124
(AWS, 32 msgs spoofing us, SPF-fail/no-DKIM, all correctly rejected by p=reject) --
exactly the signal the --alert path is meant to surface.
2026-06-19 08:54:41 -05:00
justin
8e5590b492 mail: DMARC aggregate-report parser + dedicated dmarc@ mailbox ingestion
Tool 2 of the deliverability monitoring pair (Tool 1 = mail_reputation_monitor).
DMARC rua reports from dozens of operators (Google, Yahoo, Comcast, Cox, Bell,
Mimecast, Cisco ESA, GMX, mail.com, ...) were landing in ops@ (dmarc@ was a DL),
burying real mail and never parsed. Now ingested + queryable:

- dmarc@performancewest.net converted DL -> dedicated Carbonio mailbox; isolated
  IMAP creds in server .env, surfaced to workers in docker-compose.yml (mirrors
  OPS_IMAP_*). 29 historical reports moved ops@ -> dmarc@ via IMAP.
- scripts/dmarc_report_parser.py: IMAP fetch unseen -> decompress .gz/.zip/.xml
  (namespace-agnostic: classic + urn:ietf:params:xml:ns:dmarc-2.0 GMX/mail.com) ->
  parse aggregate XML -> upsert dmarc_report (keyed (org_name,report_id), no-op on
  re-parse) + dmarc_record per source IP. dmarc_pass = dkim_aligned OR spf_aligned.
  Marks \Seen. --dry-run/--all/--alert (7d per-IP summary + Telegram if one of OUR
  IPs <95% pass, or EXTERNAL IP sends >=20 failing msgs as us = spoofing under
  p=reject). psycopg2 imported lazily so --dry-run runs without the driver.
- api/migrations/102_dmarc_aggregate.sql: dmarc_report + dmarc_record tables.
- infra/cron/pw-dmarc-parser: 06:20 UTC daily --alert (after reputation, before scrub).
- docs/deliverability.md: DMARC section DONE; query examples.

Verified: dry-run --all parses all 28 reports (1 non-report test probe), 0 unknown
after the namespace fix.
2026-06-19 08:50:20 -05:00
justin
b45332b5f7 infra(cron): nightly mail-reputation snapshot (pw-mail-reputation)
Runs mail_reputation_monitor --alert at 06:10 UTC, piping the day's postfix log
(sudo cat, same pattern as pw-warmup-tg-alert) into the DB-connected workers
container. Builds the daily SNDS-equivalent reputation trend and Telegram-alerts
on operator regressions. Installed to /etc/cron.d/pw-mail-reputation.
2026-06-19 08:38:35 -05:00
justin
08f651dc1e feat(deliverability): mail reputation monitor (SNDS-equivalent from postfix logs)
Adds scripts/mail_reputation_monitor.py + migration 101 (mail_reputation_daily).
Sender reputation is judged by the RECEIVING operator (Microsoft/Google/Yahoo/
Proofpoint), and the provider portals (SNDS/Postmaster/CFL) need a login and lag
24-48h. Our postfix logs already carry the ground truth in real time: every send
records the receiving host + SMTP response, and the response classifies WHY:
  250            -> accepted
  451 4.7.500    -> throttled (Microsoft rate-limiting a cold IP)
  550 5.7.x      -> reject_reputation (spam/reputation)
  550 5.1.1/5.4.1-> reject_recipient (dead mailbox / access denied = list hygiene)
  550 ...SPAM    -> reject_content (SpamAssassin)

The parser classifies each egress delivery (out0x/hcout/relay) by (sending_ip,
receiver, outcome, reason_code) and upserts ONE daily aggregate row per bucket
(idempotent ON CONFLICT), so a nightly cron over the rotated log gives a queryable
trend without re-parse double-counting. --alert prints a per-operator summary and
Telegram-alerts on regressions (>=10% reputation rejects, or Microsoft >=70%
throttled). Reads stdin ("-") so the host-owned /var/log/mail.log can be piped
into the DB-connected workers container.

Motivation: 2026-06-19 audit found ~80% of Microsoft sends were getting 451 4.7.500
throttles on the warming IPs -- this makes that trend visible as reputation recovers.
2026-06-19 08:35:45 -05:00
justin
bd7ba23841 docs(deliverability): Yahoo CFL ENROLLED for both domains (reporting fbl@)
performancewest.net + send.performancewest.net both show Enrolled in the Yahoo
Sender Hub, reporting email fbl@. All three FBLs (Google Postmaster, MS SNDS+JMRP,
Yahoo CFL) now complete.
2026-06-19 08:29:12 -05:00
justin
b8b6444084 docs(deliverability): Yahoo CFL verification keys added for both domains
Added yahoo-verification-key TXT records via Hestia for performancewest.net
(apex) and send.performancewest.net; both propagated to all HE.net slaves +
public resolvers. Ready to click Verify in the Yahoo CFL form, complaint dest fbl@.
2026-06-19 02:13:48 -05:00
justin
a9bbfbf59b docs(deliverability): Microsoft MANUAL 2 fully DONE — SNDS access + JMRP both set
SNDS access requested/granted for 207.174.124.94 + .107; JMRP feeds registered
with complaint dest fbl@. Section marked complete. SNDS data populates in ~24-48h.
2026-06-19 02:03:30 -05:00
justin
f293466519 docs(deliverability): JMRP complaint dest set to fbl@performancewest.net
Corrected: JMRP feed destination was set to fbl@ directly (no forward needed);
ARF complaints route to ops@.
2026-06-19 01:00:16 -05:00
justin
60540f949d docs(deliverability): JMRP done — both IPs registered (pw1/.94, pw2/.107)
Note JMRP delivers ARF complaints to the signed-in MS account's email, not
automatically to fbl@; set a forward if that account isn't fbl@performancewest.net.
2026-06-19 00:59:49 -05:00
justin
776817c727 docs(deliverability): correct SNDS entry URL (snds.microsoft.com does not resolve)
Use the legacy sendersupport.olc.protection.outlook.com/snds/ (308-redirects) or
the direct substrate.office.com/ip-domain-management-snds/SNDS app URL. Flag that
snds.microsoft.com has no DNS.
2026-06-19 00:46:25 -05:00
justin
7828ee4587 docs(deliverability): fix SNDS/JMRP URLs for Microsoft's 2026 substrate migration
SNDS moved off sendersupport.olc.protection.outlook.com to
substrate.office.com/ip-domain-management-snds/. The old /snds/ and /pm/ links
308-redirect there. Document that the footer/help links going to microsoft.com
are boilerplate (not broken), and that you must Log in FIRST or the Request
Access / JMRP links bounce to login.microsoftonline.com (expected, not dead).
Add working direct links + canonical https://snds.microsoft.com entry point.
2026-06-19 00:45:59 -05:00
justin
e18f23634a docs(deliverability): document consumer-domain exclusion two-layer model + scrub
Records the Apple/iCloud addition, the builder-vs-list-based distinction, the
scrub_listmonk_consumer reconciliation tool + daily cron, and the 2026-06-19
first-run numbers (7,943 trucking + 21 HC stale consumer subs blocklisted).
2026-06-19 00:01:17 -05:00
justin
72c69a05c9 infra(cron): daily Listmonk consumer-domain reconciliation (pw-listmonk-scrub)
Runs scrub_listmonk_consumer against both listmonk and listmonk_hc at 06:30 UTC,
before the campaign builders, so any ENABLED subscriber matching the authoritative
exclusion list is blocklisted retroactively. Keeps list-based campaigns (FCC
Direct Contacts, CRTC/USF, etc.) from leaking onto consumer mailboxes after a new
domain (e.g. Apple/iCloud) is added to the exclusion list. Installed to
/etc/cron.d/pw-listmonk-scrub on the host.
2026-06-19 00:00:46 -05:00
justin
b40fc7ec36 feat(deliverability): exclude Apple consumer mail + scrub stale consumer subs from Listmonk
The fmcsa campaign builders already exclude gmail/yahoo/microsoft/etc. from NEW
audience selections, but two reputation leaks remained on the LIST-BASED side:

1. iCloud/Apple gap. icloud.com/me.com/mac.com were never in the exclusion set.
   A 2026-06 Listmonk audit found 1,321 ENABLED iCloud subscribers on list 3
   ("FCC Carriers - Direct Contacts") -- the single largest enabled-consumer
   bucket -- being cold-blasted with no exclusion at all. Add APPLE_CONSUMER_DOMAINS.

2. Stale already-imported consumer subs. List-based campaigns (e.g. the running
   CRTC/USF blast on list 3) keep hitting consumer addresses imported BEFORE the
   relevant domain joined the exclusion list. gmail.com was still the #1 bounce
   domain via that campaign even though new selections exclude it. Add
   scrub_listmonk_consumer.py: reconciles the live Listmonk subscriber table
   against the authoritative exclusion list and blocklists any ENABLED subscriber
   whose address is_blocked(). Idempotent; re-run whenever the exclusion grows so
   it applies retroactively. Uses the same 'blocklisted' terminal state as the
   bounce handler, so contacts are excluded from all current/future campaigns
   without deleting history. Supports --dry-run and both listmonk / listmonk_hc.
2026-06-18 23:55:58 -05:00
justin
49842bddbb docs(deliverability): Microsoft #1 priority + role mailboxes created (Carbonio)
Created postmaster@/abuse@/fbl@/dmarc@ as Carbonio DLs -> ops@ (they previously
REJECTED 5.1.1, which would have blocked SNDS verification AND was silently
dropping all DMARC aggregate reports). Verified accept-at-MX + delivered E2E.
Reframe Microsoft as the #1 monitoring priority (85% of audience), Yahoo as
lowest (<1%); add Carbonio admin access note; note DMARC parser now worth building.
2026-06-18 23:31:20 -05:00
justin
3ca960aca5 docs+infra(deliverability): document bulk subdomain; ansible signs send.performancewest.net
- infra/ansible/roles/mail: refactor OpenDKIM to support multiple signing domains
  via opendkim_signing_domains list (root + send.performancewest.net). Loops
  keygen/ownership/keytable/signingtable so the live two-domain setup is
  reproducible from ansible.
- infra/ansible group_vars: add bulk_mail_subdomain + campaign_from_* +
  campaign_reply_to documentation vars (map to CAMPAIGN_FROM / HC_CAMPAIGN_FROM
  env read by the builder scripts). smtp_from (transactional) stays on root.
- docs/deliverability.md: rewrite TL;DR with the carrierone-vs-performancewest
  A/B proof (same server/IPs, different From domain -> Inbox vs Junk) and the
  ~85% Microsoft / 14% Google / <1% Yahoo audience mix; add the bulk-subdomain
  section, SPF trim, rehab-disabled, and the Hestia DNS automation runbook.
2026-06-18 23:12:05 -05:00