- params.ini 新增 [mail]:source=app(默认,用 conf/app.ini [mail])或 own(用 account/password/smtp/port/skip_tls_verify 这套);--mail-source app|own 可临时覆盖(命令行 > params.ini) - 切换后发信与退信核查(IMAP,含主机推导)都走这套账号;切换到 own 但 配置缺项时直接 FATAL,不用半套配置 - password 支持 env:变量名:params.ini 会被 git 跟踪,明文密码时打印警告 - 交互模式:[mail] 配齐时多问一次来源;保存时 [mail] 小节原样回写 - 修复:save_params 缺少 batch_size/smtp_idle_reconnect 形参,但调用点已在 传 → 交互模式选「保存到 params.ini」会 TypeError 崩溃(上一并行编辑漏改) - 补回上一轮被漏掉的 README「合并信封批量发送」章节与 452 安全机制条目
18 KiB
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 smtp_idle_reconnect = 30 ; SMTP 空闲超时秒数,超时就重连 batch_size = 0 ; 合并信封:内容相同的收件人每 N 人一封(0=逐封)
[bounce] ; 退信核查 / 发送中限流巡检(见下节) watch_bounces = 1 ; 发送中巡检限流退信,发现即中止 watch_every = 10 ; 每发 N 封巡检一次 prune_unknown = 0 ; 未分类退信是否也按可重试补发 no_invalid_list= 0 ; 停用无效地址清单 invalid_file = ; 留空用默认 broadcast_invalid.json bounce_report = ; 留空用默认 imap_host = ; 留空按 SMTP 域名推导 imap_port = 993 since = auto ; auto = 从断点清单最早记录开始扫 since_days = 3
conf/app.ini 路径的解析优先级: 命令行 -c > params.ini [path] config > 环境变量 TAMABOX_CONFIG_PATH > ./conf/app.ini
安全边界:params.ini 不提供 --send / --yes——群发只能在命令行显式指定 (或走交互模式在会话中确认),防止改配置文件时误发全量邮件。
优先级:命令行参数 > params.ini > 内置默认。所有 [bounce] 项都有同名命令行
参数可临时覆盖(如 --watch-every 5、--no-watch-bounces)。布尔值写
1/0、yes/no、true/false、on/off 均可,留空或写错会忽略并回退默认
(控制台会给出警告)。
交互模式(默认)
直接运行 python3 broadcast.py 即进入交互模式(无需加任何参数);
命令行带了 --send/--to/--yes/--dry-run 之一、或显式 --no-interactive 时不进入,
按参数直接执行(脚本化/定时任务用)。
只问缺的,有值不问:交互开头先打印一份「参数确认」摘要,凡已有值的参数 (params.ini / 命令行 / app.ini external_url 兜底)直接沿用、不再逐项询问; 要改值请编辑 params.ini,或干脆把一次性内容(旧域名、到期日等)直接写死到 模板 html 里并删掉对应 {{占位符}}。只有「模板用到但还没有值」的占位符和 完全缺失的 site_url 才会补问。交互流程:
- 参数摘要(有值直接沿用)+ 补问缺失项
- 模板模式:
- 多语言 —— 按 users.language 使用各自语言模板(默认)
- 单文件 —— 所有人发送 templates/single.html 通用模板
- 运行方式直接在交互中选择(不需要命令行 --send/--to/--yes):
- 演练 —— 只列收件人、统计与渲染预览,不发信(默认)
- 测试 —— 只给一个邮箱发一封,验证模板与发信链路
- 群发 —— 向全体未注销用户发送;可顺手设 --limit 小量试水, 并可选择是否保留发送前的最终确认(默认保留)
- 最后可选把补全的参数保存回 params.ini,下次运行直接生效
单文件模式(通用模板)
不按用户语言发送,所有人都收到 templates/single.html 这一封:
命令行
python3 broadcast.py --single --send --to [email protected]
或交互模式里"模板模式"选 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 [email protected]
正式群发(建议先 --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 [email protected]
退信核查(验证真实送达 · 按原因分类处理)
背景: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 天)
发送中限流巡检(边发边看,及时止损)
限流退信是异步投到发件箱的——SMTP 当场返回 250,几十秒后收件箱才收到 「您的账号外发频率超过邮件系统限制」。如果只等发完再核查,等发现时限流那一批 早就全废了。所以正式群发时会边发边巡检收件箱:
- 每
--watch-every封(默认 10)登录一次 IMAP 查新退信 - 只看本次运行开始之后到达的退信,历史退信不会误触发
- 一旦出现限流类退信 → 立即停止发送
- SMTP 当场就返回限流(如
450 MI:CEL 发送频率超限)→ 同样立即停止 - 善后:把限流退信的地址从断点清单剔除,等限制恢复后重跑自动补发
- 巡检到的「地址不存在」类退信照旧写入无效地址清单
这两个开关优先写在 params.ini 的 [bounce] 小节(长期生效),命令行只用来 临时覆盖:
params.ini
[bounce] watch_bounces = 1 ; 0 关闭巡检 watch_every = 10 ; 每 10 封查一次
命令行临时覆盖(优先级更高,只影响这一次)
python3 broadcast.py --send --yes --watch-every 5 python3 broadcast.py --send --yes --no-watch-bounces
(--to 单封测试时本就不巡检)
中止时的输出示例:
[中止发送] 收件箱出现 1 条限流退信(如 [email protected]:关键词「您的账号外发频率超过邮件系统限制」) [善后] 已将 1 个限流退信地址从断点清单剔除,等限制恢复后重跑同一条命令即可自动补发。 发送中止:已成功 30 封,失败 0 封,未发送 30 封。
注意:巡检依赖 IMAP 可用。若连续两轮连不上 IMAP,会自动关闭本次巡检并提示
(不影响发送),此时请发完手动跑一次 --check-bounces。
无效地址清单 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)与上次扫描位置(INBOX UID 游标 + UIDVALIDITY)都记录在 broadcast_bounces_seen.json: 默认从上次位置续扫(UID n+1:*),只拉新邮件的头部,重复核查不会 重复报告/重复剔除,也不会随邮箱邮件增多越扫越慢
- 以下情况自动回退为按日期窗口从头扫(扫完位置照常更新): 首次核查(无位置记录)、邮箱 UIDVALIDITY 变化(换号/重建邮箱)、 显式指定 --since、加 --full-scan
- 发送中巡检不推进扫描位置(它只看本次运行之后新到的退信, 范围内的历史邮件不标记「已扫」),位置只由 --check-bounces 推进
- 每次核查另写一份明细报告 broadcast_bounce_report.json(含每个收件人的 类别与判定依据,以及 scan_mode / scan_cursor)
发信账号来源:app.ini / 程序自带 SMTP
默认用 conf/app.ini [mail] 的 SMTP(站内信跟随站点自己的邮箱)。切到程序 自带的一套后,发信与退信核查(IMAP)都走这套账号:
[mail]
source = app ; app(默认)= conf/app.ini [mail];own = 下面这套
account = [email protected]
password = env:MY_SMTP_PASSWORD ; 支持 env:变量名,推荐
smtp = smtp.other.com
port = 465
skip_tls_verify = 0
临时切换(优先级高于 params.ini):--mail-source own / --mail-source app
交互模式里若 [mail] 配齐了,会多问一次选哪个来源。
安全提醒:
- params.ini 会被 git 跟踪,不要把密码明文写进去;用
env:变量名从环境变量读取,或把 params.ini 加入 .gitignore - 明文写死时脚本会打印警告,但不会阻止运行
- 切到 own 但 [mail] 缺 smtp/account/password 时直接 FATAL,不会用半套配置
合并信封批量发送(可选)
默认逐人一封(一个 SMTP 信封 = 1 个 RCPT TO)。开启 batch_size 后, 「渲染后内容完全相同」的收件人合并发送:一个信封 = 1×MAIL FROM + N×RCPT TO + 1×DATA,发送次数从「人数」降到「信封数」(如 305 人、 每 50 人一封 → 约 7 次发信)。
开启方式(0=关闭): broadcast.py --send --single --batch-size 25 或 params.ini [send] batch_size = 25
与限流的关系(要点):
- 服务商按「发信次数」计频率 → 合并后成倍降低触发概率(主要收益)
- 服务商按「单位时间收件人总数」计数 → 无缓解,仍靠 delay/pause 控制节奏
- 单封收件人数上限常见 50~100:超限的 RCPT 会被 452 拒收,脚本自动把 batch_size 砍半、被拒者重试,不会中止也不会误判为限流
- 信封收件人对其他收件人不可见;批量信封的 To: 头显示为「站点名+发件邮箱」
适用的前提是内容逐字相同:模板含 {{name}}/{{box_link}} 等个人化占位符时, 渲染结果逐人不同,会自动落回逐封,不会错合。断点续发按人记录(DATA 被 服务器接收即整批入账,被拒的除外);发送中限流巡检照常按收件人数计数。
安全机制
- 默认 dry-run;--send 需终端输入 yes 确认(--yes 跳过)
- 发送前打印数据库统计与语言分布,人工核对
- 每封间隔 --delay 秒(默认 1.0);--pause-every/--pause-for 防限流
- 按语言分群发送,组间 --group-pause 秒(默认 3.0)
- SMTP 断线自动重连:服务器会掐掉空闲连接,--delay 调大(如 60s/封)时
必现「Server not connected / please run connect() first」,整批失败。
连接空闲超过
smtp_idle_reconnect秒(默认 30,0 关闭)就主动重连; 仍遇到断线则立即重试最多 3 次(间隔 2s/4s),失败才会记为该收件人失败 - SMTP 拒收(refused)计入失败并写入报告
- 批量信封遇 452「收件人数超限」自动砍半拆批重试;限流类拒收仍立即中止
- From/Subject 头自动做 RFC2047 编码(中文显示名不会被 QQ 邮箱 550 拒收)
- 自动定位 users 表所在 schema(避免 psql 命中别的同名空表)
- 语言列自动试跑探测(language → lang → NULL 兜底),老库没有该列也能发