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

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

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

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

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"),很容易被忽略过去。
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 + autopilotFollowUp,无视节假日/时段限制——这就是能把"14天"压缩成"1小时"的机制,见下方"准备工作"第3步。这个开关只对选中的测试客户生效,跟下面的真实开关完全独立,见下方"规则4"。
Run Test Follow-up Now同上POST /api/autopilot/run-test-followup-now手动触发一次:先轮询真实收件箱(checkInboxReplies),再跑 generateFollowupQueue不会autopilotFollowUp——那部分只能靠上面的每小时 cron(Test Follow-up 开着)或真的等到下一个整点。
Preview Send Queue同上GET /api/autopilot/preview-queue展示两部分:「First Emails」(可批量发)和「Follow-ups」(cold_restart / followup1 / followup2 / warm / high_intent / reengagement,逐条发送,发送前都会重新校验一次)。
!整个测试期间,以下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:测试执行范围必须严格限定在你选中的这一个测试客户身上,绝不能波及任何其他真实客户。这条比听起来更容易被违反——因为 autopilotFollowUp() 这类函数在正式环境的每小时 cron 里,本来就要被调用一次处理所有真实客户;如果测试模式的"允许发送"这个开关也去读 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",不影响邮件内容或发送逻辑本身——测试通过等于正式打开开关后行为一样。

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 after rejection(Cycle 1),以及新加的 Cycle 2 / Cycle 3 / Cycle 4+ cooldown(Warm trigger opens 和 Daily Send Limit 不用改,保持默认 2 和 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. 不要打开第一封邮件。等下一次每小时的测试 cron(或重启一次服务器,重启会立即跑一次 autopilot——如果这么做,注意这会同时触发一次真实爬虫抓取)。
  2. 打开 Preview Send Queue → Follow-ups 里应该出现一条 cold_restart,主题固定是 A fresh start — {company},正文来自 Settings → Email Templates 里保存的 Cold Restart 模板(2026-07-30 起改为完全按模板生成,不再是 AI 现场写的内容——如果正文跟你在模板里编辑保存的不一样,就是 bug)。
  3. Send。确认它带着模板里的内容真的进了收件箱。
  4. 仍然不要打开它。等下一次 Test Follow-up 的整点触发。
会自动收到一封 followup_2(最后一封,以回复形式串联主题)。客户状态变成 coolingrejection_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"。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

A2 · 打开一次

Followup 1(进队列)→ 发送 → Followup 2(进队列)→ 进入 Cooling。

  1. 真的打开第一封邮件一次(在私人邮箱里点开它,让 Brevo 的 open 事件真实触发)。
  2. 确认 Brevo 后台/邮件的追踪像素被加载后,点 Run Test Follow-up Now。Follow-ups 里应该出现一条 followup1
  3. 从 Preview Send Queue 里发送它。
  4. 再次 Run Test Follow-up Now——应该出现 followup2(这一条同样是进队列,不是自动发,因为客户表现出过参与)。
  5. 发送它。
结果和 A1 一样进入 Cooling,区别是两封都走的是队列人工发送,不是自动发。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

A3 · 打开2次以上(Warm)

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

  1. 真的把第一封邮件打开两次(阈值是 "Warm trigger after (opens)" 设置项,默认2次)。
  2. Run Test Follow-up Now → 应该出现一条 warm,主题固定是 Still relevant — {company},正文来自 Settings → Email Templates 里保存的 Warm 模板(2026-07-30 起改为完全按模板生成,不再是 AI 现场写的内容)。
  3. 发送它,再 Run Test Follow-up Now 一次 → 出现 followup2
Warm → F2 → Cooling,走的是 warm 分支而不是 A2 的 followup1 分支。想验证边界的话,可以先只打开1次确认走的是 A2 路径,再补开第2次确认切到 warm 路径。
Auto Warm 开关此时开没开都不影响上面的行为——测试客户永远先进队列等你手动点 Send;这个开关只在正式环境对真实客户生效,见"规则4"。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

A4 · 点击了链接(High Intent)

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

  1. 真的点击第一封邮件里的链接。
  2. Run Test Follow-up Now → 应该出现一条 high_intent,主题固定是 Quick chat — {company},正文来自 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。
客户 → activequestion_received_at 被设置(Clients 标签页里会显示成一个待处理标记)。不会有 AI 自动起草回复——这类留给你自己手动回。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

B3 · 软拒绝 新行为

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

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

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

  1. 确认测试客户名下目前只登记了一个联系邮箱。
  2. 回复:"请把我们移除,我们不感兴趣,不要再联系了。"
  3. Run Test Follow-up Now。
那条 email_log 记录 → rejected。客户 → not_interestedrejection_type='hard'
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

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

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

  1. 给测试客户加一个也能收到邮件的第二联系邮箱,比如 UPDATE clients SET contact_email = CONCAT(contact_email, ', your+alt@address.com') WHERE id=<id>,并给这个邮箱补一条 email_logtype='first', status='sent'),让它看起来是一个真实、未被拦截的联系人。
  2. 邮箱回复和 B4 一样的硬拒绝措辞。
  3. Run Test Follow-up Now。
