Stella · c:\Stella 本地环境 · 2026-07-29

外联引擎 — 全场景测试方案

把这周改动过的每一条路径都拆成一份可执行清单:改哪些设置、发什么内容的回复或做什么动作、应该看到什么结果、去哪里核实。覆盖:参与度跟进序列、回复分类、冷却/再触达、换联系人重置、发送前二次校验、跨发送路径的拦截逻辑。

⚠ 从 A组开始的所有场景,都应该在一份从生产库拷出来的独立测试副本上做,不要连生产库本体——搭建方法见文末"H0 测试环境搭建";全部测完确认没问题后,"H1 正式接管时序"是从测试库切回真实生产环境的完整步骤。

0 / 0 scenarios done

0. 测试工具面板 Auto-Pilot 标签页

这些按钮/开关都在同一个 Auto-Pilot 标签页里,但分布在两个不同的子面板——之前把它们混在一张表里描述得不够准确,这里订正一下,并说明每一个到底调用了哪个后端接口。

按钮 / 开关所在位置调用的接口实际做什么
Run First Email Now「Email Outreach Autopilot」面板 → Auto Send New Emails 那一行POST /api/autopilot/run-now立即给测试客户发首封邮件,跳过每日上限/工作时段/节假日检查。注意:只有当 Auto Send New Emails 开关是 OFF 时才可点——开关 ON 时它会变灰禁用(不是消失,是变成灰色不可点,配文字提示 "Disabled while auto-send is ON"),很容易被忽略过去。2026-08-07 重构 优先按 test_client_id(这家公司)动态查当前符合条件的下一条 lead,不再死认一个固定的 test_lead_id——爬虫在同一家公司发现新联系人后,这个按钮也总能找到最新那一条。只有全新测试客户(还没生成过 clients 记录,即从没成功发过第一封邮件)才会退回用固定的 test_lead_id 查,作为启动阶段的兜底。
Set Test Client「Behaviour Triggers / Re-engagement」面板POST /api/autopilot/test-client指定 test_lead_id / test_client_id,下面几个控件都只作用于这一个客户。
Test Follow-up: ON同上每小时 cron,受 test_followup_enabled 控制开启后每小时自动对测试客户跑一遍 generateFollowupQueue + autopilotRecycleReengagements,无视节假日/时段限制——这就是能把"14天"压缩成"1小时"的机制,见下方"准备工作"第3步。2026-08-24 起 generateFollowupQueue 已经独自负责全部6种类型的判断(包括"Cold Restart 发过、没打开、升级到 Followup 2"这一段,不管打开次数多少),autopilotRecycleReengagements(原名 autopilotFollowUp)现在只剩"再触达发了没人回、超时收回 Cooling"这一件不相关的事。这个开关只对选中的测试客户生效,跟下面的真实开关完全独立,见下方"规则4"。
Run Test Follow-up Now同上POST /api/autopilot/run-test-followup-now手动触发一次:先轮询真实收件箱(checkInboxReplies),再跑 generateFollowupQueue(现在独自完成"生成 Cold Restart"和"没打开、升级到 Followup 2"两段,不用再依赖另一个函数接力),最后跑 autopilotRecycleReengagements 处理跟这两段完全无关的"再触达超时收尾"。
Preview Send Queue同上GET /api/autopilot/preview-queue展示两部分:「First Emails」(可批量发)和「Follow-ups」(cold_restart / followup1 / followup2 / warm / high_intent / reengagement,逐条发送,发送前都会重新校验一次)。2026-08-07 Follow-ups 每条右侧显示的收件邮箱,现在是实际会发给谁(跟发送时 pickFollowupEmail() 解析出的地址完全一致),不再是这家公司名下全部已知邮箱的原始列表——之前如果一家公司同时有新旧两个联系人,这里会把两个邮箱都显示出来,容易让人以为要发给两个人。
!整个测试期间,以下7个真实开关必须保持关闭:Auto Send New Emails、Auto Followup 1、Auto Followup 2、Auto Cold Restart、Auto Warm、Auto High Intent、Auto Reengagement——等所有场景都测完了再手动逐个打开用于正式环境。它们跟 Test Follow-up 是两套独立机制:不管这几个开关是开是关,测试客户触发的 Cold Restart / Warm / High Intent / Reengagement 邮件永远会先停在 Preview Send Queue 里等你手动点 Send(2026-07-30 起明确固定的设计,不会因为开关状态而变化)——这几个开关只决定真实客户是否跳过这一步直接自动发送,见下方"规则4"。
!如果打算用真实 Brevo 来测 opened/clicked/webhook 这几项:Brevo 只能把 webhook 推给一个公网能访问到的地址。如果是在本地 c:\Stella(localhost)上测,Brevo 推不进来,除非开了内网穿透(如 ngrok)并把 Brevo 后台的 webhook 地址临时改过去;如果打算直接在生产环境(OnlineStella)上测,要注意这些改动目前只在本地 c:\Stella 里,还没有部署到生产,生产上测到的还是旧逻辑。

0.5 贯穿全局的规则 先懂这几条,后面才讲得通

下面这几条不属于任何一个具体场景,而是所有场景背后共用的底层逻辑。之所以单独拎出来,是因为它们容易被忘记,一忘就会看着某个场景的结果觉得"不对啊,怎么跟想的不一样"。

规则1 · 客户只要回复,无论当前处于哪个阶段,都会立刻被"捞出来"重新分类——但不是发新的第一封邮件

这条是最容易被忽略、也最重要的一条。

不管客户当前是刚发完 first 还在等打开、是在 followup1/warm/high_intent 排队中、还是已经进了 cooling 在倒数第几轮的冷却期——只要他真的回复了一封邮件,系统检查收件箱时(checkInboxReplies)就会立刻按回复内容重新判定这个客户该去哪,把它从原本正在走的自动化路径里"捞出来",不会等原来的流程走完。具体去哪,看回复内容属于哪一类(对应 B组):

回复类型不管之前是什么状态,回复后会变成
感兴趣 / 提问直接变 active,即使当时正处在 cooling 里也一样会被拉出来
软拒绝变/刷新成 cooling,冷却计时器从这一刻重新算
硬拒绝如果这是最后一个还没被拒绝的联系人 → 变 not_interested;即使当时是 cooling 也会被直接改掉
!但要注意——这里说的"捞出来重新分类",不等于会重新发一封"第一封邮件"。回复触发的是"改状态",不是"重新开始一轮全新outreach"。系统里唯一会让一个客户真正收到一封全新 first 类型邮件的情况,是规则3说的"爬虫在这家公司发现了不同的联系人",这是两件完全不同的事,不要混在一起。
→这条规则解释了 E1 为什么存在:正因为回复能在任何时刻打断任何流程,队列里躺着的 followup1/warm/cold_restart 等邮件才必须在真正发送前再查一次客户最新状态,否则就会出现"客户三天前已经回复拒绝了,但队列里那封没人及时点掉的邮件还是被发出去了"这种尴尬情况。

规则2 · 为什么 reengagement 邮件发出去后,客户状态是变回 contacted 而不是留在 cooling

cooling 这个状态代表的含义是"我们现在什么都不做,纯粹在倒数等待"。但 reengagement 邮件一旦真的发出去了,客户收件箱里就又躺着一封等待回复的邮件了——这和当初发完第一封邮件后的处境,本质上是同一回事:都是"发了一封东西,正等对方反应"。所以系统直接复用了 contacted 这个早就存在的状态来表示"正在等待",而不是另外发明一个"cooling 期间发了 reengagement 后的特殊等待状态"。

这样做的好处是:判断"等了多久还没反应、该不该重新打入冷宫"这件事,可以直接复用原本就用来监控 contacted 客户的那套逻辑(也就是 C2 场景里说的"cycle 2+ 客户"检查),不用为 reengagement 这一种情况单独再写一套监控代码。

✓所以看到"reengagement 发送后客户变回 contacted",不是 bug,也不是"退回了原点"——它就是"现在正在等这封 reengagement 的回音"的意思,跟第一次发 first 邮件后进入 contacted 是同一个概念,只是 cycle 字段记着这已经是第几轮了。

规则3 · 唯一能让客户"重新收到一封真正意义上的第一封邮件"的情况

只有一种情况:爬虫在一家已经是 cooling / not_interested / bounced 的公司,抓到了一个和档案里不一样的联系邮箱(详见 D组)。这时系统才会把这家公司当成一个全新的机会,重新走一遍从 first 开始的完整流程,并且把这个新联系人之前累积的所有跟进计时清零(靠 contact_reset_at 这个字段实现,具体见 D1)。除此之外——不管是客户自己回复、还是冷却期满自动发 reengagement——都不会触发"重发第一封邮件"这个动作,最多只是把状态改变或者发一封 reengagement/跟进邮件。

规则4 · 测试必须和真实环境判断逻辑完全一致,但绝不能碰到除测试客户以外的任何真实客户

两条同等重要的原则,2026-07-30 明确定下的,任何测试相关的代码改动都要同时满足这两条。

原则A:判断逻辑必须和正式环境一模一样。候选人筛选条件(是否打开过、是否过了天数、cold_restart 互斥、cycle<=1、第2轮+续冷却)测试客户和真实客户走的是同一段代码、同一条 SQL,不允许测试模式用一套简化过的逻辑——不然测出来的结果没有意义。

原则B:测试执行范围必须严格限定在你选中的这一个测试客户身上,绝不能波及任何其他真实客户。这条比听起来更容易被违反——因为 generateFollowupQueue() 这类函数在正式环境本来就要每天9点被调用一次处理所有真实客户,并且真的会读 Auto Followup 1/2 这些全局设置决定要不要自动发送;如果测试模式的"允许发送"也去读同一批全局设置,那为了测试而打开它,就会同时让"处理所有真实客户"的那次调用也真的开始自动发信——所以 Test Follow-up 这个开关必须是独立于真实开关的,只作用于测试客户,Auto Followup 1/2 和 Auto Send New Emails 整个测试期间都要保持关闭。

!2026-07-30 审查时还发现一个真实违反了原则B的 bug:generateFollowupQueue() 里生成 reengagement 邮件那部分,之前完全没按测试客户限定范围,点 Run Test Follow-up Now 或 Test Follow-up 每小时自动跑,都会把全部真实客户里"cooling 且今天到期"的记录一并处理掉。已修复并用真实数据验证:造一个真实客户和一个测试客户都到期,只跑测试客户的检查,确认真实客户完全没被碰。
!同一天还有一版实现方向反了:新加的 Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 这4个开关,一开始参照 F1/F2 的模式写成"测试模式下强制自动发送、跳过 Preview Send Queue"。但 F1/F2 从设计上本来就没有"进队列人工审核"这一步——不管测试还是正式,一直都是直接发送;而这4个类型不一样:它们本来就有 Preview Send Queue 这道人工复核关卡(正式环境也一样,就是为了让人在邮件真的发出去前,看一眼模板拼出来的内容对不对),测试客户必须继续走这道关卡,不能因为要测"自动发送"这个功能本身就把它绕过去。已改正:autoColdRestart / autoWarm / autoHighIntent / autoReengagement 这4个门禁条件改成 !isTestMode && 真实开关——测试模式下永远视为关闭(一定进队列,等人工点 Send),只有非测试的正式调用才会去读真实开关,决定真实客户要不要跳过队列直接自动发。发送本身用的都是同一个 sendFollowupQueueItem(),内容生成也是同一段代码,所以这个改动只影响"什么时候自动点 Send",不影响邮件内容或发送逻辑本身——测试通过等于正式打开开关后行为一样。

规则5 · Clients 标签页的 Contacted 分类,现在是互斥的,并且带一个子状态筛选下拉框 2026-08-04

这条直接影响你在下面每个场景里"去哪个 tab 验证结果"。

改之前,Contacted tab 是个"宽口径"分类:只要发过邮件,即使客户现在的真实状态是 cooling / not_interested / bounced,也会一并显示在 Contacted 里——结果就是同一个客户会在两个 tab 里同时出现(比如一个 bounced 客户,Bounced tab 有它,Contacted tab 也有它)。现在改成和 Active / Cooling / Not Interested / Bounced / Competitors 这几个 tab 完全一样的口径:Contacted tab 只显示 status='contacted' 的客户,一个客户永远只会出现在一个 tab 里。

同时在 Contacted tab 里,"All Industries" 筛选框右边新增了一个 "All Contacted Stages" 下拉框(只在 Contacted tab 打开时才显示),可以直接按卡片上那个状态标签筛选:Awaiting Reply / Cold Restart Pending / Cold Restart / Follow-up 1 Pending / Follow-up 1 / Follow-up 2 Pending / Follow-up 2 / Warm Pending / Warm / High Intent Pending / High Intent / Re-engagement Pending / Re-engaged——这些选项和 createClientRow() 里算出来的卡片状态标签是一一对应的,"Pending" 代表还在 Preview Send Queue 里等你审核,不带 Pending 的代表已经发出去、还没排下一个。

→这个下拉框对下面 A/B/C 组场景的验证很有用:比如 A1 把 cold_restart 排进队列后,与其在 Contacted 列表里翻找,不如直接把下拉框切到 "Cold Restart Pending" 一步定位;C1 发完 reengagement 后,切到 "Re-engaged" 就能立刻确认。
→CSV 导出(Export CSV 按钮)现在也会带上这个子状态筛选,导出的内容和当前屏幕上看到的完全一致——之前这两者其实不一致(导出接口一直是纯 status='contacted',页面却是宽口径),这次一并修掉了,两边现在共用同一段筛选逻辑(buildClientsWhereClause)。

1. 准备工作 — 每轮测试开始前做一遍

  1. 在 Opportunities 里找一条(或新建一条)线索,把它的 contact_email 改成你自己的私人邮箱。
  2. 点 Set Test Client,选中这条线索。
  3. 确认 Auto Followup 1、Auto Followup 2、Auto Send New Emails、Auto Cold Restart、Auto Warm、Auto High Intent、Auto Reengagement 这7个真实开关都是 OFF,并且整个测试期间都保持关闭——它们跟 Test Follow-up 是两套独立机制(见"规则4"),测试不需要打开它们(测试客户触发这几类邮件时永远走 Preview Send Queue 人工审核,跟这些开关状态无关),等所有场景都测完了才手动打开用于正式环境。
  4. 在 Settings → Autopilot 里,把所有"天数"类设置都改成 0:Followup 1、Followup 2、Cold Restart、Warm send、High Intent、Cooldown(Cycle 1),以及新加的 Cycle 2 / Cycle 3 / Cycle 4+ cooldown(Warm trigger opens 和 Daily Send Limit 不用改,保持默认 1 和 20 即可)。改成 0 意味着"只要过了任意一点时间就算达标",下一次 cron 或手动触发就会立刻生效,不用真等好几天。
  5. 把 Test Follow-up 打开(ON)。
  6. 点 Run First Email Now。确认邮件真的到了你的私人邮箱。
→下面每个场景默认都是从这个状态开始的(已发出一封 first 邮件,客户状态是 contacted)。测完一个独立场景后,重新走一遍第5步(或换一条新线索)再进入下一个场景,避免互相干扰。

1.5 标准复位 SQL 每个场景卡片末尾都会引用这一段

所有场景都是在同一个测试客户身上反复测的,所以复位逻辑是通用的:把 <test_client_id> 换成你的测试客户 id(Preview Send Queue 或客户详情页能看到),跑完下面这5条,就能回到"刚发完 first、状态 contacted、cycle 1"的干净起点,可以立刻测下一个场景。每个场景卡片下面的"清理"提示都是指这一段——除非那个场景另外说明了要多做点什么(比如 B5、F2/F3 会产生测试客户以外的额外记录,D1 不涉及测试客户)。

DELETE FROM followup_queue WHERE client_id = <test_client_id>;
DELETE FROM email_log WHERE client_id = <test_client_id> AND type != 'first';
DELETE FROM client_activities WHERE client_id = <test_client_id>;
UPDATE email_log SET status = 'sent', open_count = 0 WHERE client_id = <test_client_id> AND type = 'first';
UPDATE clients SET status = 'contacted', cycle = 1, rejection_type = NULL, reengagement_date = NULL,
    question_received_at = NULL, contact_reset_at = NULL WHERE id = <test_client_id>;
→这5条分别做的事:清空跟进队列(不管是待发、已发还是跳过的都删掉)→ 删掉除了最初那封 first 以外的所有邮件记录(followup1/2、cold_restart、warm、high_intent、reengagement 全部清掉)→ 清空活动日志(回复记录、自动回复记录等)→ 把 first 这封邮件本身的状态和打开次数也归零(不然下次还是"已打开"状态)→ 最后把客户表本身的状态、轮次、拒绝类型、再触达日期这些都归零。

A组:参与度路径 — 客户没回复,只看打开/点击行为。对应(并订正了)App 里 Follow-up Sequence 弹窗的内容。

A1 · 完全没打开 逻辑本身没变,但现在会按 contact_reset_at 分段计算

Cold Restart(进队列)→ 发送 → Followup 2(自动发)→ 进入 Cooling。

  1. 不要打开第一封邮件。点 Run Test Follow-up Now,不用等一小时。
  2. 打开 Preview Send Queue → Follow-ups 里应该出现一条 cold_restart,主题是 Re: {第一封邮件的原主题}(2026-08-06 起,Cold Restart/Warm/High Intent/Followup1/Followup2/Reengagement 这6类统一改成这个格式,不再各自用独立文案——实测发现独立主题即使带了正确的回复邮件头,Gmail 也不会把它跟原邮件合并成同一个对话串,客户看不到之前的邮件内容,所以改成跟原主题挂钩的 "Re:" 格式,确保能在 Gmail 里正确合并显示),正文来自 Settings → Email Templates 里保存的 Cold Restart 模板(2026-07-30 起改为完全按模板生成,不再是 AI 现场写的内容——如果正文跟你在模板里编辑保存的不一样,就是 bug)。
  3. 点 Send。确认它带着模板里的内容真的进了收件箱。
  4. 仍然不要打开它。再点一次 Run Test Follow-up Now。
✓会自动收到一封 followup_2(最后一封,以回复形式串联主题)。客户状态变成 cooling,rejection_type = 'no_response',reengagement_date = 今天 + Cooldown 天数,cycle 加1。
→SELECT status, rejection_type, reengagement_date, cycle FROM clients WHERE id = <test_client_id>
→Auto Cold Restart 开关此时开没开都不影响上面的行为——测试客户永远先进队列等你手动点 Send;这个开关只在正式环境对真实客户生效(跳过队列直接自动发),见"规则4"。
!2026-08-21 修复之前这一步必须等每小时的测试 cron 或重启服务器(重启还会连带真实触发一次爬虫抓取,属于误伤)——原因是 Run Test Follow-up Now 背后的 /api/autopilot/run-test-followup-now 只调用了 generateFollowupQueue(),没调用 autopilotFollowUp()。这两个函数分工不同:generateFollowupQueue() 能生成 Cold Restart,但它的 opens===0 分支只有"生成 Cold Restart"这一段,没有像打开1次/Warm/High Intent 那三条路径一样带"升级到 Followup 2"的第二段;"Cold Restart 发完、还是没打开、升级成 Followup 2"这一步,是 autopilotFollowUp() 单独管的。所以点这个按钮之前,不管点多少次都不会有 Followup 2,看起来像是卡住了。当时把 autopilotFollowUp({testClientId}) 补进了这个接口,效果跟每小时 cron 完全一致,两步都能直接点按钮测,不用再等。2026-08-24 起这里说的"两个函数分工"已经是旧账了——generateFollowupQueue() 现在自己就能完成"生成 Cold Restart"和"升级到 Followup 2"两段,不再需要另一个函数接力,见上面工具面板表格里的最新说明。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

A2 · 打开一次(Warm → Followup 1 → Followup 2)

Warm(进队列,按模板生成)→ 发送 → Followup 1(进队列)→ 发送 → Followup 2(进队列)→ 进入 Cooling。

!2026-08-22 起,Warm 和 Followup 1 不再是"打开次数不同、各走各的"两条独立分支——现在只要打开次数达到 behavior_warm_opens(Settings 里的 "Warm trigger after (opens)",默认 1 次,因为 Gmail 等邮箱的图片代理会缓存追踪像素,"数到第2次打开"这个信号本身就测不准,见下面第306行附近的历史说明)就先进 Warm,Warm 发出后没回复才轮到 Followup 1,Followup 1 没回复才轮到 Followup 2。也就是说"只打开一次"现在触发的是 Warm,不再是 Followup 1。
  1. 真的打开第一封邮件一次(在私人邮箱里点开它,让 Brevo 的 open 事件真实触发)。
  2. 确认 Brevo 后台/邮件的追踪像素被加载后,点 Run Test Follow-up Now。Follow-ups 里应该出现一条 warm,正文来自 Settings → Email Templates 里保存的 Warm 模板。
  3. 从 Preview Send Queue 里发送它。
  4. 再次 Run Test Follow-up Now——应该出现一条 followup1(正文用的是新版"help you get set up"这段中性措辞,不再预设对方到底看没看过邮件,因为这条现在只会接在 Warm 后面出现)。
  5. 发送它,再 Run Test Follow-up Now 一次 → 出现 followup2。
✓Warm → Followup1 → F2 → Cooling,三封全部走队列人工发送,不是自动发。
→Auto Warm / Auto Followup 1 开关此时开没开都不影响上面的行为——测试客户永远先进队列等你手动点 Send;这几个开关只在正式环境对真实客户生效,见"规则4"。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

A3 · 调高 Warm 阈值不会有实际效果——只要打开过就算 Warm 2026-08-25 起

验证"把 behavior_warm_opens 手动调高(比如调到3)之后,客户只打开了1次,会怎样"——这是个边界确认,不是常规路径,默认设置(阈值1)下走不到这里,A2 已经覆盖默认情况。

!根本原因:Brevo 实际能可靠抓取的,只有"这封邮件有没有被打开过"这个信号,抓不出可靠的"打开了几次"这种更细的数字——现实里 open_count 基本只会停在1,不会真的涨到2、3。所以 behavior_warm_opens 这个设置调到2以上,在当前 Brevo 的抓取能力下不会有任何实际效果——代码里把它的判断值封顶在1(`Math.min(warmOpenThreshold, 1)`),不管界面上这个数字填多少,只要客户真的打开过一次,就一定会走 Warm,不会因为你调高了这个数字就被晾在一边、也不会误落回 Cold Restart。这条设置本身没有被删掉或锁死——万一以后 Brevo 那边打开次数抓取能力有提升,这个设置可以重新变得有意义,只是现在调高它不产生任何效果。
  1. 把 Settings 里 behavior_warm_opens("Warm trigger after (opens)")临时改成 3。
  2. 真的打开第一封邮件1次。
  3. 等够 Warm send after (days) 设置的天数,Run Test Follow-up Now → Follow-ups 里正常出现一条 warm,不会因为阈值调到了3而被拦下、也不会误触发 cold_restart。
  4. 把 behavior_warm_opens 改回默认值 1,测试完记得复位。
✓确认"阈值调多高都不影响结果"——只要客户真的打开过邮件(哪怕只有1次),就会正常走 Warm→Followup1→Followup2 这条链路,不受这个设置具体填了几这个数字影响。
🧹 清理:跑完执行"1.5 标准复位 SQL",并确认 behavior_warm_opens 已经改回 1,即可开始下一个场景。

A4 · 点击了链接(High Intent)

High Intent 邮件(进队列)→ 发送 → Followup 2(进队列)→ 进入 Cooling。点击优先级高于打开——不会退回到 warm 路径。

  1. 真的点击第一封邮件里的链接。
  2. Run Test Follow-up Now → 应该出现一条 high_intent,主题是 Re: {第一封邮件的原主题}(见 A1 场景里 2026-08-06 那条说明),正文来自 Settings → Email Templates 里保存的 High Intent 模板(2026-07-30 起改为完全按模板生成,不再是 AI 现场写的内容)。
  3. 发送它,再跑一次 → 出现 followup2。
→Auto High Intent 开关此时开没开都不影响上面的行为——测试客户永远先进队列等你手动点 Send;这个开关只在正式环境对真实客户生效,见"规则4"。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

A5 · 自动回复(度假自动回复)

确认系统不会把度假自动回复误判成真实回复。

  1. 把测试邮箱设成 out-of-office 自动回复,或者直接手动回一封标题像 "Automatic reply: Out of Office" 的邮件。
  2. Run Test Follow-up Now(会触发 checkInboxReplies)。
✓会记一条 client_activities,类型是 auto_reply。客户状态和 email_log.status 都不受影响——这个客户会继续走 A1(完全没打开)那条路径,就像什么都没发生过一样。
→SELECT * FROM client_activities WHERE client_id=<id> ORDER BY created_at DESC LIMIT 3
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

B组:回复分类路径 这周新实现 — 这些状态流转在这之前压根不存在,是本轮测试的重点。下面每个场景默认客户处在"刚发完 first,状态 contacted"这个最简单的起点;但按"规则1",这套分类逻辑在客户处于 cooling、处在第几轮都一样适用——如果时间允许,建议挑一两个场景故意先把客户弄进 cooling 再回复,确认结果和"从 contacted 直接回复"一致。

B1 · 感兴趣

  1. 回复第一封邮件:"这个看起来不错,想再多了解一下,能约个电话吗?"
  2. Run Test Follow-up Now。
✓客户 → active。跟进序列就此停止,不会再有任何自动跟进邮件排队。
→2026-07-30 起 这一类也覆盖了"明确邀请以后再联系"的回复,比如 "过几个月再联系我们吧"——这种以前会被归为 B3 软拒绝,现在按你的决定归到这里,同样变成 active。可以单独试一遍这句话,确认走的是这条路径。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

B2 · 提问

  1. 回复:"这个多少钱,需要签合同吗?"
  2. Run Test Follow-up Now。
✓客户 → active,question_received_at 被设置(Clients 标签页里会显示成一个待处理标记)。不会有 AI 自动起草回复——这类留给你自己手动回。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

B3 · 软拒绝 新行为

判断标准是"有没有明确邀请以后再联系"——有,走 B1(active);没有,走这里(cooling)。见 B1 的补充说明。

  1. 回复:"我们暂时不招人,谢谢。"(注意:不是"过几个月再联系"这种带邀请性质的说法,那种现在走 B1)
  2. Run Test Follow-up Now。
✓客户 → cooling,rejection_type='soft',reengagement_date = 今天 + Cooldown 天数。以前这类回复只会把这一个邮箱标记为不可再发,但客户整体状态完全不变——确认这次不再是这样。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

B4 · 硬拒绝,只有一个联系人 新行为

  1. 确认测试客户名下目前只登记了一个联系邮箱。
  2. 回复:"请把我们移除,我们不感兴趣,不要再联系了。"
  3. Run Test Follow-up Now。
✓那条 email_log 记录 → rejected。客户 → not_interested,rejection_type='hard'。
→2026-08-07 新增 之后再用同一个联系人(或任何联系人)回复一句表示感兴趣的话(比如"其实我们改主意了,想聊聊"),Run Test Follow-up Now,确认客户能从 not_interested 正确救回 active——之前这条 UPDATE 的 WHERE status IN (...) 没包含 not_interested,救不回来,已修复。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

B5 · 硬拒绝,但还有其他联系人有效 新行为,也是这次改动真正的设计初衷

一个人拒绝了,不代表这家公司所有人都拒绝了——不应该因此关掉整个公司。

  1. 给测试客户加一个也能收到邮件的第二联系邮箱,比如 UPDATE clients SET contact_email = CONCAT(contact_email, ', your+alt@address.com') WHERE id=<id>,并给这个邮箱补一条 email_log(type='first', status='sent'),让它看起来是一个真实、未被拦截的联系人。
  2. 用主邮箱回复和 B4 一样的硬拒绝措辞。
  3. Run Test Follow-up Now。
