发信账号来源开关:app.ini / 程序自带 SMTP

- 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 安全机制条目
This commit is contained in:
tamakyi
2026-09-08 00:23:51 +08:00
parent d4e5c611b0
commit 1407fa9fb6
3 changed files with 202 additions and 5 deletions
+145 -5
View File
@@ -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] 程序自带 SMTPsource=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):
"""发信账号来源切换:appconf/app.ini [mail],默认)/ ownparams.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 的 SMTPown = 程序自带 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/portpassword 支持 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)