主邮箱那条 email_log → rejected。但客户状态仍然是 contacted——不会变成 not_interested,因为第二个邮箱还没被拦截。去活动日志里应该能看到"其他联系人仍然有效"这类记录。
接下来再用第二个邮箱也回复一次硬拒绝,确认这时公司才真正变成 not_interested——这才是"所有联系人都拒绝了"这个判断真正生效的时候。
!🧹 清理(这条比标准复位多一步):这个场景给测试客户加了第二个联系邮箱,标准复位 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 邮件;发送后客户变回 contactedcycle 仍是2。
  3. 不回复,等 Followup 2 天数那么久(测试期已改成0),确认又自动回到 cooling。
  4. 重复第2-3步两次,检查 cycle 与冷却天数是否照上表递增。
也可以顺便去 Clients 标签页肉眼确认:客户在 contacted 状态、cycle>=2 时,卡片上的状态标签会显示成"🔄 Re-engaged ×N"而不是普通的"Contacted",鼠标悬停能看到具体是第几轮——这是 2026-07-30 加的一个 UI 提示,方便一眼看出这不是全新客户。

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

上面第2步会看到 clients.status 变成了 contactedcycle 却已经是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 · 爬虫在已归档公司发现新联系人 已用自动化数据库测试验证,实际很难在真实操作里复现

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

!已用7种脚本化数据库场景验证过(同一联系人 → 保持归档状态;不同联系人 → contact_email 被合并、contact_reset_at 被打上时间戳、cooling 状态下待发的再触达被取消)——全部通过。这一条建议"只观察、不主动测":如果接下来几周,一家已经标记为 cooling/not_interested/bounced 的公司又以一个新招聘负责人的邮箱发帖了,去看一下这条 clients.contact_reset_at 是否被更新,以及是否真的发出了一封全新的 first 邮件。
🧹 清理:不涉及测试客户,不需要跑复位 SQL。

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

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

  1. 先让某条 followup1(或任意类型)静静地躺在队列里(参考 A2 第2步)。
  2. 在点发送之前,用别的方式让客户状态先变化——比如随便回复一句什么(会经由 B1–B4 改变状态),或者模拟一次退信: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。不会有邮件真的被发出去。
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

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

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

路径接口同一联系人再出现换了联系人再出现
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)拦截 — 静默跳过,不会发放行 — 现在和 F1 用同一个 isCompanyArchived,换了联系人会自动发出,并触发 contact_reset_at 重置

F1/F3 可以复用 D1 的验证结论(本质是同一个判断函数)。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 · 两封邮件同时在外,一个事件

这是唯一真正需要靠真实 Brevo 才能测到位的场景——本地环境无法收到 webhook,交给真实环境验证。

  1. 给同一个客户先后发出 first 邮件和一封 followup1——这样 email_log 里会有两条记录,各自带着不同的 brevo_message_id
  2. 只打开较早那封(first),先不要碰后面那封 followup1
  3. 去数据库确认结果。
只有 first 那条记录的 status/open_count 会更新,更晚发出的 followup1 那条应该完全不受影响。修复之前,这类事件会被无脑记到"最近发出的那封"上,不管实际打开的是哪一封。
SELECT type, status, open_count, brevo_message_id FROM email_log WHERE client_id=<id> ORDER BY sent_at ASC
🧹 清理:跑完执行"1.5 标准复位 SQL"即可开始下一个场景。

为什么记错行不只是"账算错了",而是会导致错误决策

generateFollowupQueue 每次决定"这个客户接下来该走哪条路",看的只是 first 这一封邮件自己的 open_count/status,不会去看客户名下其他邮件。所以打开/点击/退信这些事件如果被记到了错误的那一行,不是"记录不准但无所谓",而是直接改变了系统接下来要走的分支

