diff --git a/README.md b/README.md index 3243e5b..d24c0dd 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,50 @@ conf/app.ini 路径的解析优先级: - 每次核查另写一份明细报告 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 = bot@other.com + 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 跳过) @@ -272,6 +316,7 @@ conf/app.ini 路径的解析优先级: 连接空闲超过 `smtp_idle_reconnect` 秒(默认 30,0 关闭)就主动重连; 仍遇到断线则立即重试最多 3 次(间隔 2s/4s),失败才会记为该收件人失败 - SMTP 拒收(refused)计入失败并写入报告 + - 批量信封遇 452「收件人数超限」自动砍半拆批重试;限流类拒收仍立即中止 - From/Subject 头自动做 RFC2047 编码(中文显示名不会被 QQ 邮箱 550 拒收) - 自动定位 users 表所在 schema(避免 psql 命中别的同名空表) - 语言列自动试跑探测(language → lang → NULL 兜底),老库没有该列也能发 diff --git a/broadcast.py b/broadcast.py index a92a8c0..00733ae 100644 --- a/broadcast.py +++ b/broadcast.py @@ -83,7 +83,7 @@ from email.mime.text import MIMEText from email.utils import formataddr, make_msgid, parsedate_to_datetime from urllib.parse import quote_plus -SCRIPT_VERSION = "2026-09-07.batchenv.v4" +SCRIPT_VERSION = "2026-09-08.mailsource.v5" DEFAULT_STATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "broadcast_state.json") DEFAULT_REPORT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "broadcast_report.json") @@ -1567,7 +1567,7 @@ def load_params(path): data = {"config": "", "site_url": "", "box_prefix": "", "vars": {}, "to": "", "limit": None, "delay": None, "group_pause": None, "smtp_idle_reconnect": None, - "batch_size": None, + "batch_size": None, "mail": {}, "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} @@ -1600,9 +1600,77 @@ def load_params(path): data[key] = cast(raw) except ValueError: print(f"[警告] params.ini [bounce] {key}={raw!r} 不是合法整数,已忽略") + + # [mail] 程序自带 SMTP(source=own 时取代 conf/app.ini 的 [mail]) + if cp.has_section("mail"): + m = {} + m["source"] = cp.get("mail", "source", fallback="").strip().lower() or "app" + for key in ("account", "password", "smtp"): + m[key] = cp.get("mail", key, fallback="").strip() + m["skip_tls_verify"] = bool(_parse_bool( + cp.get("mail", "skip_tls_verify", fallback="").strip(), "skip_tls_verify")) + raw = cp.get("mail", "port", fallback="").strip() + try: + m["port"] = int(raw) if raw else 465 + except ValueError: + print(f"[警告] params.ini [mail] port={raw!r} 不是合法整数,已忽略") + m["port"] = 465 + data["mail"] = m return data +def resolve_mail_password(raw): + """SMTP 密码:支持 env:变量名 从环境变量取值,避免明文写进配置文件。""" + raw = (raw or "").strip() + if raw.lower().startswith("env:"): + name = raw[4:].strip() + if not name: + return "" + return (os.environ.get(name) or "").strip() + return raw + + +def apply_mail_source(cfg, args, params, verbose=True): + """发信账号来源切换:app(conf/app.ini [mail],默认)/ own(params.ini [mail])。 + + 就地覆盖 cfg 的 mail_* 键,后续 SMTP 发信与 IMAP 退信核查都跟着走这套。 + 优先级:命令行 --mail-source > params.ini [mail] source > app。 + 返回实际生效的来源。 + """ + m = (params or {}).get("mail") or {} + src = (getattr(args, "mail_source", None) or "").strip().lower() + if src not in ("app", "own"): + src = (m.get("source") or "app").strip().lower() + if src != "own": + if verbose: + print(f"[发信账号] 来源 = conf/app.ini [mail]" + f"({cfg['mail_smtp']} / {cfg['mail_account']})") + return "app" + + smtp = (m.get("smtp") or "").strip() + account = (m.get("account") or "").strip() + raw_pw = (m.get("password") or "").strip() + password = resolve_mail_password(raw_pw) + missing = [k for k, v in (("smtp", smtp), ("account", account), ("password", password)) + if not v] + if missing: + sys.exit(f"[FATAL] params.ini [mail] 缺少或不完整:{', '.join(missing)}。" + f"补全后重试,或改用 --mail-source app(用 conf/app.ini 的 SMTP)") + if raw_pw and not raw_pw.lower().startswith("env:"): + print("[警告] [mail] password 以明文写在 params.ini(该文件通常会被 git 跟踪)," + "建议改成 env:变量名 从环境变量读取,或把 params.ini 加入 .gitignore") + + cfg["mail_smtp"] = smtp + cfg["mail_account"] = account + cfg["mail_password"] = password + cfg["mail_port"] = int(m.get("port") or 465) + cfg["mail_skip_tls_verify"] = bool(m.get("skip_tls_verify")) + if verbose: + print(f"[发信账号] 来源 = 程序自带 params.ini [mail]" + f"({smtp}:{cfg['mail_port']} / {account})") + return "own" + + def _parse_bool(raw, key=""): """解析 1/0、yes/no、true/false、on/off;空串返回 None(表示未配置)。""" low = (raw or "").strip().lower() @@ -1617,7 +1685,27 @@ def _parse_bool(raw, key=""): return None -def _preserve_extra_sections(path, managed=("path", "site", "vars", "send", "bounce")): +def _read_mail_section(path): + """读取已有 params.ini 的 [mail] 小节(保存时回写,避免被覆盖丢弃)。""" + out = {} + if not path or not os.path.isfile(path): + return out + 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 + if not cp.has_section("mail"): + return out + for k in ("source", "account", "password", "smtp", "port", "skip_tls_verify"): + out[k] = cp.get("mail", k, fallback="").strip() + return {k: v for k, v in out.items() if v} + + +def _preserve_extra_sections(path, managed=("path", "site", "vars", "send", "bounce", "mail")): """读取已有 params.ini 里非托管的小节,原样保留(避免保存时丢掉手写的配置)。""" if not path or not os.path.isfile(path): return [] @@ -1641,9 +1729,11 @@ def _preserve_extra_sections(path, managed=("path", "site", "vars", "send", "bou def save_params(path, config, site_url, box_prefix, extra_vars, lang_labels=None, - bounce=None): + bounce=None, batch_size=0, smtp_idle_reconnect=30.0, mail=None): """把参数写回 params.ini(覆盖写;vars 逐行 key = value)。 - bounce:当前生效的退信/巡检设置,回写进 [bounce] 小节以便下次直接沿用。""" + bounce:当前生效的退信/巡检设置,回写进 [bounce] 小节以便下次直接沿用。 + batch_size / smtp_idle_reconnect / mail:同属 [send]/[mail] 小节,必须回写, + 否则覆盖写会把手工加的键抹掉(mail 的小节值从原文件读回,只覆盖传入项)。""" lines = ["; broadcast.py 参数文件:每次运行自动读取;命令行参数优先级更高", "; 注意:本文件不控制 --send/--yes,正式发送仍需命令行显式指定", ""] lines.append("[path]") @@ -1663,6 +1753,10 @@ 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("; SMTP 连接空闲超过该秒数就主动重连再发(delay 调大时必开,0 关闭)") + lines.append(f"smtp_idle_reconnect = {float(smtp_idle_reconnect):g}") + lines.append("; 合并信封批量发送:渲染内容完全相同的收件人每 N 人共用一个信封(0=逐封)") + lines.append(f"batch_size = {int(batch_size)}") lines.append("") lines.append("; 退信核查 / 发送中限流巡检(均可用同名命令行参数临时覆盖)") lines.append("[bounce]") @@ -1685,6 +1779,24 @@ def save_params(path, config, site_url, box_prefix, extra_vars, lang_labels=None lines.append("; since = auto 表示从断点清单最早一条记录的时间开始扫描") lines.append(f"since = {b.get('since', 'auto')}") lines.append(f"since_days = {b.get('since_days', 3)}") + lines.append("") + # [mail]:值从原文件读回(含 env: 形式的密码),再用传入的覆盖项更新 + mv = {"source": "app", "account": "", "password": "", "smtp": "", "port": "465", + "skip_tls_verify": "0"} + mv.update(_read_mail_section(path)) + for k, v in (mail or {}).items(): + if v not in (None, ""): + mv[k] = str(v) + lines.append("; 发信账号来源:app = 用 conf/app.ini [mail] 的 SMTP(默认);") + lines.append("; own = 用下面这套程序自带 SMTP(--mail-source 可临时覆盖)") + lines.append("[mail]") + lines.append(f"source = {mv['source'] or 'app'}") + lines.append(f"account = {mv['account']}") + lines.append("; password 支持 env:变量名(推荐),避免明文写进会被 git 跟踪的文件") + lines.append(f"password = {mv['password']}") + lines.append(f"smtp = {mv['smtp']}") + lines.append(f"port = {mv['port'] or 465}") + lines.append(f"skip_tls_verify = {mv['skip_tls_verify'] or 0}") lines.extend(_preserve_extra_sections(path)) with open(path, "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") @@ -1729,6 +1841,15 @@ def run_interactive(cfg, templates, extra_vars, args, config_path): f"(每 {args.watch_every} 封一次)" f" 来源:params.ini [bounce] / 命令行,改值请编辑 params.ini") + # 发信账号来源:只有 params.ini [mail] 配齐了才提供切换 + if getattr(args, "_own_mail_ready", False): + cur = (getattr(args, "mail_source", None) or "").strip().lower() + default_src = cur if cur in ("app", "own") else "app" + src = ask("发信账号来源(app = conf/app.ini 的 SMTP;own = 程序自带 params.ini [mail])", + default_src).strip().lower() + args.mail_source = "own" if src == "own" else "app" + print(f" 发信账号 = {'程序自带 params.ini [mail]' if args.mail_source == 'own' else 'conf/app.ini [mail]'}") + # 站点地址:只有完全没值时才问(值链已在 main 里兜底过 external_url) if not site_url: site_url = ask("站点地址 site_url(如 https://box.shiroko.one)", "") @@ -1822,6 +1943,7 @@ def run_interactive(cfg, templates, extra_vars, args, config_path): save_params(DEFAULT_PARAMS, config_path, site_url, box_prefix, extra_vars, batch_size=getattr(args, "batch_size", 0) or 0, smtp_idle_reconnect=getattr(args, "smtp_idle_reconnect", 30.0) or 30.0, + mail={"source": getattr(args, "mail_source", None) or "app"}, bounce={ "watch_bounces": args.watch_bounces, "watch_every": args.watch_every, @@ -1908,6 +2030,12 @@ def build_arg_parser(): help=f"参数文件路径(默认自动读取脚本同目录 params.ini,不存在则跳过;CLI 参数优先级更高)") parser.add_argument("--no-params", action="store_true", help="不读取 params.ini 参数文件") + parser.add_argument("--mail-source", default=None, choices=["app", "own"], + help="发信账号来源:app = conf/app.ini [mail](默认);" + "own = 程序自带 SMTP(读 params.ini [mail] 的" + " account/password/smtp/port,password 支持 env:变量名)。" + "命令行优先于 params.ini [mail] source。切换后发信与" + "退信核查(IMAP)都走这套账号") parser.add_argument("--interactive", "-i", action="store_true", default=None, help="交互模式(默认开启:不带任何运行参数运行脚本时自动进入)。" "运行后逐项询问参数,并直接选择运行方式(演练/测试一封/群发)") @@ -2071,10 +2199,20 @@ def main(): elif not args.interactive: print("[警告] site_url 为空:{{site}} 与无个性域名用户的 {{box_link}} 将为空串") + # 程序自带 SMTP 是否已配齐(交互模式据此提供来源切换) + _m = params.get("mail") or {} + args._own_mail_ready = bool((_m.get("smtp") or "").strip() + and (_m.get("account") or "").strip() + and (_m.get("password") or "").strip()) + if args.interactive: site_url, args.box_prefix, extra_vars = run_interactive( cfg, templates, extra_vars, args, config_path) + # ---- 发信账号来源切换(app.ini / 程序自带 params.ini [mail])---- + # 必须在交互模式之后(交互里可能改选)、退信核查与发信之前(两者都用 cfg) + mail_source = apply_mail_source(cfg, args, params) + # ---- 退信核查模式:不查数据库、不发信;扫描(含可选剔除)后结束, # 除非同时指定 --send(剔除并补发),此时继续走正常发送流程 ---- if args.check_bounces: @@ -2105,6 +2243,8 @@ def main(): print(f" [db] host:port = {cfg['db_host']}:{cfg['db_port']} db={cfg['db_name'] or '(空)'}") print(f" [mail] smtp:port = {cfg['mail_smtp']}:{cfg['mail_port']}") print(f" [mail] account = {cfg['mail_account'] or '(空)'}") + print(f" [mail] 来源 = " + f"{'程序自带 params.ini [mail]' if mail_source == 'own' else 'conf/app.ini [mail]'}") print(f" [断点续发] state_file = {args.state}") print("=" * 56) diff --git a/params.ini b/params.ini index 4ce3d74..628a65f 100644 --- a/params.ini +++ b/params.ini @@ -32,6 +32,18 @@ smtp_idle_reconnect = 30 ; 模板含 {{name}} 等个人化占位符时内容逐人不同,自动回退逐封,不会错合。 batch_size = 0 +; 发信账号来源:app = 用 conf/app.ini [mail] 的 SMTP(默认,站内信跟随站点邮箱) +; own = 用下面这套程序自带 SMTP(命令行 --mail-source own 可临时覆盖) +; 切到 own 后,发信与退信核查(IMAP)都走这套账号 +[mail] +source = app +account = +; password 支持 env:变量名(推荐),避免明文写进会被 git 跟踪的文件 +password = +smtp = +port = 465 +skip_tls_verify = 0 + ; 退信核查 / 发送中限流巡检开关 ; 这些都能在 params.ini 里长期配置,命令行同名参数可临时覆盖 [bounce]