Haraka 的 data.signatures 插件:基于邮件正文签名字符串的 SMTP 入站过滤实战
Haraka 的 data.signatures 插件:基于邮件正文签名字符串的 SMTP 入站过滤实战
Haraka 是一个基于 Node.js 的事件驱动 SMTP 服务器,其强大的插件体系允许在 DATA 阶段对邮件内容做深度检查。data.signatures 插件正是这一体系中的轻量级正文过滤工具:它允许管理员在一个配置文件中写入一组"签名"字符串,插件会在收件人发送正文时逐条扫描邮件正文文本,一旦命中即拒绝该封邮件。读完本文,你将掌握该插件的启用方法、data.signatures 配置文件格式、其基于 MIME 正文树的递归匹配原理,以及结合源码与测试用例理解它在 Haraka 事务流程中的精确执行时机。
插件概述:一行一个签名的正文拦截器
data.signatures 的核心思路非常简单但实用:与其部署完整的垃圾邮件内容过滤引擎(如 SpamAssassin、rspamd),不如先把一些确定性的、特征明显的垃圾文本片段(例如已知的推广话术、欺诈文案中的固定短语)写入配置,让 Haraka 在收到邮件正文后逐条比对,命中即 DENY。
官方文档 docs/plugins/data.signatures.md 对该插件的定位描述如下:
This plugin allows you to add string signatures to a configuration file and have this plugin scan the body text of an email for those strings. Mails matching these signatures will be blocked.
即:把字符串签名写入配置文件,插件扫描邮件正文文本,匹配签名的邮件将被拦截。它没有正则、没有评分系统,是纯粹的字符串匹配——这既是它的"轻",也是它的"边界",下文会详述。
在 Plugins.md 的插件注册表中,该插件的功能描述为 "Block emails whose bodies match signatures"(拦截正文匹配签名的邮件)。
启用插件与配置文件
第一步:在 config/plugins 中登记
Haraka 通过 config/plugins 文件决定启用哪些插件,被注释(# 开头)的插件不会加载。在默认配置中 data.signatures 处于注释状态,需要你手动取消注释并放到合适的位置,例如放在 DATA 阶段插件的分组里:
# DATA
# ----------
# attachment
# bounce
# clamd
# dkim
data.signatures
# headers
# limit
插件顺序可能影响行为,官方建议使用 haraka -o -c /path/to/haraka/config 查看插件及其钩子的实际执行顺序。
第二步:创建 config/data.signatures 文件
插件读取的配置文件名与插件名相同,为 data.signatures(位于 Haraka 实例的 config 目录下,即与 config/plugins 同目录)。文件内容为每行一个签名字符串,例如:
Buy cheap meds!
Nigerian prince needs your help
free cryptocurrency giveaway
this.config.get('data.signatures', 'list') 以 list 类型读取该文件,返回一个字符串数组,每行一个条目,空行与以 # 开头的注释行会被跳过。修改配置文件后无需重启服务,Haraka 的配置热重载机制会自动重新加载(hook_data_post 每次执行都会重新 config.get)。
执行时机:hook_data 与 hook_data_post 的配合
data.signatures 注册了两个钩子,它们的配合决定了"何时开始解析正文、何时完成判定"。
hook_data:开启正文解析开关
在 plugins/data.signatures.js 中:
exports.hook_data = (next, connection) => {
// enable mail body parsing
if (connection?.transaction) connection.transaction.parse_body = true
next()
}
hook_data 在 DATA 命令到达、邮件数据开始传输之前触发。这里做了一件关键的事:把 connection.transaction.parse_body 置为 true。
在 Haraka 的 transaction.js 中,parse_body 默认为 false,表示默认不解析邮件正文——Haraka 会把 DATA 数据流式写入队列文件(message_stream),但不构建正文的内存表示。只有某些插件显式开启后,正文才会被解析。这一点是理解 data.signatures 性能特征的关键:它按需开启解析,不会让所有邮件都付出正文解析的开销。
parse_body 被置位后,在 transaction.js 检测到头部结束(header/body 分隔空行)时会调用 ensure_body(),惰性创建 message.Body 实例并挂载各类过滤器与 MIME 边界监听器(transaction.js)。
hook_data_post:扫描正文并判定
exports.hook_data_post = function (next, connection) {
if (!connection?.transaction) return next()
const sigs = this.config.get('data.signatures', 'list')
if (check_sigs(sigs, connection.transaction.body)) {
return next(DENY, 'Mail matches a known spam signature')
}
next()
}
hook_data_post 在整封邮件的 DATA 传输完成之后、进入队列(queue)钩子之前触发,是所有 data_post 阶段插件(如 plugins/block_me.js、plugins/prevent_credential_leaks.js)中较早做判定的一类钩子。此时 transaction.body 已经完整构建,可以安全扫描。
判定的返回值为 DENY(来自 haraka-constants 的钩子返回常量),并附带拒绝文案 'Mail matches a known spam signature'。这个文案会作为 SMTP 应答的一部分发回给对端,同时 Haraka 会记录日志并中止后续处理流程(不会进入队列投递)。
匹配算法:递归扫描 MIME 正文树
真正执行匹配的是 check_sigs 函数(plugins/data.signatures.js):
function check_sigs(sigs, body) {
for (let i = 0, l = sigs.length; i < l; i++) {
if (body.bodytext.includes(sigs[i])) return 1
}
for (let i = 0, l = body.children.length; i < l; i++) {
if (check_sigs(sigs, body.children[i])) return 1
}
return 0
}
这里有三个值得注意的实现细节:
-
子串匹配:
body.bodytext.includes(sigs[i])使用的是 JavaScript 的String.prototype.includes,即子串包含判断,不是正则匹配、不是全词匹配,且大小写敏感。因此签名"free"能命中"free giveaway",但无法命中"Free giveaway"(除非再写一个小写签名)。签名不必是完整句子,任何出现在正文中的连续字符序列都可以作为签名。 -
递归遍历 MIME 子部分:一封邮件可能有 text/plain、text/html 等多个 MIME 部分,也可能有嵌套的 multipart 结构。
haraka-email-message的Body对象将正文组织成树形结构:每个节点有bodytext(该部分的文本内容)和children(子 MIME 部分数组)。check_sigs先检查当前节点,再对每个子节点递归调用自身,实现对整个正文树的深度优先扫描。这意味着签名无论出现在纯文本部分还是 HTML 部分(包括嵌套 multipart 的子部分)都会被捕获。 -
短路返回:一旦任何签名在任一节点命中,立即返回 1 并终止后续扫描,避免无谓遍历。
该递归行为在测试 test/plugins/data.signatures.js 中有明确覆盖:父节点文本干净、子节点文本包含签名时,仍返回 DENY。
源码证据:正文树从何而来
transaction.body 是 haraka-email-message 库的 Body 实例,由 transaction.js 的 ensure_body() 创建:
this.body = new message.Body(this.header)
Body 通过监听 MIME 边界来切分各个部分(this.body.on('mime_boundary', () => this.incr_mime_count()),见 transaction.js),最终每个部分形成带 bodytext 与 children 的树节点。DATA 传输期间,每个数据行经过去除 CR、去 dot-stuffing 等处理后喂给 body.parse_more()(transaction.js),在 end_data() 中调用 body.parse_end() 收尾(transaction.js)。因此 hook_data_post 运行时,正文树已经完整。
测试 test/transaction.js 也印证了这一点:transaction.body.children.length 与 transaction.body.bodytext.length 在解析完成后可被断言。
测试验证:五种典型场景的行为确认
Haraka 的 test/plugins/data.signatures.js 用 node:test 框架系统验证了插件行为,可以作为你配置签名时的行为参考:
| 场景 | 测试输入 | 期望结果 |
|---|---|---|
| 正文命中签名 | 正文含 spam_signature_text |
返回 DENY,文案含 "spam" |
| 正文未命中签名 | 正文为普通文本 | next() 放行 |
| 签名列表为空 | config.get 返回 [] |
next() 放行 |
| 子 MIME 部分命中 | 父节点干净、children[0] 含签名 |
返回 DENY |
| 多签名取首个命中 | 列表 ['no_match', 'buy_cheap_pills'],正文含后者 |
返回 DENY |
| 无事务(无邮件数据) | connection.transaction = null |
直接 next() 放行 |
测试还覆盖了 hook_data 的无事务保护逻辑(test/plugins/data.signatures.js):当连接没有事务时直接放行,不抛异常。
实战要点与局限
部署位置建议
data.signatures 与 block_me、prevent_credential_leaks 等同样注册 hook_data_post 的插件是"同阶段竞争"关系。如果你希望它在其它正文检查之前先行拦截(从而避免后续插件多做无用功),应把 data.signatures 排在这些插件之前。可以用 haraka -o -c <config目录> 查看钩子顺序来确认。
签名设计建议
- 覆盖大小写变体:由于匹配大小写敏感,同一个垃圾特征建议同时配置常见的大小写变体,如
"cheap pills"与"CHEAP PILLS"。 - 用稳定短语而非整句:垃圾邮件常被自动化改造,整句容易失配;选择特征稳定的短语片段命中率更高。
- 避免过短签名:子串匹配意味着过短的签名(如 2~3 个字符)极易误伤正常邮件,务必权衡误报风险。
- 只匹配正文:该插件只扫描
body,不扫描邮件头部(Header)。如需针对头部(如 Subject)做检查,应配合其他插件或自行扩展。
已知边界
- 纯字符串包含匹配,不支持正则表达式、不支持模糊匹配,也无法配置忽略大小写。
- 判定发生在 DATA 收完后,属于"事后拦截":完整正文(含附件)已从对端接收,但不会进入队列投递。若你需要在正文传输过程中提前中止,需考虑其他机制。
- 它拦截的是进入 Haraka 的邮件,不影响 Haraka 出站队列中已排队的邮件。
小结
data.signatures 是 Haraka 插件体系里"小而美"的代表:它只依赖两个钩子、一个配置文件和一个递归匹配函数,却能在 DATA 阶段提供确定性的正文过滤能力。其核心价值在于用最少的配置成本拦截特征明确的垃圾文本,同时其实现清晰地展示了 Haraka 的三个底层机制:parse_body 按需开启正文解析、haraka-email-message 的 MIME 正文树结构、以及 hook_data_post 阶段 DENY 对邮件流程的终止作用。对于希望在不引入重型内容过滤引擎的前提下快速建立正文黑名单过滤的 Haraka 运维者,这是一个开箱即用的选择。
相关参考文件:文档 docs/plugins/data.signatures.md、实现 plugins/data.signatures.js、测试 test/plugins/data.signatures.js、正文树构建逻辑 transaction.js、插件启用配置 config/plugins。