From f83ea3e29a67c8b501bf6a5e94a82629f56b7b13 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Mon, 7 Sep 2026 19:25:20 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=80=E4=BF=A1/=E5=B7=A1=E6=A3=80=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E7=A7=BB=E5=85=A5=20params.ini=20[bounce]=EF=BC=8C?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=A1=8C=E4=BB=85=E4=BD=9C=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来这些开关只能靠命令行传,每次群发都得敲一长串,也容易漏。现在统一在 params.ini 的 [bounce] 小节长期配置,优先级为 命令行 > params.ini > 内置默认。 改动: - load_params 新增 [bounce] 解析:watch_bounces / watch_every / prune_unknown / no_invalid_list / invalid_file / bounce_report / imap_host / imap_port / since / since_days;新增 _parse_bool 支持 1/0、yes/no、true/false、on/off, 留空或非法值忽略并回退默认(带警告) - 相关 argparse 默认值改为 None,以便区分「用户显式传了」还是「没传」—— 没传才用 params.ini 的值,保证命令行优先级 - save_params 回写 [bounce](用当前生效值),并用 _preserve_extra_sections 原样保留文件里其它手写小节,避免保存时把配置抹掉 - 交互模式参数摘要里显示巡检开关的当前值与来源 - 把 argparse 构造抽成 build_arg_parser(),便于测试 - params.ini 补上 [bounce] 小节的默认配置与注释;README 同步 --- README.md | 33 ++++++++++- broadcast.py | 161 +++++++++++++++++++++++++++++++++++++++++++++------ params.ini | 28 +++++++++ 3 files changed, 201 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bd7a304..0ecaf9d 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,29 @@ README — TamaBox 站内信群发工具(mail-broadcast) delay = 1.0 group_pause = 3.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` 即进入交互模式(无需加任何参数); @@ -193,9 +210,19 @@ conf/app.ini 路径的解析优先级: - 善后:把限流退信的地址从断点清单剔除,等限制恢复后重跑自动补发 - 巡检到的「地址不存在」类退信照旧写入无效地址清单 - python3 broadcast.py --send --yes # 默认已开启巡检 - python3 broadcast.py --send --yes --watch-every 5 # 每 5 封查一次(更及时,IMAP 登录更频繁) - python3 broadcast.py --send --yes --no-watch-bounces # 关闭巡检(--to 单封测试本就关闭) +这两个开关**优先写在 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 单封测试时本就不巡检) 中止时的输出示例: diff --git a/broadcast.py b/broadcast.py index 7927ca4..adfc0a7 100644 --- a/broadcast.py +++ b/broadcast.py @@ -32,10 +32,13 @@ TamaBox 站内信群发工具(独立项目,零依赖,不依赖、不修改 - SMTP 拒收捕获:send_message 返回的 refused 计入失败 - 退信分类核查:只有「限流/临时性」退信才剔除补发;「地址不存在」这类 永久失败不补发,并记入无效地址清单(broadcast_invalid.json)永久跳过 - - 发送中限流巡检:每 --watch-every 封查一次收件箱,一旦出现 + - 发送中限流巡检:每 N 封查一次收件箱,一旦出现 「外发频率超过邮件系统限制」类退信(或 SMTP 当场报限流)立即中止发送, 并把这批限流地址从断点清单剔除,等限制恢复后重跑自动补发 +参数优先级:命令行 > params.ini > 内置默认。退信/巡检相关开关([bounce] 小节) +与站点、限速等一样都能在 params.ini 里长期配置,命令行只在需要临时覆盖时用。 + 用法示例: # 1) 演练:列出收件人 + 统计(不发信) python3 broadcast.py -c /path/to/conf/app.ini --dry-run \ @@ -1350,7 +1353,10 @@ def load_params(path): sys.exit(f"[FATAL] 解析参数文件失败: {e}") data = {"config": "", "site_url": "", "box_prefix": "", "vars": {}, "to": "", - "limit": None, "delay": None, "group_pause": None} + "limit": None, "delay": None, "group_pause": None, + "watch_bounces": None, "watch_every": None, "prune_unknown": None, + "no_invalid_list": None, "invalid_file": "", "bounce_report": "", + "imap_host": "", "imap_port": None, "since": "", "since_days": None} data["config"] = cp.get("path", "config", fallback="").strip() data["site_url"] = cp.get("site", "site_url", fallback="").strip().rstrip("/") data["box_prefix"] = cp.get("site", "box_prefix", fallback="").strip() @@ -1364,11 +1370,65 @@ def load_params(path): data[key] = cast(raw) except ValueError: print(f"[警告] params.ini [send] {key}={raw!r} 不是合法数字,已忽略") + + # [bounce] 退信核查 / 发送中巡检的开关 + for key in ("watch_bounces", "prune_unknown", "no_invalid_list"): + val = _parse_bool(cp.get("bounce", key, fallback="").strip(), key) + if val is not None: + data[key] = val + for key in ("invalid_file", "bounce_report", "imap_host", "since"): + data[key] = cp.get("bounce", key, fallback="").strip() + for key, cast in (("watch_every", int), ("imap_port", int), ("since_days", int)): + raw = cp.get("bounce", key, fallback="").strip() + if raw: + try: + data[key] = cast(raw) + except ValueError: + print(f"[警告] params.ini [bounce] {key}={raw!r} 不是合法整数,已忽略") return data -def save_params(path, config, site_url, box_prefix, extra_vars, lang_labels=None): - """把参数写回 params.ini(覆盖写;vars 逐行 key = value)。""" +def _parse_bool(raw, key=""): + """解析 1/0、yes/no、true/false、on/off;空串返回 None(表示未配置)。""" + low = (raw or "").strip().lower() + if not low: + return None + if low in ("1", "yes", "y", "true", "on", "开", "启用"): + return True + if low in ("0", "no", "n", "false", "off", "关", "禁用"): + return False + print(f"[警告] params.ini [bounce] {key}={raw!r} 不是合法布尔值" + f"(用 1/0 或 yes/no),已忽略") + return None + + +def _preserve_extra_sections(path, managed=("path", "site", "vars", "send", "bounce")): + """读取已有 params.ini 里非托管的小节,原样保留(避免保存时丢掉手写的配置)。""" + if not path or not os.path.isfile(path): + return [] + cp = configparser.ConfigParser(comment_prefixes=(";", "#"), + inline_comment_prefixes=(";", "#"), + interpolation=None, strict=False) + cp.optionxform = str + try: + cp.read(path, encoding="utf-8") + except Exception: # noqa: BLE001 + return [] + out = [] + for sec in cp.sections(): + if sec.strip().lower() in managed: + continue + out.append("") + out.append(f"[{sec}]") + for k, v in cp.items(sec): + out.append(f"{k} = {v}") + return out + + +def save_params(path, config, site_url, box_prefix, extra_vars, lang_labels=None, + bounce=None): + """把参数写回 params.ini(覆盖写;vars 逐行 key = value)。 + bounce:当前生效的退信/巡检设置,回写进 [bounce] 小节以便下次直接沿用。""" lines = ["; broadcast.py 参数文件:每次运行自动读取;命令行参数优先级更高", "; 注意:本文件不控制 --send/--yes,正式发送仍需命令行显式指定", ""] lines.append("[path]") @@ -1388,6 +1448,29 @@ def save_params(path, config, site_url, box_prefix, extra_vars, lang_labels=None lines.append("; to = 可选:默认测试收件邮箱(正式群发用命令行 --send,不加 --to)") lines.append("delay = 1.0") lines.append("group_pause = 3.0") + lines.append("") + lines.append("; 退信核查 / 发送中限流巡检(均可用同名命令行参数临时覆盖)") + lines.append("[bounce]") + b = bounce or {} + yesno = lambda v: "1" if v else "0" # noqa: E731 + lines.append("; 发送中巡检收件箱,出现「外发频率超过邮件系统限制」类退信立即中止发送") + lines.append(f"watch_bounces = {yesno(b.get('watch_bounces', True))}") + lines.append("; 每发 N 封巡检一次(调小更及时,但 IMAP 登录更频繁)") + lines.append(f"watch_every = {b.get('watch_every', 10)}") + lines.append("; 退信核查时,未分类的退信也按可重试一并剔除补发") + lines.append(f"prune_unknown = {yesno(b.get('prune_unknown', False))}") + lines.append("; 停用无效地址清单(既不跳过已知无效地址,也不写入新的硬退信)") + lines.append(f"no_invalid_list = {yesno(b.get('no_invalid_list', False))}") + lines.append("; 留空则用脚本同目录的默认文件名") + lines.append(f"invalid_file = {b.get('invalid_file', '') or ''}") + lines.append(f"bounce_report = {b.get('bounce_report', '') or ''}") + lines.append("; 留空则按 SMTP 域名推导(smtp.xxx → imap.xxx)") + lines.append(f"imap_host = {b.get('imap_host', '') or ''}") + lines.append(f"imap_port = {b.get('imap_port', 993)}") + lines.append("; since = auto 表示从断点清单最早一条记录的时间开始扫描") + lines.append(f"since = {b.get('since', 'auto')}") + lines.append(f"since_days = {b.get('since_days', 3)}") + lines.extend(_preserve_extra_sections(path)) with open(path, "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") @@ -1427,6 +1510,9 @@ def run_interactive(cfg, templates, extra_vars, args, config_path): print(f" box_prefix = {box_prefix}") for k in sorted(extra_vars): print(f" {{{{{k}}}}} = {extra_vars[k]}") + print(f" 发送中巡检退信 = {'开启' if args.watch_bounces else '关闭'}" + f"(每 {args.watch_every} 封一次)" + f" 来源:params.ini [bounce] / 命令行,改值请编辑 params.ini") # 站点地址:只有完全没值时才问(值链已在 main 里兜底过 external_url) if not site_url: @@ -1518,12 +1604,25 @@ def run_interactive(cfg, templates, extra_vars, args, config_path): except EOFError: save_ans = "" if save_ans in ("y", "yes"): - save_params(DEFAULT_PARAMS, config_path, site_url, box_prefix, extra_vars) + save_params(DEFAULT_PARAMS, config_path, site_url, box_prefix, extra_vars, + bounce={ + "watch_bounces": args.watch_bounces, + "watch_every": args.watch_every, + "prune_unknown": args.prune_unknown, + "no_invalid_list": args.no_invalid_list, + "invalid_file": args.invalid_file, + "bounce_report": args.bounce_report, + "imap_host": args.imap_host, + "imap_port": args.imap_port, + "since": args.since, + "since_days": args.since_days, + }) print(f"[交互模式] 已保存到 {DEFAULT_PARAMS}") return site_url, box_prefix, extra_vars -def main(): +def build_arg_parser(): + """构造命令行解析器(单独抽出来,便于测试与复用)。""" parser = argparse.ArgumentParser( description="TamaBox 站内信群发工具(读取 conf/app.ini,模板在 templates/ 目录,按用户语言发送)", formatter_class=argparse.RawDescriptionHelpFormatter, @@ -1597,34 +1696,34 @@ def main(): help="配合 --check-bounces:把「可重试」退信(限流/临时性)从断点续发清单" "剔除后结束(仅剔除,本次不发送);再加 --send 则剔除后立即补发。" "「地址不存在」等永久失败不会被剔除(不补发)") - parser.add_argument("--prune-unknown", action="store_true", + parser.add_argument("--prune-unknown", action="store_true", default=None, help="配合 --check-bounces:无法分类的退信也按「可重试」处理,一并剔除补发" - "(默认不剔除,避免给死信地址反复重发)") - parser.add_argument("--invalid-file", default=DEFAULT_INVALID, metavar="FILE", + "(默认不剔除,避免给死信地址反复重发;也可在 params.ini [bounce] 设置)") + parser.add_argument("--invalid-file", default=None, metavar="FILE", help=f"无效地址清单(硬退信地址)路径;默认脚本同目录 broadcast_invalid.json。" f"清单里的地址每次运行都会跳过,即使 --reset-state") parser.add_argument("--reset-invalid", action="store_true", help="清空无效地址清单(慎用:这些地址多半仍不可达)") - parser.add_argument("--no-invalid-list", action="store_true", + parser.add_argument("--no-invalid-list", action="store_true", default=None, help="本次不使用无效地址清单:既不跳过已知无效地址,也不写入新的硬退信") - parser.add_argument("--bounce-report", default=DEFAULT_BOUNCE_REPORT, metavar="FILE", + parser.add_argument("--bounce-report", default=None, metavar="FILE", help="退信核查明细报告路径(JSON);默认脚本同目录 broadcast_bounce_report.json") - parser.add_argument("--watch-bounces", dest="watch_bounces", action="store_true", default=True, + parser.add_argument("--watch-bounces", dest="watch_bounces", action="store_true", default=None, help="发送过程中巡检收件箱:每 --watch-every 封查一次," "一旦出现「外发频率超过邮件系统限制」等限流退信立即中止发送(默认开启)") parser.add_argument("--no-watch-bounces", dest="watch_bounces", action="store_false", help="关闭发送中巡检(--to 单封测试时本就关闭)") - parser.add_argument("--watch-every", type=int, default=10, metavar="N", + parser.add_argument("--watch-every", type=int, default=None, metavar="N", help="发送中每发 N 封巡检一次退信(默认 10;调小会更频繁登录 IMAP)") - parser.add_argument("--imap-host", default="", + parser.add_argument("--imap-host", default=None, help="IMAP 服务器地址(默认由 SMTP 域名推导:smtp.xxx → imap.xxx)") - parser.add_argument("--imap-port", type=int, default=993, + parser.add_argument("--imap-port", type=int, default=None, help="IMAP 端口(默认 993,SSL)") - parser.add_argument("--since", default="auto", metavar="auto|YYYY-MM-DD", + parser.add_argument("--since", default=None, metavar="auto|YYYY-MM-DD", help="退信扫描起点:auto(默认)= 从群发记录(断点清单)最早一条的" "时间开始,只核查本次群发相关的退信;" "也可显式指定日期如 2026-09-07") - parser.add_argument("--since-days", type=int, default=3, + parser.add_argument("--since-days", type=int, default=None, help="断点清单为空时的回退扫描范围(天),默认 3 天") parser.add_argument("--subject", default=None, help="全局覆盖邮件主题(忽略各语言 subject.txt;支持占位符)") @@ -1632,8 +1731,12 @@ def main(): help="全局覆盖 HTML 正文模板文件(忽略按语言模板;调试用)") parser.add_argument("--plain", metavar="FILE", help="全局覆盖纯文本正文模板文件(调试用)") + return parser + + +def main(): global args - args = parser.parse_args() + args = build_arg_parser().parse_args() # 默认交互模式:没给任何运行方式相关参数(--send/--to/--yes/--dry-run)就自动进入交互; # 显式 --no-interactive 强制关闭(脚本化/定时任务用) @@ -1685,6 +1788,28 @@ def main(): args.delay = params["delay"] if args.group_pause == 3.0 and params.get("group_pause") is not None: args.group_pause = params["group_pause"] + + # 退信核查 / 发送中巡检:命令行没给(None)时才用 params.ini [bounce] + if args.watch_bounces is None: + args.watch_bounces = True if params.get("watch_bounces") is None else params["watch_bounces"] + if args.watch_every is None: + args.watch_every = params.get("watch_every") or 10 + if args.prune_unknown is None: + args.prune_unknown = bool(params.get("prune_unknown")) + if args.no_invalid_list is None: + args.no_invalid_list = bool(params.get("no_invalid_list")) + if args.invalid_file is None: + args.invalid_file = params.get("invalid_file") or DEFAULT_INVALID + if args.bounce_report is None: + args.bounce_report = params.get("bounce_report") or DEFAULT_BOUNCE_REPORT + if args.imap_host is None: + args.imap_host = params.get("imap_host") or "" + if args.imap_port is None: + args.imap_port = params.get("imap_port") or 993 + if args.since is None: + args.since = params.get("since") or "auto" + if args.since_days is None: + args.since_days = params.get("since_days") or 3 extra_vars = {**params.get("vars", {}), **parse_var(args.var)} # CLI --var 覆盖文件 # ---- conf/app.ini 路径解析:CLI > params.ini > 环境变量 > ./conf/app.ini ---- diff --git a/params.ini b/params.ini index c2ce9b2..53b296f 100644 --- a/params.ini +++ b/params.ini @@ -18,3 +18,31 @@ old_domain = box.tama.guru ; to = 可选:默认测试收件邮箱(正式群发用命令行 --send,不加 --to) delay = 1.0 group_pause = 3.0 + +; 退信核查 / 发送中限流巡检开关 +; 这些都能在 params.ini 里长期配置,命令行同名参数可临时覆盖 +[bounce] +; 发送中巡检收件箱:出现「外发频率超过邮件系统限制」类限流退信立即中止发送 +; 中止后会把限流地址从断点清单剔除,等限制恢复重跑即自动补发 +watch_bounces = 1 + +; 每发 N 封巡检一次退信。调小更及时,但 IMAP 登录更频繁(不建议 < 5) +watch_every = 10 + +; 退信核查时,分类不明的退信是否也按「可重试」一并剔除补发(默认 0,保守不补发) +prune_unknown = 0 + +; 停用无效地址清单:既不跳过已知无效地址,也不写入新的硬退信 +no_invalid_list = 0 + +; 留空则用脚本同目录的默认文件名 +invalid_file = +bounce_report = + +; IMAP(退信核查要用发件邮箱的 IMAP;留空则按 SMTP 域名推导 smtp.xxx → imap.xxx) +imap_host = +imap_port = 993 + +; since = auto 表示从断点清单最早一条发送记录的时间开始扫描,只查本次群发的退信 +since = auto +since_days = 3