资讯动态

Activepieces 注册邮箱校验:用 ZeroBounce 实时验证替代内置黑名单(决策 000032 全解析)

发布时间:2026/9/13 7:15:25 来源:尧图企业网站定制
Activepieces 注册邮箱校验用 ZeroBounce 实时验证替代内置黑名单决策 000032 全解析【免费下载链接】activepiecesAI Agents MCPs AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows AI Agents • MCPs for AI Agents项目地址: https://gitcode.com/GitHub_Trending/ac/activepieces导读本文解读 Activepieces 认证体系中的一条关键架构决策brain/knowledge/decisions/000032-a-signup-address-is-checked-against-zerobounce-not-a-bundled-blocklist.md注册路径上的邮箱防御从随发布打包的一次性邮箱域名黑名单切换为调用 ZeroBounceGET /v2/validate托管 API 实时判定。读完本文你将掌握该模块的完整拒绝/放行规则、AP_ZEROBOUNCE_API_KEY开关语义、disposable 域名缓存的边界设计、两条注册调用链的静默拒绝伪装策略以及自托管升级时需要注意的默认行为变化。决策总览一个导出、一次调用、两条防线该决策的落点是认证目录下的新模块 zerobounce.ts它只导出唯一一个函数zerobounce.maySignUpexport const zerobounce { maySignUp, }maySignUp的职责一句话概括对每一个尚无账号的邮箱地址向 ZeroBounce 的GET https://api.zerobounce.net/v2/validate发起实时校验当返回spamtrap、abuse或带disposable、toxic、possible_trap、global_suppression子状态的do_not_mail时拒绝注册。与此同时原先用于防御的一次性邮箱域名黑名单包disposable-email-domains正式退出注册路径它只在离线清理脚本中保留详见后文。模块内部两个常量直接决定了拒绝语义zerobounce.ts#L16-L17const REFUSED_STATUSES new Set([spamtrap, abuse]) const REFUSED_DO_NOT_MAIL_SUB_STATUSES new Set([disposable, toxic, possible_trap, global_suppression])判定函数refusedBy的逻辑是状态命中spamtrap/abuse直接拒绝否则只有当状态是do_not_mail且子状态落入上述集合时才拒绝。这个拒绝集是后续一切设计的起点下面逐一展开。背景为什么内置黑名单必然落后于攻击者决策文档记录了这条路线的由来Cloud 版本当时正遭受机器人批量注册。原有防御是在模块加载时用disposable-email-domains包构建一个Set由disposableEmail.assertMaySignUp查询。它的本质问题是列表是发布时的构建产物列表在 release 时被冻结进 tag发布之后新注册的临时域名不可见只能等下一次发布跑在旧 tag 上的实例永远看不到新域名清理机器人注册遗留平台的 purge 脚本之所以必须显式传--domains参数正是因为这个包并不知道实际被滥用的规避域名。用决策原文的话说随发布一起分发的黑名单永远落后于攻击者。临时域名可以零成本持续注册几周前编译进 tag 的列表看不到它们而托管服务回答的是今天的域名只有当下的版本才有意义。这是切换为 ZeroBounce 实时校验最根本的动因。拒绝集与放行集一份精确到子状态的判定表这是整个模块最核心、也最容易踩坑的部分它拒绝的是垃圾而不是不存在。下面两张表综合了决策文档与单元测试zerobounce.test.ts的完整语义。拒绝注册maySignUp返回falseZeroBounce statussub_status说明spamtrap—蜜罐地址abuse—投诉/滥用地址do_not_maildisposable一次性/临时邮箱域名do_not_mailtoxic毒性地址do_not_mailpossible_trap潜在蜜罐do_not_mailglobal_suppression全局抑制列表放行注册maySignUp返回trueZeroBounce statussub_status设计意图valid—正常地址catch-all—通配邮箱域unknowngreylisted不确定invalidmailbox_not_found邮箱不存在故意放行见下invalidpossible_typo疑似拼写错误故意放行do_not_mailrole_based角色地址如info故意放行do_not_mailrole_based_catch_all角色通配地址故意放行do_not_mailmx_forwardMX 转发地址故意放行三条故意放行的理由1.invalid邮箱不存在必须放行。虽然随机地址机器人撞真实域名时正是invalid能拦下的场景但拒绝它会带来两个更糟的后果把公开注册端点变成任意域名任意地址是否存在的信箱存在性预言机oracle把普通用户拼错自己邮箱的真实注册也拒掉。而且决策指出靠这条路混进来的机器人依然无法验证验证码——它们只能得到一行未验证的身份记录这正是清理脚本已经能处理的路径。2. 角色地址info、sales、automation必须放行。do_not_mail同样覆盖role_based、role_based_catch_all、mx_forward拒绝它们等于拒绝一个团队用常用角色邮箱注册自动化工具这种最普通的行为。因此拒绝集被刻意取成do_not_mail的滥用那一半而不是整个状态。3. 判定大小写不敏感。测试明确覆盖了status: DO_NOT_MAIL, sub_status: Disposable仍被拒绝的场景对应实现中每处比较都先.toLowerCase()zerobounce.test.ts#L99-L102。失败开放任何读不懂的答案都等于放行决策文档把这条原则摆在很高的位置永远失败开放fail open。端点不可达、超时、key 无效、信用额度耗尽——每一种情况都让地址通过只留一条日志。理由很直白验证服务宕机导致注册整体不可用比它想防的滥用更糟。实现上有三个具体体现zerobounce.ts#L80-L95HTTP 层失败请求抛错或返回空响应 →log.warn后放行业务层错误体ZeroBounce 对无效 key 和信用耗尽返回的是HTTP 200 {error: ...}体所以必须显式检查response.data.error而非依赖状态码命中则log.error后放行空判定响应连 verdict 都没有如{}→ 放行。这与同目录下 turnstile.ts 的姿态互为镜像但有一个刻意差异Turnstile 在 siteverify给出了错误状态应答时是拒绝的siteVerifyAnswered分支抛VALIDATION错误而 ZeroBounce 这边没有任何等价物——任何读不懂的 ZeroBounce 答案都是无判定因为拒绝需要一条可信的拒绝依据而不是一个没读明白。密钥即开关AP_ZEROBOUNCE_API_KEY的语义开关设计遵循一个原则决策文档引用了.claude/rules/self-hosting.md的约束禁止出现看起来已启用、实际未配置就静默失效的东西付费 API 不能成为默认值。因此key 的存在就是开关只有设置了AP_ZEROBOUNCE_API_KEY时检查才会运行没配 key 的实例既看不到检查、也不会报错system-props.ts中对应条目为ZEROBOUNCE_API_KEY环境变量前缀AP_拼出AP_ZEROBOUNCE_API_KEY见 system-props.ts#L130空白 key 等价于未设置apiKey()会先.trim()空串返回undefined测试明确覆盖了空环境变量行不能烧信用zerobounce.test.ts#L64-L71。原开关AP_ALLOW_DISPOSABLE_EMAILS因此被删除而非保留它曾经控制的唯一事情现在由 key 决定留一个只会和 key 意见一致的旋钮没有意义。后文会说明删除后对自托管实例的具体影响。验证码先行信用消耗以已过验证码为界两条调用链中校验的先后顺序是承重结构不是偶然。在密码登录的requestCode流程passwordless-auth.service.ts#L22-L30中async requestCode({ email, platformId, captchaToken, remoteIp }): Promisevoid { await turnstile.assertSolved({ token: captchaToken, remoteIp, log }) const existingIdentity await userIdentityService(log).getIdentityByEmail(email) if (isNil(existingIdentity)) { const maySignUp await zerobounce.maySignUp({ email, log }) if (!maySignUp) { return } } // ... 创建未验证身份并发送 OTP }turnstile.assertSolved先于校验调用执行。这是每个地址的信用消耗以通过验证码的请求为上限、而非以网络包为上限的机制保证——没解出验证码的请求连验证 API 都摸不到一个包子弹不会变成 N 次付费调用。同时注意existingIdentity判断已存在的身份直接跳过校验重复登录零成本详见成本模型小节。两条调用链的静默拒绝伪装成各自的合法结局模块只回答一个布尔问题——maySignUp返回布尔、永不抛错——因为两个调用点需要不同的伪装路径一requestCode返回 204验证码永不到达拒绝时直接return效果与成功完全一致HTTP 204、不创建身份、不发码。用户的卡片正常前进到输入验证码一步然后什么都不会收到。对攻击者而言端点无法区分这个地址被拒了和这个地址不存在/没发码。路径二signUp抛EMAIL_IS_NOT_VERIFIED与真实流程逐字节一致在 authentication.service.ts#L18-L29 中async signUp(params: SignUpParams): PromiseAuthenticationResponse { if (params.provider UserIdentityProvider.EMAIL) { const maySignUp await zerobounce.maySignUp({ email: params.email, log }) if (!maySignUp) { throw new ActivepiecesError({ code: ErrorCode.EMAIL_IS_NOT_VERIFIED, params: { email: params.email.toLowerCase().trim() }, }) } } // ... }这个错误码正是真实 Cloud 注册会抛的那个——真实流程是身份被创建为未验证状态然后getOnboardingResponse抛出它。因此前端表单渲染的是它一贯的CheckEmailNote无法从外观上分辨。两种伪装与决策 000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md 中邀请检查用的静默返回同源可区分的应答会把公开端点变成预言机——这里是该地址是否一次性邮箱的预言机。所以本路径上完全没有DOMAIN_NOT_ALLOWED错误码它重新变回平台域名白名单的专属语义。伪装的代价误判对用户和支持都不可见唯一的痕迹是[zerobounce#refuses] address refused这行日志。因此用户端假阳性表现为验证码没到用户无从知晓原因支持端这行日志是回答为什么验证码没来的唯一途径。一个被测试抓住的归一化细节决策文档记录了一个实战翻车点第一版实现把params.email原样回显而真实路径回显的是从存储身份读回、已小写化的地址——于是混合大小写的地址在拒绝时返回Zb-DispYopMail.com、真注册返回zb-dispyopmail.com一个完美的预言机就这么诞生了。修复要求任何伪装都必须应用与身份服务一致的toLowerCase().trim()该清洗逻辑见 user-identity-service.ts#L18。钉死这个行为的测试断言的是整个响应体的toEqual而不仅是状态码zerobounce.test.ts这正是它能抓到该回归的原因。disposable 域名缓存一份有边界、无 TTL、非 LRU 的列表失败开放 可耗尽的信用余额组合出一个攻击面攻击者烧光信用就能让后续所有地址通过。而拒绝不产生身份记录同一地址可以被无限重试——没有缓存的话每次重试都烧一个信用。缓存的全部意义就是把反复打同一个域名的代价压到一次。数据结构单 key 有序数组上限 500const DISPOSABLE_DOMAIN_CACHE_KEY zerobounce:disposable-domains:v1 const DISPOSABLE_DOMAIN_CACHE_SIZE 500命中对数组做成员测试Array.includes新增追加后slice(-500)列表满了丢最旧的因此猛打一个域名永远只花一个信用而轮换新域名仍每域名一信用——这属于边界edge的职责Cloudflare 对注册路径的限流规则不属于应用的职责。为什么是单个 key 的列表而不是每域名一个 key单个 key热路径上只有一次 Redis 读容量上限就是一次slice(-500)每域名一个 key想限界还需要一个额外索引来跟踪。三个刻意的取舍插入序而非真 LRU命中不提升条目位置因为提升意味着在每次被拒注册上都写一次 Redis读-改-写非原子两个实例同时添加不同域名可能丢失其中一个——代价是未来多花一个信用对一个缓存来说是正确的交换无 TTL条目活到被 500 个新域名挤掉为止。纠正一个错误判定的方法是删掉这个 key 让列表重建——便宜但前提是有人知道这个 key 存在。Redis 是缓存不是记录flush 或allkeys-lru驱逐会静默丢弃整个列表——损失的是信用而非正确性。四个单元测试分别钉住这一半的语义zerobounce.test.ts#L130-L231追加到版本化 key 下满 500 时丢掉最旧domain-0.com被挤出mailinator.com落在末尾已存在的域名不重写列表命中已知域名不调 API、不花信用缓存命中的拒绝仍尊重邀请豁免缓存读失败 → 放行且不崩溃缓存写失败 → 仍然拒绝无域名的地址如not-an-email完全不碰缓存。只有域名形状的判定才按域名缓存这是防误伤的边界disposable是域名属性可以按域名缓存而toxic、global_suppression、spamtrap、abuse描述的是单个信箱——按域名缓存它们会让gmail.com上一个坏信箱连累整个域的所有账号被拒。允许类判定也不缓存缓存了这个域没问题就会跳过该域其他信箱的地址级检查。实现中isDisposableVerdict严格限定do_not_maildisposable才触发rememberDisposableDomain测试逐条验证了其余四种判定永不按域名缓存。成本模型一个地址一个信用综合requestCode的跳过逻辑与refuses的调用路径信用消耗的完整边界是已有账号的地址requestCode直接跳过校验重复登录零成本被拒地址不产生身份 → 重试同一地址每次仍花一信用由验证码和限流在前面兜底信用耗尽降级为失败开放而不是宕机。后果与迁移自托管默认变松了决策明确承认两个后果注册路径从此依赖第三方自托管失去了一项默认就有的检查。没有 key 的实例会接受临时邮箱这是为自托管刻意做的放松且刻意不写进 breaking-changes.mdx升级不报错、无需任何操作当时判断是没有自托管用户在依赖内置列表。代价是这次丢失是无声的——真正依赖过它的人是从临时邮箱注册里学到的而不是从升级指南里。AP_ALLOW_DISPOSABLE_EMAILS同理仍然设置它的实例正常启动值被忽略。拒绝错误码保持DOMAIN_NOT_ALLOWED的原因拒绝集是域名口味的disposable 等所以沿用现有错误文案Email domain is disallowed不违和不需要新错误码、不需要前端改动。但决策文档留下一个预警如果哪天把invalid加进拒绝集这条消息对地址级场景就是错的届时需要独立错误码。离线黑名单只活在 purge 脚本里批量分类存量身份的清理脚本不能在每行上烧信用所以disposable-email-domains仍是它的运行时依赖——而运行时镜像是bun install --production安装的如果降级成devDependency脚本运行环境里根本不会有这个包。这是该包没有彻底消失的原因。不变的豁免已经持有已接受邀请的成员永远不被拒绝与旧黑名单行为一致实现见 zerobounce.ts#L113-L119测试见 zerobounce.test.ts#L104-L109。总结这条决策把注册防滥用从发布时冻结的静态名单换成按需查询的托管判定并围绕它建立了一整套边界拒绝集精确到子状态宁可放走拼写错误也不当邮箱预言机、任何读不懂的答案一律失败开放、信用消耗以验证码和缓存为界、拒绝动作伪装成两条链各自的合法结局、缓存只接受域名形状的判定且以 500 条有界列表实现。对运营者最需要记住的运维事实有三AP_ZEROBOUNCE_API_KEY的存在即开关、错误判定的纠正方式是删除zerobounce:disposable-domains:v1这个 key、以及排查验证码为何未到的唯一线索是address refused日志行。【免费下载链接】activepiecesAI Agents MCPs AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows AI Agents • MCPs for AI Agents项目地址: https://gitcode.com/GitHub_Trending/ac/activepieces创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价