被误记的事件本来应该发生什么误记之后实际会发生什么
10天前的 first 被重新打开,却记到了昨天的 followup1 上系统看到 first 打开过一次,走"打开一次"那条路(该发 followup1 或该发 followup2)first 的 open_count 还是0,系统仍然认为"完全没反应",继续把客户当冷客户处理,可能提前把一个其实有兴趣的客户送进 cold_restart 甚至 cooling
客户点了链接,却被记到了另一封邮件上触发 high_intent,这是优先级最高的分支,会立刻追一封针对性更强的邮件真正该收到 high_intent 追问的高意向客户,系统完全没察觉,继续走普通节奏,白白错过一个热信号
first 真的退信了,退信状态却被记到后面一封没退信的邮件上first 那个地址被标记为死地址,以后不会再往这个地址发真正的死地址一直显示"sent"(看起来正常),系统不知道它已经收不到,可能继续用;而一封其实寄达了、根本没问题的邮件却被误标成 bounced,之后可能被错误地当成坏地址处理

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 · 标记后自动开关必须失效

验证即使真实的自动发送开关是打开的,被标记 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 候选人查询),只要这条查询条件没被改动,就不需要单独验证。

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

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

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

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

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

  1. 接管前,对生产库做一次快照备份(纯粹是保险,用不上最好)。
  2. 先彻底停掉 OnlineStella 的进程(PM2 层面真正 stop,不是点某个页面上的开关)。这一步和下一步之间不要有任何真实自动化在跑——两边进程绝不能同时连着生产库,否则爬虫、收件箱监听、自动发送都可能撞车、重复处理。
  3. 把新版 Stella 的 .env 数据库连接从"测试副本"改成指向生产库本体(不是 H0 拷的那份旧快照——OnlineStella 停之前一直在真实运行,数据比你测试用的那份新)。
  4. 启动新版 Stella 连生产库。
  5. 跑一次 DESCRIBE clients,确认 contact_reset_atrejection_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. 一切正常后,把上面几个真实开关逐个手动打开,不要一次性全开——开一个观察一轮,再开下一个。
  11. Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 这4个新开关,打开后第一次有对应类型的邮件产生时,去 Preview Send Queue 确认它没有停在里面——应该是已经直接进了 email_log 变成已发送状态;如果还停在队列里等人工点 Send,说明开关没生效。
  12. 盯着接管后第一次整点 cron、第一次9点每日 generateFollowupQueue 的日志,确认行为符合预期。
!第2步是整个交接过程中风险最高的一步:只要新旧两个进程有任何时间上的重叠,同时连着同一份生产库,就可能出现两套自动化同时判断"这个客户该发了"、两套收件箱监听同时读到同一封回信处理两次这类问题。宁可交接窗口留出几分钟完全没有任何一边在跑,也不要图省事让两边重叠。

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

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

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

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

#问题影响修复状态
① 收件箱配错发件地址(email_sender)一直是 connect@jobvista.app,但 .envIMAP_USER 一直是 support@jobvista.app —— 两个不是同一个邮箱客户回复全部落在 connect@ 的收件箱里,Stella 这套"读信 → AI 判断情绪 → 更新客户状态"(checkInboxReplies / analyseReplySentiment)从来没有真正处理过一封真实客户回复。已确认 support@ 里没有任何真实客户回复,全部都在 connect@。需要人工把 .envIMAP_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 被正确改成 coolingserver.js soft_rejection 分支),冷却到期日从"今天"(backfill 执行的那天)往后推 60 天,不是从 5/8 号往后推,时间会比理想情况晚一些,这个没有完美解法。任务2重放 email_log 里绿源物流那条 unsubscribed 记录,clients.status 被正确同步成 not_interested,正式退出联系名单。东方物流不需要 backfill 做任何事,他的数据从头到尾都是对的。

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

