问题:退信核查此前凡是退信一律从断点清单剔除、下次重跑补发。 但退信分两类——「您的账号外发频率超过邮件系统限制」这类限流退信重发 可成功;「地址不存在」这类硬退信重发也发不出去,只会浪费每日发信额度、 拖垮发信信誉。 改动: - 新增退信分类 rate/hard/unknown:优先用 DSN 状态码 5.x.x/4.x.x 判定, 同一封退信里的多个收件人可分别归类;无 DSN 时回退正文关键词匹配 (硬退信优先于限流,宁可不补发可疑地址) - --prune-state 只剔除「可重试」退信;永久失败不剔除,即不再补发 - 新增无效地址清单 broadcast_invalid.json:硬退信地址记入后每次运行 直接跳过(含 --reset-state),附 --reset-invalid / --no-invalid-list - 新增 --prune-unknown / --invalid-file / --bounce-report 参数 - 新增退信明细报告 broadcast_bounce_report.json(含每个收件人判定依据) - 修正 _part_text 取正文的两个解码坑:utf-8 正文默认 base64 传输编码未 解码、str 形态 payload 走 raw-unicode-escape 致中文变 \uXXXX,两者都会 让中文关键词匹配静默失效 - 补充退信主题关键词(delivery failed、未能送达、无法送达 等) - README 补充退信分类表与无效地址清单说明 - 新增 .gitignore,移除误入库的 __pycache__/broadcast.cpython-313.pyc
211 lines
11 KiB
Markdown
211 lines
11 KiB
Markdown
README — TamaBox 站内信群发工具(mail-broadcast)
|
||
=================================================
|
||
|
||
独立项目:只读生产 conf/app.ini 的数据库与 SMTP 配置,向「未注销且有邮箱」的用户
|
||
按语言群发邮件。零 pip 依赖(数据库走系统 psql/mysql 客户端,SMTP 走 Python 标准库)。
|
||
|
||
目录结构
|
||
--------
|
||
broadcast.py 主脚本
|
||
params.ini 参数文件(运行时自动读取;命令行参数优先级更高)
|
||
templates/ 模板目录(想改文案只动这里)
|
||
zh-CN.html 简体中文正文
|
||
zh-CN.subject.txt 简体中文主题(一行)
|
||
zh-CN.plain.txt 简体中文纯文本(可选;没有就自动从 HTML 抽取)
|
||
zh-TW.* / en.* / ja.* 其余三种语言同构
|
||
single.html 单文件通用模板(--single 模式;所有人不论语言都发这封)
|
||
single.subject.txt 单文件模式主题(一行)
|
||
broadcast_state.json (运行后生成)断点续发记录,已发邮箱重跑自动跳过
|
||
broadcast_report.json (运行后生成)每次运行的监控报告
|
||
broadcast_invalid.json (运行后生成)无效地址清单:硬退信地址,永久跳过
|
||
broadcast_bounce_report.json (运行后生成)退信核查明细(含分类与判定依据)
|
||
broadcast_bounces_seen.json (运行后生成)已核查退信的 Message-ID,避免重复报告
|
||
|
||
参数文件 params.ini
|
||
-------------------
|
||
脚本每次运行自动读取(不存在则跳过,不报错),可用 --params 指定其他路径、
|
||
--no-params 关闭。命令行参数 > params.ini > 内置默认。
|
||
|
||
[path]
|
||
config = ./auto/conf/app.ini ; conf/app.ini 路径(相对路径按运行时所在目录解析)
|
||
|
||
[site]
|
||
site_url = https://box.shiroko.one ; 对应 {{site}},也是 box_link 的前缀
|
||
box_prefix = /_/ ; 个性域名链接前缀
|
||
|
||
[vars] ; 自定义占位符:模板里用 {{键名}} 引用
|
||
old_domain = box.tama.guru
|
||
expire_date = 2026-12-24
|
||
|
||
[send]
|
||
to = ; 可选:默认测试收件邮箱
|
||
delay = 1.0
|
||
group_pause = 3.0
|
||
|
||
conf/app.ini 路径的解析优先级:
|
||
命令行 -c > params.ini [path] config > 环境变量 TAMABOX_CONFIG_PATH > ./conf/app.ini
|
||
|
||
安全边界:params.ini 不提供 --send / --yes——群发只能在命令行显式指定
|
||
(或走交互模式在会话中确认),防止改配置文件时误发全量邮件。
|
||
|
||
交互模式(默认)
|
||
--------
|
||
直接运行 `python3 broadcast.py` 即进入交互模式(无需加任何参数);
|
||
命令行带了 --send/--to/--yes/--dry-run 之一、或显式 --no-interactive 时不进入,
|
||
按参数直接执行(脚本化/定时任务用)。
|
||
|
||
**只问缺的,有值不问**:交互开头先打印一份「参数确认」摘要,凡已有值的参数
|
||
(params.ini / 命令行 / app.ini external_url 兜底)直接沿用、不再逐项询问;
|
||
要改值请编辑 params.ini,或干脆把一次性内容(旧域名、到期日等)直接写死到
|
||
模板 html 里并删掉对应 {{占位符}}。只有「模板用到但还没有值」的占位符和
|
||
完全缺失的 site_url 才会补问。交互流程:
|
||
- 参数摘要(有值直接沿用)+ 补问缺失项
|
||
- 模板模式:
|
||
1) 多语言 —— 按 users.language 使用各自语言模板(默认)
|
||
2) 单文件 —— 所有人发送 templates/single.html 通用模板
|
||
- 运行方式直接在交互中选择(不需要命令行 --send/--to/--yes):
|
||
1) 演练 —— 只列收件人、统计与渲染预览,不发信(默认)
|
||
2) 测试 —— 只给一个邮箱发一封,验证模板与发信链路
|
||
3) 群发 —— 向全体未注销用户发送;可顺手设 --limit 小量试水,
|
||
并可选择是否保留发送前的最终确认(默认保留)
|
||
- 最后可选把补全的参数保存回 params.ini,下次运行直接生效
|
||
|
||
单文件模式(通用模板)
|
||
--------
|
||
不按用户语言发送,所有人都收到 templates/single.html 这一封:
|
||
# 命令行
|
||
python3 broadcast.py --single --send --to you@example.com
|
||
# 或交互模式里"模板模式"选 2
|
||
用途:以后再做站内通知(维护公告、功能上线等)时,直接编辑 single.html
|
||
的「正文内容区」,样式无需重做;每个用户依然按其个性域名收到专属 box_link。
|
||
若存在 single.subject.txt 则用其主题,否则回退「【{{site_title}}】站内通知」。
|
||
|
||
模板风格
|
||
--------
|
||
四语言模板已对齐原程序 templates/mail/ 的样式(Google 风格邮件):
|
||
白底居中卡片、#dadce0 细边框 8px 圆角、标题 24px 带下边框分隔、
|
||
正文 Roboto 14px rgba(0,0,0,0.87)、次要信息 rgba(0,0,0,0.54)、
|
||
操作按钮 #4184F3 蓝底白字、页脚 11px 浅灰居中。
|
||
改动配色/排版时保持内联 style 写法即可(邮件客户端兼容)。
|
||
|
||
修改文案
|
||
--------
|
||
直接编辑 templates/ 下对应语言的 .html / .subject.txt / .plain.txt,
|
||
保存后下次运行即生效,不需要改任何 Python 代码。
|
||
新增语言:放一份 <新语言码>.html + .subject.txt 进 templates/ 即会被自动识别。
|
||
|
||
占位符(发送时逐用户替换)
|
||
--------------------------
|
||
{{name}} 用户名(空则显示「用户」)
|
||
{{domain}} 用户个性域名(可能为空)
|
||
{{box_link}} 该用户的提问箱链接 = site-url + box-prefix + domain
|
||
无个性域名时退回站点首页
|
||
{{email}} 收件邮箱
|
||
{{site}} 站点地址(--site-url)
|
||
{{site_title}} 站点名(app.ini [app] title)
|
||
{{year}} 当前年份(版权行用)
|
||
{{任意自定义}} 用 --var key=value 传入,如 {{old_domain}}、{{expire_date}}
|
||
|
||
注意:模板里写了但没有提供值的占位符会被替换为空串,
|
||
结束时会在控制台与报告中列出缺失键,方便发现笔误。
|
||
|
||
个性域名链接规则
|
||
----------------
|
||
box_link = {--site-url}{--box-prefix}{domain}
|
||
默认 box-prefix 为 /_/,即 https://box.shiroko.one/_/某人的域名
|
||
用户没有个性域名时,{{box_link}} 退回站点首页。
|
||
|
||
常用命令
|
||
--------
|
||
# 默认即交互模式:逐项确认参数,并选择模板模式与运行方式
|
||
python3 broadcast.py
|
||
|
||
# 演练(不发信,跳过交互)
|
||
python3 broadcast.py --dry-run --no-interactive
|
||
|
||
# 测试:给单个邮箱发一封(命中数据库用户则套用其真实数据)
|
||
python3 broadcast.py --send --to you@example.com
|
||
|
||
# 正式群发(建议先 --limit 50 试水;中断后重跑自动断点续发)
|
||
python3 broadcast.py --send --yes
|
||
|
||
# 临时覆盖 params.ini 的值(CLI 优先级更高)
|
||
python3 broadcast.py -c /别的/conf/app.ini --send --var expire_date=2027-01-01
|
||
|
||
# 从头重发(清空断点续发记录,慎用)
|
||
python3 broadcast.py --send --reset-state
|
||
|
||
# 单文件模式:所有人发 templates/single.html(不按语言)
|
||
python3 broadcast.py --single --send --to you@example.com
|
||
|
||
退信核查(验证真实送达 · 按原因分类处理)
|
||
----------------------------------------
|
||
背景:SMTP 返回 250 只代表服务器收下了,不代表送达。阿里云企业邮箱等对
|
||
「发送频率超限」「收件人不存在」等情况往往是先收下、再异步把退信通知投到
|
||
发件账号的收件箱——脚本当时的「发送成功」并不真实。
|
||
|
||
**退信不是一类,必须分类处理**(v3 起):
|
||
|
||
| 类别 | 典型原因 | 脚本动作 |
|
||
| ---- | -------- | -------- |
|
||
| 可重试 `rate` | 「您的账号外发频率超过邮件系统限制」、系统繁忙、DSN 4.x.x | 从断点清单剔除 → 下次/本次补发 |
|
||
| 永久失败 `hard` | 地址不存在、用户不存在、DSN 5.x.x | **不补发**;记入无效地址清单,永久跳过 |
|
||
| 未分类 `unknown` | 判不出来 | 默认不补发(保守);`--prune-unknown` 可强制补发 |
|
||
|
||
以前是「凡是退信一律剔除补发」,结果**地址不存在的死信也会被反复重发**——
|
||
既浪费每日发信额度,又拖垮发信信誉。现在只有真正可重试的才会补发。
|
||
|
||
判定优先级:DSN 状态码(5.x.x / 4.x.x,最权威,且**同一封退信里的多个收件人
|
||
可分别归类**)→ 正文硬退信关键词 → 限流/临时性关键词 → 正文 SMTP 状态码 → 未分类。
|
||
硬退信优先于限流:宁可少补发一个可疑地址,也不给死信反复重发。
|
||
|
||
# 发送完过几分钟,扫描退信并分类报告(仅核查,不改动任何清单)
|
||
python3 broadcast.py --check-bounces
|
||
|
||
# 仅剔除:把「可重试」的退信从断点续发清单剔除后结束,本次不发送
|
||
#(下次任意一次重跑会自动补上这批人;地址不存在的不会补发)
|
||
python3 broadcast.py --check-bounces --prune-state
|
||
|
||
# 剔除并补发:剔除后继续正常发送流程,本次就把可重试的用户补上
|
||
python3 broadcast.py --check-bounces --prune-state --send
|
||
|
||
# 未分类的也按可重试一并剔除补发(确认不是死信后再用)
|
||
python3 broadcast.py --check-bounces --prune-state --prune-unknown
|
||
|
||
# 可选:--imap-host imap.xxx.com(默认由 SMTP 域名推导 smtp.→imap.)
|
||
# --imap-port 993;扫描起点 --since auto(默认,见下)
|
||
扫描起点(避免把群发无关的退信算进来):
|
||
- 默认 auto:从断点续发清单(broadcast_state.json)最早一条发送记录的
|
||
时间开始——即只核查本次群发发出的那些邮件的退信
|
||
- --since 2026-09-07 可显式指定起点日期
|
||
- 断点清单为空时回退为 --since-days N(默认 3 天)
|
||
|
||
无效地址清单 broadcast_invalid.json
|
||
-----------------------------------
|
||
硬退信(地址不存在等)的地址会写进这里,之后**每次运行都直接跳过**,
|
||
即使加了 --reset-state 也不会再发——重发也发不出去,只会浪费额度。
|
||
条目里保留了退信原因、主题与时间,便于核对到底是哪些地址失效了。
|
||
|
||
python3 broadcast.py --reset-invalid # 清空清单(确认地址已修正后用)
|
||
python3 broadcast.py --no-invalid-list ... # 本次忽略清单(既不跳过也不写入)
|
||
python3 broadcast.py --invalid-file /path/to/other.json # 换一个清单文件
|
||
|
||
说明:
|
||
- 用的是 conf/app.ini [mail] 的账号与密码(需邮箱已开启 IMAP,密码为邮箱登录密码)
|
||
- 交互模式的运行方式「4) 退信核查」里同样三选一:仅核查 / 仅剔除 / 剔除并补发,
|
||
选择「仅剔除 / 剔除并补发」时会再问一次是否连未分类的一起补发
|
||
- 已核查过的退信(按 Message-ID)记录在 broadcast_bounces_seen.json,
|
||
重复核查不会重复报告/重复剔除
|
||
- 每次核查另写一份明细报告 broadcast_bounce_report.json(含每个收件人的类别与判定依据)
|
||
|
||
安全机制
|
||
--------
|
||
- 默认 dry-run;--send 需终端输入 yes 确认(--yes 跳过)
|
||
- 发送前打印数据库统计与语言分布,人工核对
|
||
- 每封间隔 --delay 秒(默认 1.0);--pause-every/--pause-for 防限流
|
||
- 按语言分群发送,组间 --group-pause 秒(默认 3.0)
|
||
- SMTP 拒收(refused)计入失败并写入报告
|
||
- From/Subject 头自动做 RFC2047 编码(中文显示名不会被 QQ 邮箱 550 拒收)
|
||
- 自动定位 users 表所在 schema(避免 psql 命中别的同名空表)
|
||
- 语言列自动试跑探测(language → lang → NULL 兜底),老库没有该列也能发
|