Files
tamabox-broadcast-mailsystem/README.md
T
2026-09-07 18:04:51 +08:00

177 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (运行后生成)每次运行的监控报告
参数文件 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 只代表服务器收下了,不代表送达。阿里云企业邮箱等对
「发送频率超限」「收件人不存在」等情况往往是先收下、再异步把退信通知投到
发件账号的收件箱——脚本当时的「发送成功」并不真实。
# 发送完过几分钟,扫描近 3 天的退信通知,列出实际未送达的收件人(仅核查)
python3 broadcast.py --check-bounces
# 仅剔除:把这些用户从断点续发清单剔除后结束,本次不发送
#(下次任意一次重跑会自动补上这批人)
python3 broadcast.py --check-bounces --prune-state
# 剔除并补发:剔除后继续正常发送流程,本次就把退信用户补上
python3 broadcast.py --check-bounces --prune-state --send
# 可选:--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 天)
说明:
- 用的是 conf/app.ini [mail] 的账号与密码(需邮箱已开启 IMAP,密码为邮箱登录密码)
- 交互模式的运行方式「4) 退信核查」里同样三选一:仅核查 / 仅剔除 / 剔除并补发
- 已核查过的退信(按 Message-ID)记录在 broadcast_bounces_seen.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 兜底),老库没有该列也能发