!前置条件与执行顺序:这一节必须在"H1 · 正式接管的时序"完全走完、新版 Stella 已经稳定连着生产库运行之后才能做——backfill 的目标是把数据补进新版 Stella 正在使用的这份生产库,接管都还没做的话补了也没有意义。任务1开始之前,需要确认 .envIMAP_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天前)这个时间参数换成 2026-04-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 的额度一次性打满,影响到同一天其他实时功能(邮件分类、社媒生成等)。
跑完之后,2026-04-23 至今真实回复过"感兴趣 / 婉拒 / 硬拒绝 / 提问"的客户,clients.status 会被正确更新到位。因为 cooling 状态有一条触发路径就是"客户回复了婉拒"(soft_rejection),这一步做完之后,那些原本应该进入冷却期的客户会自然跟着落进 cooling,re-engagement 的时间线也会跟着开始生效——不需要为 cooling / re-engagement 单独再做什么(原因见下方"为什么 warm/high_intent/cooling/re-engagement 不需要单独 backfill")。
!三个要提前知道的坑:
1) re-engagement 的冷却期时间线会比"理想情况"晚——如果今天(8月)才补上 5 月的一条历史回复,系统计算冷却到期日(reengagement_date)是从"今天"往后推算的,不是从 5 月那个真实时间点往后推。这个没有完美解法,只能接受这个折中,不影响正确性,只是时间会晚一些。
2) 可能覆盖掉人工手动改过的状态——如果这几个月里你在界面上凭电话沟通等人工渠道,手动改过某个客户的状态,backfill 重放老邮件时有可能把这个手动修正覆盖掉,因为系统目前没有"这条是人工改的,不要自动覆盖"这种标记。建议 backfill 跑完之后,抽查一下你记得手动改过的那几个客户,确认状态没有被意外覆盖,需要的话手动改回来。
3) 4 月 23 日之前的极少数邮件——如果当时恰好撞上"③ 自动发信不建客户档案"那个窗口期(client_id 先天缺失),现在因为已经加了兜底匹配(按回信邮箱直接查 clients.contact_email),backfill 理论上一样能正确接住,不需要额外处理,但值得在抽查阶段多留意一下这类特别早期的记录。

任务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 分类里,之前因为同步缺口而"消失"的客户会正确显示出来。

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

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


设置项 ↔ 代码对照表

界面字段设置键名默认值被谁读取
Daily Send Limitautopilot_daily_limit20autopilotAutoSend
Followup 1 after (days)behavior_followup1_days14autopilotFollowUp(opens=0 路径)、generateFollowupQueue(opens=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_opens2generateFollowupQueue
Warm send after (days)behavior_warm_send_days3generateFollowupQueue
High Intent after (days, no reply)behavior_high_intent_days7generateFollowupQueue
Cooldown after rejection (days) — Cycle 1reengagement_cooldown_days60getCooldownDaysForCycle,仅第1轮冷却适用
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) 这个共用函数,读取上面这几个设置项,三处用到冷却天数的代码(autopilotFollowUp 的两处、/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. 确认没有遗留问题后,再手动打开 Auto Followup 1 / Auto Followup 2 / Auto Send New Emails / Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 用于正式环境——测试期间这7个必须一直是关着的(见"规则4")。

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. 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.
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 + autopilotFollowUp for the test client only, ignoring holiday/hours gates — this is what compresses "14 days" into "1 hour," see Setup step 3 below. 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. Does not run autopilotFollowUp — that only happens via the hourly cron above, or the real next hourly tick.
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).
!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 autopilotFollowUp() get called once an hour in production to process every real client. If test mode's "is sending allowed" gate also read a global setting like Auto Followup 1/2, flipping that setting 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.

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 after rejection (Cycle 1), and the new Cycle 2 / Cycle 3 / Cycle 4+ cooldown fields (Warm trigger opens and Daily Send Limit can stay default — 2 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. Wait for the hourly test cron (or restart the server — that runs an immediate autopilot pass, which also triggers a real crawl).
  2. Open Preview Send Queue → Follow-ups should show a cold_restart item, with a fixed subject A fresh start — {company} 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. Wait for the next hourly Test Follow-up tick.
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.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

A2 · Opened once

Followup 1 (queued) → sent → Followup 2 (queued) → Cooling.

  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 followup1 item should appear.
  3. Send it from Preview Send Queue.
  4. Run Test Follow-up Now again — a followup2 should appear (also queued, not auto-sent, since the client showed engagement).
  5. Send it.
Same cooling transition as A1, but both emails went through the queue, not auto-send.
🧹 Cleanup: run "1.5 Standard reset SQL" and you're ready for the next scenario.

A3 · Opened 2×+ (Warm)

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

  1. Really open the first email twice (threshold is the "Warm trigger after (opens)" setting, default 2).
  2. Run Test Follow-up Now → a warm item appears, with a fixed subject Still relevant — {company} and a body pulled from the Warm 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, then run Test Follow-up Now again → followup2 appears.
Warm → F2 → Cooling, via the warm branch rather than A2's followup1 branch. To check the boundary, open once first to confirm the A2 path, then a second time to confirm it switches to warm.
The Auto Warm 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.

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 a fixed subject Quick chat — {company} 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'.
🧹 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 contactednot 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.
!🧹 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, with the exact cycle on hover — a 2026-07-30 UI addition so it's obvious at a glance this isn't a brand-new client.

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 verified via automated DB test, hard to reproduce live

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. 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 clients.contact_reset_at on that row and confirm a fresh first email actually went out.
🧹 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

  1. Get a followup1 (or any type) item sitting pending in the queue (see A2 step 2).
  2. Before sending it, change the client's status another way — reply with anything (flips status via B1–B4), or simulate a bounce: 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.
🧹 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

PathRouteSame contact reappearsDifferent contact reappears
F1 — fully automated (autopilotAutoSend)/api/autopilot/run-now, hourly cronblocked — never generated/eligibleallowed — fresh cycle via isCompanyArchived
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)blocked — silently skipped, won't sendallowed — now shares F1's isCompanyArchived, sends automatically and triggers contact_reset_at

