← back to Pitch Guard
Refine: never bother clients who already transacted or were already followed up
7308e45e42e98ceafb4ac72168d682b15d56b7f8 · 2026-06-16 14:53:13 -0700 · SteveStudio2
Root cause (2026-06-16): the paid/shipped risk-gate lived ONLY in the UI Send
button, so a native Gmail-web send bypassed it — already-closed clients
(incl. one PAID+ORDERED+SHIPPED) got pitched a quote followup.
- assessRisk() + alreadyFollowedUp() shared probes
- candidate scanner now EXCLUDES level>=2 (ORDERED/INVOICED/PAID/SHIPPED) and
anyone followed up in last 21d — they never become candidates, so never drafted
- /api/followup/create refuses to draft a transacted contact (defense-in-depth)
- swept + trashed today's followup drafts to transacted clients (gsmsriedel/louie/
nicole/jgarner/sis9lives/tmmd44/daddydas) + duplicate drafts to already-sent ones
Files touched
Diff
commit 7308e45e42e98ceafb4ac72168d682b15d56b7f8
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Tue Jun 16 14:53:13 2026 -0700
Refine: never bother clients who already transacted or were already followed up
Root cause (2026-06-16): the paid/shipped risk-gate lived ONLY in the UI Send
button, so a native Gmail-web send bypassed it — already-closed clients
(incl. one PAID+ORDERED+SHIPPED) got pitched a quote followup.
- assessRisk() + alreadyFollowedUp() shared probes
- candidate scanner now EXCLUDES level>=2 (ORDERED/INVOICED/PAID/SHIPPED) and
anyone followed up in last 21d — they never become candidates, so never drafted
- /api/followup/create refuses to draft a transacted contact (defense-in-depth)
- swept + trashed today's followup drafts to transacted clients (gsmsriedel/louie/
nicole/jgarner/sis9lives/tmmd44/daddydas) + duplicate drafts to already-sent ones
---
server.js | 47 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 47 insertions(+)
diff --git a/server.js b/server.js
index 6edeb15..5aade7a 100644
--- a/server.js
+++ b/server.js
@@ -85,6 +85,39 @@ const SIGNALS = [
{ key: 'ordered', level: 2, label: 'ORDERED', re: /\b(order #?\d|order confirmation|order placed|your order|purchase order|p\.?o\.? ?#?\d)\b/i },
{ key: 'invoiced', level: 2, label: 'INVOICED', re: /\b(invoice ?#?\d|invoice attached|here('| i)s your invoice|invoice for)\b/i },
];
+// level ≥ this means "already transacted — do not bother with a quote followup"
+const BLOCK_LEVEL = 2;
+
+// Phrasings the followup bot uses — to detect we've ALREADY nudged this contact.
+const FU_RE = /follow[\s-]?up|following up|check(ing)? in|circl(e|ing) back|haven'?t heard|gentle (nudge|reminder)|chase up|did(n'?t| not) (you )?see/i;
+
+// Reusable transaction-risk probe (the Samantha tripwire). Shared by the candidate
+// scanner AND the draft-create guard so a client who already ordered/paid/shipped is
+// NEVER surfaced as a candidate and NEVER drafted — not merely blocked at the Send
+// button (a native Gmail-web send bypasses that button entirely, which is how
+// already-closed clients got pitched on 2026-06-16).
+async function assessRisk(email) {
+ email = String(email || '').toLowerCase();
+ let level = 0; const labels = new Set();
+ try {
+ const out = await georgeRetry('/api/search', { query: { account: ACCOUNT, q: `from:${email} OR to:${email}`, maxResults: 40 } });
+ for (const m of (out.messages || [])) {
+ if (m.error) continue;
+ const hay = `${m.subject || ''} ${m.snippet || ''}`;
+ for (const s of SIGNALS) if (s.re.test(hay)) { level = Math.max(level, s.level); labels.add(s.label); }
+ }
+ } catch (e) { return { level: 0, labels: [], error: e.message }; }
+ return { level, labels: [...labels] };
+}
+
+// Have we already SENT this contact a followup in the last 21 days? (in:sent only —
+// an unsent draft doesn't count.) Used to avoid double-nudging someone.
+async function alreadyFollowedUp(email) {
+ try {
+ const fu = await georgeRetry('/api/search', { query: { account: ACCOUNT, q: `in:sent to:${email} newer_than:21d`, maxResults: 10 } });
+ return (fu.messages || []).some(m => !m.error && FU_RE.test(`${m.subject || ''} ${m.snippet || ''}`));
+ } catch { return false; }
+}
// ── vendor vs client classification ──────────────────────────────────────
// The followup bot drafts "following on the quote" replies to vendor support
@@ -242,6 +275,12 @@ async function runCandidateScan() {
const dr = await georgeRetry('/api/search', { query: { account: ACCOUNT, q: `in:draft to:${contact}`, maxResults: 2 } });
if ((dr.messages || []).some(x => !x.error)) { await sleep(150); continue; } // already drafted
} catch (e) { uncertain = true; }
+ // transaction tripwire: if they already ordered/paid/shipped, they are NOT a
+ // followup candidate — never surface them, so they can never be drafted/sent.
+ const risk = await assessRisk(contact);
+ if (risk.level >= BLOCK_LEVEL) { await sleep(150); continue; } // already transacted → leave alone
+ // and don't double-nudge someone we already followed up recently.
+ if (await alreadyFollowedUp(contact)) { await sleep(150); continue; }
candidates.push({
threadId: info.msg.threadId,
subject: info.msg.subject || '(no subject)',
@@ -484,6 +523,14 @@ app.post('/api/followup/create', async (req, res) => {
try {
const { to, subject } = req.body || {};
if (!to) return res.status(400).json({ error: 'to required' });
+ // defense-in-depth: NEVER create a followup draft for someone who already
+ // transacted — even if a caller (or stale UI) asks for it. The candidate
+ // scanner already excludes them; this is the second wall.
+ if (!req.body.override) {
+ const risk = await assessRisk(to);
+ if (risk.level >= BLOCK_LEVEL)
+ return res.status(409).json({ error: 'blocked: contact already transacted (' + risk.labels.join(', ') + ') — not drafting a quote followup', risk: risk.labels });
+ }
const subj = /^re:/i.test(subject || '') ? subject : 'Re: ' + (subject || 'your inquiry');
const out = await george('/api/drafts', { method: 'POST', body: { account: ACCOUNT, to, subject: subj, body: FOLLOWUP_BODY, no_source_tag: true } });
candCache = null; // invalidate so a rescan reflects the new draft
← 0d9ee55 Fix non-deterministic followup-candidate scanner: single-fli
·
back to Pitch Guard
·
Add Designer Wallcoverings logo to header; (GEORGE_URL fixed 75b161a →