✓主邮箱那条 email_log → rejected。但客户状态仍然是 contacted——不会变成 not_interested,因为第二个邮箱还没被拦截。去活动日志里应该能看到"其他联系人仍然有效"这类记录。
→接下来再用第二个邮箱也回复一次硬拒绝,确认这时公司才真正变成 not_interested——这才是"所有联系人都拒绝了"这个判断真正生效的时候。
→2026-08-07 新增 · 未联系过的联系人保护 这个变体测的是另一条路径:第2步改成加第二联系邮箱,但不要给它补 email_log(也就是这个联系人还从没被发过信)。用主邮箱回复硬拒绝,Run Test Follow-up Now → 客户状态应该仍然不变,不会因为"第二联系人没有已发送记录"而被误判成"没有其他有效联系人"。活动日志里应该能看到"a known contact hasn't been emailed yet"这条记录。这个场景对应的是 D1 里说的真实触发过的那个漏洞。
!🧹 清理(这条比标准复位多一步):这个场景给测试客户加了第二个联系邮箱,标准复位 SQL 不会把它去掉,需要额外单独处理:
UPDATE clients SET contact_email = SUBSTRING_INDEX(contact_email, ',', 1) WHERE id = <test_client_id>;
再执行一遍"1.5 标准复位 SQL"清掉两个邮箱各自产生的 email_log/活动记录。

C组:冷却与再触达 — 客户没动静之后会发生什么。

C1 · 再触达按期触发

  1. 用上面任意场景(A1 或 B3 最快)把客户先弄进 cooling。
  2. 直接把等待期拨到今天:UPDATE clients SET reengagement_date = CURDATE() WHERE id=<id>。
  3. Run Test Follow-up Now → 打开 Preview Send Queue,Follow-ups 里应该出现一条 reengagement,正文来自 Settings → Email Templates 里保存的 Reengagement 模板。
  4. 点 Send。
✓发送后客户变回 contacted(为什么是 contacted 不是继续留在 cooling,见上面"规则2"——简单说就是:邮件发出去了,现在是"等对方回音"的状态,跟第一次发 first 邮件后的处境是同一回事,系统直接复用了同一个状态)。
→Auto Reengagement 开关此时开没开都不影响上面的行为——测试客户永远先进队列等你手动点 Send;这个开关只在正式环境对真实客户生效(跳过队列直接自动发),见"规则4"。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

C2 · 冷却期随轮次递增

只有第1轮会走完整的邮件序列,第2轮起每轮只发一封 reengagement 邮件。2026-07-30 起 90/180/365 这三个数字不再是写死的,Settings → Autopilot → Re-engagement 面板里新增了 Cycle 2 / Cycle 3 / Cycle 4+ cooldown 三个输入框,测试期间同样可以改成 0。

第1轮:first → cold_restart → F2 → cooling(冷却60天,cycle: 1→2) ↓ 冷却期满 第2轮:reengagement邮件 → 等一段时间没反应 → cooling(冷却90天,cycle: 2→3) ↓ 冷却期满 第3轮:reengagement邮件 → 等一段时间没反应 → cooling(冷却180天,cycle: 3→4) ↓ 冷却期满 第4轮起:reengagement邮件 → 等一段时间没反应 → cooling(冷却固定365天,cycle继续+1)
  1. 走 A1 完成第1轮,进入 cooling。
  2. 用 C1 的办法把 reengagement_date 拨到今天,触发 reengagement 邮件;发送后客户变回 contacted,cycle 仍是2。
  3. 不回复,等 Followup 2 天数那么久(测试期已改成0),确认又自动回到 cooling。
  4. 重复第2-3步两次,检查 cycle 与冷却天数是否照上表递增。
→也可以顺便去 Clients 标签页肉眼确认:客户在 contacted 状态、cycle>=2 时,卡片上的状态标签会显示成"🔄 Re-engaged ×N"而不是普通的"Contacted"——这是 2026-07-30 加的一个 UI 提示,方便一眼看出这不是全新客户。2026-08-04 现在更快的办法是:打开 Clients → Contacted tab,把新加的 "All Contacted Stages" 下拉框切到 "Re-engaged",直接筛出来,不用在列表里翻找(见"规则5")。2026-08-07 口径改了 N 现在是真实发送过的 re-engagement 邮件次数(鼠标悬停能看到具体几轮冷却期已完成),不再是内部的 cycle 计数器——旧口径下 cycle=2 时显示"×2",但实际 re-engagement 邮件只发过1次,容易让人误以为发了2次;如果一家公司经历过换联系人重置,新旧联系人各自的 re-engagement 次数也不会混在一起算。

这里的 contacted 是什么意思——容易看错的一点

上面第2步会看到 clients.status 变成了 contacted,cycle 却已经是2、3、4——第一眼很容易误会成"系统把它当成了一个刚认识的全新客户"。不是这样的:clients.status 这个字段总共就 new / contacted / active / cooling / not_interested / bounced / competitor 这几个取值,没有专门给"reengagement 发出后等回应"单独造一个新状态,而是直接复用了 contacted 这个值——它在这里的含义是"当前有一封 outreach 邮件发出去了、正等对方反应",跟真正第一次认识这家公司时的 contacted 是同一个字段值、但不是同一件事,要看 cycle 字段才知道这其实是第几轮了。这样设计是为了能直接复用原本监控"contacted 客户等多久没反应该怎么办"的那套代码,不用为 reengagement 专门再写一套。

如果在等待期间(第2轮或以后)客户突然回复了呢

不管客户是在第1轮还没打开、还是已经进了第2、3、4轮的 cooling/等待期,只要真的回复了,checkInboxReplies 一检查到就会立刻按回复内容重新分类,把它从"按部就班等冷却期满"这套自动化节奏里拉出来——感兴趣/提问 → 直接变 active;软拒绝 → 刷新回 cooling,冷却计时器从这一刻重算;硬拒绝 → 如果这是最后一个还没被拒绝的联系人,变 not_interested。但注意:这几种结果都只是"改状态",都不会让客户重新收到一封全新的"第一封邮件"。系统里唯一会让客户真正收到一封全新 first 邮件的情况,只有 D组说的那种——爬虫在这家公司发现了一个和档案里不一样的联系人,那才是真正"重新开始",跟这里说的"回复被重新分类"是两件完全不同的事,别搞混。

🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景(会把 cycle 一并归零,不用额外操作)。

D组:换联系人重置 全新子系统 — 爬虫在一家已归档的公司发现了不同的人,这家公司会获得一次真正意义上的全新周期。

D1 · 爬虫在已归档公司发现新联系人 2026-08-06 真实环境触发过一次,顺带修了两个缝隙

这套逻辑只会从真实爬虫内部触发(Seek/Indeed/TradeMe/SJS/Himalayas 解析到一条真实招聘帖时)——没有任何 UI 操作能直接激活它,没法像上面的场景一样手动驱动。

!已用7种脚本化数据库场景验证过(同一联系人 → 保持归档状态;不同联系人 → contact_email 被合并、contact_reset_at 被打上时间戳、cooling 状态下待发的再触达被取消)——全部通过。2026-08-06 测试期间还真实触发过一次(测试客户公司名撞上了爬虫抓到的真实招聘信息),不是模拟的,顺带发现两处需要修的地方,都已修复:
  1. 之前完全无迹可寻——触发之后 cycle/reengagement_date 静默改变,时间线上找不到任何原因,只看到状态莫名其妙变了。现在 isCompanyArchived() 会写一条 client_activities 记录("🆕 New contact found: {邮箱} — starting fresh outreach"),时间线弹窗会把这条记录和其他历史事件混在一起按真实时间排好序显示出来;旧联系人的历史邮件记录完全保留,不会因为这次重置而消失。
  2. 硬拒绝误判风险——如果重置发生后,新联系人的第一封邮件还没来得及发出去,这时候旧联系人恰好发来一封硬拒绝,之前的代码会因为"新联系人没有任何已发送、未被拒绝的邮件记录"而误判成"没有其他有效联系人了",直接把公司标成 not_interested——即使这个新联系人根本还没被联系过。现在硬拒绝判断新增了"是否存在从未发过信的已知联系人"这层保护,具体测试步骤见 B5 那条"未联系过的联系人保护"分支。

继续追查这次真实触发的过程中,同一条链路上又挖出三个更深的问题,一并修复(2026-08-07):

  1. 发信后邮箱被覆盖,不是合并——"发出第一封邮件后回填客户记录"这段代码(真实自动发信循环、"Send All First Emails"批量路由、单条手动发送共用的 ensureClientForLead()、以及测试用的 Run First Email Now,一共4处)之前都是 contact_email = 新邮箱 直接覆盖整个字段,不是合并进已有的逗号分隔列表。新联系人的第一封邮件一旦真的发出去,isCompanyArchived() 之前辛苦合并进去的老联系人邮箱反而会被冲掉丢失——The Painting Company 这个测试客户真实中招过,老邮箱被冲得只剩新邮箱一个。已改成发信前先查一次数据库当前值,再合并写回,不再是无脑覆盖。
  2. isCompanyArchived() 自我拉黑——真实自动发信循环、以及"Send All First Emails"批量发送路由,之前会在真正发信之前再调用一次 isCompanyArchived() 做"最后确认"。但这个函数第一次调用(爬虫抓取时)已经把新邮箱合并进 contact_email 了,第二次调用比较的是"已经合并过的邮箱",永远会判定为"已知联系人"从而被永久跳过——新联系人的第一封邮件因此永远发不出去,这就是2026-08-06那次真实卡住的根本原因。已删除这个多余的第二次调用。
  3. 联系人姓名没跟着换——邮箱合并只处理了 contact_email,从没更新过 contact_person,导致给新联系人发的邮件称呼还是老联系人的名字(比如实际收件人是"Dave",邮件称呼却还是"John")。现在合并邮箱的同时也会把姓名换成新联系人的(提取不到像样姓名就清空,落到"Hi there",绝不会保留旧姓名去称呼一个不同的人)。

contacted / active 期间发现新联系人,现在怎么处理

2026-08-07 之前,只要公司当前是 contacted 或 active 状态(原联系人的序列还在进行中),爬虫爬到的任何新联系人都会被 isCompanyArchived() 当成"已归档"整条丢弃,连 b2b_leads 里的记录都不会留下——如果这条招聘帖后来下架、爬虫再也没爬到过同一条,这个新联系人就永久性地丢失了。现在改成:照常记录进 b2b_leads,但不会立即合并进 contact_email、不会打扰原联系人正在进行的序列。等这家公司真正因为"没回应"要从 contacted 转入 cooling 的那一刻(2026-08-24 起是 sendFollowupQueueItem 的 Followup2 完成分支、以及 autopilotRecycleReengagements(原名 autopilotFollowUp)的 cycle 2+ 收尾这两处,不再是原来同一个函数里的两段),系统会主动回头检查 b2b_leads 里有没有这种"记录了但还没合并"的新联系人——如果有,就直接走"发现新联系人"那套流程(合并邮箱+姓名、cycle 归1、清空 reengagement_date),而不是照常排一封给老联系人的 re-engagement 邮件。

→可以脚本化验证这条:把测试客户手动设成 contacted,往它的公司名下插入一条带新邮箱的 b2b_leads(status='new'),确认它不会立刻出现在 clients.contact_email 里;然后把测试客户拨到 F2 已发、即将因没反应转 cooling 的临界点,Run Test Follow-up Now(或等 Test Follow-up 每小时 cron),确认这时候 contact_email 和 contact_person 才真正更新,状态直接进入"🆕 New Contact Found"这个 cooling 分支,而不是普通的"排 N 天后发 re-engagement"。
→这一条建议继续"只观察、不主动测":如果接下来几周,一家已经标记为 cooling/not_interested/bounced 的公司又以一个新招聘负责人的邮箱发帖了,去客户详情的时间线弹窗看——应该能看到一条"🆕 New contact found"记录,clients.contact_reset_at 被更新,而且旧联系人的历史邮件记录还完整保留在上面。
🧹 清理:不涉及测试客户,不需要跑复位 SQL。

E组:发送前二次校验 — 队列里的每一项在真正发送前都会被重新检查一遍。

E1 · 邮件在排队期间"过期"了

2026-08-07 现在有两条独立的防线:如果是客户直接回复导致的状态变化,回复处理的同时就会立刻把排队项标掉,不用等到点 Send 才发现;如果状态变化是通过别的方式发生的(没经过回复处理这条链路),才会退回"点 Send 时才被拦下来"这条本来就有的保险丝。这个场景把两种情况都测一遍。

变体1:客户真的回复了(现在会立刻清掉,不用等点 Send)

  1. 先让某条 followup1(或任意类型)静静地躺在队列里(参考 A2 第2步)。
  2. 用 B1(感兴趣)/B2(提问)/B3(软拒绝)/B4(硬拒绝)任意一种真实回复,让客户状态发生变化。
  3. 回到 Preview Send Queue 刷新看一眼,不用点 Send。
✓这条排队项应该已经从"待发送"列表里自己消失了(数据库里 status='skipped')——不用你手动点 Send 才发现它失效,markClientReplied() 在处理这条回复、改状态的同一时刻就顺手把它清掉了。

变体2:状态变化不是通过回复触发的(还是走"点 Send 时才拦下"这条旧路径)

  1. 同样先让一条排队项躺着。
  2. 不经过回复,直接改数据库模拟退信:UPDATE email_log SET status='bounced' WHERE client_id=<id> ORDER BY sent_at DESC LIMIT 1。
  3. 现在再去点这条仍显示"待发送"的队列项的 Send。
✓发送会被拒绝,提示类似"Client is now … — skipped."。这条队列项的状态会变成 skipped,而不是 sent。不会有邮件真的被发出去。
→同一次改动里,生成候选这一步(generateFollowupQueue 每天9点定时任务)本身也收紧了:判断条件从 status IN ('contacted','active') 改成只认 status = 'contacted'。之前 'active' 状态的客户理论上也可能被选中生成一条候选,但发送前的复查永远要求精确是 contacted,'active' 从来发不出去——等于白生成一条注定失败、只会让 Preview Queue 看着奇怪的排队项。这条不好用测试客户直接复现(要卡在候选生成的具体时间窗口),了解即可,不强制验证。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

F组:三条发送路径的归档拦截 — 同一条规则,三种不同的落实方式:F1 全自动无需提示;F2 有人工在场,多给一句"是不是同一个人"的提示;F3 现在和 F1 用同一套判断逻辑。

F1–F3 · not_interested / bounced 公司防止重新联系 F2/F3 本轮升级

验证的是这条规则:公司已经被标成 not_interested 或 bounced 之后,同一个联系人再出现不该被重新联系;换了个不同联系人则应该正常放行,当成一个全新机会,不该被上一个人的拒绝连累。

先说清楚这条规则实际是怎么落实的,因为容易理解错:真正做判断的只有一个地方——isCompanyArchived(),而且只在 crawler.js 里、爬虫准备把一条新线索插入 b2b_leads 表的那一刻调用一次。同一联系人 → 这条线索压根不会被插入 b2b_leads;换联系人 → 正常插入,同时把新邮箱合并进 clients.contact_email,判定为全新周期。F1(全自动主流程)和 F3(批量发送)都没有自己的拦截逻辑,只是分别去读"现在 b2b_leads 里有哪些能发的线索"——既然同一联系人的线索从一开始就没能力进这张表,F1/F3 自然也就看不到它、更谈不上要不要拦,不是"F1/F3 收到了、判断一下、悄悄跳过"。F2 是唯一的例外,见下方说明。

路径接口同一联系人再出现换了联系人再出现
F1 — 全自动主流程(autopilotAutoSend)/api/autopilot/run-now、每小时 cron这条线索根本不存在于候选池里,无需拦截放行 — 经由 isCompanyArchived 判定为全新周期,正常生成候选并发送
F2 — 手动单条发送POST /api/opportunities/leads/:id/send弹窗警告 — "已标记为…(同一个联系人),仍要发送吗?",人工确认弹窗警告 — 提示会明确写"看起来换了个联系人,之前是 X",人工确认
F3 — 批量发送POST /api/autopilot/send-queue(Preview Send Queue → Send All First Emails)这条线索根本不会有草稿、不会出现在 Preview Queue 列表里,无需拦截放行 — 现在和 F1 用同一个 isCompanyArchived,换了联系人会自动发出,并触发 contact_reset_at 重置

为什么 F2 不一样,需要自己单独查一次:F2 操作的是你直接指定的某一条已经存在的线索 id——这条线索完全可能是在公司被标成 not_interested 之前就已经躺在 b2b_leads 里了(那时候公司还没被拒绝,插入时压根没触发过 isCompanyArchived 的拦截),所以 F2 必须在真正发送前自己单独查一次公司当前的真实状态,弹窗确认,不能假设"能走到这一步就说明已经检查过了"——这跟 F1/F3 只处理"当下正常插入进来的新线索"是不同的场景。

怎么测:F1/F3 不需要单独构造"同一联系人"的测试用例(构造不出来,线索进不了表),直接复用 D1 的验证结论即可(本质是同一个判断函数);F1/F3 只需要测"换联系人后能否正常放行发出"这一半。F2 单独测两半:把测试客户手动标成 not_interested,分别用同一个邮箱和一个新邮箱去发,确认弹窗文案里"同一个联系人" vs "换了联系人,之前是…"这两句话分别在对的场景下出现。

!🧹 清理:F2/F3 测试会为测试客户所在的公司名新建一条 b2b_leads 记录(用来触发发送流程),标准复位 SQL 不会清理这张表,需要额外单独处理:
DELETE FROM b2b_leads WHERE company_name = (SELECT company_name FROM clients WHERE id = <test_client_id>) AND status != 'sent';
如果测试过程中不小心真的发成功了(status='sent'),把上面 != 'sent' 去掉,连同已发送的那条一起清掉;再跑一遍"1.5 标准复位 SQL"把测试客户自身的状态改回 contacted。

G组:Webhook message-id 精确归因 — 打开/点击/退信要归到真正触发它的那封邮件上,而不是"不管哪个事件,都算给最近发的那封"。

G1 · 点击事件归因与优先级 2026-08-24 改版

这是唯一真正需要靠真实 Brevo 才能测到位的场景——本地环境无法收到 webhook,交给真实环境验证。三个子场景都围绕同一件事:点击事件有没有被记到正确的邮件上,以及记对之后系统会不会真的据此改变分支。

子场景 A(核心):Cold Restart 里的链接被点击 → 触发 High Intent

  1. 让测试客户走到"Cold Restart 已发送、Followup 2 还没发"这个阶段。
  2. 确认这封 Cold Restart 邮件里的链接是 https://www.jobvista.app 这种真链接(而不是裸文字 "jobvista.app")——包括数据库 settings.cold_restart_template 里存的版本,不只是代码默认值。
  3. 点击邮件里的链接。
  4. 点 Run Test Follow-up Now(或等下一次每小时测试 cron)。
✓email_log 里 Cold Restart 那一行 status 变成 clicked;系统不会直接生成 Followup 2、也不会转 cooling,而是在 Preview Queue 里生成一条 high_intent 类型的排队邮件。2026-08-24 起这条链路才第一次被支持——此前 Cold Restart 自己的点击完全不会被判断逻辑读取,只有 First Email 的点击才算数(见下方"背景")。
→SELECT type, status, open_count, brevo_message_id FROM email_log WHERE client_id=<id> ORDER BY sent_at ASC
→ SELECT id, type, status FROM followup_queue WHERE client_id=<id> ORDER BY created_at DESC LIMIT 1(确认最新一条是 high_intent,不是 followup2)

子场景 B(边界测试):Warm 里的链接被点击 → 不应该触发 High Intent

  1. 让测试客户走到 Warm 已发送阶段(打开次数达到 Warm 阈值后自然会走到这一步)。
  2. 点击 Warm 邮件里的链接。
  3. 点 Run Test Follow-up Now。
✓email_log 里 Warm 那一行 status 会变成 clicked(这个记录本身没问题,只用于点击率统计),但不会生成 High Intent——客户仍然按 Warm→Followup 1→Followup 2 原节奏继续走。这一条是故意排除的:Warm 邮件本身的内容已经跟 High Intent 很接近,点击 Warm 不算一个独立的"高意向"信号。这条测试专门用来防止以后不小心把 Warm 也并进点击判断范围而不自知。
→同上,确认 Warm 那一行 status='clicked',但 followup_queue 里没有新增 high_intent 记录。
🧹 清理:两个子场景测完后执行"1.5 标准复位 SQL"即可开始下一个场景。

子场景 C:两封邮件同时在外,事件的行归属

  1. 给同一个客户先后发出 first 和 cold_restart 两封邮件——email_log 里会有两条记录,各自带着不同的 brevo_message_id。
  2. 用下面这个模拟工具,选中较早那封(first),事件类型选"退信",点模拟——不用真的发到一个会退信的地址,也不用等 Brevo 真实回调。
  3. 看模拟结果、或去数据库确认。