F1/F3 can reuse D1's verification (it's literally the same function). Test F2 separately: 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 · Two emails in flight, one event

The one scenario that genuinely needs real Brevo to test properly — local can't receive webhooks, verify against a real environment.

  1. Send a client's first email, then a followup1 — two email_log rows, each with its own brevo_message_id.
  2. Only open the older one (first) — leave the followup1 untouched.
  3. Check the database.
Only the first row's status/open_count updates — the newer followup1 row is completely unaffected. Before the fix, this kind of event would have been blindly credited to whichever row was sent most recently, regardless of which one actually generated it.
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.

Why misattribution isn't just "the bookkeeping is off" — it drives the wrong decision

Every time generateFollowupQueue decides which path a client goes down next, it looks only at the first email's own open_count/status — it never checks any other email on file for that client. So if an open/click/bounce event gets recorded on the wrong row, it's not just a harmless bookkeeping error — it directly changes which branch the system takes next:

Misattributed eventWhat should happenWhat actually happens instead
The 10-day-old first gets reopened, but it's recorded on yesterday's followup1System sees first was opened once, takes the "opened once" path (due for followup1 or followup2)first's open_count is still 0, system still thinks "no response at all," keeps treating the client as cold — possibly pushing a genuinely interested client into cold_restart or even cooling prematurely
The client clicks a link, but it gets recorded on a different emailTriggers high_intent, the highest-priority branch — an immediate, more targeted follow-up goes outThe high-intent client who should get that targeted follow-up never gets flagged at all — the system proceeds at the normal pace and misses a hot signal entirely
first genuinely bounced, but the bounce gets recorded on a later, healthy emailfirst's address gets flagged dead, future sends to it stopThe genuinely dead address keeps showing "sent" (looks fine) and may keep getting used; meanwhile a perfectly deliverable email gets wrongly marked bounced and may later get treated as a bad address

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 · 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.

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

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 the real toggles back on one at a time — not all at once. Enable one, watch a cycle, then enable the next.
  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. If it's still sitting in the queue waiting for a manual Send, the toggle didn't take effect.
  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.
!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.

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

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 IN ('contacted','active'), which cooling doesn't satisfy — she's excluded outright, and will not receive a warm follow-up just because she opened the email twice; that would contradict the rejection she already gave. Eastern Logistics — his status has stayed contacted the entire time, so "opened twice, 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 2026-04-23, pulling the entire backlog 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.).
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).
!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.

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) 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.


Settings ↔ code map

UI fieldSetting keyDefaultRead by
Daily Send Limitautopilot_daily_limit20autopilotAutoSend
Followup 1 after (days)behavior_followup1_days14autopilotFollowUp (opens=0 path), generateFollowupQueue (opens=1 path)
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_opens2generateFollowupQueue
Warm send after (days)behavior_warm_send_days3generateFollowupQueue
High Intent after (days, no reply)behavior_high_intent_days7generateFollowupQueue
Cooldown after rejection (days) — Cycle 1reengagement_cooldown_days60getCooldownDaysForCycle, cycle-1 cooling only
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 all three places that used to compute this independently (autopilotFollowUp's two spots, the /api/autopilot/send-followup/:id route) call it instead of duplicating the logic.

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. 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).