Updateprospecting4:04 AM · ZRosserMcIntosh
a hard bounce suppresses the address, never the business's domain
- SuppressionEffect.domainWide (false for hard_bounce and Brevo 'blocked').
- suppressionDomainForKind / suppressionDomainUpdate: a new bounce row has no domain; re-applying never weakens a domain an earlier opt-out wrote, and still repairs shared-mailbox domains to null.
- applySuppression, the Brevo blocklist import (docs + dry-run output) and the legacy backfill script all use them.
Refactorprospecting4:00 AM · ZRosserMcIntosh
one suppression model — one domain list, one writer, one matching rule
- suppression-rules.ts SHARED_MAILBOX_DOMAINS is the only list (gains hughes.net, ptd.net, inbox.com from free-mail.ts); free-mail.ts and its test are deleted, their coverage moved to suppression.test.ts.
- suppression.ts findSuppressionRow is the only read: send.ts findSuppression, the assessment-report send, and the Brevo segment sync call it; requeue.ts (and its script) and the console's follow-up list use isAddressSuppressed(addr, rows).
- send.ts suppress() (zero callers, wrote gmail.com domains) is deleted.
- /api/pitch/unsubscribe calls applySuppression directly (kind unsubscribed, deduped OPTED_OUT event) instead of the webhook helper with the admin_blocked default plus its own event write.
Updateprospecting3:54 AM · ZRosserMcIntosh
one US-only jurisdiction verdict at the send gate and in the planner
- send.ts: the live gate refuses with the verdict's reason.
- cron/outreach-send toSendable: selects website, drops out-of-jurisdiction rows, clears any slot they held (future or due) and reports them in 'skipped'; they are never auto-approved. Stale follow-ups to such prospects lose their slot instead of being re-spread.
- schedule.ts assignSendSlots no longer carries its own CASL rule; the cron's candidate filter is the planner's single jurisdiction rule.
Choreprospecting3:47 AM · ZRosserMcIntosh
keep the safe draft inside the 195-word target
- A long verified asset plus a long observation put the business-specific
- safe draft at 196 words; trim one clause so typical drafts land in band.
Choreprospecting3:43 AM · ZRosserMcIntosh
dry-run script to null placeholder review figures
- Nulls JewelerProspect.googleReviews/googleRating where the pair is exactly
- the discovery model's placeholder (100 @ 4.8 or 0 @ 0) and no verified
- evidence row states that figure. --quarantine-evidence also rejects the
- matching unverified evidence rows; unsent emails quoting a placeholder are
- listed, never modified. Read-only by default (--apply to write); idempotent.
Featureprospecting3:41 AM · ZRosserMcIntosh
owners from About pages, honest site fetch, owner-first contact choice
- Browser-like User-Agent with a contact URL; 401/403/429/5xx and timeouts are UNKNOWN, never "missing" (Big Island's sitemap returned 403 only to us).
- Sitemap is absent only when /sitemap.xml, /sitemap_index.xml and any robots.txt Sitemap: line all answered and none was a sitemap.
- Visible text excludes <script>/<style> and keeps block boundaries (the Naifeh "prices on the homepage" were script text).
- Product-schema absence only from a real product page whose JSON-LD parsed (Gurinsky's had valid Product JSON-LD the category page lacked); a script-rendered page asserts no missing H1.
- Fetches up to three About / Our Story / Meet-the-family pages and extracts the owner / founder / president (JSON-LD founder or a role phrase) with the page it came from, plus any address published there.
- Captures the store's own structured aggregateRating with provenance and flags an authorized luxury-brand dealership.
- A likely name is confirmed when the store's own page names that person; a name its cited page does not bear out is downgraded to unknown.
- "Confident enough" to greet by first name: confirmed, or likely with a first-party source, an owner-type role, and not a business-name word — and never into another person's mailbox ("Hi Joe" to amanda@).
- Address ranking: owner's own mailbox > info@ > a salesperson's; switching to the owner's own published address only before anything was sent.
- Luxury-brand dealers get an ICP disqualifiedReason (lead-score tier D).
3:33 AM · ZRosserMcIntosh
Merge branch 'worktree-agent-afbdd3113716cd860' (prospecting console redesign)
3:31 AM · ZRosserMcIntosh
Merge branch 'worktree-agent-af75eaddd76c2238d' (prospecting send path)
3:29 AM · ZRosserMcIntosh
Merge branch 'worktree-agent-a34b4ceae967d4e3e' (prospecting compliance)
Testsprospecting3:25 AM · ZRosserMcIntosh
steady the console UI tests under full-suite load
- The dialog round-trip tests passed alone but timed out at the 5 s default
- when the whole prospecting suite (PDF renders included) ran in parallel.
- Give the two UI files a 30 s test / 10 s find budget, and type the loader
- test's Prisma mocks so their implementations take the query argument.
Updateprospecting3:17 AM · ZRosserMcIntosh
evidence-backed first touches across every rule layer
- Discovery no longer asks the model for ratings or review counts (46 prospects held its "100 reviews @ 4.8" placeholder; 7 emails quoted it), persists none, and drops model rows carrying a review figure or an absence.
- New email-evidence.ts: every number in the subject/body must appear in the evidence; a rating/review count only from a row with VERIFIED provenance; a named problem ("no sitemap", "doesn't mention X") only from our own dated fetch (a 403 proves nothing; "doesn't mention X" is checked against the captured element text). Fail-closed when no evidence is supplied.
- Stage 1 downgrades unverified review figures and unverified absences.
- Rules are built from the attachment mode (the writer was told to reference a PDF the production validator forbids). FIRST_TOUCH_ATTACH is now opt-in.
- Pain reframed to bridal appointments, custom inquiries, repeat customers and Maps calls; American spelling enforced for US stores; subjects are never cut mid-phrase; no price comparison of any kind ("a tenth of what we'll charge" is gone from every layer).
- A draft that fails twice is PARKED ("Parked for review:" errorDetail, fact check failed) instead of replaced with the canned template; the safe draft always names the business.
- paid-traffic ($2,000/mo + $1,250 onboarding), local-seo and bridal-growth retired to the K99 ladder; onboarding shown as waived with no amount; retail-growth defaults to K99 Growth.
- Arm C frozen; single-arm mode (A) by default, PROSPECTING_EXPERIMENT_MODE=ab restores the 50/50 split. Arms documented in config.ts.
- ICP fit: discovery drops 3+ store operations, luxury-brand dealers, marketplace-only sellers and pop-ups; lead-score caps 500+ reviews / 3+ stores at C and an explicit exclusion at D.
Updateprospecting3:16 AM · ZRosserMcIntosh
standard-panel confirm result shadowed its success counter
- The in-page confirmation in StandardPanel.runFix was bound to `ok`, which the function already declares as its success counter
- a redeclaration that would fail the build. Renamed to `confirmed`. Adds smoke tests for the Tools area (each section mounts on open; Start Autopilot, one-off search and the payment test all ask first and do nothing when cancelled) and for the expanded prospect card (block reason, truthful attachment badge, no dead buttons, Rebuild asks with its cost).
Featureprospecting3:13 AM · ZRosserMcIntosh
predict the attachment gate so unscheduled rows never read as ready
- A first touch is only refused
- and its reason recorded
- when the cron reaches it. Rows the planner stops scheduling (e.g. once expired decks are filtered out of the sendable set) would otherwise look "ready" on the console. The loader now also reads the email's attachment policy and the age of the deck it attaches, and predicts the two attachment refusals (first-touch attachments switched off; deck past PROPOSAL_VALIDITY_DAYS), mirroring send.ts resolveEmailAttachment. A recorded refusal still wins: the copy gate runs before the attachment gate. Also: status auto-refresh every two minutes instead of one, and tap-to-copy phone / email chips on the deal page header.
Featureprospecting3:10 AM · ZRosserMcIntosh
redesign the console around health, blocks and hot leads
- Health strip: LIVE/PAUSED/BLOCKED with the real reason, last send, sent today vs cap, queue ready/blocked/needs-you, replies + inbox heartbeat, 7-day bounces/unsubs/complaints, AI spend; a red "No emails sent in N days" alarm on weekdays. The header no longer claims "Nothing sends without approval" - it states what auto-approve actually does.
- Blocked: every refused first touch grouped by reason with the gate's exact message; targeted fixes (rewrite / rebuild / re-queue) that state the count, the cost, and that auto-approve will send the result live.
- Needs you: hot leads (checkout, reply, human-timed click) with tap-to-copy phone and tap-to-call, due next steps, parked drafts, next sends.
- Follow-ups (steps 2-4) listed read-only, past-due and suppressed flagged; deliberately no approve button.
- Pipeline: bucket tabs, sort and arm filter moved next to the list (they only rendered inside Autopilot, so "Process all" acted on 0 rows); tabs are now a partition; bulk actions show their count and disable at 0.
- Rows: "Send now" hidden when a gate is known to refuse; "Approve" only when auto-approve is off; one "Rebuild" replaces Fetch site / Full pipeline / Reassess / Regenerate (no alert(), no page reload).
- Tools: Autopilot + one-off search + add prospect, build standard (loads on open), payment test, AI spend detail, settings, scoring/diagnosis.
- Every spend/live-send action confirms in-page (no window.confirm), including Start Autopilot and Diagnosis "Use this guidance".
- Deal page: score panel now passes contactEmailVerification (every prospect showed Mail-only capped at C); stage changes confirm; the dead "Draft proposal email" is gone; PostGrid tucked under Advanced.
- Calendar: the tracking rule reads PROSPECTING_TRACK_OPENS instead of claiming tracking is off.
- Relative times render after mount only (React #418 hydration error).
Featureprospecting3:09 AM · ZRosserMcIntosh
read model for console health, blocks and hot leads
- console-health-rules.ts (pure, tested): classifies every unsent first touch as ready / needs-you / blocked-by-<reason>, maps send-gate messages to owner-readable reasons with the fix that clears each, derives live/paused/blocked with the actual reason, the "no sends in N weekdays" alarm, and hot leads (reply, checkout, or a click at least 10 minutes after delivery - the 7 audited "deck clicks" were scanners at +1-3 min).
- console-health.ts: the SELECT-only loader; each read degrades its own panel instead of failing the page.
- GET /api/admin/prospecting/health: read-only status for the console.
Updateprospecting3:06 AM · ZRosserMcIntosh
re-queue the Brevo-outage FAILED rows safely (pure planner + dry-run script)
- requeue.ts planOutageRequeue (pure, tested): only FAILED rows with the 401 / unrecognised-IP text; skips stopped prospects, suppressed addresses (free-mail aware), steps already SENT, prospects that already hold a newer draft for that step, follow-ups whose previous step never went, and older duplicates. Re-queued rows become DRAFT with no slot and no error, so they re-earn approval through the cron's triage and gates.
- scripts/prospecting/reset-401-failures.ts: dry run by default, writes only with --apply (the old --write flag is refused), prints which rows the cron will park as Needs rewrite. Idempotent; the UPDATE is guarded on status = FAILED. NOT run.
Updateprospecting3:06 AM · ZRosserMcIntosh
unblock the send path coherently — slug links, needs-rewrite parking, clamped schedules, 10/day ramp
- attachmentDecision (send-readiness.ts): with the flag off a first touch never carries the deck, and a withheld deck no longer refuses the send.
- needsRewriteReason: a body that promises an attachment that will not be sent, or a first touch on an expired deck, is PARKED: status DRAFT, errorDetail "Needs rewrite: <reason>", no slot. No migration — the same prefix convention as "Scheduled send blocked:".
- The cron triages the queue every tick (triageQueue): parks those rows instead of planning/refusing/retrying them, releases a parked row as soon as its copy or deck changes, and keeps the 20h backoff for ordinary refusals. The send path returns needsRewrite so the cron never overwrites the parked reason with the retry marker.
- a slot older than DUE_GRACE_MS (1h) is STALE — never fired late; first touches are re-planned into future slots, stale follow-ups re-spread onto future weekday mornings (9–11 local) under the follow-up lane cap;
- new follow-ups are placed no sooner than their cadence day AND the cadence gap after the previous touch actually went out, never in the past (nextWeekdayMorning returned past times);
- the planner reserves capacity already spoken for (today's sends, due rows, approved follow-ups); unplannable rows lose stale slots. "Send before re-planning" is unchanged; cadence stays */15.
Updatepitch3:05 AM · ZRosserMcIntosh
$125 checkout accepts the named slug as well as the legacy token
- offer-link.ts: one place that knows an order answers at two permanent URLs. isOfferLinkSegment accepts slug or token and rejects garbage; offerUrlForBody picks whichever of the two a body actually carries.
- checkout: accepts slug or token (normalised), looks the order up by either, and returns the buyer to the canonical named URL instead of bouncing through the token redirect. Stripe metadata still carries the token and orderId the webhook reads.
- Standard grading (admin): grades paid-arm emails against the link the body carries, so slug-link drafts stop showing as off-standard.
Updateprospecting3:04 AM · ZRosserMcIntosh
fetch reply bodies only for prospect senders; tidy blocked reason
- The Gmail poller reads headers for every inbox message and pulls the full body only when the sender matches outreach we sent, so non-prospect mail costs what it did before.
- A Brevo `blocked` event without its own reason is stored as "blocked", not "blocked: blocked".
Updateprospecting3:03 AM · ZRosserMcIntosh
every prospecting admin write requires OWNER or MANAGER
- requireKaturaAdmin() with no roles admits EMPLOYEE. Only emails/[id] POST and
- prospects/[id] DELETE were OWNER/MANAGER, so any employee could start $100
- Autopilot runs, run discovery, rewrite every draft, switch the writer's
- guidance, create test payments, and (with auto-approve on) cause live sends
- by regenerating a draft.
- requireProspectingWriter() (lib/prospecting/admin-guard.ts) now guards all 18
- write handlers: brevo-sync, discover, prospects POST, prospects/[id] PATCH and
- DELETE, fetch-site, generate, mail-piece, process, proposal-email, reverify,
- emails/[id] POST, emails/rewrite-all, diagnose POST, diagnose/[id] PATCH,
- mail-pieces/[id] POST, experiment/test-payment, runs POST. Read-only GETs are
- unchanged. Deliberate exception: runs/[id] POST, which only accepts "stop"
- and only reduces spend.
- Tests: a static scan fails any new write handler that doesn't use the guard,
- and each write handler, called as an EMPLOYEE, answers 403 before touching
- the database.
Featureprospecting3:00 AM · ZRosserMcIntosh
dry-run scripts to import Brevo's blocklist and cancel stale follow-ups
- import-brevo-blocklist.ts: GET /v3/smtp/blockedContacts (paged), map the reason codes (unsubscribedVia*, hardBounce, contactFlaggedAsSpam, adminBlocked), keep the strongest signal per address, and apply each through applySuppression: suppression row, prospect SUPPRESSED / LOST, every unsent email cancelled, deduped event, bounce / complaint stamp only when missing. Defaults to prospecting addresses; --all for the whole account; --check-contacts also asks the contact API about each emailed address. Idempotent.
- cancel-stale-follow-ups.ts: cancels unsent step 2+ drafts whose prospect bounced, opted out, complained, replied, is suppressed, is outside the US, or whose first touch is over 21 days old. Idempotent.
- The decisions live in pure, tested modules (brevo-blocklist.ts, stale-follow-ups.ts) so the scripts stay thin.
- backfill-suppressions.ts no longer writes a domain for shared mailbox domains (email.com, 1network.com).
Updateprospecting3:00 AM · ZRosserMcIntosh
follow-up drafting stops paying for the same failure
- findFollowUpCandidates: the where clause wrote `emails` twice, so for steps 3-4 the touch-1 cutoff and its bounce / complaint / reply checks were silently replaced. Both clauses now sit in AND. The previous step must have sent at least 3 days ago, and a first touch older than 21 days gets no follow-ups (the 79 August step-2 drafts are all past that).
- Candidates skip non-US prospects (isSendableJurisdiction), suppressed addresses / business domains, and steps under backoff.
- Failure memo: every recurring draft failure writes a FOLLOW_UP_DRAFT_FAILED event (step, attempt, reason, spent); the drafter waits 6h, then 12h, and gives up after 3 attempts. No schema change.
- Daily budget: 12 draft attempts per UTC day (successes and failures, PROSPECTING_FOLLOWUP_DRAFT_DAILY_BUDGET), checked both when choosing candidates and before the model call.
- followUpSendNotBefore(first, step, now) clamps a follow-up slot to now for the scheduler (not wired: the outreach-send route owns the slot).
Featureprospecting2:52 AM · ZRosserMcIntosh
isSendableJurisdiction — cold email to US prospects only
- Pure allowlist helper for the send gate: Canadian provinces (CASL) and the UK
- (PECR) are not sendable, an unknown jurisdiction fails closed, and a .ca / .uk
- address or website overrides a US state code. Not yet wired into send.ts,
- which the send-path change owns.
Updateprospecting2:52 AM · ZRosserMcIntosh
honour "remove" / "unsubscribe" replies in the Gmail poller
- Fetch replies in `full` format (same gmail.readonly scope), take the prospect's own words only: Gmail / Apple Mail / Outlook quoted history and our own footer are stripped before anything is read.
- remove / unsubscribe / stop / take me off / not interested (plus do not contact, opt out) in the first 500 characters, or a new subject such as the List-Unsubscribe mailto's "unsubscribe", suppresses through applySuppression: suppression row, prospect SUPPRESSED, every unsent step cancelled, OPTED_OUT event. "stop" and "remove" only count in an opt-out shape, so "stop by the store" and "remove the old banner" stay ordinary replies.
- Other replies keep the REPLIED contract; the cancel now covers every unsent status. The REPLIED event stores a 200-char snippet and an optOut flag.
- The dormant Mailgun inbound hook uses the same writer instead of send.ts suppress().
Updateprospecting2:52 AM · ZRosserMcIntosh
one suppression writer, email-level for shared mailbox domains
- applySuppression (suppression.ts) is the single writer: email-level row always, domain-level only for a business's own domain (free mail, ISP mail and web-host aliases get none), first reason wins, domain re-derived so old free-mail domain rows are repaired on the next touch.
- Cancels every unsent DRAFT / APPROVED / SCHEDULED / FAILED email to the recipient at every step, not only DRAFT / APPROVED.
- The client is a parameter so scripts/prospecting/ can run the same path.
- Brevo webhook: prospecting writes run before the D2C writes for bounces and complaints too, and `blocked` / `invalid_email` keep their own event name.
Featurecatalog2:45 AM · ZRosserMcIntosh
make /api/meta-catalog a superset of the website crawl
- scope = what the crawl can see: Katura storefront, ACTIVE, not Rosser-only, a public price (it previously included other storefronts and Rosser-only pieces); inquiry-only and showcase pieces with a price are kept, labelled custom_label_2
- every product image in PDP order plus variant images: image_link + up to 10 additional_image_link; products with no usable image are left out instead of sending an empty image_link
- sale_price is the promotion / native sale price the storefront charges, only when lower; availability follows the PDP rule
- brand KATURA, specific Google jewelry categories (rings 200, earrings 194, ...), item_group_id for variant groups, return_policy_info per RETURN_POLICY.md (30 days ready-to-ship; lab-grown and made-to-order final sale)
- control characters stripped so one bad description cannot invalidate the file; an empty result is a 503, never an empty feed that would wipe the catalog on a scheduled replace
- META_EVENTS_SETUP.md: steps to switch the catalog from the crawl to this feed
Featuretracking2:45 AM · ZRosserMcIntosh
send hashed email, phone, name and address on Meta events
- signed-in customers: profile + default saved address (getCapiCustomerContact) on ViewContent, AddToCart, InitiateCheckout, AddPaymentInfo and Purchase
- checkout guests: InitiateCheckout / AddPaymentInfo carry what was typed (email, phone, name, address; a gift sends only the buyer's email and phone) in the JSON body. The InitiateCheckout server mirror now waits for submit, page hide or a signed-in session so it has something to carry; the pixel still fires on arrival
- consultation bookings: one Lead per booking under a shared ld_ event id, the Conversions API twin sent by /api/consultation with the typed email, phone, name and country; the offer landing pages now fire the pixel Lead too
- browser pixel: fbq('init') gets hashed em/ph/fn/ln/ct/st/zp/country/external_id for signed-in customers; Automatic Advanced Matching stays on; still never for internal visitors
- blank or malformed fields are omitted, never hashed as sha256('')
- staff, test emails (including one typed at checkout) and the opt-out cookie send nothing; staff profiles are never looked up
- sendCapiEvent refuses to send from non-production (preview) deployments, whatever sourceHost a caller passes
Updatetracking2:44 AM · ZRosserMcIntosh
gate the webhook Purchase on the host the checkout ran on
- create-payment-intent and /api/checkout record the request host in the Stripe metadata (sourceHost)
- the Stripe webhook's two Meta Purchase sends use it instead of a fixed katura1999.com, so sendCapiEvent drops checkouts completed on investor., k99. or any non-storefront host; intents from before this change fall back to the apex
- iOS-app intents from /api/checkout/payment-intent (metadata source=ios-app, no channel) are no longer reported as website Purchases
Updatetracking1:01 AM · ZRosserMcIntosh
let the Meta Pixel deliver browser events through the CSP
- fbevents 2.9.x sends each event by POSTing a form into a hidden
- www.facebook.com iframe. form-action 'self' and a frame-src without
- facebook.com blocked every browser PageView/ViewContent/AddToCart, so
- Meta received server (CAPI) events only and could not deduplicate them.