✓只有 first 那条记录的 status 会变成 bounced,更晚发出的 cold_restart 那条完全不受影响。如果记错行,后果是真实的:真正的死地址会一直显示"sent"、系统不知道它已经收不到会继续用;而一封其实寄达了、根本没问题的邮件却被误标成 bounced,之后可能被错误地当成坏地址处理。
🧪 模拟工具 — 直接调用真实的事件处理逻辑,不用 curl
怎么用:
  1. 先准备好两封邮件 — 正常操作把 first 和 cold_restart 都发出去(跟平时测试一样,Run Test Follow-up Now 之类)。
  2. 拿客户 ID — 跟平时查数据库一样,跑:
    SELECT id FROM clients WHERE company_name LIKE '%公司名%';
    查出来的数字(比如 75317)就是客户 ID。
  3. 用这个面板 — 客户 ID 填进输入框 → 点"查这个客户的邮件"(下面出现下拉框,列出这个客户所有邮件,格式类似 #967 · first · sent · 2026-08-27 ...,#后面是这封邮件自己的 id)→ 在下拉框里选中要模拟的那一封(按场景 C 的要求,选较早那封,也就是 first)→ 事件类型选"退信 (hard_bounce)" → 点"模拟"。
  4. 看结果 — 会直接显示类似:
    ✓ 匹配正确 —— 模拟目标:#967(first),实际改动落在:#967(first, 现在状态=bounced)
    意思是:你选的是 #967(first 那封),系统处理完之后确实是 #967 这一行被改成了 bounced。如果"实际改动落在"显示的是另一个编号(比如 #968,也就是 cold_restart 那封),前面会显示"✗ 匹配错了",说明代码有问题。
→SELECT type, status, open_count, brevo_message_id FROM email_log WHERE client_id=<id> ORDER BY sent_at ASC
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

背景:为什么"点击"要分邮件类型讨论,"打开"不用

generateFollowupQueue 判断"打开次数"(opens)时,看的只是 first 这一封邮件自己的 open_count——但要走到"两封邮件同时在外"这种可能记错行的状态,前提是打开阈值早就跨过去了,所以一次记错行的迟到打开,不会翻盘任何分支决策,不再需要单独测。判断"点击"则不同,2026-08-24 起明确定义为:点击 first 自己的链接,或点击 cold_restart 自己的链接,都会被判断逻辑读取并触发 High Intent;点击 warm/followup1/followup2/high_intent/reengagement 自己的链接则只会被记录、不会被判断逻辑读取。子场景 A/B 就是分别验证这两半是不是都对。

G2 · 回信记录本身缺失客户关联时的兜底匹配 2026-08-01 新增修复

验证即使某封发件记录先天没有关联到客户(历史遗留问题——早期 autopilotAutoSend 不会主动建客户档案,详见 H2 背景说明第③条),客户真的回信时依然能被正确识别、状态正常更新,不会因为这条记录本身有缺陷就把回信悄悄丢掉。

  1. 走一遍标准准备工作:Set Test Client → Run First Email Now,确认收到邮件。
  2. 手动跑一条 SQL,故意把这封邮件在数据库里的客户关联字段清空,模拟"当时建档没跟上"这个历史问题:
    UPDATE email_log SET client_id = NULL WHERE client_id = <test_client_id> AND type = 'first';
  3. 正常回复这封邮件,比如跟 B1 一样写"这个看起来不错,想再多了解一下"。
  4. 点 Run Test Follow-up Now。
✓尽管这封邮件的记录本身"先天残缺"(没有关联到客户),客户状态依然会被正确识别、更新成 active——证明就算历史数据有缺陷,回信也不会因此丢失,系统还是能把它对应回正确的客户身上(靠新加的第三层匹配:按回信邮箱直接查 clients.contact_email)。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

Watchlist · 手工审核标记 2026-08-01 新增场景 — 被标记 Watchlist 的线索/客户,不管真实的 Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 开关是开是关,都必须强制停在 Preview Send Queue 等人工审核,不能被自动发出去。这组场景不走 A-G 组那套"Set Test Client"机制——那套机制本来就是设计成绕开所有拦截条件,测不出 Watchlist 有没有生效,必须用真实的手工发送流程来测。

Watchlist · 完整机制说明 2026-08-26 补充

这是背景说明,不是可执行的测试步骤——先搞清楚这个标记具体在哪几个环节起作用,再看下面 Watchlist1/2 具体怎么测。

manual_review 这个字段,b2b_leads 和 clients 两张表里都有,是"公司级别"的意图标记,不是只存在某一张表里:

阶段生效方式
First EmailautopilotAutoSend() 挑候选人的查询明确排除 b2b_leads.manual_review = 1 的线索,第一封邮件永远不会被自动发出,必须去 Opportunities 手动点发送。
转化成正式 Client标记会跟着一起转过去(ensureClientForLead/批量发送路由里的 manual_review = manual_review OR ?),不会因为转化就丢失标记。
后续6种跟进类型(Cold Restart/Warm/High Intent/Followup1/Followup2/Re-engagement)generateFollowupQueue() 该怎么生成候选还是怎么生成,一视同仁,会正常生成一条 pending 的排队邮件;但最后那段"按优先级+每日上限自动发送"的循环里明确判断 if (item.manual_review || !autoToggleForType[item.type]) continue;——不管对应 Auto 开关开没开,直接跳过,永远停在 pending,只能靠人工去 Preview Send Queue 点 Send。
在哪里管理Opportunities 列表里线索行前面的复选框(🔖),批量勾选/取消走 PUT /api/opportunities/leads/bulk-flag;查看和编辑待发内容,是 Opportunities 里"筛选:Watchlist"这个视图——这个视图故意不限制 status,所以一家公司不管走到第几个阶段,只要还带着标记,都会一直出现在这个视图里,用同一个编辑器改内容、点发送。

Watchlist · 标记后自动开关必须失效

验证即使真实的自动发送开关是打开的,被标记 Watchlist 的这一个客户依然会被强制摁住,不会被自动发出去。

  1. 准备工作跟平时一样,把 Settings → Autopilot 里所有"天数"类设置改成 0。
  2. 在 B2B Leads 里选一条线索,把它的联系邮箱改成你自己的私人邮箱。
  3. 点这条线索行前面的 Watchlist 复选框(🔖 图标),确认它被标记进了 Watchlist。
  4. 不要用 Set Test Client + Run First Email Now——直接在这条线索上点手工的 Send Email 按钮发出去。Watchlist 要测的是"真实客户的真实自动化流程会不会被拦住",用测试客户机制会直接绕开所有拦截判断,测不出东西。
  5. 这一次可以临时把 Auto Cold Restart / Auto Warm / Auto High Intent 这几个真实开关打开(平时测试要求它们必须关闭,这是唯一的例外——因为整个测试范围就锁定在这一条打了 Watchlist 标记的线索上,不会影响到其他真实客户)。
  6. 等系统状态从 New 变成 cold_restart 或者 followup1(取决于有没有打开过邮件,两者不会同时出现)。
  7. 去 Preview Send Queue 确认这封邮件在等着,没有因为开关是打开的就自动发出去。
  8. 手动点发送,在私人邮箱里打开/点击这封邮件,等 Brevo 把打开/点击事件推回来,系统状态升级成 warm/high_intent。
  9. 再去 Preview Send Queue 确认,同样在等着,依然没有自动发出去。
✓整条链路走下来,每一步都是"进队列等人工点"而不是"自动发出去"——证明哪怕真实开关是打开的,Watchlist 标记也能把这条线索强制摁住,等人工审核,这就是 Watchlist 本来该做的事。
!测完记得把第5步临时打开的那几个真实开关重新关闭,避免影响到其他真实客户。这条场景涉及的是真实客户(不是测试客户机制),标准复位 SQL 对它不适用——测试结束后手动把这个联系人从 Watchlist 移除、或者直接删掉这条测试用的线索/客户记录。
→批量爬虫扫描发现的全新线索、一开始就带 Watchlist 标记的情况,涉及一次性扫描全部真实线索这个大范围动作,没法像上面这样安全地单独测一条——这部分不做真实场景测试,代码里已经在候选人筛选的 SQL 条件里直接排除了 manual_review = 1 的线索(autopilotAutoSend 候选人查询),只要这条查询条件没被改动,就不需要单独验证。

Watchlist2 · 有排队邮件未处理时,复选框必须锁住无法取消 2026-08-26 新增

验证"取消 Watchlist 标记"这个动作,在这个客户名下还有没处理完的排队邮件时会被正确拦住——不然下一次自动任务一跑,这条从没被人审核过的排队邮件会立刻被自动发出去。

  1. 沿用 Watchlist1 场景,让测试用的这条线索/客户带着 Watchlist 标记,走到"名下有一条 pending 排队邮件"这个状态(比如 Cold Restart 或 Warm 正躺在 Preview Send Queue 里等审核)。
  2. 去 Opportunities 列表,尝试点掉这条线索前面的 Watchlist 复选框。
  3. 确认复选框是灰色、点不动的状态,鼠标悬停能看到提示文字说明原因。
  4. 把这条 pending 邮件处理掉(在线索详情页的编辑器里点 Send 真发出去,或者点 Skip 跳过)。
  5. 回到 Opportunities 列表,确认复选框恢复成可以点击的状态。
  6. 这次点掉复选框,确认能正常取消 Watchlist 标记。
✓只要名下还有没处理的排队邮件,复选框就锁住无法取消;排队清空后,复选框恢复正常。后端也有同样的保护(PUT /api/opportunities/leads/bulk-flag 会在这种情况下拒绝取消标记),不是只靠前端禁用按钮这一层——就算跳过界面直接调接口,一样会被拒绝。
🧹 清理:跟 Watchlist1 一样涉及真实客户,标准复位 SQL 不适用,测完手动清理。

Follow-up Draft · Clients 详情页的手动跟进面板 2026-08-26 重做 — 这个面板会在真实引擎判断的"当前该轮到的类型"基础上,自由生成内容(可以参考真实模板但不用照抄)后手动发出去,发送走的是跟自动引擎完全相同的 followup_queue + sendFollowupQueueItem() 这套机制,不会跟自动流程产生重复。

Follow-up Draft 1 · 面板显示的类型必须跟真实引擎判断一致

验证面板顶部显示的"当前该轮到的类型",跟时间线弹窗、真实引擎会生成的类型完全一致,不是另外一套自己猜的逻辑。

  1. 走标准准备工作,让测试客户处于"发完 First Email、还没打开过"的状态。
  2. 打开这个客户的 Clients 详情页,确认 Follow-up Draft 标签显示"Follow-up Draft (Cold Restart):",Generate 按钮可以点。
  3. 真实打开一次这封邮件,刷新详情页,确认标签变成"Follow-up Draft (Warm):"。
  4. 点击邮件里的链接,刷新详情页,确认标签变成"Follow-up Draft (High Intent):"。
  5. 对照时间线弹窗(Email Timeline)里"预测下一步"显示的类型,应该跟面板上显示的完全一致。
✓面板的类型判断和时间线弹窗、真实引擎完全同步——三处判断类型时用的都是同一个共享函数 decideFollowupType(),不存在各自维护一份、容易对不上的风险。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

Follow-up Draft 2 · 手动发送不会跟自动流程重复,且能正确续接下一步

验证手动发送这一步真的会替代掉自动引擎本该做的事,不会两边都发一遍;也验证发送之后,自动引擎能正确从"这一步已经发过"续接到下一步,不会重跑或卡住。

  1. 让测试客户走到某个引擎已经生成了 pending 排队邮件的状态(比如打开过邮件,Warm 已经在 Preview Send Queue 里等审核)。
  2. 去这个客户的 Clients 详情页,Follow-up Draft 面板此时应该显示"Warm",点 Generate 生成内容,点 Send 发出去。
  3. 去 Preview Send Queue 确认:刚才那条 Warm 排队记录已经消失(变成 sent 状态),不是又多出来一条重复的。
  4. 去 email_log 确认新记录的 type 是真实的 warm,不是笼统的编号。
  5. 等够 Followup 1 after (days) 设置的天数,Run Test Follow-up Now,确认自动生成的是 followup1(不是重新生成一次 Warm,也不是卡住不知道下一步)。
✓手动发送的这条记录,跟自动发送产生的效果完全一样——同一张表、同一个发送函数,引擎后续判断"这一步发过没有"的时候,读到的是同一份真实记录,续接正常,不会重复也不会卡住。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

H组:部署交接 —— 测试库到正式接管 — 2026-07-30 定下的方案:新版 Stella 先连一份从生产库拷出来的独立副本做全场景测试,跟 OnlineStella 正式环境完全隔离;全部测完没问题,再让新版 Stella 正式接管、连上生产库本体,同时彻底停掉 OnlineStella。

H0 · 测试环境怎么搭(开始测试前)

  1. 从生产库整体拷贝一份出来(比如 mysqldump 导出再导入一个新库名),新版 Stella 的 .env 连接这份独立副本,不要连生产库本体。
  2. 新版 Stella 首次连上这份副本启动时,会自动把 contact_reset_at、rejection_type 这两个新列加上去(纯新增,不影响其他数据)。
  3. 确认 OnlineStella 那边的部署完全没有改动——它应该继续正常连着生产库本体、正常跑它自己的自动化,你的日常业务不受这次测试影响。
✓这样一来,本文档前面 A-G 组所有场景(包括第1步"把所有天数改成0"这种破坏性很强的设置改动)都只发生在这份独立副本里,不管怎么测、怎么改设置,都不可能影响到 OnlineStella 正在服务的真实客户。

H1 · 正式接管的时序 全部场景测试通过后再做

!先说清楚 Stella 和 www.jobvista.app 的关系,避免混淆:www.jobvista.app 是面向求职者/企业的 JobVista 产品官网本身,跟 Stella 不是同一个东西——Stella 是服务于这个产品的内部 CRM / 外联自动化工具(负责找企业线索、自动发开发信、跟进),运营自己用,不是 www.jobvista.app 这个域名下跑的东西。Stella 跑在独立的子域名上(测试阶段是 stellatest.jobvista.app),不影响 www.jobvista.app 本身的访问。

2026-10 更新:实际走的路径跟这里原本写的顺序不一样,而且进展比这份文档原计划更快——没有先完整走完 H1 流程、再做 H2 backfill,而是先部署了一个独立的测试子域名(stellatest.jobvista.app,跟旧的 Stella 完全分开的一套 Node 进程),直接连生产库本体(不是 H0 那种隔离副本)。H2 的 backfill(任务1-3)、H3 的脏数据清理、下面新增的任务4/5,全部是在这个实例上、对着生产库本体做的。这段文字写的时候,旧版 Stella(文档里一直叫 OnlineStella)的 Node 进程已经在今天(本轮测试期间)被删除了——不是"两边并行跑,旧的继续服务"这种状态,而是旧的已经不存在,stellatest.jobvista.app 现在是唯一在跑的 Stella 实例。7 个 Auto-send 开关目前仍然是关闭状态,这一点没变。换句话说,下面 H1 步骤2"停掉 OnlineStella"实质上已经发生,只是不是按这里写的顺序、也不是作为一个刻意规划好的步骤去做的——剩下还没做的主要是:确认子域名以后要不要从 stellatest 改成更像正式环境的名字(纯命名问题,不影响功能),以及正式打开 7 个 Auto-send 开关。

这一步不是改几个设置那么简单,是两个独立进程的交接,顺序错了会有真实风险,请按顺序做,不要图快跳步骤。

  1. 接管前,对生产库做一次快照备份(纯粹是保险,用不上最好)。
  2. 先彻底停掉 OnlineStella 的进程(PM2 层面真正 stop,不是点某个页面上的开关)。这一步和下一步之间不要有任何真实自动化在跑——两边进程绝不能同时连着生产库,否则爬虫、收件箱监听、自动发送都可能撞车、重复处理。
  3. 把新版 Stella 的 .env 数据库连接从"测试副本"改成指向生产库本体(不是 H0 拷的那份旧快照——OnlineStella 停之前一直在真实运行,数据比你测试用的那份新)。
  4. 启动新版 Stella 连生产库。
  5. 跑一次 DESCRIBE clients,确认 contact_reset_at、rejection_type 已经自动加上了。
  6. 去 Settings → Autopilot 确认所有天数类设置、Cooldown 数值都是生产真实值(14 / 14 / 7 / 3 / 7 / 60 / 90 / 180 / 365,真实 Daily Send Limit),不是测试期间在副本里改过的0——这是最容易出事的一步:如果这几个设置在生产库上被误留成0,新实例一启动,会瞬间判定所有真实客户都逾期,可能对整个客户名单猛发一轮。
  7. 确认 Auto Send New Emails / Auto Followup 1 / Auto Followup 2 / Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement / Auto Crawl Data 这几个真实开关此时都是关闭状态。
  8. 观察日志几分钟(pm2 logs),确认没有报错、启动流程正常、(如果 Auto Crawl 后面会开)爬虫翻页记录正常。
  9. 确认反向代理/端口指向的是新进程,不是残留的旧进程。
  10. 一切正常后打开这几个真实开关。Auto Send New Emails 有真实的 Daily Send Limit 把关,可以按原计划直接开。Auto Cold Restart / Auto Warm / Auto High Intent / Auto Followup 1 / Auto Followup 2 / Auto Reengagement 这6个共用 Settings → Standard → Email Send Limit → Capacity & Queue 里的 Follow-up Daily Limit,推荐这样开:

    推荐做法——先把 Follow-up Daily Limit 调低(比如 20-30),6 个开关一起打开:这个量级下,就算某个类型有问题,当天真发出去的总量也很小,影响范围可控,同时能一次性完整看到 6 个类型各自的真实效果,不用像逐个开关那样等好几天(generateFollowupQueue 一天只跑一次,严格一个一个开、每个都等一整天的话,6 个类型可能要排到一周之后才能全部验证完)。跑完一轮之后,去 email_log / Preview Send Queue 抽查这次真发出去的邮件,6 个类型各挑几封,确认模板渲染、收件人、发送后的状态流转(比如 Follow-up 2 发完是不是正确进入了 cooling)都是对的。连续观察几天没问题,再把 Follow-up Daily Limit 调回 250。

    如果想更谨慎、一个一个隔离着排查:也可以先去 Send Capacity & Queue 看一眼 6 个类型各自真实的 Pending 量级,按积压量从小到大开,不是按代码里 High Intent→Warm→Cold Restart→Follow-up1→Follow-up2→Re-engagement 那个固定顺序开——那个顺序只是同一天多个类型同时开着、互相抢 Follow-up Daily Limit 额度时的分配规则(只在多个开关同时开着时才起作用,不会记住你先开的是哪个),不代表开关必须照这个顺序打开。先开积压最小的类型,确认没问题再开下一个,把积压最大的(大概率是 Cold Restart 或 Follow-up 1)留到最后,出问题时影响范围最小;缺点是全部开完可能要好几天。
  11. 对 Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 这4个新开关,打开后第一次有对应类型的邮件产生时,去 Preview Send Queue 确认它没有停在里面——应该是已经直接进了 email_log 变成已发送状态。但先排除两种正常情况再下结论:①这个客户是不是 watchlist(是的话,不管开关开没开,永远停在队列里等人工点 Send,这不代表开关没生效);②当天的 Follow-up Daily Limit 额度是不是已经被更高优先级的类型用完了(用完的话,会正常留到下一次 generateFollowupQueue 运行,也不代表开关没生效)。排除这两种情况之后,如果还停在队列里,才说明开关没生效。
  12. 盯着接管后第一次整点 cron、第一次9点每日 generateFollowupQueue 的日志,确认行为符合预期——尤其留意日志里 [FollowupQueue] Auto-sent N queued item(s) 和 N auto-eligible item(s) held back - daily follow-up cap reached 这两行,能直接看出当天有多少被发了、多少被上限挡住留到下一次。
!第2步是整个交接过程中风险最高的一步:只要新旧两个进程有任何时间上的重叠,同时连着同一份生产库,就可能出现两套自动化同时判断"这个客户该发了"、两套收件箱监听同时读到同一封回信处理两次这类问题。宁可交接窗口留出几分钟完全没有任何一边在跑,也不要图省事让两边重叠。
!Cold Restart / Followup 1 这类积压,现在靠真实的每日上限自动节流,不用再手动分批:2026-08-13 核实过 generateFollowupQueue() 的候选人查询(server.js)没有 LIMIT,是对全部 status='contacted' 客户的穷举扫描——停摆几个月积攒下来的历史客户,大概率会有一大批同时符合 Cold Restart(一直没打开)和 Followup 1(打开过一次、没下文)的条件。当天已经把这个补上了:这6个类型现在共用一个真实的每日发送上限(autopilot_followup_daily_limit,默认 250,Settings → Standard → Email Send Limit → Capacity & Queue 里的 Follow-up Daily Limit 可以改,最高 300),按优先级 High Intent → Warm → Cold Restart → Follow-up 1 → Follow-up 2 → Re-engagement 发放——每天最多发这么多,用不完的额度不会累积到第二天,发不完的候选人留在 Pending,下一次 generateFollowupQueue 跑的时候接着按同样规则发,不会丢,也不会一次性爆发。

接管当天按上面步骤9的推荐做法打开这6个开关即可,不需要再像之前版本要求的那样先手动清一遍积压——上限本身就会把每天的发送量控制住。默认值是 250,Brevo 账号本身的额度上限是 300,这里刻意设了一点余量。

举例:Follow-up Daily Limit 具体怎么算

假设 Follow-up Daily Limit 设的是 250,6 个 Auto 开关全部打开,今天队列里躺着这些"等发"的邮件:

类型Pending其中 Watchlist
High Intent30
Warm50
Cold Restart4009
Follow-up 12004
Follow-up 2101
Re-engagement80

9 点定时任务跑起来,实际发生的是:

  1. 按优先级发,不是雨露均沾:先把 High Intent 的 3 个全发(剩 247 额度)→ 再把 Warm 的 5 个全发(剩 242 额度)→ 轮到 Cold Restart,400 个里刨掉 9 个 watchlist 还剩 391 个候选,只够发 242 个,剩下 149 个继续留在 Pending → 额度花光,Follow-up 1(196 个非 watchlist 候选)、Follow-up 2(9 个非 watchlist 候选)、Re-engagement(8 个)今天一封都不发,全部留到明天。
  2. 发失败的不算钱:上面那 242 个尝试里,如果有 2 个客户刚好在这几分钟里自己回信了,系统会跳过这 2 个不发,也不会占用额度——会多试 2 个候选人补上,保证真正发出去的还是尽量接近该有的名额,不白白浪费。
  3. 手动发的也占用额度:如果 9 点之前你自己在 Preview Send Queue 手动点发了 50 封,9 点定时任务算出来的额度就是 250-50=200,不是满额 250——目的是保护 Brevo 一天的总发送量,不是只管"自动"发的那部分。
  4. 没发完的不会丢,明天接着排:Cold Restart 剩的 149 个、Follow-up 1 的 196 个,原样留在 Pending,不会消失也不会被重发。明天额度重置回 250,还是按同样的优先级顺序,先紧着上面几类,剩下的才轮到 Cold Restart 继续清。
  5. watchlist 的 14 个(9+4+1),不管开关和额度,永远不会被这套逻辑自动发——这是 watchlist 本身的设计目的,需要你自己去 Preview Send Queue 手动点 Send,Preview Send Queue 因此不会因为开关打开就完全清空,watchlist 的部分会一直留着等你处理。
  6. 测试客户不受影响:如果候选人是专门用来测试的"测试客户",不管开关开没开、额度够不够,系统都不会自动发,永远要手动点 Send。
!部署/重启前必须自己确认一次开关和积压现状:这次改动让 generateFollowupQueue() 处理所有 status='pending' 的记录,不只是这次新生成的——这是故意的(为了让"额度用完留到明天接着发"这套机制成立)。但也意味着:如果重启前数据库里,某个类型的 Auto 开关已经是开着的,并且已经攒了一批 Pending(比如测试阶段手滑开过、忘了关),下次程序一跑起来(重启服务,或者到了每天 9 点),这批积压会立刻开始按新规则真实发送,不会等你再手动确认一次。接管当天启动新版 Stella 之前,务必先确认这6个 Auto 开关现在的真实状态——如果都是关的(H1 步骤6要求的状态),就没有这个风险;如果有任何一个意外开着,先想清楚要不要保留这批积压再继续。

H2 · 正式接管后的历史数据 backfill 必须在 H1 完成之后才做

✓2026-10 更新:任务1、任务2、任务3 已在 stellatest 影子实例上、对着生产库本体全部执行完成,执行方式跟本节原计划一致(临时路由、一次性触发、确认无误后删除)。跑完之后做过一次全面的最终核对(任务1+任务2+手动补的 Lifeblood 个案),逐项在 phpMyAdmin 里核对过真实结果,确认最终状态都正确。具体执行细节、过程中新发现的坑,见每个任务下面补充的"✅ 实际执行结果"说明。

2026-08-01 排查线上数据时发现:由于两个独立的历史 bug,4 月下旬到现在这段时间,一部分本该发生的状态变化实际上从没写进 clients 表。这些 bug 本身已经在代码里修好了,但已经"漏掉"的这段历史,代码修复不会自动帮你补——需要在新版 Stella 正式接管生产库之后,单独跑一次性的数据补录。这里把来龙去脉、要做的两件事、以及每件事要注意的坑都写清楚,供接管后照着执行。

背景:为什么会漏,漏了什么

一共有三个独立的历史 bug,分别在这里发现、这里修复:

#问题影响修复状态
① 收件箱配错发件地址(email_sender)一直是 connect@jobvista.app,但 .env 里 IMAP_USER 一直是 support@jobvista.app —— 两个不是同一个邮箱客户回复全部落在 connect@ 的收件箱里,Stella 这套"读信 → AI 判断情绪 → 更新客户状态"(checkInboxReplies / analyseReplySentiment)从来没有真正处理过一封真实客户回复。已确认 support@ 里没有任何真实客户回复,全部都在 connect@。需要人工把 .env 的 IMAP_USER/IMAP_PASSWORD 切到 connect@(本文档不负责这一步,属于接管配置的一部分,见下方"前置条件")
② Webhook 写库没等完成handleBrevoWebhook 里更新 clients 表的 db.run() 全部是"发出去不等"的写法,没有 await——如果响应返回 Brevo 和进程重启/数据库抖动撞在一起,写入可能静默丢失,不会报错、不会留下任何痕迹用真实生产数据核实过:email_log 表本身完整、连续,没有缺口;缺的是这些正确记录没有同步到 clients.status,导致 Not Interested / Bounced 分类里漏了一批本该在里面的客户✅ 已修复 —— c:\Stella 和 c:\OnlineStella 两边的 handleBrevoWebhook 都已经把相关写入包上 await,确保真正写完才回应 Brevo
③ 自动发信不建客户档案autopilotAutoSend(批量自动发冷邮件的主流程)原本只查询是否已有 clients 记录,查不到也不创建,全靠服务器重启时的一次性迁移脚本兜底补建 —— 但这个迁移只在重启那一刻跑,两次重启之间发出的邮件,email_log.client_id 可能一直是空的如果客户的第一封邮件恰好落在"还没建档"的这段窗口期,之后不管这个客户什么时候回信,系统都按"查无此人"处理,直接丢弃,不会分类、不会更新状态✅ 已修复 —— autopilotAutoSend 发信前会先确保客户档案存在;同时给 checkInboxReplies 加了第三层兜底匹配(按回信邮箱直接查 clients.contact_email),这样即使历史邮件先天缺了 client_id,回信一样能被正确接住

真正开始批量往真实客户发信的日期,是用 email_log 里 unsubscribed / opened / clicked / replied 这四类各自独立的最早一条记录互相印证出来的——四个信号的最早时间都集中在 2026-04-23 这一天附近,不是最初猜测的 3 月,也不是中途猜测的 6 月。下面两个任务都以这个日期为起点。

举个例子,把整件事串起来

三个虚构的客户,分别对应"任务1要处理的情况"、"任务2要处理的情况"、"什么都不用管、自己会好的情况":

客户历史上真实发生了什么backfill 之前,系统以为怎样
阳光建材(联系人 Lisa)5/2 收到第一封开发信;5/5 打开 2 次;5/8 回信"价格有点高,暂时不考虑"——一次明确的婉拒因为收件箱一直配错,回信从没被看到,系统以为"发出去了,一直没回"
绿源物流6/10 直接点了邮件里的退订链接Brevo 正确判断出这是退订,但写 clients 表那几行代码当时没等它写完就回应了,偏巧那一刻服务器重启,这次写入悄悄丢了——email_log 是对的,clients.status 没跟上
东方物流(联系人 Mark)5/10 收到第一封开发信;5/14 打开 2 次;之后没有任何回复,也没有退订,就是单纯看了没理会系统看到的和实际发生的完全一致,没有任何缺口

8月,新版 Stella 正式接管,跑 backfill:任务1连上 connect@ 邮箱,翻到 Lisa 5/8 那封回信,AI 判断是婉拒,调用 markClientReplied()——阳光建材的 clients.status 被正确改成 cooling(server.js soft_rejection 分支),冷却到期日从"今天"(backfill 执行的那天)往后推 60 天,不是从 5/8 号往后推,时间会比理想情况晚一些,这个没有完美解法。任务2重放 email_log 里绿源物流那条 unsubscribed 记录,clients.status 被正确同步成 not_interested,正式退出联系名单。东方物流不需要 backfill 做任何事,他的数据从头到尾都是对的。

代码上线,autopilot 定时任务开始正常跑之后:阳光建材——因为任务1已经把她的状态改成了 cooling,generateFollowupQueue() 挑候选人时有一条硬性条件 c.status = 'contacted',cooling 不满足这条,她会被直接排除,不会因为"打开过邮件"而又收到一封 warm 跟进邮件,跟她已经婉拒这件事不矛盾。东方物流——他的状态自始至终一直是 contacted,"打开过邮件、还没发过 warm"这个条件本来就一直是对的,generateFollowupQueue() 下一次运行时会直接命中他,正确生成一条 warm 跟进邮件——不需要为"5月的那次开信"单独写脚本去补,系统检查的是"现在符不符合条件",不是"5月当时有没有实时触发过"。

!前置条件与执行顺序:这一节必须在"H1 · 正式接管的时序"完全走完、新版 Stella 已经稳定连着生产库运行之后才能做——backfill 的目标是把数据补进新版 Stella 正在使用的这份生产库,接管都还没做的话补了也没有意义。任务1开始之前,需要确认 .env 的 IMAP_USER/IMAP_PASSWORD 已经从 support@ 切到 connect@(对应真实密码),并且确认这个账号真的能连上(node 里跑一次 ImapFlow 连接测试,或者看 pm2 logs 有没有 IMAP 相关报错)。

正确顺序是:H1 接管完成 → 任务1 + 任务2(这两个任务之间谁先谁后都行,互不依赖,甚至可以合并成一次脚本一起跑)→ 抽查确认结果没问题 → 再打开自动发送相关的开关(Auto Send New Emails / Auto Followup 1 / Auto Followup 2,以及 Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 这4个,见 H1 步骤6/9)。顺序反了的话,backfill 还没做完,客户状态还停留在旧的"contacted",这时候如果自动化已经在跑,可能会给已经婉拒/退订过的客户又发一封不该发的邮件。
!怎么实际执行:backfill 不是一个能在 cPanel 里直接点一下运行的独立脚本,而是要写成代码、跟着 Stella 这个 Node 程序一起跑。具体流程:把 backfill 的逻辑写成一段临时代码(加进 server.js,类似测试阶段反复用过的那种临时路由);像平时更新代码一样,通过 cPanel 的 File Manager 把改完的 server.js 上传,再去 Setup Node.js App 那个模块里重启应用(这一步只是用来部署代码,还不会真的执行 backfill);重启之后,backfill 代码已经在跑着的 Stella 程序里了,访问一个特定的网址来实际触发它执行;确认结果没问题后,再把这段临时代码删掉、重新上传一次干净版本、再重启一次。

任务1 · 回复情绪分类 backfill(连 connect@ 邮箱,2026-04-23 至今)

  1. 写一个一次性脚本(不是常驻接口,跑完即可删除),复用 checkInboxReplies() 里逐封处理邮件的完整逻辑,唯一的改动是把"只查最近7天"(since = 7天前)这个时间参数换成一个固定的起点 since = 2026-04-23——效果是从 2026-04-23 一直查到脚本实际运行的那一天(不是只查 4 月 23 日当天),把这中间几个月积压的邮件一次性全部拉下来处理。
  2. 邮件必须按 sent_at / 收件时间正序(从最早到最新)逐封处理——如果同一个客户在这几个月里回复过不止一次(比如先感兴趣、后来又拒绝),按正序处理完,最终状态才会正确停在最新的那一条上。
  3. 识别到自动回复(度假自动回复等)时,必须调用现成的 detectAutoReply() 判断,不要囫囵当成真实客户情绪去分类——这一步直接复用现成函数,不要自己重写一遍类似逻辑,避免细节判断不一致。
  4. 每一封真实回复,必须直接调用 markClientReplied() 这个函数本身(而不是照着它的逻辑重新写一遍)——这样能自动继承它已经写好的几层保护:按 contact_reset_at 限定范围的 hard_rejection 公司归档逻辑(不会误伤"联系人已经换过一轮"的公司)、按 message-id 写入 client_activities 的去重(保证跟以后正常的 7 天轮询不会重复处理同一封信)、清空 question_received_at 等细节。
  5. 排除你会额外提供的"这段时间里已经人工手动处理过"的客户名单,这些不需要 AI 重新分类一遍。
  6. 控制 Groq 调用节奏,配合已经上线的"按模型分开追踪 token 用量 + gpt-oss 额度不够自动借用 qwen"这套机制(callGroq / pickFreshProtagonist 附近的额度检查逻辑),不要因为一次性处理几个月的积压邮件,把当天 gpt-oss-120b 的额度一次性打满,影响到同一天其他实时功能(邮件分类、社媒生成等)。

为什么必须调用这个函数本身、不能自己重写——三条保护各举一个例子:

① contact_reset_at 保护,防止用旧联系人的拒绝信错杀新联系人——青山制造,3月的联系人 Tom 回信"不需要,别再联系了"(硬拒绝);6月爬虫在青山制造又抓到了新的联系人 Amy,系统给这家公司重新开了一轮(重置时间记在 contact_reset_at 里)。补录脚本翻到 3 月 Tom 那封拒绝信时,不能因为这封信就把"青山制造"整个公司标成不再联系——现在联系的是 Amy,Amy 什么都没拒绝过,不该被 Tom 三个月前的话连累。markClientReplied() 会先判断这封回信是在 contact_reset_at 之前还是之后,是之前的旧回信就不会用来误杀新联系人;自己重写逻辑很容易漏掉这一步。

② message-id 去重保护,防止同一封信被处理两次——补录脚本这次是一次性把几个月的旧邮件翻一遍处理掉,处理完之后系统还会照常继续巡检收件箱。如果漏了"记一个已处理标记"这一步,以后系统正常巡检万一又扫到同一封旧邮件,会误以为是新回信,把改状态、发通知那套逻辑再跑一遍——同一封信被处理两次,客户动态记录里会出现重复的两条。markClientReplied() 会用邮件自带的 message-id 记一条"处理过了"的标记,以后再扫到同一封信会自动跳过;自己重写脚本容易忘记打这个标记。

③ 清空 question_received_at,防止留下一个过时的"还在等回答"状态——客户之前问过一个问题(比如"价格怎么算"),系统记了一个"在等回答"的时间戳。后来客户又发来一封信把事情说清楚了(比如直接说不需要了),这个"等回答"的时间戳应该被清掉,不然界面会一直显示"客户还在等你回复问题",其实是个过时的假状态。markClientReplied() 处理新回信时会顺手清掉这类过时标记;自己单独写状态更新代码,很容易只改了状态字段,忘了这些连带的细节字段也要跟着清。

这三条都是"自己重写容易漏、用现成函数就自动不会漏"的坑,所以宁可让补录脚本调用这个函数本身,也不要照着逻辑抄一遍。

✓跑完之后,2026-04-23 至今真实回复过"感兴趣 / 婉拒 / 硬拒绝 / 提问"的客户,clients.status 会被正确更新到位。因为 cooling 状态有一条触发路径就是"客户回复了婉拒"(soft_rejection),这一步做完之后,那些原本应该进入冷却期的客户会自然跟着落进 cooling,re-engagement 的时间线也会跟着开始生效——不需要为 cooling / re-engagement 单独再做什么(原因见下方"为什么 warm/high_intent/cooling/re-engagement 不需要单独 backfill")。
✓✅ 实际执行结果:跑完自动匹配后,剩下 11 封没能自动归位的历史邮件,逐封人工核对:10 封是真实噪音(Stella 自己发出的"额度即将用完"系统通知被误收进同一个收件箱、工单系统的自动回执、一封不相关的"申请已收到"确认信、运营自己当时测试时发的一封回信),确认跳过。剩下 1 封是真实客户回复,但3层自动匹配全部没认出来——Lifeblood(client #3063):对方的真实回信是经一个第三方中转服务(noreply@outhire.co)转发过来的,发件地址和 message-id 跟 Stella 实际发给 careers@redcrossblood.org.au 的那封邮件完全没有关联,常规的"按收件邮箱/按 message-id"匹配逻辑天然找不到这种情况。这种属于自动化覆盖不到的边界 case,不是 bug,人工确认是真实回复后,手动指定 client_id 跑了一次跟常规流程完全一样的处理链路(extractReplyBody→detectAutoReply→markClientReplied,没有另写一套特殊逻辑),结果正确写入。
!三个要提前知道的坑:
1) re-engagement 的冷却期时间线会比"理想情况"晚——如果今天(8月)才补上 5 月的一条历史回复,系统计算冷却到期日(reengagement_date)是从"今天"往后推算的,不是从 5 月那个真实时间点往后推。这个没有完美解法,只能接受这个折中,不影响正确性,只是时间会晚一些。
2) 可能覆盖掉人工手动改过的状态——如果这几个月里你在界面上凭电话沟通等人工渠道,手动改过某个客户的状态,backfill 重放老邮件时有可能把这个手动修正覆盖掉,因为系统目前没有"这条是人工改的,不要自动覆盖"这种标记。建议 backfill 跑完之后,抽查一下你记得手动改过的那几个客户,确认状态没有被意外覆盖,需要的话手动改回来。
3) 4 月 23 日之前的极少数邮件——如果当时恰好撞上"③ 自动发信不建客户档案"那个窗口期(client_id 先天缺失),现在因为已经加了兜底匹配(按回信邮箱直接查 clients.contact_email),backfill 理论上一样能正确接住,不需要额外处理,但值得在抽查阶段多留意一下这类特别早期的记录。

任务1 执行过程中顺带发现并修复的2个真实问题 2026-10

这两个不是另开的任务,而是在逐封核对那11封"自动匹配没认出来"的历史邮件时,顺带发现 checkInboxReplies()/analyseReplySentiment()/extractReplyBody() 本身也有缺口,当场一起修掉、一起跑进了 backfill:

问题真实案例修复
① 求职者自动回执被误判成客户回复Raiseit 那封回信内容其实是"感谢您的申请,我们会在未来几周内筛选……祝您求职顺利"——这是 Stella 发的开发信误投进了对方 ATS 系统的求职者收件箱,系统自动回的是"收到申请"这类话术,根本不是针对 Stella 这封开发信的回复,但当时没有专门的分类能识别这种情况,会被当成普通回复往下走分类流程新增第5种回复分类 misdirected,专门识别"这是 ATS 自动回复求职者的,不是真人针对我们这封开发信的回复"这种情况,处理方式跟自动回复(isAutoReply)一样:只记日志,不改状态,避免被误判成软拒绝或硬拒绝
② 没有声明编码方式的邮件正文解析乱码Raiseit 这封回信的真实正文里埋着没有在邮件头声明编码方式的 =0D 这类转义残留(VTNZ 那封回信则是声明了 base64 但当时没解码),直接影响到①能不能正确读出回信的真实内容去做情绪判断extractPlainTextPart/extractReplyBody 补齐了三层解码:multipart 叶子节点声明了编码方式的按声明解码、非 multipart 兜底路径声明了编码方式的也按声明解码、新增一层"没声明任何编码方式,但正文里出现2次以上 =XX 这种转义残留特征"的频率判断兜底(专门应对像 Raiseit 这种发信方自己没声明编码、但正文里确实带着转义残留的情况)

任务2 · Not Interested / Bounced 状态重新同步

这个任务跟任务1完全独立,互不依赖,可以按任意顺序做,甚至可以合并到同一次脚本里一起跑。背景是"② Webhook 写库没等完成"这个 bug——email_log 里 unsubscribed / bounced 这两类记录本身没有缺失(已经用真实生产数据核实过,4-7 月每个工作日都有连续记录),缺的只是这些正确记录当时没有同步写进 clients.status。

  1. 查询 email_log 里所有 status IN ('unsubscribed','bounced') 的记录,按 sent_at 正序取出——不限定具体某个月份,因为这个 bug 本质是"偶发的写入丢失",理论上随时可能发生,不是固定在某一个月的窗口内,限定月份反而可能漏掉窗口外的个案。
  2. 按时间正序逐条重放:status = 'bounced' → clients.status = 'bounced';status = 'unsubscribed' → clients.status = 'not_interested'。同一个客户如果历史上有多条记录,正序处理完,状态会正确停在最新的那一条。
  3. 按你的决定,这次 backfill 不使用"active / not_interested / bounced 状态不可被覆盖"这条保护(autopilotAutoSend 判断新客户时用得上,但这里刻意不用)——原因是当前只有 2 家 active 客户,风险很小,直接覆盖即可,不用为了极小概率的场景增加复杂度。
  4. 每条重放的同时,补一条 client_activities 记录,body 前面加一个类似 [Backfilled] 的标记前缀,方便以后区分这条是当时实时产生的,还是这次补录的。
✓Not Interested / Bounced 分类里,之前因为同步缺口而"消失"的客户会正确显示出来。
✓✅ 实际执行结果:重放完成,在 phpMyAdmin 里按状态分类做了分组计数核对,Not Interested / Bounced 两个分类的数量和明细都跟 email_log 里对应的 unsubscribed/bounced 原始记录对得上,没有发现异常。

任务3 · brevo_message_id 格式修正(尖括号)2026-08-24 发现

跟上面两个任务的性质不太一样——不是"事件当时没被处理",而是"事件处理时认错了对象"。根因:Brevo 发信 API 返回、以及它自己后台展示的 Message ID,格式都带尖括号(比如 <202608240648.18253653303@smtp-relay.mailin.fr>),但 sendViaBrevo() 存进 email_log.brevo_message_id 时一直原样存的带尖括号的值;而 webhook 收到 Brevo 推送的打开/点击/退信事件时,会先把它带的 message-id 去掉尖括号再拿去数据库里查。两边格式对不上,WHERE brevo_message_id = ? 从来没有真正匹配成功过,每次都会静默退回到"这个邮箱最近发出的那一封"这个兜底方案。

大多数情况下这个兜底方案蒙对了结果(一个客户同一时间通常只有一封邮件在外,"最近发出的那封"碰巧就是对的),所以这个 bug 一直没被发现,直到 2026-08-24 测试"两封邮件同时在外"这类场景才真正暴露:两次打开事件都被记到了后发出的那封(Cold Restart)头上,先发出的 First Email 反而显示"从没打开过"。server.js 存库那一步已经改成存之前先去掉尖括号(不影响回复串联逻辑,那段代码本来就同时兼容带、不带尖括号两种输入),但这个改动只对代码上线之后新发的邮件生效,已经存在数据库里的旧记录格式不会自动被修正,需要单独跑一次。

  1. 确认这次改动过的 server.js 已经部署、新进程已经在跑(跟本节步骤4一致)。
  2. 跑一次性 SQL,把已有的 brevo_message_id 值批量去掉首尾尖括号:
    UPDATE email_log
    SET brevo_message_id = REPLACE(REPLACE(brevo_message_id, '<', ''), '>', '')
    WHERE brevo_message_id LIKE '<%>';
✓这条 SQL 只是格式归一化,不涉及表结构改动,可以放心执行,重复跑也不会有副作用(WHERE 条件保证已经是干净格式的行不会被重复处理)。跑完之后,任何正在进行中的真实客户(不管邮件是重启前发的还是重启后新发的),只要之后再发生打开/点击/退信事件,都能被正确匹配回真正触发这次事件的那一封邮件,而不再是"最近发出的那封"。
✓✅ 实际执行结果:SQL 已在生产库跑过,批量去掉了历史 brevo_message_id 的尖括号,没有报错。
!这条 SQL 修不了已经发生的历史误记——重启之前如果某个真实客户已经出现过"两封邮件同时在外"的情况,并且在这期间发生过打开/点击/退信,这些事件当时就已经被写到了错误的那一行,open_count/status 已经定型,这条 SQL 只解决格式问题,不会把已经写错的数据挪回正确的行。这类历史误记目前没有批量自动纠正的办法(Brevo 后台的 Email Activity 详情页可以按邮件逐条查到真实的打开/点击记录,需要的话只能个案对照手工修正)。多数客户不受影响——只有那些确实同时收到过不止一封在途邮件的客户,才可能撞上这个问题。

执行顺序:这条 SQL 什么时候跑都可以,不依赖任务1/任务2,也不需要等它们完成,建议跟任务1/任务2一起,在 H1 完成之后一次性跑掉。

任务4 · 多联系人公司的 to_email 全链路追踪重构 2026-10 发现并修复

亲自去真实的 Preview Send Queue 里逐条看的时候发现的,不是靠查数据库发现的:Auckland Transport 出现了两条重复的 High Intent 排队邮件,同时好几家多联系人公司的称呼明显不对("Hi Dr"、"Hi me",或者称呼和实际收件邮箱对不上)。

根因:pickFollowupEmail(clientId, contactEmail) 解析的是"这家客户整体上该用哪个地址"(按互动强度:回复过 > 点击过 > 打开过,同等情况下按最近一次发信时间 tie-break),完全没有"这条跟进邮件具体是关于哪一封原始邮件"这个概念。Auckland Transport 真实有两个独立联系人(Rennie、Lynn),各自收到过自己的"第一封邮件"、各自点过自己那封的链接——但两个人都被这套逻辑解析成了同一个地址,其中一个人的互动记录被静默吞掉,完全没有任何报错或提示。

修复:给 followup_queue 加了 to_email 列,从候选查询到最终发送的整条链路都改成按这条跟进邮件具体对应哪一封原始邮件来追踪,不再是笼统的"这家公司的地址":

  • generateFollowupQueue() 的候选查询,关联上 lead_id 拿到这条原始邮件对应的具体联系人数据;去重条件从"按 client_id"改成"按 (client_id, to_email)",两个联系人不会再互相顶替
  • INSERT followup_queue 时把 to_email 一起存下来,不再是发送那一刻才现算
  • sendFollowupQueueItem() 找"上一封邮件"来拼 Subject/In-Reply-To 这些续接邮件串的信息时,按存下来的 to_email 精确限定,不再是不分青红皂白地挑这家公司最早的那一封
  • Preview Send Queue 接口、redirectStalePendingToHighIntent()(点击链接后把排队邮件临时改成 High Intent 那条逻辑)、手动"重新生成"接口,全部同步改成优先用存下来的 to_email,只有旧数据没有这个字段时才退回旧的 pickFollowupEmail() 兜底
✓✅ 实际验证结果:改完之后用真实的 Auckland Transport(Rennie/Lynn)数据验证——第一次验证时 Lynn 那条依然显示了错误的称呼,说明"查过 lead_id 关联没问题"不等于"数据真的对",又往下挖了一层才找到真正根因(见下方任务5),两次真实数据验证都通过后才确认任务4完成。
!这个修复只对新生成的排队记录生效——改动上线之前已经在 followup_queue 里躺着的旧记录,to_email 是空的,发送时还是会退回旧的 pickFollowupEmail() 逻辑。受影响的多联系人公司,需要手动 DELETE FROM followup_queue WHERE client_id = ? AND status = 'pending' 清掉旧记录,再触发一次 generateFollowupQueue() 重新生成,才能用上新逻辑——这一步已经在验证过程中对 Auckland Transport 等几家公司做过,大规模的一次性清理见下方任务5末尾的"942条全部重新生成"。

任务5 · 邮件称呼(问候语)解析全面重构 2026-10 发现并修复,范围最大的一次

起因同样是亲自翻 Preview Send Queue 发现的,不是靠自动化测试发现的:陆续看到 Wellington City Mission"Hi Applications"、Sanitarium"Hi Talent"、Hierarchy Group"Hi Abby"、AFFCO"Hi Lucas"(收件地址明明是 brett.innes@)这类明显不对或者可疑的称呼。这是这一轮篇幅最长、反复最多的一次修复,这里只记最终结论和路上几个真正关键的发现,不复述每一轮排查细节。

· 排查路上走过的弯路(有意义的弯路,不是浪费时间)

  1. 第一反应——扩充黑名单:一开始思路是"遇到一个垃圾词就加一个"(talent/acquisition/applications/human/resources/careers/cv/distribution/fabrication/managing/people/senior/supervisor 等等),这套办法能堵住已经发现的具体案例,但本质是在打地鼠,发现一个修一个,不成体系。
  2. 公司名误当人名——"Hawkeswood Mining, Careers":发现光靠黑名单堵不住"contact_person 字段里直接写进了公司名本身"这类情况(比如这条真实数据,"Hawkeswood"被当成人名提取出来,因为它确实首字母大写、不在任何黑名单里)。加了一道结构性检查:候选词如果等于这家公司自己名字的首词,直接判定不是人名——比继续堵词更一次性。
  3. 升级成"行业标准的共享邮箱检测":查证了邮件验证行业本身怎么处理这类问题(ZeroBounce 等工具的"role-based email detection"),确认"维护一份已知角色前缀词表、逐段比对邮箱前缀"是业内标准做法,不是自己瞎想的;原来只看"邮箱前缀有没有分隔符"的简单判断换成了这套更可靠的词表比对,顺带堵住了 no-reply@、customer-service@ 这类带连字符但其实是共享邮箱的地址。

· 真正的转折点:contact_person 这个数据源本身不可信

用真实数据核实 AFFCO / Fresh Meats NZ / Papamoa Dental 这三条"称呼明显不对"的记录时发现:不是关联错了联系人,是同一条 lead 记录内部,姓名字段和邮箱字段从一开始就没对上——专门加了一个诊断字段去核实"这条 lead 自己的邮箱是不是真的等于实际收件地址",三条全部显示"是"(lead_email_matches_to_email: true),但姓名完全是另一个人(比如 lead #55583,contact_person="Lucas Bird",contact_email="brett.innes@affco.co.nz",这两个字段压根不是同一个人)。这说明问题出在最早的抓取/建档环节,不是查询关联的问题——就算关联链路完全正确,contact_person 这段抓取来的文本本身也可能是错的,靠"多核实一层关联"堵不住这类问题。

✓最终设计(简化掉了前面所有分层校验):彻底不再用 contact_person 这段抓取文本来生成称呼,只信收件地址本身——邮箱是共享/角色邮箱(命中角色词表) → 统一 "there";邮箱是"名.姓"这种个人格式、而且能解析出一个像样的名字 → 用这个名字;解析不出来(比如首字母缩写、部门代码、称谓词)→ 同样 "there"。这套逻辑统一应用到全部四处生成称呼的地方:首次冷邮件(buildEmailPrompt)、跟进队列生成与发送(resolveGreetingName)、重新激活邮件(直接固定 "there",不再有单联系人例外)、客户详情页的"Follow-up Draft"预览面板(predictFollowupType)——四处口径完全一致,不会出现"预览显示一个称呼、真实发送又是另一个"的情况。

从收件地址解析名字本身,路上也找到并修了几个真实漏洞(全部是拿真实数据测出来的,不是凭空想的边界情况):

真实案例问题修复
executive-recruitment@ffa.int → "Hi Executive"审计方法本身有盲区——只对比"称呼 vs 邮箱推断的名字"是否一致,当两边都错成同一个词时查不出来"executive"等职位词补进黑名单,同时按"职位/部门/招聘"这整个语义类别系统性列全,不再零散补词
MR.T.THEBAKER@gmail.com → "Hi Mr"从 contact_person 文本提取名字时本来就会跳过"Mr/Dr"这类称谓,但从邮箱地址提取名字的那条代码路径漏了这个检查邮箱解析也补上同样的称谓跳过逻辑;跳过称谓后如果剩下的部分太短(比如这个案例剩下的"T"),直接判定解析不出来
NA.TA.Operations@averydennison.com → "Hi Na""NA"是地区/部门代码(North America),不是人名——真人的个人邮箱前缀几乎总是全小写,只有系统生成的代码类前缀才会保持原始大写邮箱原文中某一段本来就是全大写、且长度≤3,判定为代码缩写,不当人名处理
bd.mckee@outlook.co.nz → "Hi Bd""bd"读起来跟"姓名首字母缩写.姓"(B.D. McKee)一样合理,没有办法从这两个字母本身区分它是缩写还是真的短名字能接受的最短长度从2位提到3位——代价是连带会把 Jo/Bo/El 这类本来可能是对的真实短名字也归到 "there",按"拿不准就 there"的原则,这个代价可以接受
!一次性清理:改动上线之前,followup_queue 里已经生成的 942 条待发记录,body 里存的是生成当时就定好的称呼(不是发送那一刻现渲染的),所以旧的错误称呼会一直原样躺在里面,直到被发送或者过期——不会因为代码改了而自动变好。全部 942 条已经清空、用最新逻辑重新生成过一遍。
!排查方式:没有靠人工一条条翻 Preview Send Queue 去找剩下的问题,而是写了两个临时诊断接口——一个按"称呼词"去重聚合、返回每个独特词出现的次数和样本(避免一次性把几百条明细糊在屏幕上没法看),另一个合并了"清空旧队列 + 用最新逻辑重新生成 + 再审计一次"三步,一条命令跑完直接看最终是不是干净的。这两个接口命名为 /api/admin/audit-followup-greetings 和 /api/admin/regenerate-and-audit-greetings,属于临时诊断用途,应在确认不再需要时一并删除(见"测试结束后记得复位")。

为什么 warm / high_intent / cold_restart / re-engagement 不需要单独 backfill

这几类不是存起来的历史记录,而是 generateFollowupQueue() 每次运行时,根据 email_log 当前的 open_count / status,加上"距离首封邮件过了几天"现场算出来的,不依赖"当时是否被实时处理过"。只要满足两个条件:底层的 email_log.open_count / status 数据本身是对的(已确认——这块走的是 Brevo 的 opened/click webhook 事件,一直在正常记录,不受收件箱配错这个问题影响;但"正常记录"指的是事件本身没有被漏记,不代表一定记在了正确的那一封邮件上——如果某个客户当时恰好有两封邮件同时在外,可能撞上上面任务3那个 message-id 格式问题,记错了行,具体局限见任务3的说明),以及代码上线后 autopilot 的每小时 / 每日定时任务正常跑起来——那么任何当前真实符合 warm / high_intent / cold_restart 条件的客户,下一次定时任务运行时就会被自动、正确地捕捉到,不需要额外写脚本去补历史。re-engagement 同理,是靠 cooling.reengagement_date 到期后自动触发的,只要 cooling 状态被任务1正确同步了,re-engagement 会自然跟上,不需要单独处理。

H3 · 清理垃圾公司名 2026-08-28 发现

根因:crawler.js 抓 TradeMe 职位时,如果页面上专门放公司名的 CSS 选择器(.jb-listing__company-name 等)没抓到内容,会退而求其次,用一段正则从页面 meta 描述文字里"猜"一个公司名出来(crawler.js 里 jobData.meta_desc 那段兜底逻辑)。这段正则不设长度上限,遇到叙述性的长段描述文字,可能把一整句、甚至好几句话都当成"公司名"截下来,直接存进了 clients.company_name——不只是显示问题,这些"公司"会被当成正常线索走完整套自动化流程,已确认至少一条已经被真实发过一封跟进邮件。

测试库里当时已确认的两条真实例子:id 33529("Based in Twizel or Oamaru [换行] About the role [换行] Thi...",status=new,从未联系过)、id 19789("About the Role I am a 32-year-old woman who leads...",status=contacted,已经真发过一封跟进邮件)。代码层面的修复见下方"顺便:crawler.js 怎么改比较保险",这里只处理已经进了库的历史脏数据。

✓✅ 2026-10 在生产库实际执行结果(跟上面测试库的例子是不同的id,不要混淆):按下面第一步的识别 SQL 在生产库本体里查出 3 条真实记录——73608(从未联系过,按"第一种处理方式"直接删除)、5971(已经真发过邮件;但人工核对发现这条跟 19789 情况不同——这条的原始职位页面其实能找回真实公司名,不是彻底的垃圾数据,所以没有走原计划的两种处理方式,而是直接把 company_name 改成了正确的 "Bendigo Terrace Farming",比删除或停用更合适,算是执行过程中补出来的"第三种处理方式")、19789(到真正执行这一步时,发现 status 已经正确是 not_interested 了,不需要再额外处理——大概率是因为这条记录的真实订阅/退信事件已经被上面任务2的重新同步正确捕捉到,H3 执行时它已经不再是问题数据)。
!2026-08-28 实测纠正:长度阈值筛选不可靠,已弃用——最早的方案是 LENGTH(company_name) > 45 初筛,实际在测试库跑出19条结果,人工核对后只有2条是真垃圾,另外17条全是长度巧合超过45字符的真实公司名(比如 "Advanced Accounting & Business Advisory Limited"、"Wilson Contracting Ltd and Smiths Transport Ltd" 这种)。纯靠长度判断误伤率太高,不能作为筛选依据,下面改成按内容关键词识别。

第一步:识别,只读不改

两条真垃圾数据都包含 "about the role" 这个词组(大小写不一),这是 TradeMe 职位描述页常见的标题用语,正常公司名几乎不可能出现这个短语;33529 那条另外还能看出里面混进了真实的换行符(\n),公司名同样不该出现换行。用这两个信号一起筛,比单纯看长度精确得多:

SELECT id, company_name, status, source, created_at
FROM clients
WHERE source = 'TradeMe'
  AND (LOWER(company_name) LIKE '%about the role%' OR company_name LIKE '%\n%')
ORDER BY created_at DESC;

这依然只是初筛,不是最终判定,跑完必须人工过一遍结果——如果这次搜出来的除了已确认的33529、19789之外没有别的,就只处理这两条即可,不需要额外操作。

第二步:按有没有真实发过邮件分两种处理

不建议无差别一律 DELETE:如果这条垃圾"客户"已经有过真实的 email_log/followup_queue/client_activities 记录(比如已经真发过一封邮件),删掉客户本身会让这些记录变成指向不存在客户的孤儿数据,也会抹掉"确实发过一封邮件"这个本该保留的事实。已确认的这两条正好一边一个例子:

  1. 33529(从未联系过)——直接删除,加了保险条件,万一它其实有历史记录就不会被误删:
    DELETE FROM clients
    WHERE id = 33529
      AND id NOT IN (SELECT DISTINCT client_id FROM email_log WHERE client_id IS NOT NULL)
      AND id NOT IN (SELECT DISTINCT client_id FROM followup_queue WHERE client_id IS NOT NULL)
      AND id NOT IN (SELECT DISTINCT client_id FROM client_activities WHERE client_id IS NOT NULL);
  2. 19789(已经真发过邮件)——保留客户行,只是让它彻底停止参与任何自动化,先撤掉还在排队没发的,再把状态改成 not_interested(generateFollowupQueue 之类的候选人查询都要求 status = 'contacted',改完以后再也不会选中它),email_log/client_activities 的历史记录原样保留:
    UPDATE followup_queue SET status = 'skipped' WHERE status = 'pending' AND client_id = 19789;
    UPDATE clients SET status = 'not_interested' WHERE id = 19789;

如果第一步的搜索结果里还有别的记录,人工确认是真垃圾之后,把上面两条 SQL 里的具体 id 换成对应的 id、按"有没有真实记录"套用同样的两种处理方式即可。

!这条清理跟 H1/H2 一样,只在正式接管、确认这批数据是生产库里真实存在的脏数据之后才执行,不是常规测试场景的一部分。

crawler.js 的代码已经修好了 2026-08-28

最早想的方案是"猜不到就老实存 'Unknown (via TradeMe)'",但这个想法有个漏洞:跟进邮件模板渲染那段代码(renderFollowupTemplate)本来就有 companyName || 'your company' 这种兜底,但这个兜底只在 companyName 真的是空值时才生效——'Unknown (via TradeMe)' 是个非空字符串,兜底不会触发,模板会把这串占位文字原样塞进邮件正文,比刚才发现的问题更离谱。改用了更干净的方案:公司名抓不到就直接不生成这条线索,跟其他来源(Seek/Indeed 等,本来就有 .filter(j => j.job_title && j.company_name))保持一致,不再单独给 TradeMe 硬凑一个公司名。没有真实公司名的线索本来就没法正常发信,与其到处打补丁处理"公司名是占位符"这种边界情况,不如从源头不让它进 b2b_leads/clients 表:

if (jobData.job_title && jobData.company_name) {
    const tmContactInfo = extractContactInfo(jobData.descText || '');
    trademeJobs.push({
        job_title: jobData.job_title,
        job_url: jobUrl,
        company_name: jobData.company_name,
        contact_person: jobData.contact_person || tmContactInfo.contactName || '',
        contact_email: tmContactInfo.email || '',
        contact_phone: tmContactInfo.phone || '',
        location: jobData.location || ''
    });
}

这条改动只影响以后新抓的数据,不会自动清理已经在库里的历史脏数据,历史部分还是要靠上面第一、二步手动跑一次。

H1-H3 全部任务确认完成、正式开关打开之后,要删掉的临时内容 2026-10

以下都是为了这一轮接管测试临时加进 server.js 的诊断/补录用接口,不是长期要保留的功能,确认不再需要之后应该整批清掉,不要带进长期生产代码:

接口用途所属任务
/api/admin/backfill-task1H2 任务1,回复情绪分类 backfill任务1
/api/admin/backfill-task2H2 任务2,Not Interested / Bounced 状态重新同步任务2
/api/admin/backfill-manual-match手动指定 client_id,补录自动匹配抓不到的个案(比如 Lifeblood 那种第三方中转回信)任务1附带
/api/admin/run-real-followup-queue-now手动立即触发一次真实的 generateFollowupQueue(),不用等下一次整点/每日定时任务任务4/5 验证用
/api/admin/audit-followup-greetings按称呼词聚合审计当前 followup_queue 里有没有可疑称呼,支持 ?word= 按词查明细任务5
/api/admin/regenerate-and-audit-greetings清空 pending 队列 + 用最新逻辑重新生成 + 再审计一次,合并成一条命令任务5

任务3(brevo_message_id 格式修正)和 H3 本身的脏数据清理,用的是直接在 phpMyAdmin 里手动跑的一次性 SQL,没有加临时接口,不需要额外清理代码;cleanup_garbled_contact_person.js(contact_person 乱码清理)是独立的维护脚本,不是 server.js 里的路由,留在项目目录里不影响什么,不需要跟上面这些接口一起删。

!删除顺序:建议等正式开关已经打开、观察了几天确认一切正常之后再删,不要在还可能需要回头补查某条记录时就先删掉诊断接口——这几个接口全部是 requireAuth 保护的只读/一次性操作,留着多观察几天没有额外风险。

设置项 ↔ 代码对照表

界面字段设置键名默认值被谁读取
Daily Send Limitautopilot_daily_limit20autopilotAutoSend
Follow-up Daily Limit 新增autopilot_followup_daily_limit250(最高 300)generateFollowupQueue 结尾的「Capped, priority-ordered auto-send pass」——6 类 Follow-up 共用这一个每日上限,按 High Intent → Warm → Cold Restart → Follow-up 1 → Follow-up 2 → Re-engagement 顺序发放,跟上面的 Daily Send Limit(只管 First Email)完全独立
Followup 1 after (days)behavior_followup1_days14generateFollowupQueue 独家读取——2026-08-22 起含义是"Warm 发出后再等多久没回复才发 Followup 1",不再是"First Email 之后"。2026-08-24 起 Cold Restart 那条零打开路径原来由单独的 autopilotFollowUp() 直发处理、完全不读这个设置;那条路径已经下线,现在 Cold Restart→Followup2 也统一归 generateFollowupQueue 管,跟这个设置一样都不涉及(Cold Restart 直接进 Followup2,不经过 Followup 1)
Followup 2 after (days)behavior_followup2_days14两者都用,作为"发出F1等价物之后再等多久发最后一封"的时长
Cold Restart after (days, no open)behavior_cold_restart_days7generateFollowupQueue
Warm trigger after (opens)behavior_warm_opens12026-08-22 起从2改成1generateFollowupQueue——打开次数≥这个值就进 Warm;Followup 1 不再是"打开次数=1"单独触发的分支,改成 Warm 发出后的下一步(见 A2 场景)
Warm send after (days)behavior_warm_send_days3generateFollowupQueue
High Intent after (days, no reply)behavior_high_intent_days7generateFollowupQueue
Cycle 1 cooldown (days) 改名,2026-08-24 起去掉"after rejection"reengagement_cooldown_days60getCooldownDaysForCycle,仅第1轮冷却适用——原来的名字"Cooldown after rejection"不准确,进 cooling 的原因不只是拒绝,没回复(no_response)也会进
Cycle 2 cooldown (days) 新增reengagement_cooldown_cycle2_days90getCooldownDaysForCycle
Cycle 3 cooldown (days) 新增reengagement_cooldown_cycle3_days180getCooldownDaysForCycle
Cycle 4+ cooldown (days) 新增reengagement_cooldown_cycle4_days365getCooldownDaysForCycle
Auto Cold Restart 新增autopilot_auto_cold_restartfalsegenerateFollowupQueue(!isTestMode && 此设置——只对非测试调用生效,决定真实客户是否跳过 Preview Send Queue 直接自动发)
Auto Warm 新增autopilot_auto_warmfalsegenerateFollowupQueue(同上,仅非测试调用)
Auto High Intent 新增autopilot_auto_high_intentfalsegenerateFollowupQueue(同上,仅非测试调用)
Auto Reengagement 新增autopilot_auto_reengagementfalsegenerateFollowupQueue(同上,仅非测试调用)

2026-07-30 之前,Cycle 2/3/4+ 这三档是写死在代码里的字面量(90/180/365),现在统一收进了 getCooldownDaysForCycle(cycle) 这个共用函数,读取上面这几个设置项,几处用到冷却天数的代码都改成调用它,不再各自写一份——包括 sendFollowupQueueItem 的 Followup2/Re-engagement 收尾、autopilotRecycleReengagements(原名 autopilotFollowUp,2026-08-24 精简后现在只剩 cycle 2+ 收尾这一处)、以及 /api/autopilot/send-followup/:id 发送接口。

同一天,Cold Restart / Warm / High Intent 这3个类型的邮件内容也从"AI 现场生成"改成了"完全按 Settings → Email Templates 里保存的模板生成"(Reengagement 一直就是按模板来的,没有变过);同时新增了上面这4个 Auto 开关,用来控制正式环境下这4类邮件要不要跳过 Preview Send Queue 直接自动发——测试客户不受这4个开关影响,永远走队列人工审核,见"规则4"。

测试结束后记得复位

  1. 把 Test Follow-up 关回 OFF。
  2. 清空测试客户(测试客户提示条上的 ✕)。
  3. 把所有"天数"类设置改回正常值(14 / 14 / 7 / 3 / 7 / 60,Cycle 2/3/4+ 改回 90 / 180 / 365)。
  4. 删除或归档 B5、F2/F3 场景里为了测试临时建的客户/线索记录。
  5. 2026-10 新增 删除 H2 任务1-5 过程中加进 server.js 的临时诊断/补录接口(/api/admin/backfill-task1、/api/admin/backfill-task2、/api/admin/backfill-manual-match、/api/admin/run-real-followup-queue-now、/api/admin/audit-followup-greetings、/api/admin/regenerate-and-audit-greetings 等),确认不再需要之后统一清掉,不要把一次性诊断路由带进长期生产代码。
  6. 确认没有遗留问题后,再手动打开 Auto Followup 1 / Auto Followup 2 / Auto Send New Emails / Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 用于正式环境——测试期间这7个必须一直是关着的(见"规则4")。

清理 contact_person 乱码数据 一次性维护脚本,非常规测试场景

这不是一个要跑的测试场景,而是配合"规则5"里提到的另一处修复(2026-08-04)留下的一次性收尾工作,记在这里方便以后查。

背景:为什么会有乱码

upsertClient() 里合并联系人信息的逻辑,之前是"只要抓到新的联系人名字就往后追加",不会替换掉旧的。同一家公司只要被 Seek/Indeed/TradeMe/SJS 反复抓到(招聘信息本来就会重复出现),contact_person 就会不断变长,变成一长串逗号拼接的碎片——生产库里查到最长的一条超过 4500 字符。同一天还修了 extractContactInfo() 里人名抓取的正则本身(触发词大小写不敏感、人名部分强制要求真大写,避免把 "at"、"on" 这类词也当成人名的一部分抓进去,详见代码里的注释)。

修复之后:upsertClient() 改成"只保留最新一次抓到的名字,直接替换旧值",不再无限追加——contact_person 唯一的用途是邮件称呼(isLikelyName() 本来就会在名字不像真名时兜底显示 "there"),保留一个可能猜错的名字没问题,但让它无限累积成一段乱码不行。但这个修复只能防止以后再变乱,已经存在的乱码数据不会自动被清掉,所以需要跑一次一次性清理。

脚本做什么 — cleanup_garbled_contact_person.js(项目根目录)

连接数据库的方式和 server.js 完全一样(自动识别 DB_SOCKET_PATH:生产环境走 socket,本地走 host/port),所以不管在本地还是生产服务器上跑,只要用的是当前环境自己的 .env,就能连到对应的库,不需要额外配置。

逻辑很简单:找出 clients.contact_person 长度超过 60 字符的所有记录(真实的"名 姓"基本不可能超过这个长度),默认只打印报告、不改数据;加上 --apply 参数才会真正把这些记录的 contact_person 清空。清空之后不用管——下次这家公司被爬虫重新抓到,新的替换逻辑会自动填回一条干净的名字;一直没被重新抓到就一直空着,邮件称呼会兜底显示 "there",不影响发送。

怎么在生产服务器上跑

  1. 把 cleanup_garbled_contact_person.js 上传到生产服务器的项目目录(跟 server.js 同一个目录),用你平时更新 server.js 的同一套方式(cPanel File Manager / FTP 等)。
  2. 进 cPanel → Terminal,cd 到项目目录。
  3. 先跑 dry-run,只看报告,不改任何数据:
    node cleanup_garbled_contact_person.js
  4. 看一遍打印出来的清单(公司名 + 乱码内容预览),确认这些确实是垃圾数据,不是巧合的合法长文本。
  5. 确认没问题后,加上 --apply 再跑一次,这次才会真正清空:
    node cleanup_garbled_contact_person.js --apply
✓2026-08-04 在生产库跑过一次 dry-run,找到 396 条超过 60 字符的记录,抽查过内容全部是逗号拼接的碎片,没有误伤合法数据的情况。
✓✅ 2026-10 正式带 --apply 在生产库执行:脚本输出确认清空了 397 条记录(跟 8 月那次 dry-run 的 396 条数字对不上是正常的——中间隔了两个月,这期间爬虫又抓到了新的乱码数据,多出的1条在预期之内)。
!只会清空 contact_person 这一个字段,不碰其他任何字段,也不会删除客户记录本身——不影响状态流转、跟进队列或已发邮件历史。

Stella · c:\Stella local dev · 2026-07-29

Outreach Engine — Full Scenario Test Plan

Every path a client can take from first email to close, laid out as a checklist: what settings to set, what to click or reply, what should happen, and where to verify it. Covers engagement sequencing, reply classification, cooling/re-engagement, contact-reset, send-time guards, and cross-pathway blocking.

⚠ Every scenario from Group A onward should run against an independent test copy of the database, cloned from production — never against production itself. Setup steps are at the bottom under "H0 Test environment setup"; once everything passes, "H1 Production cutover sequence" walks through switching back to the real environment.

0 / 0 scenarios done

0. Testing toolkit Auto-Pilot tab

These buttons/toggles all live in the same Auto-Pilot tab, but split across two different sub-panels. Here's exactly what each one calls server-side.

Button / toggleWhere it livesWhat it callsWhat it actually does
Run First Email Now"Email Outreach Autopilot" panel → the Auto Send New Emails rowPOST /api/autopilot/run-nowSends the test lead's first email immediately — skips daily limit, working-hours, and holiday checks. Note: only clickable while Auto Send New Emails is OFF — with that toggle ON it goes grey and disabled (not hidden — just disabled, with a small "Disabled while auto-send is ON" hint), easy to miss. rebuilt 2026-08-07 Now prefers a dynamic lookup by test_client_id (the company) for whatever lead is currently eligible, instead of a fixed test_lead_id — so it still finds the right lead after the crawler discovers a new contact at the same company. Only falls back to the fixed test_lead_id for a brand-new test client that has no clients row yet (first email never sent) — a bootstrap case with nothing to scope the dynamic lookup by.
Set Test Client"Behaviour Triggers / Re-engagement" panelPOST /api/autopilot/test-clientPoints test_lead_id / test_client_id at the lead you pick — every other control below acts on this one client only.
Test Follow-up: ONsame panelhourly cron, gated on test_followup_enabledEvery hour, automatically runs generateFollowupQueue + autopilotRecycleReengagements for the test client only, ignoring holiday/hours gates — this is what compresses "14 days" into "1 hour," see Setup step 3 below. As of 2026-08-24 generateFollowupQueue alone owns the decision for all six types, including "Cold Restart sent, still unopened, escalate to Follow-up 2" regardless of open count; autopilotRecycleReengagements (formerly autopilotFollowUp) now only does the unrelated job of recycling a timed-out Re-engagement back into cooling. This toggle only ever affects the selected test client, completely independent of the real toggles below — see Rule 4.
Run Test Follow-up Nowsame panelPOST /api/autopilot/run-test-followup-nowOne-shot: polls the real inbox (checkInboxReplies), then runs generateFollowupQueue (which now completes both "generate Cold Restart" and "still unopened, escalate to Follow-up 2" entirely on its own, no longer needing a second function to finish the job), then runs autopilotRecycleReengagements for the separate "stale Re-engagement" cleanup.
Preview Send Queuesame panelGET /api/autopilot/preview-queueShows two sections: First Emails (bulk-sendable) and Follow-ups (cold_restart / followup1 / followup2 / warm / high_intent / reengagement — sent one at a time, each re-validated at send time). 2026-08-07 The recipient email shown for each Follow-up is now the actual address it'll send to (resolved via the same pickFollowupEmail() logic used at send time), not the company's full raw list of known addresses — previously a company with both an old and a new contact would show both, making it look like it might send to two people.
!All seven real toggles — Auto Send New Emails, Auto Followup 1, Auto Followup 2, Auto Cold Restart, Auto Warm, Auto High Intent, Auto Reengagement — must stay OFF for the entire testing period, and only get turned on one at a time for real once every scenario is done. They're an independent mechanism from Test Follow-up: regardless of whether these toggles are on or off, anything the test client triggers for Cold Restart / Warm / High Intent / Reengagement always stops in Preview Send Queue first, waiting for a manual Send click (fixed this way as of 2026-07-30, and never varies with toggle state) — these toggles only control whether a real client skips that step and auto-sends. See Rule 4.
!If you plan to test opened/clicked/webhook events against real Brevo: Brevo can only push webhooks to a publicly reachable address. On local c:\Stella (localhost) it can't reach you unless you set up a tunnel (e.g. ngrok) and temporarily point Brevo's webhook URL there. If you plan to test directly against production (OnlineStella) instead — note this week's changes currently exist only in local c:\Stella, not yet deployed to production, so production will still run the old logic.

0.5 Rules that apply everywhere understand these first, everything else follows

These aren't tied to any one scenario — they're the shared logic underneath all of them. Called out separately because they're easy to forget, and forgetting them makes a scenario's result look "wrong" when it isn't.

Rule 1 · A reply always pulls the client out for reclassification, no matter what stage it's in — but it never triggers a fresh first email

The single most important, most-forgotten rule.

Whether the client just got the first email and is waiting to be opened, is sitting in the followup1/warm/high_intent queue, or is deep into some cooling cycle — the moment they actually reply, the next inbox check (checkInboxReplies) reclassifies them immediately based on the reply content and pulls them out of whatever automated path they were on. Where they land depends on the reply type (matches group B):

Reply typeBecomes this, regardless of prior state
Interested / questionactive — pulled out even from mid-cooling
Soft rejectioncooling (fresh or refreshed) — the cooldown timer restarts from this moment
Hard rejectionIf this was the last non-suppressed contact → not_interested, overriding even a cooling state
!But "pulled out for reclassification" does not mean a fresh "first" email gets sent. A reply changes status — it never restarts a brand-new outreach cycle. The only thing that ever sends a genuinely new first-type email is Rule 3's "crawler found a different contact at this company" — a completely different mechanism, don't conflate the two.
→This is exactly why E1 exists: because a reply can interrupt any path at any moment, anything sitting in the followup1/warm/cold_restart queue must be re-checked against the client's latest status right before it actually sends — otherwise you'd get an awkward case where a client rejected three days ago but a stale queued email nobody dismissed still goes out.

Rule 2 · Why a client flips back to contacted once the reengagement email is sent, instead of staying in cooling

cooling means "we're doing nothing right now, purely counting down." But the moment the reengagement email is actually sent, there's once again a live email in the client's inbox awaiting a reply — the exact same situation as right after the original first email. So the system just reuses the existing contacted state to mean "waiting," rather than inventing a separate "waiting after a mid-cooling reengagement" state.

The payoff: the logic for "how long has this been waiting, should it go back to cooling" can directly reuse the same machinery that already watches contacted clients (the "cycle 2+" check described in C2) — no separate monitoring code needed just for reengagement.

✓So seeing "client flips back to contacted after reengagement sends" isn't a bug or a reset to square one — it means "currently waiting on a reply to this reengagement," conceptually identical to the very first contacted state, just with cycle tracking which round this is.

Rule 3 · The only situation that sends a genuinely new "first" email

Exactly one case: the crawler finds a contact email at a cooling/not_interested/bounced company that differs from what's on file (see group D). Only then does the system treat the company as a fresh opportunity, restart the whole sequence from first, and zero out all the previous contact's accumulated follow-up timing (via contact_reset_at, see D1). Nothing else — not a client reply, not an automatic reengagement send — ever triggers a fresh first email. At most those change status or send a reengagement/follow-up.

Rule 4 · Testing must match production's decision logic exactly, but must never touch any real client other than the test client

Two equally important principles, settled explicitly on 2026-07-30 — any change to test-mode code has to satisfy both.

Principle A: decision logic must be identical to production. Candidate-selection criteria (opened or not, elapsed days, the cold_restart mutual exclusion, cycle<=1, the cycle 2+ recycle) run through the exact same code and the exact same SQL for a test client as for a real one — test mode is never allowed to use a simplified version, or the results it produces mean nothing.

Principle B: test execution must stay strictly confined to the one client you selected, and must never spill over onto any other real client. This is easier to violate than it sounds — functions like generateFollowupQueue() get called once a day in production to process every real client, and genuinely read global settings like Auto Followup 1/2 to decide whether to auto-send. If test mode's "is sending allowed" gate also read those same global settings, flipping one on to test the path would simultaneously make the "process every real client" call start actually auto-sending too. So Test Follow-up has to be its own switch, independent of the real toggles, scoped only to the test client — Auto Followup 1/2 and Auto Send New Emails must stay off for the whole testing period.

!A 2026-07-30 review turned up a real violation of Principle B: the reengagement-generation half of generateFollowupQueue() had no test-client scoping at all — clicking Run Test Follow-up Now, or the hourly Test Follow-up cron, would sweep up and queue reengagement emails for every real cooling client due that day. Fixed and verified with real data: created one real client and one test client both due for reengagement, ran the check scoped to the test client only, confirmed the real one was completely untouched.
!The same day also shipped one implementation backwards at first: the four new toggles — Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement — were initially wired to force auto-send in test mode and skip Preview Send Queue entirely, following F1/F2's precedent. But F1/F2 never had a queue-and-manually-approve step to begin with — direct-send, identical in test and production, always. These four types are different: they've always had a Preview Send Queue manual-review step (in production too, specifically so a human can eyeball the template-rendered content before anything actually goes out), and the test client must keep going through that same gate — testing the auto-send feature itself is not a reason to bypass it. Fixed: the four gates (autoColdRestart / autoWarm / autoHighIntent / autoReengagement) are now !isTestMode && the real toggle — always treated as off (always queued, waiting for a manual Send) in test mode, with only a non-test production call reading the real toggle to decide whether a real client skips the queue and auto-sends. The send itself still runs through the same sendFollowupQueueItem(), and content generation is the same code either way, so this change only affects when Send gets clicked automatically, never the email content or send logic — passing testing means it behaves identically once the real toggle is flipped on.

Rule 5 · The Clients tab's Contacted filter is now mutually exclusive, with its own stage sub-filter dropdown 2026-08-04

This directly affects where you go to verify a scenario's result below.

Before this, the Contacted tab was a "broadened" bucket: any client that had ever been emailed showed up there even if their real current status was cooling / not_interested / bounced — so the same client could appear in two tabs at once (a bounced client showed under both Bounced and Contacted). It now matches how Active / Cooling / Not Interested / Bounced / Competitors already worked: the Contacted tab only shows clients with status='contacted', so a given client only ever appears in exactly one tab.

The Contacted tab also gained a new "All Contacted Stages" dropdown next to "All Industries" (only visible while the Contacted tab is open), letting you filter directly by the badge text shown on each card: Awaiting Reply / Cold Restart Pending / Cold Restart / Follow-up 1 Pending / Follow-up 1 / Follow-up 2 Pending / Follow-up 2 / Warm Pending / Warm / High Intent Pending / High Intent / Re-engagement Pending / Re-engaged — these map 1:1 to what createClientRow() computes for the card badge; "Pending" means it's still sitting in Preview Send Queue awaiting review, no "Pending" suffix means it already sent and nothing is queued yet.

→Useful for verifying the A/B/C scenarios below: after A1 queues a cold_restart, filter straight to "Cold Restart Pending" instead of scanning the whole Contacted list; after C1 sends a reengagement, filter to "Re-engaged" to confirm at a glance.
→CSV export (the Export CSV button) now carries this stage filter too, so the exported file always matches exactly what's on screen — previously the two disagreed (the export endpoint always used a plain status='contacted' while the page used the broadened definition). Both now share one filter-building function (buildClientsWhereClause).

1. Setup — once per test run

  1. In Opportunities, find (or create) a lead and set its contact_email to your own personal inbox.
  2. Click Set Test Client and select that lead.
  3. Confirm Auto Followup 1, Auto Followup 2, Auto Send New Emails, Auto Cold Restart, Auto Warm, Auto High Intent, and Auto Reengagement are all OFF, and keep them off for the entire testing period — they're an independent mechanism from Test Follow-up (see Rule 4); testing doesn't need them on (whatever the test client triggers for these types always goes through Preview Send Queue for manual review, regardless of toggle state), and they only get turned on for real once every scenario is done.
  4. In Settings → Autopilot, set every day-based field to 0: Followup 1, Followup 2, Cold Restart, Warm send, High Intent, Cooldown (Cycle 1), and the new Cycle 2 / Cycle 3 / Cycle 4+ cooldown fields (Warm trigger opens and Daily Send Limit can stay default — 1 and 20). A 0 means "eligible the moment any time at all has passed," so the next cron tick or manual trigger fires instantly instead of making you wait real days.
  5. Turn Test Follow-up ON.
  6. Click Run First Email Now. Confirm the email lands in your personal inbox.
→Each scenario below assumes you're starting fresh from this state (one first email sent, client status contacted). Re-run step 5 (or pick a new test lead) between independent scenarios so they don't interfere with each other.

1.5 Standard reset SQL referenced at the end of every scenario card

Every scenario reuses the same one test client, so the reset logic is universal: swap <test_client_id> for your test client's id (visible in Preview Send Queue or the client detail page), run the 5 statements below, and you're back to the clean starting point — "just sent first, status contacted, cycle 1" — ready for the next scenario immediately. The "cleanup" note at the bottom of every scenario card refers to this block, unless that scenario says it needs something extra (B5 and F2/F3 create records beyond the test client itself; D1 doesn't touch the test client at all).

DELETE FROM followup_queue WHERE client_id = <test_client_id>;
DELETE FROM email_log WHERE client_id = <test_client_id> AND type != 'first';
DELETE FROM client_activities WHERE client_id = <test_client_id>;
UPDATE email_log SET status = 'sent', open_count = 0 WHERE client_id = <test_client_id> AND type = 'first';
UPDATE clients SET status = 'contacted', cycle = 1, rejection_type = NULL, reengagement_date = NULL,
    question_received_at = NULL, contact_reset_at = NULL WHERE id = <test_client_id>;
→What each line does: clear the follow-up queue (pending, sent, or skipped — all of it) → delete every email except the original first (followup1/2, cold_restart, warm, high_intent, reengagement all go) → clear the activity log (replies, auto-replies, etc.) → zero out the first email's own status and open count (otherwise it still reads "opened" next time) → finally reset the client row's status, cycle, rejection type, and reengagement date.

Group A: Engagement paths — no reply, just open/click behaviour. Matches (and corrects) the app's Follow-up Sequence modal.

A1 · Never opened logic unchanged, now segmented by contact_reset_at

Cold Restart (queued) → sent → Followup 2 (auto-sent) → Cooling.

  1. Don't open the first email. Click Run Test Follow-up Now — no need to wait an hour.
  2. Open Preview Send Queue → Follow-ups should show a cold_restart item, with subject Re: {the first email's original subject} (as of 2026-08-06, Cold Restart/Warm/High Intent/Followup1/Followup2/Reengagement all switched to this format instead of each having its own distinct subject line — testing showed a distinct subject stops Gmail from threading the message into the original conversation even with correct reply headers set, so the client never sees the prior email; using "Re:" tied to the real original subject is what actually gets Gmail to group them) and a body pulled from the Cold Restart template saved under Settings → Email Templates (as of 2026-07-30 this is fully template-driven, not AI-written on the fly — if the body doesn't match what you saved in the template, that's a bug).
  3. Click Send. Confirm the templated content actually lands in your inbox.
  4. Still don't open it. Click Run Test Follow-up Now again.
✓An auto-sent followup_2 (final email, threaded as a reply) arrives. Client status flips to cooling, rejection_type = 'no_response', reengagement_date = today + Cooldown days, cycle increments.
→SELECT status, rejection_type, reengagement_date, cycle FROM clients WHERE id = <test_client_id>
→The Auto Cold Restart toggle doesn't affect any of the above, on or off — the test client always queues first, waiting for a manual Send. That toggle only takes effect for real clients in production (skipping the queue and auto-sending) — see Rule 4.
!Fixed 2026-08-21This step used to require waiting for the hourly test cron or restarting the server (which also triggers a real crawl as a side effect) — because Run Test Follow-up Now's endpoint (/api/autopilot/run-test-followup-now) only called generateFollowupQueue(), never autopilotFollowUp(). The two split the work: generateFollowupQueue() can generate the Cold Restart item, but unlike the opened-once/Warm/High Intent paths, its opens===0 branch has no second stage that escalates to Follow-up 2 — that escalation ("Cold Restart sent, still no open, time to send Follow-up 2") is handled exclusively by autopilotFollowUp(). So no matter how many times you clicked the button, Follow-up 2 would never appear — it just looked stuck. At the time, autopilotFollowUp({testClientId}) was added to that endpoint, matching the hourly cron exactly, so both steps could be driven by the button with no waiting. As of 2026-08-24 this "two functions split the work" description is out of date - generateFollowupQueue() now completes both "generate Cold Restart" and "escalate to Follow-up 2" entirely on its own, no relay to a second function needed - see the updated toolkit table above.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

A2 · Opened once (Warm → Followup 1 → Followup 2)

Warm (queued, template-driven) → sent → Followup 1 (queued) → sent → Followup 2 (queued) → Cooling.

!As of 2026-08-22, Warm and Followup 1 are no longer two independent branches split by open count — now, once the open count reaches behavior_warm_opens (Settings' "Warm trigger after (opens)", default 1 — dropped from 2 because providers like Gmail proxy-cache the tracking pixel server-side, so a real second open often can't be observed at all, not just delayed), Warm fires first; if there's still no reply after Warm, Followup 1 is next; if still no reply after that, Followup 2. So "opened just once" now triggers Warm, not Followup 1 directly.
  1. Really open the first email once (in your personal inbox, so Brevo's real open event fires).
  2. Once you're sure the tracking pixel loaded, click Run Test Follow-up Now. A warm item should appear, body pulled from the Warm template saved under Settings → Email Templates.
  3. Send it from Preview Send Queue.
  4. Run Test Follow-up Now again — a followup1 item should appear (body now uses the neutral "help you get set up" wording, since this step only ever fires after Warm, not for someone who may never have opened anything).
  5. Send it, then run Test Follow-up Now again → followup2 appears.
✓Warm → Followup1 → F2 → Cooling, all three going through the queue, not auto-send.
→The Auto Warm / Auto Followup 1 toggles don't affect any of the above, on or off — the test client always queues first, waiting for a manual Send. Those toggles only take effect for real clients in production — see Rule 4.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

A3 · Raising the Warm threshold has no real effect — one open is always enough as of 2026-08-25

Confirms what happens if behavior_warm_opens is manually raised (e.g. to 3) and the client only opens once — a boundary check, not the default path (A2 already covers the default threshold of 1).

!Root cause: the only signal Brevo can reliably report is whether an email was opened at all — it can't reliably report a real repeat-open count. In practice open_count only ever settles at 1, it doesn't genuinely climb to 2 or 3. So raising behavior_warm_opens above 1 currently has no real effect given Brevo's actual tracking capability — the code now caps the comparison at 1 (Math.min(warmOpenThreshold, 1)), so no matter what number is typed into the Settings field, a client who's genuinely opened the email even once always qualifies for Warm — they're never left stranded, and never wrongly fall back to Cold Restart either. The setting itself hasn't been removed or locked - if Brevo's open-tracking granularity ever improves, raising it could become meaningful again; it just has no effect today.
  1. Temporarily raise behavior_warm_opens ("Warm trigger after (opens)") to 3 in Settings.
  2. Really open the first email once.
  3. Wait out the days set for Warm send after (days), then Run Test Follow-up Now → a warm item appears normally — not blocked by the threshold being set to 3, and not misrouted into cold_restart either.
  4. Set behavior_warm_opens back to its default of 1 once you're done.
✓Confirms the threshold's value doesn't change the outcome — any client who genuinely opened the email (even once) follows the normal Warm→Followup1→Followup2 chain, regardless of what number this setting is configured to.
🧹 Cleanup: run "1.5 Standard reset SQL" and confirm behavior_warm_opens is back to 1 before starting the next scenario.

A4 · Clicked a link (High Intent)

High Intent email (queued) → sent → Followup 2 (queued) → Cooling. Clicked outranks opened — never falls back to the warm path.

  1. Really click the link in the first email.
  2. Run Test Follow-up Now → a high_intent item appears, with subject Re: {the first email's original subject} (see the 2026-08-06 note under A1) and a body pulled from the High Intent template saved under Settings → Email Templates (as of 2026-07-30 this is fully template-driven, not AI-written on the fly).
  3. Send it, run again → followup2.
→The Auto High Intent toggle doesn't affect any of the above, on or off — the test client always queues first, waiting for a manual Send. That toggle only takes effect for real clients in production — see Rule 4.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

A5 · Auto-reply (out-of-office)

Confirm a vacation responder isn't mistaken for a real reply.

  1. Set your test inbox to an out-of-office auto-responder, or manually reply with a subject like "Automatic reply: Out of Office."
  2. Run Test Follow-up Now (triggers checkInboxReplies).
✓A client_activities row of type auto_reply gets logged. Client status and email_log.status stay untouched — the client keeps following the A1 (never opened) path as if nothing happened.
→SELECT * FROM client_activities WHERE client_id=<id> ORDER BY created_at DESC LIMIT 3
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

Group B: Reply classification new this week — these outcomes didn't exist before. The highest-value scenarios to test. Each assumes the client is at the simplest starting point ("just sent first, status contacted"); per Rule 1, this classification logic applies identically whether the client is in cooling or mid-cycle — if time allows, try replying from a cooling client too and confirm the result matches replying from contacted.

B1 · Interested

  1. Reply to the first email: "This looks great, we'd love to hear more, can we set up a call?"
  2. Run Test Follow-up Now.
✓Client → active. The sequence stops — no further follow-ups get queued.
→as of 2026-07-30 This category also covers replies that explicitly invite future contact, e.g. "check back with us in a few months" — this used to be classified as B3's soft rejection, but per your decision it now lands here too, becoming active. Worth testing that exact phrase on its own to confirm it takes this path.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

B2 · Question

  1. Reply: "How much does this cost, and is there a contract?"
  2. Run Test Follow-up Now.
✓Client → active, question_received_at set (shows as a pending badge in the Clients tab). No AI auto-draft — this one's meant for a human answer.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

B3 · Soft rejection new behaviour

The dividing line is whether the reply explicitly invites future contact — if it does, it's B1 (active); if not, it's here (cooling). See B1's note.

  1. Reply: "We're not hiring at the moment, thanks." (not the "check back in a few months" phrasing — that now routes to B1)
  2. Run Test Follow-up Now.
✓Client → cooling, rejection_type='soft', reengagement_date = today + Cooldown days. Previously this reply only suppressed the one email address and left client status completely untouched — confirm that's no longer the case.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

B4 · Hard rejection, only one contact new behaviour

  1. Make sure the test client currently has only one contact email on file.
  2. Reply: "Please remove us, we're not interested and don't want any further contact."
  3. Run Test Follow-up Now.
✓That email_log row → rejected. Client → not_interested, rejection_type='hard'.
→added 2026-08-07 Now reply again from the same contact (or any contact) with something interested-sounding ("actually, we've changed our mind, let's talk"), Run Test Follow-up Now, and confirm the client is correctly revived from not_interested back to active — previously that UPDATE's WHERE status IN (...) didn't include not_interested, so it silently failed to revive; now fixed.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

B5 · Hard rejection, another contact still viable new behaviour — the actual point of this change

One person saying no doesn't speak for the whole company — shouldn't close the company out entirely.

  1. Give the test client a second contact email you also control, e.g. UPDATE clients SET contact_email = CONCAT(contact_email, ', your+alt@address.com') WHERE id=<id>, plus a matching email_log row (type='first', status='sent') so it reads as a real, un-suppressed contact.
  2. Reply from your primary address with the same hard-rejection wording as B4.
  3. Run Test Follow-up Now.
✓Primary address's email_log row → rejected. Client status stays contacted — not not_interested — because the second address is still un-suppressed. The activity log should note "other contacts remain."
→Now hard-reject from the second address too, and confirm the company only then flips to not_interested — that's the "every contact exhausted" condition actually firing.
→added 2026-08-07 · untried-contact protection A different variant of step 1: add the second contact email, but do not add a matching email_log row for it (so this contact has never actually been emailed yet). Hard-reject from the primary address, Run Test Follow-up Now → the client's status should stay unchanged, not get wrongly flipped to not_interested just because the second contact has no sent-email record. The activity log should show a "a known contact hasn't been emailed yet" entry. This is the exact gap that D1 found live in production.
!🧹 Cleanup (extra step beyond the standard reset): this scenario added a second contact email to the test client, which the standard reset doesn't remove — handle that separately first:
UPDATE clients SET contact_email = SUBSTRING_INDEX(contact_email, ',', 1) WHERE id = <test_client_id>;
then run "1.5 Standard reset SQL" to clear the email_log/activity rows both addresses generated.

Group C: Cooling & re-engagement — what happens after a client goes quiet.

C1 · Re-engagement fires on schedule

  1. Get a client into cooling via any scenario above (A1 or B3 are quickest).
  2. Force the wait to be over: UPDATE clients SET reengagement_date = CURDATE() WHERE id=<id>.
  3. Run Test Follow-up Now → open Preview Send Queue, Follow-ups should show a reengagement item, with a body pulled from the Reengagement template saved under Settings → Email Templates.
  4. Click Send.
✓After sending, the client flips back to contacted (why contacted and not still cooling — see Rule 2 above: the email is now out and the client is "waiting for a reply," the same situation as after the original first email, so the system reuses the same state).
→The Auto Reengagement toggle doesn't affect any of the above, on or off — the test client always queues first, waiting for a manual Send. That toggle only takes effect for real clients in production (skipping the queue and auto-sending) — see Rule 4.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

C2 · Cooldown escalates across cycles

Only cycle 1 goes through the full email sequence — from cycle 2 on, each round sends just one reengagement email. as of 2026-07-30 The 90/180/365 numbers are no longer hardcoded — Settings → Autopilot → Re-engagement now has Cycle 2 / Cycle 3 / Cycle 4+ cooldown fields you can also set to 0 during testing.

Cycle 1: first → cold_restart → F2 → cooling (60d cooldown, cycle: 1→2) ↓ cooldown ends Cycle 2: reengagement email → silence for a while → cooling (90d cooldown, cycle: 2→3) ↓ cooldown ends Cycle 3: reengagement email → silence for a while → cooling (180d cooldown, cycle: 3→4) ↓ cooldown ends Cycle 4+: reengagement email → silence for a while → cooling (fixed 365d cooldown, cycle keeps incrementing)
  1. Run through A1 to complete cycle 1, landing in cooling.
  2. Use C1's method to force reengagement_date to today, triggering the reengagement email; after sending, client returns to contacted, cycle still 2.
  3. Don't reply — wait out the Followup 2 day setting (0 during testing) and confirm it automatically returns to cooling.
  4. Repeat steps 2-3 twice more, checking cycle and cooldown days escalate per the table above.
→You can also eyeball this in the Clients tab: a client in contacted with cycle>=2 shows "🔄 Re-engaged ×N" instead of a plain "Contacted" badge — a 2026-07-30 UI addition so it's obvious at a glance this isn't a brand-new client. 2026-08-04 Faster now: open Clients → Contacted tab and switch the new "All Contacted Stages" dropdown to "Re-engaged" to filter straight to it instead of scanning the list (see Rule 5). 2026-08-07 meaning changed N is now the actual count of re-engagement emails sent (hover to see how many cooling rounds have actually completed), not the raw cycle counter — under the old meaning, cycle=2 showed "×2" even though only 1 re-engagement email had ever gone out, reading like it had been sent twice. If a company has been through a contact reset, the old and new contact's re-engagement counts also no longer get lumped together.

What contacted means here — an easy thing to misread

Step 2 above shows clients.status flipping to contacted while cycle is already 2, 3, or 4 — easy to first read as "the system is treating this as a brand-new client it just met." It isn't: clients.status only has the values new / contacted / active / cooling / not_interested / bounced / competitor, and there's no dedicated status for "waiting for a reply to a mid-cooling reengagement" — it just reuses contacted. Here it means "there's currently an outreach email out, awaiting a reply" — the same field value as genuinely first meeting this company, but not the same event; check the cycle field to know which round this actually is. This design lets the system reuse the exact same "how long has a contacted client been silent" monitoring code, instead of writing a separate one just for reengagement.

What if the client replies mid-wait (cycle 2 or later)

Whether the client is still on cycle 1 unopened, or deep into cycle 2/3/4's cooling/waiting period — the moment they actually reply, the next checkInboxReplies check reclassifies them immediately and pulls them out of the "wait for the cooldown to end" rhythm: interested/question → straight to active; soft rejection → refreshed back to cooling, cooldown timer restarting from that moment; hard rejection → not_interested if this was the last non-suppressed contact. But note: all of these are status changes only — none of them send the client a fresh "first" email. The only situation that sends a genuinely new first email is group D — the crawler finding a different contact at this company. That's a real "start over"; a reply being reclassified is a completely different event — don't conflate the two.

🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario (it zeroes cycle too — nothing extra to do).

Group D: Contact reset new subsystem — a company gets a genuinely fresh cycle when the crawler finds a different person there.

D1 · Crawler finds a new contact at an archived company fired for real on 2026-08-06, found and fixed two gaps

Only fires from inside the real crawler (Seek/Indeed/TradeMe/SJS/Himalayas parsing a live posting) — no UI action triggers it directly, so it can't be driven manually like the scenarios above.

!Already verified with 7 scripted DB scenarios (same contact → stays archived; different contact → contact_email merged, contact_reset_at stamped, pending cooling re-engagement cancelled) — all passed. This also fired for real during testing on 2026-08-06 (the test client's company name happened to collide with a real live posting the crawler picked up) — not simulated. That live trigger surfaced two gaps, both now fixed:
  1. Previously left no trace at all — cycle/reengagement_date changed silently, with nothing in the timeline explaining why the client's state had just changed. isCompanyArchived() now writes a client_activities note ("🆕 New contact found: {email} — starting fresh outreach"), and the timeline modal merges it chronologically with every other historical event — the previous contact's email history is never lost, it stays visible above the new marker.
  2. Hard-rejection false positive — if the reset fires and the new contact's first email hasn't gone out yet, and the old contact happens to send a hard rejection in that window, the old code treated "the new contact has no sent, non-rejected email record" as "no other viable contact left" and closed the company out to not_interested — even though the new contact was a completely untried opportunity. Hard-rejection handling now also checks for any known contact who's never been emailed at all; see the "untried-contact protection" variant under B5.

Digging further into that live trigger surfaced three deeper problems on the same code path, also fixed (2026-08-07):

  1. Email got overwritten instead of merged after sending — the "backfill the client record after the first email goes out" code (the real auto-send loop, the "Send All First Emails" batch route, the shared ensureClientForLead() used by the manual single-send, and the test-mode Run First Email Now — four sites total) all did a straight contact_email = new-address overwrite instead of merging into the existing comma-separated list. The moment a new contact's first email actually went out, whatever old contact isCompanyArchived() had carefully merged in got wiped out — this actually happened to The Painting Company test client, whose old address was overwritten down to just the new one. Now re-reads the current DB value right before merging instead of blindly overwriting.
  2. isCompanyArchived() was blacklisting itself — the real auto-send loop and the "Send All First Emails" batch route used to call isCompanyArchived() a second time, right before actually sending, as a "final check." But the first call (at crawl time) had already merged the new email into contact_email — so the second call was comparing the new email against a list that now already contained it, always concluding "already known contact" and permanently skipping it. The new contact's first email could therefore never go out — this is the exact root cause of the real stall on 2026-08-06. The redundant second call has been removed.
  3. Contact name didn't follow the email — the merge only ever touched contact_email, never contact_person, so emails to the new contact kept greeting them with the OLD contact's name (e.g. actually addressed to "Dave" but still greeted as "John"). Merging the email now also swaps in the new contact's name (or clears it if nothing name-like was extracted, falling back to "Hi there" — never carrying over a stale name onto a different person).

What happens now if a new contact turns up mid-sequence (contacted/active)

2026-08-07 Previously, as long as a company was contacted or active (the current contact's sequence still in progress), any new contact the crawler found was treated as fully "archived" by isCompanyArchived() and dropped entirely — not even recorded in b2b_leads. If that job posting later disappeared and the crawler never revisited it, that contact was gone for good. Now: it's still recorded in b2b_leads as normal, but not merged into contact_email yet and doesn't disturb the current contact's in-progress sequence. The moment the company would genuinely transition from contacted to cooling for lack of response (as of 2026-08-24 that's sendFollowupQueueItem's Followup2 completion branch and autopilotRecycleReengagements's (formerly autopilotFollowUp) cycle 2+ recycle - no longer two spots in the one same function), the system proactively checks b2b_leads for exactly this kind of "recorded but not yet merged" contact — if one exists, it takes the "new contact found" path (merge email + name, cycle back to 1, clear reengagement_date) instead of queuing the usual re-engagement email to the old contact.

→Scriptable to verify: manually set the test client to contacted, insert a b2b_leads row (status='new') with a new email under its company name, and confirm it does NOT immediately show up in clients.contact_email. Then push the test client to the point where F2 has been sent and it's about to transition to cooling for no response, Run Test Follow-up Now (or wait for the hourly Test Follow-up cron), and confirm contact_email/contact_person only update at that point, landing directly in the "🆕 New Contact Found" cooling state instead of the normal "reengagement queued in N days."
→Still treat this as watch-only: if a company you've marked cooling/not_interested/bounced posts again under a new recruiter's email in the coming weeks, check the client's timeline modal — you should see a "🆕 New contact found" entry, clients.contact_reset_at updated, and the previous contact's email history still fully intact above it.
🧹 Cleanup: doesn't touch the test client — no reset needed.

Group E: Send-time guard — every queued item is re-checked right before it actually sends.

E1 · A queued item goes stale before you click Send

2026-08-07 Two independent lines of defence now: if the status change comes from a real client reply, the queue item gets cancelled the moment that reply is processed — no need to wait until Send to discover it's stale. If the status change happens some other way (not through the reply pipeline), it falls back to the original "caught at Send time" safety net. This scenario covers both.

Variant 1: a real reply (cancelled immediately, before you'd even click Send)

  1. Get a followup1 (or any type) item sitting pending in the queue (see A2 step 2).
  2. Reply with any of B1 (interested) / B2 (question) / B3 (soft rejection) / B4 (hard rejection) to change the client's status.
  3. Refresh Preview Send Queue — don't click Send.
✓The queue item should have already disappeared from "pending" on its own (status='skipped' in the DB) — you don't need to click Send to discover it's stale; markClientReplied() cancels it in the same moment it processes the reply and changes status.

Variant 2: status changed some other way (still caught only at Send time)

  1. Same as above, get an item sitting pending.
  2. Without going through a reply, change status directly: UPDATE email_log SET status='bounced' WHERE client_id=<id> ORDER BY sent_at DESC LIMIT 1.
  3. Now click Send on the still-"pending" queue item.
✓Send is refused with a message like "Client is now … — skipped." The queue item flips to skipped, not sent. No email actually goes out.
→Same change also tightened candidate generation itself (the daily 9am generateFollowupQueue cron): the condition changed from status IN ('contacted','active') to just status = 'contacted'. An 'active'-status client could previously still get selected as a candidate, but the send-time re-check always requires exactly contacted — 'active' could never actually send, so it was generating a doomed item that just sat in Preview Queue looking wrong. Hard to reproduce on demand with the test client (needs a specific timing window at generation), so this one is FYI-only, not a required check.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

Group F: Archived-company blocking across all three send paths — same rule, three different enforcement styles: F1 is fully automated with no prompt; F2 has a human present, so it adds a "same person or different?" hint; F3 now shares F1's exact logic.

F1–F3 · not_interested / bounced re-contact prevention F2/F3 upgraded this round

Confirms this rule: once a company is marked not_interested or bounced, the same contact reappearing shouldn't get re-contacted; a different contact reappearing should be allowed normally, treated as a genuinely fresh opportunity, not penalized for the previous person's rejection.

Worth being precise about how this rule is actually enforced, since it's easy to misread: there is exactly one place that makes this call — isCompanyArchived(), called only from crawler.js, only at the moment the crawler is about to insert a new lead into b2b_leads. Same contact → the lead never gets inserted into b2b_leads at all. Different contact → inserted normally, the new email gets merged into clients.contact_email, judged a fresh cycle. F1 (fully automated) and F3 (batch send) have no interception logic of their own — they just each read "what's currently sitting in b2b_leads as eligible." Since a same-contact lead never had the ability to make it into that table in the first place, F1/F3 never even see it, let alone have to decide whether to block it — it's not "F1/F3 receives it, evaluates, quietly skips." F2 is the one exception — see below.

PathRouteSame contact reappearsDifferent contact reappears
F1 — fully automated (autopilotAutoSend)/api/autopilot/run-now, hourly cronThe lead simply doesn't exist in the candidate pool — nothing to blockallowed — fresh cycle via isCompanyArchived, generates and sends normally
F2 — manual single sendPOST /api/opportunities/leads/:id/sendwarns — "already marked … (same contact), send anyway?", human confirmswarns — explicitly says "looks like a different contact, was X", human confirms
F3 — batch sendPOST /api/autopilot/send-queue (Preview Send Queue → Send All First Emails)The lead never gets a draft, never appears in the Preview Queue list — nothing to blockallowed — now shares F1's isCompanyArchived, sends automatically and triggers contact_reset_at

Why F2 is different and needs its own live check: F2 operates on a specific, already-existing lead id you point it at directly — that lead could easily have been sitting in b2b_leads from before the company was ever marked not_interested (back when it was inserted, the company hadn't been rejected yet, so isCompanyArchived never had anything to block). So F2 has to check the company's current real status itself, right before sending, and confirm with a popup — it can't assume "reaching this point means it was already checked," unlike F1/F3 which only ever process leads freshly inserted under the current state.

How to test it: F1/F3 don't need a dedicated "same contact" test case at all (there's no way to construct one — the lead can't get into the table) — just reuse D1's verification (it's literally the same function). F1/F3 only need testing on the "different contact gets through" half. Test F2 separately, both halves: mark the test client not_interested, then try sending once with the same email and once with a new one — confirm the warning text correctly says "same contact" vs. "different contact, was …" in each case.

!🧹 Cleanup: testing F2/F3 creates an extra b2b_leads row for the test client's company (needed to drive the send flow) that the standard reset doesn't touch:
DELETE FROM b2b_leads WHERE company_name = (SELECT company_name FROM clients WHERE id = <test_client_id>) AND status != 'sent';
If it actually went through (status='sent'), drop the != 'sent' filter and delete that row too; then run "1.5 Standard reset SQL" to put the test client itself back to contacted.

Group G: Webhook message-id attribution — opens/clicks/bounces route to the exact email that triggered them, not just "whatever was sent most recently."

G1 · Click attribution and priority revised 2026-08-24

The one scenario that genuinely needs real Brevo to test properly — local can't receive webhooks, verify against a real environment. All three sub-scenarios circle the same question: does a click event land on the correct email, and once it does, does the system actually act on it?

Sub-scenario A (core): a click on Cold Restart's own link → triggers High Intent

  1. Get the test client to the point where Cold Restart has been sent and Followup 2 hasn't yet.
  2. Confirm the Cold Restart email's link is a real one, https://www.jobvista.app (not bare text "jobvista.app") — including the version stored in settings.cold_restart_template in the database, not just the code default.
  3. Click the link in the email.
  4. Click Run Test Follow-up Now (or wait for the next hourly test cron).
✓The Cold Restart row in email_log has its status flip to clicked; the system does not generate Followup 2 or move to cooling — instead a high_intent item appears in the Preview Queue. As of 2026-08-24 this path is newly supported — before that fix, a click on Cold Restart's own email was never read by the decision logic at all, only a click on the First Email counted (see the background note below).
→SELECT type, status, open_count, brevo_message_id FROM email_log WHERE client_id=<id> ORDER BY sent_at ASC
→ SELECT id, type, status FROM followup_queue WHERE client_id=<id> ORDER BY created_at DESC LIMIT 1 (confirm the newest row is high_intent, not followup2)

Sub-scenario B (boundary check): a click on Warm's own link should NOT trigger High Intent

  1. Get the test client to the point where Warm has been sent (reaching the Warm open threshold gets you there naturally).
  2. Click the link in the Warm email.
  3. Click Run Test Follow-up Now.
✓The Warm row's status does flip to clicked (that recording itself is fine, it just feeds click-rate reporting), but no High Intent gets generated — the client keeps following the normal Warm→Followup1→Followup2 pace. This exclusion is intentional: Warm's own content already reads similarly to High Intent's, so a click there isn't treated as a distinct "high intent" signal. This test exists specifically to catch a future regression where Warm accidentally gets folded into the click check.
→Same query as above — confirm the Warm row shows status='clicked' but no new high_intent row appears in followup_queue.
🧹 Cleanup: after both sub-scenarios, run "1.5 Standard reset SQL" and you're ready for the next scenario.

Sub-scenario C: two emails in flight, event attribution

  1. Send a client's first email, then a cold_restart — two email_log rows, each with its own brevo_message_id.
  2. Use the simulator below: pick the older one (first), set event type to "bounce", click Simulate — no need to actually send to a bouncing address or wait for a real Brevo callback.
  3. Check the simulator's result, or check the database.
✓Only the first row's status flips to bounced — the newer cold_restart row is unaffected. Misattribution here has a real consequence: the genuinely dead address keeps showing "sent" (looks fine) and may keep getting used, while a perfectly deliverable email gets wrongly marked bounced and may later be treated as a bad address.
🧪 Simulator — calls the real event-processing logic directly, no curl needed
How to use it:
  1. Get two emails outstanding first — send both first and cold_restart as usual (Run Test Follow-up Now etc., same as any other test).
  2. Get the client ID — same as any other DB check in this doc, run:
    SELECT id FROM clients WHERE company_name LIKE '%company name%';
    the number you get back (e.g. 75317) is the client ID.
  3. Use the panel above — enter the client ID → click "Load this client's emails" (a dropdown appears listing every email for this client, formatted like #967 · first · sent · 2026-08-27 ..., the number after # is that email's own row id) → pick the one you want to simulate (per Scenario C, pick the older one, i.e. first) → set event type to "Bounce (hard_bounce)" → click Simulate.
  4. Read the result — it shows something like:
    ✓ Matched the right row — simulated on: #967 (first), event actually landed on: #967 (first, now status=bounced)
    meaning: you picked #967 (the first email), and after processing, row #967 really is the one that got flipped to bounced. If "landed on" shows a different id instead (e.g. #968, the cold_restart row), it'll say "✗ Matched the WRONG row" instead — that means there's a real bug.
→SELECT type, status, open_count, brevo_message_id FROM email_log WHERE client_id=<id> ORDER BY sent_at ASC
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

Background: why "click" needs a per-email-type breakdown but "open" doesn't

When generateFollowupQueue checks open count (opens), it looks only at the first email's own open_count — but reaching a state where misattribution is even possible (two emails outstanding) already implies the open threshold was crossed long ago, so a late, misattributed open can no longer flip any branch decision — no longer worth a dedicated test. Click is different: as of 2026-08-24, a click on first's own link OR on cold_restart's own link is read by the decision logic and triggers High Intent; a click on warm/followup1/followup2/high_intent/reengagement's own link is recorded but never read. Sub-scenarios A/B verify both halves of that split.

G2 · Fallback matching when a reply's own record has no client attached fixed 2026-08-01

Confirms that even when an outgoing email's own record was never linked to a client (a historical gap — early autopilotAutoSend didn't reliably create a client record; see H2's background item ③), a genuine reply from that contact still gets correctly identified and updates the right client's status, instead of silently vanishing because that one record happens to be incomplete.

  1. Run the standard setup: Set Test Client → Run First Email Now, confirm the email arrives.
  2. Run a manual SQL statement to deliberately clear that email's client link in the database, simulating the historical gap ("the client record didn't get created in time"):
    UPDATE email_log SET client_id = NULL WHERE client_id = <test_client_id> AND type = 'first';
  3. Reply to the email normally — the same B1 wording works fine: "This looks interesting, could we set up a call to learn more?"
  4. Click Run Test Follow-up Now.
✓Even though that email's own record is "incomplete" (never linked to a client), the client's status is still correctly identified and updated to active — proving that a gap in historical data doesn't cause a reply to get lost; the system can still trace it back to the right client (via the newly-added third fallback: matching the reply's sender email directly against clients.contact_email).
🧹 Cleanup: run "1.5 Standard reset SQL" and move on to the next scenario.

Watchlist · hand-reviewed hold new scenario, 2026-08-01 — a lead/client flagged for Watchlist must stay forced into Preview Send Queue for manual review, regardless of whether the real Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement toggles are on or off — it can never get auto-sent. This scenario doesn't use the A-G groups' "Set Test Client" mechanism — that mechanism is deliberately designed to bypass every guard, so it can't reveal whether Watchlist is actually working. This has to be tested through the real manual-send flow instead.

Watchlist · how the mechanism actually works added 2026-08-26

Background only, not an executable test - understand exactly where this flag takes effect before running Watchlist1/2 below.

manual_review exists on both b2b_leads and clients - it's a company-level intent, not something that lives in just one table:

StageHow it takes effect
First EmailautopilotAutoSend()'s candidate query explicitly excludes leads with b2b_leads.manual_review = 1 - the first email can never be auto-sent, it has to be sent manually from Opportunities.
Converting to a real ClientThe flag carries over (the manual_review = manual_review OR ? clause in ensureClientForLead and the batch send route) - conversion never loses the flag.
The 6 follow-up types (Cold Restart/Warm/High Intent/Followup1/Followup2/Re-engagement)generateFollowupQueue() generates candidates the same way regardless - a pending queue item still gets created normally. But the final "priority-ordered, capped auto-send" pass explicitly checks if (item.manual_review || !autoToggleForType[item.type]) continue; - regardless of whether the matching Auto toggle is on, it's skipped and stays pending forever, waiting for a human to click Send in Preview Send Queue.
Where it's managedThe checkbox (🔖) on each lead's row in the Opportunities list - checking/unchecking goes through PUT /api/opportunities/leads/bulk-flag. Reviewing and editing what's pending is the Opportunities "Filter: Watchlist" view - that view deliberately doesn't restrict by status, so a flagged company keeps showing up there at every stage, using the same editor to edit content and send.

Watchlist · the real auto-send toggles must have no effect once flagged

Confirms that even with the real auto-send toggles turned on, this one Watchlist-flagged client still gets forcibly held back rather than auto-sent.

  1. Set up as usual: in Settings → Autopilot, set every day-based setting to 0.
  2. In B2B Leads, pick a lead and change its contact email to your own private address.
  3. Check the Watchlist checkbox (the 🔖 icon) on that lead's row, and confirm it's now flagged.
  4. Don't use Set Test Client + Run First Email Now — send it directly with the manual Send Email button on that lead instead. What Watchlist needs to prove is "does it hold back a real client's real automation" — the test-client mechanism bypasses every guard by design, so it can't show anything here.
  5. This time it's fine to temporarily turn on Auto Cold Restart / Auto Warm / Auto High Intent (normally these must stay off during testing — this is the one deliberate exception, since the entire blast radius is scoped to this one Watchlist-flagged lead and won't touch any other real client).
  6. Wait for the status to move from New to cold_restart or followup1 (depending on whether the email's been opened yet — only one of the two will appear).
  7. Check Preview Send Queue and confirm the email is sitting there waiting, and has not been auto-sent despite the toggle being on.
  8. Manually send it, then open/click the email in your private inbox, and wait for Brevo to report the open/click back — the status should escalate to warm/high_intent.
  9. Check Preview Send Queue again — it should still be sitting there waiting, still not auto-sent.
✓Every step along the way lands as "queued, waiting for a manual click" rather than "sent automatically" — proving that even with the real toggles on, a Watchlist flag forcibly holds this lead back for manual review, which is exactly what Watchlist is supposed to do.
!Remember to turn the toggles you temporarily enabled in step 5 back off afterward, so no other real client is affected. This scenario touches a real client, not the test-client mechanism, so the standard reset SQL doesn't apply — afterward, manually remove this contact from Watchlist, or just delete the test lead/client record entirely.
→The case of a brand-new lead discovered by a bulk crawler scan that's already Watchlist-flagged from the start involves a full scan across every real lead, so it can't be safely isolated to one record the way the scenario above can. This case isn't covered by a live scenario — the candidate-selection SQL in autopilotAutoSend already excludes manual_review = 1 leads directly, and as long as that query condition stays intact, no separate verification is needed.

Watchlist2 · the checkbox must lock while a follow-up is still pending review added 2026-08-26

Confirms that removing a Watchlist flag is correctly blocked while this client still has an unresolved pending follow-up - otherwise the very next automatic run would auto-send content nobody ever reviewed.

  1. Following on from Watchlist1, get the test lead/client, still Watchlist-flagged, into a state where it has one pending queue item (e.g. Cold Restart or Warm sitting in Preview Send Queue awaiting review).
  2. In the Opportunities list, try unchecking that lead's Watchlist checkbox.
  3. Confirm the checkbox is greyed out and unclickable, with a tooltip explaining why.
  4. Resolve that pending email (click Send to actually send it, or Skip, from the lead detail editor).
  5. Back in the Opportunities list, confirm the checkbox is clickable again.
  6. Uncheck it this time, and confirm the Watchlist flag is removed successfully.
✓The checkbox stays locked as long as there's an unresolved pending item, and unlocks once the queue is clear. The backend enforces the same rule (PUT /api/opportunities/leads/bulk-flag rejects the un-flag in this state) - it's not just a disabled button on the frontend, calling the API directly gets rejected the same way.
🧹 Cleanup: touches a real client like Watchlist1 - the standard reset SQL doesn't apply, clean up by hand afterward.

Follow-up Draft · the manual follow-up panel on the Clients detail page rebuilt 2026-08-26 — this panel generates free-form content (may reference the real template but doesn't have to match it) for whatever type the real engine currently says is due, then sends it manually through the exact same followup_queue + sendFollowupQueueItem() pipeline the automatic engine uses, so it can never duplicate what the automatic flow would otherwise do.

Follow-up Draft 1 · the type shown must match what the real engine would call it

Confirms the type named at the top of the panel matches exactly what the timeline modal and the real engine would generate - not a separate guess of its own.

  1. Standard setup, get the test client to "First Email sent, not opened yet."
  2. Open this client's Clients detail page, confirm the Follow-up Draft label reads "Follow-up Draft (Cold Restart):" and the Generate button is enabled.
  3. Really open the email once, refresh the detail page, confirm the label now reads "Follow-up Draft (Warm):".
  4. Click the link in the email, refresh again, confirm the label now reads "Follow-up Draft (High Intent):".
  5. Cross-check against the Email Timeline modal's own "predicted next step" - it should name exactly the same type the panel shows.
✓The panel's type determination stays in lockstep with the timeline modal and the real engine - all three read from the same shared function, decideFollowupType(), so there's no risk of them disagreeing because each maintains its own copy.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

Follow-up Draft 2 · a manual send doesn't duplicate the automatic flow, and the next step still follows on correctly

Confirms a manual send genuinely stands in for what the automatic engine would have done (not an extra one alongside it), and that afterward the engine correctly continues from "this step is already sent" rather than repeating it or getting stuck.

  1. Get the test client into a state where the engine has already generated a pending queue item (e.g. the email's been opened, and Warm is sitting in Preview Send Queue awaiting review).
  2. On this client's Clients detail page, the Follow-up Draft panel should now read "Warm" - click Generate, then Send.
  3. Check Preview Send Queue: that Warm queue item should be gone (now sent), not sitting there duplicated alongside a second one.
  4. Check email_log: the new row's type should be the real warm, not a generic numbered label.
  5. Wait out the days set for Followup 1 after (days), then Run Test Follow-up Now, and confirm it generates followup1 (not another Warm, and not stuck not knowing what comes next).
✓A manual send produces exactly the same downstream effect as an automatic one - same table, same send function - so when the engine later checks "has this step already been sent," it's reading the same real record either way. Continuation just works, with no duplication and no stuck state.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

Group H: Deployment handoff — test copy to production cutover — the plan settled on 2026-07-30: the new version of Stella first connects to an independent copy of the database cloned from production and runs the full scenario suite there, completely isolated from the live OnlineStella environment; once everything passes, it formally takes over and connects to production itself, with OnlineStella fully stopped at the same time.

H0 · Setting up the test environment (before testing starts)

  1. Clone the entire production database (e.g. mysqldump export/import into a new database name). Point the new Stella deployment's .env at this independent copy — never at production itself.
  2. The first time the new deployment connects to this copy and starts up, it automatically adds the contact_reset_at and rejection_type columns (additive only, doesn't touch anything else).
  3. Confirm the OnlineStella deployment itself is completely untouched — it should keep running normally against production, serving real business as usual, unaffected by this testing.
✓With this in place, every scenario in groups A-G above — including step 1's fairly destructive "set every day field to 0" — only ever touches this independent copy. No matter how you test or what settings you change, none of it can reach the real clients OnlineStella is currently serving.

H1 · Production cutover sequence only after every scenario passes

!First, how Stella relates to www.jobvista.app — easy to conflate, so worth stating plainly: www.jobvista.app is the public JobVista product itself (the job board job-seekers and employers use) — that's a different thing from Stella. Stella is the internal CRM / outreach automation tool that supports that product (finds company leads, sends automated outreach, handles follow-ups) — it's a tool the operator uses, not something served at the www.jobvista.app domain itself. Stella runs on its own subdomain (during testing, stellatest.jobvista.app), which has no effect on www.jobvista.app itself.

2026-10 update: the actual path taken didn't follow this order, and is further along than this document originally planned for — instead of fully completing H1 before starting H2's backfill, a separate test subdomain was deployed first (stellatest.jobvista.app, a completely independent Node process from the old Stella), connected directly to production itself (not an isolated H0-style copy). H2's backfill (Tasks 1-3), H3's garbage-data cleanup, and the new Tasks 4/5 below were all done on this instance, against production itself. As of this writing, the old Stella deployment (what this document calls OnlineStella)'s Node process has already been deleted, today, during this round of testing — this isn't "both running in parallel, the old one still serving" — the old one no longer exists, and stellatest.jobvista.app is now the only Stella instance running at all. The 7 Auto-send toggles are still off — that part hasn't changed. In other words, step 2 below ("fully stop OnlineStella") has effectively already happened, just not in the planned, deliberate order this section describes. What's actually still left: deciding whether to rename the subdomain from stellatest to something more production-sounding (purely cosmetic, no functional effect), and formally turning on the 7 Auto-send toggles.

This isn't just flipping a few settings — it's a handoff between two independent processes. Getting the order wrong carries real risk. Follow it in sequence, don't skip ahead to save time.

  1. Take a snapshot backup of the production database before cutover (pure insurance — hopefully never needed).
  2. Fully stop the OnlineStella process first (a real PM2 stop, not just flipping a toggle on some page). Nothing real should be running between this step and the next — the two processes must never be connected to production at the same time, or the crawler, inbox monitor, and auto-send could all collide and double-process.
  3. Point the new Stella deployment's .env database connection at production itself — not the H0 snapshot (OnlineStella kept running for real right up until it stopped, so production's data is newer than whatever you tested against).
  4. Start the new Stella deployment against production.
  5. Run DESCRIBE clients once to confirm contact_reset_at and rejection_type were added automatically.
  6. In Settings → Autopilot, confirm every day-based setting and cooldown value is the real production value (14 / 14 / 7 / 3 / 7 / 60 / 90 / 180 / 365, the real Daily Send Limit) — not the 0s left over from testing on the copy. This is the single easiest step to get wrong: if these settings are accidentally left at 0 on production, the new deployment will instantly consider every real client overdue the moment it starts, and could blast the entire client list in one go.
  7. Confirm Auto Send New Emails / Auto Followup 1 / Auto Followup 2 / Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement / Auto Crawl Data are all OFF at this point.
  8. Watch the logs for a few minutes (pm2 logs) — confirm no errors, a clean startup, and (if Auto Crawl will be enabled) normal crawler paging.
  9. Confirm the reverse proxy / port is pointing at the new process, not a leftover old one.
  10. Once everything checks out, turn on these real toggles. Auto Send New Emails is protected by a real Daily Send Limit, so it can go on as originally planned. Auto Cold Restart / Auto Warm / Auto High Intent / Auto Followup 1 / Auto Followup 2 / Auto Reengagement now share the Follow-up Daily Limit under Settings → Standard → Email Send Limit → Capacity & Queue. Recommended approach:

    Preferred — lower the Follow-up Daily Limit (e.g. 20-30) and turn all six on together: at that volume, even if one type has a bug, the real send count for the day stays small and the blast radius is contained — while you get to see all six types' real behaviour in one pass, instead of waiting for each one separately (generateFollowupQueue only runs once a day, so a strict one-at-a-time rollout across all six could take the better part of a week). After a run, spot-check a handful of the real sends per type in email_log / Preview Send Queue — template rendering, recipient, and the post-send status transition (e.g. does a Follow-up 2 send correctly move the client into cooling). Once a few days look clean, raise the Follow-up Daily Limit back to 250.

    If you'd rather isolate types one at a time: check each type's real Pending count in Send Capacity & Queue first, and enable them smallest-backlog-first — not in the code's High Intent → Warm → Cold Restart → Follow-up 1 → Follow-up 2 → Re-engagement order, which is just the rule for splitting the Follow-up Daily Limit when multiple types are competing for it on the same day (it only matters when several toggles are on at once — it doesn't "remember" which order you flipped them in). Enable the smallest-backlog type, confirm it looks right, then the next, saving the largest backlog (likely Cold Restart or Follow-up 1) for last so a mistake has the smallest possible blast radius — the tradeoff is it can take several days to get through all six.
  11. For the four new toggles — Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement — once the first matching email is generated after enabling, check Preview Send Queue and confirm it did not stay there: it should already be in email_log as sent. Rule out two normal cases before concluding the toggle didn't take effect: ① is this client watchlisted (if so, it always stays in the queue for a manual Send regardless of the toggle — that's not a failure); ② has today's Follow-up Daily Limit already been spent by higher-priority types (if so, it's correctly waiting for the next generateFollowupQueue run — also not a failure). Only if neither applies and it's still sitting there does the toggle need investigating.
  12. Watch the logs closely through the first post-cutover hourly cron tick and the first 9am daily generateFollowupQueue run, to confirm behaviour matches expectations — in particular the [FollowupQueue] Auto-sent N queued item(s) and N auto-eligible item(s) held back - daily follow-up cap reached log lines, which show exactly how many went out and how many were held back by the cap for the next run.
!Step 2 is the highest-risk moment in the whole handoff: any window where the old and new processes are both connected to the same production database at the same time risks both deciding "this client is due" simultaneously, or both inbox monitors reading and double-processing the same reply. Better to leave a few minutes where genuinely neither side is running than to save time by letting them overlap.
!Cold Restart / Followup 1 backlog is now throttled by a real daily cap — no more manual draining: confirmed 2026-08-13 that generateFollowupQueue()'s candidate query (server.js) has no LIMIT — it's an exhaustive scan over every status='contacted' client, and months of accumulated backlog will very likely mean a large chunk of clients simultaneously qualify for Cold Restart (never opened) and Followup 1 (opened once, no follow-through). This is now fixed at the source: all six types share one real daily send cap (autopilot_followup_daily_limit, the Follow-up Daily Limit field under Settings → Standard → Email Send Limit → Capacity & Queue, default 250, capped at 300), spent in priority order — High Intent → Warm → Cold Restart → Follow-up 1 → Follow-up 2 → Re-engagement. At most that many go out per day; unused headroom doesn't carry over, and anything left over stays Pending and is picked up on the next generateFollowupQueue run under the same rules — nothing is lost, and nothing bursts.

Follow the recommended approach in step 9 above when flipping these six on — the cap itself keeps daily volume bounded, so there's no need to drain anything by hand first. Default is 250; Brevo's own account cap is 300, so this leaves a small buffer.

Worked example: how the Follow-up Daily Limit actually plays out

Say Follow-up Daily Limit is set to 250, all six Auto toggles are on, and today's queue looks like this:

TypePendingOf which Watchlisted
High Intent30
Warm50
Cold Restart4009
Follow-up 12004
Follow-up 2101
Re-engagement80

When the 9am run fires, here's what actually happens:

  1. Priority order, not an even split: all 3 High Intent go out first (247 left), then all 5 Warm (242 left), then Cold Restart — 400 minus 9 watchlisted leaves 391 eligible, only 242 of those fit, leaving 149 still Pending — the cap is now spent, so Follow-up 1 (196 eligible), Follow-up 2 (9 eligible), and Re-engagement (8) get zero sends today, all held for tomorrow.
  2. Failed attempts don't cost anything: if 2 of those 242 Cold Restart attempts turn out to have replied in the last few minutes, they're skipped (never send a "still there?" to someone who just replied) and don't consume any of the cap — the system tries 2 more candidates to make up for it, so the actual count sent stays close to what the cap allows.
  3. Manual sends count too: if you'd already manually sent 50 via Preview Send Queue before 9am, the run would start with only 250-50=200 remaining — the cap protects Brevo's total daily volume, not just the "auto" portion of it.
  4. Nothing left over is lost — it just waits for tomorrow: the 149 remaining Cold Restart and 196 remaining Follow-up 1 items stay Pending untouched, not resent or dropped. Tomorrow the cap resets to 250 and the same priority order applies, so higher-priority types get first claim again before Cold Restart resumes draining.
  5. The 14 watchlisted items (9+4+1) are never touched by this, regardless of toggles or cap — that's the whole point of watchlisting a client. They need a manual Send from Preview Send Queue, which is why the queue never fully empties out just because a toggle got switched on.
  6. Test clients are unaffected: whatever a test client's type/cap situation looks like, nothing auto-sends for it — a manual Send is always required, same as before.
!Check the toggles and backlog yourself before restarting/deploying: this change makes generateFollowupQueue() process every status='pending' record, not just what it just generated — intentional, since that's what lets "capped-out today, retried tomorrow" work. But it also means: if any type's Auto toggle happens to already be on in the database when you restart (e.g. left on by accident during testing) and a backlog has built up for it, the next time the code runs (a restart, or the next 9am tick) that backlog starts sending for real immediately — it won't wait for another manual confirmation. Before starting the new Stella deployment on cutover day, confirm the real state of all six Auto toggles first — if they're all off (as step 6 above requires), there's no risk here; if any is unexpectedly on, decide what you want done with that backlog before proceeding.

H2 · Historical data backfill after cutover only after H1 is fully done

✓2026-10 update: Tasks 1, 2, and 3 have all been run to completion on the stellatest shadow instance, against production itself, following the same approach this section originally described (a temporary route, triggered once, deleted once confirmed clean). A full final cross-check was done afterward (Task 1 + Task 2 + the manually-matched Lifeblood case), verified row by row in phpMyAdmin against the real data, and confirmed correct. See the "✅ actual execution result" note under each task below for specifics and what was learned along the way.

While auditing production data on 2026-08-01, we found that two independent long-standing bugs meant a chunk of state changes that should have happened between late April and now never actually made it into the clients table. Both bugs are already fixed in code, but the fix doesn't retroactively repair the history it already missed — that needs a one-time backfill, run once the new Stella deployment has taken over production. This section writes down the full context, the two tasks to run, and the pitfalls to watch for.

Background: why the gap exists, and what's missing

Three independent historical bugs, each found and fixed here:

#ProblemImpactFix status
① Wrong inbox monitoredThe sending address (email_sender) has always been connect@jobvista.app, but .env's IMAP_USER has always been support@jobvista.app — not the same mailboxEvery client reply lands in connect@'s inbox. Stella's "read replies → AI sentiment → update client status" pipeline (checkInboxReplies / analyseReplySentiment) has never actually processed a single real client reply. Confirmed: support@ has zero real client replies — they're all in connect@.Needs a manual switch of .env's IMAP_USER/IMAP_PASSWORD to connect@ (not this document's job — part of cutover configuration, see "Prerequisites" below)
② Webhook write not awaitedEvery db.run() in handleBrevoWebhook that updates clients was fire-and-forget, no await — if the response to Brevo and a process restart/DB hiccup happened to overlap, the write could silently vanish, no error, no traceVerified against real production data: email_log itself is complete and continuous, no gaps. What's missing is that these correct records never got synced into clients.status, so a batch of clients that should be in Not Interested / Bounced are missing from those views.✅ Fixed — both c:\Stella and c:\OnlineStella's handleBrevoWebhook now await every relevant write, so the response to Brevo only goes out once the state is actually persisted
③ Auto-send never creates a client recordautopilotAutoSend (the main automated cold-outreach engine) only ever looked up an existing clients row and never created one if missing, relying entirely on a one-time migration that only runs at server startup to backfill it — but that migration only fires at restart, so any email sent in the gap between two restarts could leave email_log.client_id permanently NULLIf a client's first email happened to land in that "not yet on file" window, any reply from them afterward gets treated as "no matching record" and silently dropped — never classified, never updates status✅ Fixed — autopilotAutoSend now guarantees a client record exists before sending; checkInboxReplies also gained a third fallback match (looking the reply's sender email up directly against clients.contact_email), so even a historical email with a missing client_id can still be matched correctly

The date real bulk sending to clients actually began was triangulated from the earliest record of four independent signal types in email_log — unsubscribed / opened / clicked / replied — whose earliest timestamps all cluster around 2026-04-23, not the originally-guessed March, nor the mid-conversation guess of June. Both tasks below use this date as their starting point.

A worked example, to tie it all together

Three fictional clients, each mapping to one of: "what Task 1 needs to fix", "what Task 2 needs to fix", and "nothing to do, it already heals itself":

ClientWhat actually happened historicallyWhat the system believed, before backfill
Sunshine Building Materials (contact: Lisa)4/2 [example date] received the first outreach email; opened it twice a few days later; then replied "a bit pricey, not looking right now" — a clear soft rejectionBecause the wrong inbox was being watched, the reply was never seen — the system still believed "sent, no reply yet"
Green Source LogisticsClicked the unsubscribe link directly in one of the emailsBrevo correctly identified this as an unsubscribe, but the code updating clients responded to Brevo before the write actually finished, and a server restart happened to land in that exact window — the write silently vanished. email_log is correct; clients.status never caught up.
Eastern Logistics (contact: Mark)Received the first outreach email, opened it twice, then never replied and never unsubscribed — just looked and moved onWhat the system saw matches exactly what happened — no gap at all

August, new Stella takes over, backfill runs: Task 1 connects to the connect@ mailbox, finds Lisa's reply, the AI classifies it as a soft rejection, and calls markClientReplied() — Sunshine Building Materials' clients.status is correctly set to cooling (the soft_rejection branch in server.js), with the cooldown expiry date counted forward from "today" (the day the backfill actually runs), not from the original reply date — so the timing lands somewhat later than it ideally would have. There's no clean fix for that; it's an accepted trade-off. Task 2 replays Green Source Logistics' unsubscribed record from email_log, correctly syncing clients.status to not_interested and removing them from the active pipeline. Eastern Logistics needs nothing from the backfill — their data was correct the whole time.

Once the code is live and the autopilot cron is running normally: Sunshine Building Materials — because Task 1 already moved her to cooling, generateFollowupQueue()'s candidate query has a hard condition, c.status = 'contacted', which cooling doesn't satisfy — she's excluded outright, and will not receive a warm follow-up just because she opened the email; that would contradict the rejection she already gave. Eastern Logistics — his status has stayed contacted the entire time, so "opened the email, no warm sent yet" has been true the whole time; the next time generateFollowupQueue() runs, it picks him up correctly and generates a warm follow-up — no separate script needed to "backfill" that May open event, because the check is against the client's current state, not whether it was caught in real time back in May.

!Prerequisites and execution order: this section can only happen after "H1 · Production cutover sequence" is fully done and the new Stella deployment is stably connected to production — the whole point of the backfill is to fix the database the new deployment is actually using, so doing it before cutover accomplishes nothing. Before Task 1 starts, confirm .env's IMAP_USER/IMAP_PASSWORD has been switched from support@ to connect@ (with the real password), and confirm that account can actually connect (a quick ImapFlow connection test in node, or check pm2 logs for any IMAP errors).

The correct order is: H1 cutover complete → Task 1 + Task 2 (order between these two doesn't matter — they're independent, and could even be combined into one script) → spot-check the results → only then turn on the auto-send toggles (Auto Send New Emails / Auto Followup 1 / Auto Followup 2, plus the four — Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement — see H1 steps 6/9). Get this order backwards and clients who already gave a real soft rejection or unsubscribed could still show as "contacted" while automation is already running, and receive an email they shouldn't.
!How this actually gets run: the backfill isn't a standalone script you click "run" on inside cPanel — it's code that ships as part of the Stella Node process itself. The flow: write the backfill logic as temporary code (added into server.js, the same kind of temporary route used repeatedly during testing); upload the updated server.js the same way any other code change gets deployed, via cPanel's File Manager, then restart the app from the Setup Node.js App panel (this step only deploys the code — it doesn't run the backfill yet); once restarted, the backfill logic is live inside the running Stella process, and visiting a specific URL actually triggers it; after confirming the results look right, remove that temporary code, upload a clean version again, and restart once more.

Task 1 · Reply-sentiment backfill (connect@ mailbox, 2026-04-23 to present)

  1. Write a one-time script (not a standing route — delete it once done) that reuses checkInboxReplies()'s full per-message processing logic as-is, with the only change being the lookback window: swap the usual "last 7 days" (since = 7 days ago) for a fixed start date, since = 2026-04-23 — meaning from 2026-04-23 through the day the script actually runs (not just the single day of 2026-04-23), pulling the months of backlog in between in one pass.
  2. Messages must be processed in chronological order (oldest to newest) by sent_at/received time — if the same client replied more than once over these months (e.g. interested at first, later declined), processing in order is what makes the final state land correctly on the most recent one.
  3. When a message looks like an auto-reply (out-of-office etc.), it must go through the existing detectAutoReply() check — never treat it as genuine client sentiment. Reuse the function directly rather than reimplementing similar logic, to avoid subtle behavioral drift.
  4. Every real reply must go through calling markClientReplied() itself (not a reimplementation of its logic) — that's what inherits all the protections it already has: contact_reset_at-scoped hard-rejection archiving (won't wrongly penalize a company whose contact has since been reset), message-id-based dedup written into client_activities (so this doesn't collide with the regular 7-day poll later), clearing question_received_at, and so on.
  5. Exclude the list of clients you'll separately provide that were already handled manually during this period — no need for the AI to reclassify those.
  6. Pace the Groq calls, working with the per-model token tracking + gpt-oss-to-qwen borrowing mechanism already in place (the budget check near callGroq / pickFreshProtagonist) — don't let a few months of backlog burn through gpt-oss-120b's daily budget in one go and starve other same-day real-time features (reply classification, social generation, etc.).

Why this has to call the function itself, not a rewrite — one example per protection:

① contact_reset_at protection — stops an old contact's rejection from wrongly killing a new contact's chance: Greenridge Manufacturing — back in March, the contact then, Tom, replied "not interested, please stop contacting us" (a hard rejection). In June, the crawler found a new contact, Amy, at the same company, and the system opened a fresh sequence for her (the reset moment is recorded in contact_reset_at). When the backfill script reaches Tom's March rejection, it must not archive the whole company because of it — the live contact now is Amy, and Amy never rejected anything; she shouldn't be penalized for something Tom said three months earlier. markClientReplied() checks whether a reply falls before or after contact_reset_at — an old reply from before the reset never gets used to archive the new contact. Reimplementing the logic by hand makes this check easy to miss.

② message-id dedup — stops the same email being processed twice: the backfill script processes months of old mail in one pass; afterward, the system keeps polling the inbox as normal. If the "mark this as processed" step gets skipped, a later normal poll that happens to re-encounter the same old message would treat it as a brand-new reply and run the whole status-change/notification logic again — the same email processed twice, leaving duplicate entries in the client's activity log. markClientReplied() records a "already handled" marker keyed by the email's own message-id, so a later poll automatically skips it; a hand-rolled script is easy to forget this on.

③ Clearing question_received_at — stops a stale "still waiting on an answer" state from lingering: a client once asked a question (e.g. "how does pricing work"), and the system recorded a "waiting for our answer" timestamp. Later the client sends another message that resolves it (e.g. says they're not interested after all) — that "waiting" timestamp should get cleared, or the UI keeps showing "this client is still waiting on your answer" long after it's no longer true. markClientReplied() clears markers like this as part of handling the new reply; code that only updates the status field by hand is easy to leave these stale.

All three are the kind of thing a hand-written version is likely to miss but calling the real function automatically avoids — which is why the backfill script should call the function itself rather than copy its logic.

✓Once done, every client who genuinely replied "interested / soft-rejected / hard-rejected / asked a question" since 2026-04-23 will have clients.status correctly updated. Since one of cooling's triggers is exactly "client sent a soft rejection," clients who should have entered a cooldown will now fall into cooling naturally, and their re-engagement timeline starts ticking — nothing extra needs to be done for cooling/re-engagement specifically (see "Why warm/high_intent/cooling/re-engagement don't need a separate backfill" below).
✓✅ Actual execution result: after the automated matching ran, 11 historical emails were left unmatched and reviewed by hand one by one: 10 turned out to be genuine noise (Stella's own "token budget running low" system notification landing in the same inbox, a ticketing system's auto-acknowledgment, one unrelated "application received" confirmation, and one of the operator's own test replies) — all skipped. One was a genuine client reply that none of the 3 automated matching layers could catch — Lifeblood (client #3063): their real reply arrived via a third-party relay (noreply@outhire.co) whose from-address and message-id have no relationship at all to what Stella actually sent to careers@redcrossblood.org.au, so the usual "match by recipient address / match by message-id" logic had nothing to find. This is the kind of edge case automation can't catch by design, not a bug — once confirmed by hand as a genuine reply, it was run through the exact same real pipeline (extractReplyBody → detectAutoReply → markClientReplied, with the client ID supplied manually, no special-cased logic) and recorded correctly.

Two real bugs found and fixed along the way, while reviewing Task 1's results 2026-10

Not separate tasks — these surfaced while manually reviewing those 11 unmatched historical emails, exposing gaps in checkInboxReplies()/analyseReplySentiment()/extractReplyBody() themselves. Both were fixed on the spot and swept into the same backfill run:

IssueReal caseFix
① An applicant auto-acknowledgment mistaken for a client replyRaiseit's "reply" actually read "Thanks for your application, we'll be shortlisting over the next few weeks... best of luck in your job hunt" — Stella's outreach email had landed in that company's ATS/applicant-tracking inbox instead of a real person's, and this was its automated acknowledgment to job applicants, not a reply to Stella's outreach at all. There was no category for this at the time, so it would have been classified like a normal reply.Added a 5th reply category, misdirected, specifically for "this is an ATS auto-reply to a job applicant, not a genuine reply to our outreach" — handled the same way as an auto-reply (isAutoReply): logged only, status untouched, so it can't be mistaken for a soft or hard rejection.
② Email body parsed into garbage when no encoding was declaredRaiseit's real reply body had literal =0D-style escape leftovers with no declared encoding in the email headers at all (VTNZ's reply, separately, declared base64 but wasn't being decoded) — directly affecting whether ① could even read the real reply text to classify its sentiment in the first place.extractPlainTextPart/extractReplyBody now decode at three layers: a multipart leaf part with a declared encoding, decoded per that declaration; a non-multipart fallback path with a declared encoding, same; and a new frequency-based fallback for senders that embed literal =XX-style escapes with no declared encoding at all (2+ occurrences triggers the cleanup pass) — specifically to catch cases like Raiseit's.
!Three things worth knowing ahead of time:
1) Re-engagement's cooldown timeline will run later than the "ideal" case — if today (August) is when a May reply finally gets backfilled, the cooldown expiry (reengagement_date) is calculated forward from today, not from that real May date. There's no clean fix for this; it doesn't affect correctness, just timing.
2) It could overwrite a status you corrected by hand — if you manually changed a client's status in the UI at some point in these months, based on a phone call or other out-of-band information, the backfill replaying old emails could silently overwrite that correction, since there's currently no "this was a manual edit, don't auto-overwrite" flag. Spot-check the handful of clients you remember editing by hand after the backfill runs, and revert manually if needed.
3) A small number of emails from before 2026-04-23 — if one happened to land in the "③ auto-send doesn't create a client record" gap window (a NULL client_id), the newly-added fallback match (looking the reply's email up directly against clients.contact_email) should catch it correctly regardless — but it's worth paying extra attention to these especially-early records during the spot-check.

Task 2 · Re-syncing Not Interested / Bounced status

Fully independent of Task 1 — no dependency either way, can be done in any order, or even folded into the same script. This is about bug ② — the email_log unsubscribed/bounced records themselves are not missing (verified against real production data: every weekday from April through July has continuous records). What's missing is that these correct records never got synced into clients.status.

  1. Query every email_log row with status IN ('unsubscribed','bounced'), ordered by sent_at ascending — don't restrict to a specific month, since this bug is an intermittent write failure that could in principle have happened at any time, and limiting to one guessed window risks missing cases outside it.
  2. Replay them in chronological order: status = 'bounced' → clients.status = 'bounced'; status = 'unsubscribed' → clients.status = 'not_interested'. For a client with multiple historical records, processing in order correctly lands on the most recent one.
  3. Per your call, this backfill intentionally does not apply the "active/not_interested/bounced can't be overwritten" guard (used elsewhere, e.g. in autopilotAutoSend's new-client check) — with only 2 active clients right now, the risk is small enough that a straight overwrite is simpler and fine.
  4. Log a matching client_activities entry alongside each replay, with a [Backfilled]-style prefix on the body, so it's distinguishable later from an entry generated in real time.
✓Clients who were previously "missing" from Not Interested / Bounced due to the sync gap will now correctly show up there.
✓✅ Actual execution result: replay completed; grouped counts were cross-checked in phpMyAdmin and the Not Interested / Bounced counts and detail rows matched the underlying unsubscribed/bounced records in email_log exactly, no discrepancies found.

Task 3 · brevo_message_id format fix (angle brackets) found 2026-08-24

A different kind of problem from the two tasks above — not "the event was never processed," but "the event got credited to the wrong email." Root cause: Brevo's send API response, and the Message ID its own dashboard displays, both come wrapped in angle brackets (e.g. <202608240648.18253653303@smtp-relay.mailin.fr>), but sendViaBrevo() was storing that value as-is, brackets and all, into email_log.brevo_message_id. Meanwhile, the webhook handler strips the brackets off the incoming event's message-id before looking it up in the database. The two formats never matched, so WHERE brevo_message_id = ? never actually found anything — every open/click/bounce event silently fell back to "whichever email was most recently sent to this address."

Most of the time that fallback happened to guess right (a client usually only has one email outstanding at once, so "most recent" is also "correct"), which is why this went unnoticed until testing a genuine "two emails in flight" scenario on 2026-08-24 exposed it: both open events landed on the later email (Cold Restart), while the earlier First Email showed as never opened at all. server.js now strips the brackets before storing (this doesn't affect the reply-threading logic elsewhere, which already accepts either bracketed or bare input) — but that only fixes emails sent after the new code goes live. Rows already in the database keep their old, bracketed format until a one-time fix is run.

  1. Confirm the updated server.js is deployed and the new process is running (same checkpoint as step 4 in this section).
  2. Run this one-time SQL to strip the brackets off every existing brevo_message_id value:
    UPDATE email_log
    SET brevo_message_id = REPLACE(REPLACE(brevo_message_id, '<', ''), '>', '')
    WHERE brevo_message_id LIKE '<%>';
✓Pure format normalization, no schema change — safe to run, and safe to re-run (the WHERE clause means rows already in the clean format are left alone). Afterward, any client still mid-sequence (whether their emails were sent before or after the restart) will have future open/click/bounce events correctly matched to the specific email that actually generated them, instead of defaulting to "whichever one was sent most recently."
!This SQL cannot undo misattribution that already happened — if a real client had "two emails in flight" before the restart and an open/click/bounce event landed on the wrong row during that window, that row's open_count/status is already set and this SQL only fixes formatting going forward, it doesn't move the data back to the correct row. There's currently no bulk automated way to correct that historical misattribution (Brevo's own Email Activity detail view can show the true open/click history per message if you need to reconcile a specific case by hand). Most clients are unaffected — this only matters for clients who genuinely had more than one email outstanding at the same time.

Execution order: this SQL can run at any point — it doesn't depend on Task 1 or Task 2 finishing first. Recommended to run it together with them, once H1 is complete.
✓✅ Actual execution result: the SQL has been run against production — the historical brevo_message_id angle brackets were stripped in bulk, no errors.

Task 4 · Full to_email tracking rebuild for multi-contact companies found and fixed 2026-10

Found by personally reading through the real Preview Send Queue row by row, not by querying the database: Auckland Transport had two duplicate High Intent entries queued, and several multi-contact companies had greetings that were obviously wrong ("Hi Dr", "Hi me", or a name that didn't match the actual recipient address).

Root cause: pickFollowupEmail(clientId, contactEmail) resolves "the one address this client as a whole should use" (by engagement strength: replied > clicked > opened, tie-broken by most recent send time) — it has no concept of which specific original email a given follow-up item is actually about. Auckland Transport genuinely has two independent contacts (Rennie, Lynn), each with their own "first" email and their own click — but both resolved to the same address under this logic, silently dropping one contact's entire engagement history with no error or warning at all.

Fix: added a to_email column to followup_queue, and reworked the whole chain from candidate-generation through to send-time to track which original email this specific follow-up is about, instead of a vague "this company's address":

  • generateFollowupQueue()'s candidate query now joins on lead_id to get the specific contact data tied to that original email; the dedup guard changed from "per client_id" to "per (client_id, to_email)" — the two contacts can no longer displace each other
  • to_email is now stored on INSERT into followup_queue, instead of being re-derived at send time
  • sendFollowupQueueItem()'s lookup for the "original email" to thread Subject/In-Reply-To against is now scoped by the stored to_email, instead of blindly picking the earliest email this company ever received
  • The Preview Send Queue route, redirectStalePendingToHighIntent() (the logic that upgrades a pending item to High Intent after a late click), and the manual "regenerate" endpoint were all updated to prefer the stored to_email, only falling back to the old pickFollowupEmail() when a row predates this change and has none stored
✓✅ Actual verification result: verified against the real Auckland Transport (Rennie/Lynn) data — the first verification pass still showed the wrong greeting for Lynn, proving that "the lead_id join checks out" doesn't mean "the data is actually correct" — digging one layer deeper found the real root cause (see Task 5 below). Task 4 was only considered done once both real-data verification passes came back clean.
!This fix only applies to newly-generated queue rows — rows already sitting in followup_queue before the change have no to_email and still fall back to the old pickFollowupEmail() logic at send time. Affected multi-contact companies need their stale pending rows manually cleared (DELETE FROM followup_queue WHERE client_id = ? AND status = 'pending') and regenerated to pick up the new logic — already done for Auckland Transport and a few others during verification; the full one-time sweep of all pending rows is covered at the end of Task 5 below.

Task 5 · Full rebuild of how the email greeting is resolved found and fixed 2026-10, the largest fix of this round

Also found by personally reading the real Preview Send Queue: "Hi Applications" for Wellington City Mission, "Hi Talent" for Sanitarium, "Hi Abby" for Hierarchy Group, "Hi Lucas" for AFFCO (whose recipient address was clearly brett.innes@) — obviously wrong or at least suspicious greetings, one after another. This was the longest, most-iterated fix of this round. What follows is the final conclusion and the handful of genuinely pivotal discoveries along the way, not a blow-by-blow of every round of investigation.

· Dead ends worth recording (not wasted time — they shaped the final design)

  1. First instinct — expand the blocklist: the initial approach was "hit a junk word, add it" (talent/acquisition/applications/human/resources/careers/cv/distribution/fabrication/managing/people/senior/supervisor and more). It plugged each specific case found, but it's fundamentally whack-a-mole — fix one, find another.
  2. A company's own name mistaken for a person's — "Hawkeswood Mining, Careers": found that no blocklist alone could catch a case where the contact_person field literally contained the company's own name (a real example — "Hawkeswood" was extracted as if it were a first name, since it was capitalized and wasn't on any blocklist). Added a structural check instead: if the candidate word equals the first word of this client's own company name, reject it — closes off the whole category at once rather than chasing individual company names.
  3. Upgraded to proper "role-based email" detection: looked into how the email-validation industry actually handles this (ZeroBounce and similar tools' "role-based email detection" feature), confirmed that maintaining a known list of role-indicating local-part words and checking the address segment-by-segment is the standard approach, not something invented here — replaced the old "does the address have a separator" check with this more reliable word-list match, which also closed a gap on hyphenated role addresses like no-reply@ and customer-service@ that the old check let through.

· The real turning point: contact_person itself can't be trusted

Verifying three "obviously wrong" cases against real data — AFFCO, Fresh Meats NZ, Papamoa Dental — found: it wasn't that the wrong contact got linked — the name field and the email field inside the very same lead record never matched in the first place. A diagnostic field was added specifically to check "does this lead's own email actually equal the real recipient address" — all three came back "yes" (lead_email_matches_to_email: true), yet the name was someone else entirely (e.g. lead #55583: contact_person="Lucas Bird", contact_email="brett.innes@affco.co.nz" — those two fields simply aren't the same person). The mismatch originated upstream in the original scrape/record-creation step, not in how the query joins things — contact_person's scraped text can be wrong even when every linkage check passes clean, and no amount of "verify the join" closes that off.

✓Final design (dropped every layered check that came before it): contact_person's scraped text is no longer consulted for the greeting at all — only the recipient address itself is trusted. A shared/role-based address (matches the role word list) → always "there"; a personal-pattern ("first.last") address that yields a plausible name → use it; anything that can't be confidently parsed (initials, department code, a title word) → also "there". Applied identically across all four places a greeting is ever generated: the first cold email (buildEmailPrompt), follow-up queue generation and sending (resolveGreetingName), re-engagement (now unconditionally "there", no single-contact exception left), and the client detail page's "Follow-up Draft" preview panel (predictFollowupType) — all four now agree, so the preview can never show one name while the real send uses another.

Parsing a name from the address itself also turned up a handful of real gaps (all found against real data, not hypothetical edge cases):

Real caseProblemFix
executive-recruitment@ffa.int → "Hi Executive"The audit methodology itself had a blind spot — it only flags a mismatch between the greeting and what the address alone would derive, so when both sides land on the same wrong word, nothing gets flaggedAdded "executive" and the like to the blocklist, this time systematically covering the whole job-title/department/recruiting vocabulary category at once rather than patching words one at a time
MR.T.THEBAKER@gmail.com → "Hi Mr"Parsing a name out of a contact_person string already skipped a leading title (Mr/Dr); the code path that parses a name from the email address itself was missing that same checkAdded the same title-skip to the email-parsing path; if what's left after skipping the title is too short (here, just "T"), treat it as unresolvable rather than guessing further
NA.TA.Operations@averydennison.com → "Hi Na""NA" is a region/department code (North America), not a name — a genuine personal address is almost always typed in lowercase; only a system-generated code segment tends to stay in its original uppercaseA segment that's already fully uppercase in the source, and short, is now treated as a code rather than a name
bd.mckee@outlook.co.nz → "Hi Bd""bd" reads just as plausibly as initials-before-surname (B.D. McKee) as it does a genuine short name — nothing in the two letters themselves distinguishes the twoRaised the minimum accepted length from 2 to 3 characters — the cost is that genuine short names like Jo/Bo/El now also fall to "there", an accepted trade-off under the "when unsure, say there" principle
!One-time cleanup: before this change went live, the 942 pending rows already sitting in followup_queue had their greeting baked into body at generation time (not re-rendered at send time), so the old, sometimes-wrong greetings would have stayed there untouched until sent or expired. All 942 have been cleared and regenerated fresh under the new logic.
!How the sweep was actually done: rather than manually scrolling through Preview Send Queue row by row, two temporary diagnostic endpoints were written — one that groups flagged greetings by the unique word used, returning each word's count and a few samples (so a few hundred rows don't have to be dumped onto one screen at once), and one that combines "clear the stale queue + regenerate under the latest logic + audit again" into a single call, so the final clean/dirty verdict comes back in one shot. These are named /api/admin/audit-followup-greetings and /api/admin/regenerate-and-audit-greetings — temporary diagnostic routes, to be deleted once no longer needed (see "Reset after testing").

Why warm / high_intent / cold_restart / re-engagement don't need a separate backfill

These aren't stored history — generateFollowupQueue() computes them fresh on every run, from email_log's current open_count/status plus "days since the first email," independent of whether anything was processed in real time back then. As long as two things hold — the underlying email_log.open_count/status data is correct (confirmed: this comes from Brevo's opened/click webhook events, which kept recording normally the whole time, unaffected by the wrong-inbox problem — though "recording normally" means the event itself wasn't dropped, not that it necessarily landed on the right email; if a client happened to have two emails in flight at once it could hit the message-id format issue in Task 3 above and get credited to the wrong row, see that task's caveat for the limits of what's fixable) and the autopilot hourly/daily cron is running normally once the code is live — then any client who currently genuinely matches the warm/high_intent/cold_restart criteria gets picked up automatically and correctly the next time the cron runs; no script needed to backfill history. Re-engagement works the same way, firing automatically once cooling.reengagement_date comes due — as long as cooling was correctly synced by Task 1, re-engagement follows naturally with no separate handling required.

H3 · Clean up garbage company names Found 2026-08-28

Root cause: when crawler.js scrapes a TradeMe job listing and the page's dedicated company-name CSS selector (.jb-listing__company-name etc.) comes up empty, it falls back to guessing a company name out of the page's meta description text via regex (the jobData.meta_desc fallback block in crawler.js). That regex has no length cap — against a narrative-style description it can capture a whole sentence, sometimes several, straight into clients.company_name. This isn't just a display glitch: these fake "companies" get run through the full automation pipeline like any real lead, and at least one confirmed case already had a real follow-up email sent to it.

Two confirmed real examples from the test database at the time: id 33529 ("Based in Twizel or Oamaru [newline] About the role [newline] Thi...", status=new, never contacted), id 19789 ("About the Role I am a 32-year-old woman who leads...", status=contacted, already had a real follow-up email sent to it). The code-level fix is below under "crawler.js is already fixed" — this section only covers cleaning up the historical bad data already sitting in the database.

✓✅ 2026-10 actual execution result on production (different ids from the test-database examples above — don't mix them up): the identification SQL below, run against production itself, turned up 3 real rows — 73608 (never contacted — handled the first way, deleted outright), 5971 (had already had a real email sent to it; but unlike 19789, manual review found the original job listing's real company name could actually be recovered — this wasn't pure garbage data, so instead of either of the two planned treatments, company_name was simply corrected to the real "Bendigo Terrace Farming", a better fix than deleting or deactivating — effectively a third treatment that came up during execution and wasn't anticipated by the original plan), and 19789 (by the time this step actually ran, its status was already correctly not_interested — no action needed; most likely its real unsubscribe/bounce event had already been correctly caught by Task 2's re-sync above, so it was no longer a problem by the time H3 ran).
!Corrected 2026-08-28 against real data: the length threshold isn't reliable, dropped — the first idea was a LENGTH(company_name) > 45 first pass. Run for real against the test database it returned 19 rows, and manual review found only 2 were actually garbage — the other 17 were real company names that just happened to be over 45 characters (e.g. "Advanced Accounting & Business Advisory Limited", "Wilson Contracting Ltd and Smiths Transport Ltd"). Length alone has too high a false-positive rate to filter on - switched to matching on content instead, below.

Step 1: identify — read-only

Both confirmed garbage examples contain the phrase "about the role" (case varies) — a common heading on TradeMe job description pages that a real company name would essentially never contain; 33529 also has an actual embedded newline character (\n), which a company name shouldn't have either. Matching on both together is far more precise than length alone:

SELECT id, company_name, status, source, created_at
FROM clients
WHERE source = 'TradeMe'
  AND (LOWER(company_name) LIKE '%about the role%' OR company_name LIKE '%\n%')
ORDER BY created_at DESC;

Still a first pass, not a final verdict — results need a human look afterward. If this only turns up the two already-confirmed rows (33529, 19789), nothing further is needed beyond Step 2 below.

Step 2: split into two cases by whether a real email was ever sent

Don't blanket DELETE every match: if a garbage "client" already has real email_log/followup_queue/client_activities rows (e.g. an email genuinely went out), deleting the client row would orphan those records and erase the true fact that an email was sent. The two confirmed rows are one example of each case:

  1. 33529 (never contacted) — delete outright, with a safety condition in case it turns out to have history after all:
    DELETE FROM clients
    WHERE id = 33529
      AND id NOT IN (SELECT DISTINCT client_id FROM email_log WHERE client_id IS NOT NULL)
      AND id NOT IN (SELECT DISTINCT client_id FROM followup_queue WHERE client_id IS NOT NULL)
      AND id NOT IN (SELECT DISTINCT client_id FROM client_activities WHERE client_id IS NOT NULL);
  2. 19789 (already had a real email sent) — keep the row, just take it out of all future automation. Cancel anything still queued and unsent, then set it to not_interested so candidate queries like generateFollowupQueue (which all require status = 'contacted') never pick it again, while email_log/client_activities history stays intact:
    UPDATE followup_queue SET status = 'skipped' WHERE status = 'pending' AND client_id = 19789;
    UPDATE clients SET status = 'not_interested' WHERE id = 19789;

If Step 1 turns up any additional confirmed-garbage rows, swap in their id and apply whichever of the two SQL patterns above matches whether they have real history.

!Like H1/H2, this is a one-time cleanup to run once the cutover is confirmed and this is verified to be real production garbage — not a routine test scenario.

crawler.js is already fixed 2026-08-28

The first idea was "if the real selector fails, just honestly store 'Unknown (via TradeMe)'" — but that has a hole: the follow-up template renderer (renderFollowupTemplate) already has a companyName || 'your company' fallback, but that only kicks in when companyName is genuinely empty. 'Unknown (via TradeMe)' is a non-empty string, so the fallback never fires and the template would insert that literal placeholder straight into the email body — worse than the bug just found. Went with a cleaner fix instead: if there's no real company name, don't generate the lead at all, matching how the other sources (Seek/Indeed etc.) already gate on .filter(j => j.job_title && j.company_name) — TradeMe was the one exception hand-crafting a fallback name. A lead with no real company name was never actionable anyway, so rather than patching every downstream place that might see a placeholder, it's simpler to keep it out of b2b_leads/clients in the first place:

if (jobData.job_title && jobData.company_name) {
    const tmContactInfo = extractContactInfo(jobData.descText || '');
    trademeJobs.push({
        job_title: jobData.job_title,
        job_url: jobUrl,
        company_name: jobData.company_name,
        contact_person: jobData.contact_person || tmContactInfo.contactName || '',
        contact_email: tmContactInfo.email || '',
        contact_phone: tmContactInfo.phone || '',
        location: jobData.location || ''
    });
}

This change only affects newly scraped data going forward — it won't retroactively clean up what's already in the database; the historical part still needs Steps 1-2 above run by hand.

What to delete once every H1-H3 task is confirmed done and the toggles are on for real 2026-10

These are all temporary diagnostic/backfill routes added to server.js for this round of cutover testing — not features meant to stick around long-term. Once confirmed no longer needed, delete them as a batch; don't let one-off diagnostic routes ride along into long-term production code:

RoutePurposeBelongs to
/api/admin/backfill-task1H2 Task 1, reply-sentiment backfillTask 1
/api/admin/backfill-task2H2 Task 2, Not Interested / Bounced status re-syncTask 2
/api/admin/backfill-manual-matchManually supply a client ID to backfill a case automated matching couldn't catch (e.g. Lifeblood's third-party-relay reply)Task 1 add-on
/api/admin/run-real-followup-queue-nowManually trigger a real generateFollowupQueue() run on demand, without waiting for the next hourly/daily cron tickTask 4/5 verification
/api/admin/audit-followup-greetingsAggregates the current followup_queue by greeting word to flag anything suspicious; supports ?word= to drill into one word's detailTask 5
/api/admin/regenerate-and-audit-greetingsClears pending queue rows, regenerates under the latest logic, and audits again — all in one callTask 5

Task 3 (the brevo_message_id format fix) and H3's own data cleanup were both run as one-off SQL directly in phpMyAdmin, with no temporary route added — nothing to clean up there. cleanup_garbled_contact_person.js (the contact_person garbage cleanup) is a standalone maintenance script, not a server.js route — leaving it in the project directory is harmless, no need to delete it alongside the routes above.

!Suggested order: delete these only after the real toggles have been on for a few days and everything looks normal — don't delete the diagnostic routes while there's still a chance you'll need to go back and check something. All of them are requireAuth-protected, read-only or one-shot operations, so leaving them a while longer carries no extra risk.

Settings ↔ code map

UI fieldSetting keyDefaultRead by
Daily Send Limitautopilot_daily_limit20autopilotAutoSend
Follow-up Daily Limit newautopilot_followup_daily_limit250 (capped at 300)The capped, priority-ordered auto-send pass at the end of generateFollowupQueue — all 6 follow-up types share this one daily cap, spent in order High Intent → Warm → Cold Restart → Follow-up 1 → Follow-up 2 → Re-engagement; entirely separate from Daily Send Limit above (First Email only)
Followup 1 after (days)behavior_followup1_days14Read only by generateFollowupQueue — as of 2026-08-22 this means "days after Warm was sent, not First Email". As of 2026-08-24 the Cold Restart zero-opens path used to be handled by a separate direct-send autopilotFollowUp() that never read this setting; that path has been retired, and Cold Restart→Followup2 is now also owned by generateFollowupQueue - it still skips this setting the same way, since Cold Restart goes straight to Followup2 without a Followup 1 step
Followup 2 after (days)behavior_followup2_days14both, as the wait for the final email after whichever F1-equivalent was sent
Cold Restart after (days, no open)behavior_cold_restart_days7generateFollowupQueue
Warm trigger after (opens)behavior_warm_opens1changed from 2 on 2026-08-22generateFollowupQueue — opens ≥ this value goes to Warm; Followup 1 is no longer its own "opens===1" branch, it's now the step after Warm (see scenario A2)
Warm send after (days)behavior_warm_send_days3generateFollowupQueue
High Intent after (days, no reply)behavior_high_intent_days7generateFollowupQueue
Cycle 1 cooldown (days) renamed, "after rejection" dropped 2026-08-24reengagement_cooldown_days60getCooldownDaysForCycle, cycle-1 cooling only — the old name "Cooldown after rejection" was inaccurate, cooling isn't only caused by rejection, a plain no-response also leads there
Cycle 2 cooldown (days) newreengagement_cooldown_cycle2_days90getCooldownDaysForCycle
Cycle 3 cooldown (days) newreengagement_cooldown_cycle3_days180getCooldownDaysForCycle
Cycle 4+ cooldown (days) newreengagement_cooldown_cycle4_days365getCooldownDaysForCycle
Auto Cold Restart newautopilot_auto_cold_restartfalsegenerateFollowupQueue (!isTestMode && this setting — only a non-test call reads it, to decide whether a real client skips Preview Send Queue and auto-sends)
Auto Warm newautopilot_auto_warmfalsegenerateFollowupQueue (same, non-test only)
Auto High Intent newautopilot_auto_high_intentfalsegenerateFollowupQueue (same, non-test only)
Auto Reengagement newautopilot_auto_reengagementfalsegenerateFollowupQueue (same, non-test only)

Before 2026-07-30, the cycle 2/3/4+ tiers were hardcoded literals (90/180/365). They now go through one shared getCooldownDaysForCycle(cycle) function reading the settings above, and every place that used to compute this independently calls it instead of duplicating the logic - including sendFollowupQueueItem's Followup2/Re-engagement completion, autopilotRecycleReengagements (formerly autopilotFollowUp, trimmed down 2026-08-24 to just the cycle 2+ recycle now), and the /api/autopilot/send-followup/:id route.

The same day, Cold Restart / Warm / High Intent email content switched from "AI-written on the fly" to "fully generated from the template saved under Settings → Email Templates" (Reengagement was already template-driven, unchanged). The four Auto toggles above were added alongside this, controlling whether these four types skip Preview Send Queue and auto-send in production — the test client is never affected by these toggles and always goes through the queue for manual review, see Rule 4.

Reset after testing

  1. Turn Test Follow-up back OFF.
  2. Clear the test client (✕ on the test-client indicator).
  3. Restore the day-based settings to their real values (14 / 14 / 7 / 3 / 7 / 60, Cycle 2/3/4+ back to 90 / 180 / 365).
  4. Delete or archive any throwaway client/lead rows created for B5 and F2/F3.
  5. added 2026-10 Delete the temporary diagnostic/backfill routes added to server.js during H2 Tasks 1-5 (/api/admin/backfill-task1, /api/admin/backfill-task2, /api/admin/backfill-manual-match, /api/admin/run-real-followup-queue-now, /api/admin/audit-followup-greetings, /api/admin/regenerate-and-audit-greetings, etc.) once confirmed no longer needed — one-time diagnostic routes shouldn't linger in long-term production code.
  6. Once everything checks out, turn Auto Followup 1 / Auto Followup 2 / Auto Send New Emails / Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement back on for real — they must stay off for the entire testing period (see Rule 4).

Clean up garbled contact_person data one-time maintenance script, not a regular test scenario

Not a scenario to run — a one-time cleanup that goes with the other fix mentioned in Rule 5 (2026-08-04), recorded here for reference.

Background — why the garbage happened

The contact-merge logic in upsertClient() used to append every newly-extracted contact name onto the existing value instead of replacing it. Since the same company gets re-crawled repeatedly across Seek/Indeed/TradeMe/SJS (job postings naturally reappear), contact_person kept growing into a long comma-joined string of fragments — the longest one found in production was over 4,500 characters. The same day also fixed the name-extraction regex itself in extractContactInfo() (trigger words now case-insensitive, the name portion strictly requires real Title Case, so words like "at"/"on" no longer get captured as part of the name — see the code comments for the full story).

After the fix: upsertClient() now replaces contact_person with whichever name was extracted most recently, instead of appending forever — the field's only real use is the email greeting name (isLikelyName() already falls back to "there" if it doesn't look like a real name), so keeping one possibly-wrong name is fine, letting it grow into a wall of garbage is not. This fix only prevents new garbage going forward — it doesn't retroactively clean up rows that were already corrupted, hence the one-time cleanup below.

What the script does — cleanup_garbled_contact_person.js (project root)

Connects to the database exactly the way server.js does (auto-detects DB_SOCKET_PATH — socket on production, host/port locally), so it works correctly wherever it's run as long as it's using that environment's own .env — no extra config needed.

The logic is simple: find every clients.contact_person longer than 60 characters (a real "First Last" name essentially never exceeds that), and by default just report them — no changes. Add --apply to actually clear those rows' contact_person. Once cleared, nothing else needs doing — the next time that company is crawled, the new replace-not-append logic fills in a clean single name on its own; if it's never re-crawled, the field just stays blank and the email greeting falls back to "there," which doesn't affect sending.

How to run it on the production server

  1. Upload cleanup_garbled_contact_person.js to the production server's project directory (same folder as server.js), using whatever method you normally use to update server.js (cPanel File Manager, FTP, etc.).
  2. In cPanel, open Terminal and cd into the project directory.
  3. Dry run first — report only, no changes:
    node cleanup_garbled_contact_person.js
  4. Review the printed list (company name + a preview of the garbled content) and confirm these are genuinely garbage, not coincidentally-long legitimate text.
  5. Once confirmed, re-run with --apply to actually clear them:
    node cleanup_garbled_contact_person.js --apply
✓Dry-run against production on 2026-08-04 found 396 rows over 60 characters; spot-checked and all of them were comma-joined fragments, no legitimate data caught by mistake.
✓✅ 2026-10 run for real with --apply against production: the script's own output confirmed 397 rows cleared (not matching August's dry-run count of 396 is expected — two months passed in between, and the crawler picked up one more batch of garbled data in that window).
!Only clears the contact_person field — nothing else, and no client rows are deleted. Doesn't affect status transitions, the follow-up queue, or sent-email history.