From 912facaf2cc2b9c58491921f4858356a9e11732d Mon Sep 17 00:00:00 2001 From: tamakyi Date: Mon, 7 Sep 2026 18:04:51 +0800 Subject: [PATCH] first init --- README.md | 176 +++ __pycache__/broadcast.cpython-313.pyc | Bin 0 -> 97225 bytes broadcast.py | 1660 +++++++++++++++++++++++++ params.ini | 20 + templates/en.html | 83 ++ templates/en.subject.txt | 1 + templates/ja.html | 83 ++ templates/ja.subject.txt | 1 + templates/single.html | 82 ++ templates/single.subject.txt | 1 + templates/zh-CN.html | 83 ++ templates/zh-CN.subject.txt | 1 + templates/zh-TW.html | 83 ++ templates/zh-TW.subject.txt | 1 + 14 files changed, 2275 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/broadcast.cpython-313.pyc create mode 100644 broadcast.py create mode 100644 params.ini create mode 100644 templates/en.html create mode 100644 templates/en.subject.txt create mode 100644 templates/ja.html create mode 100644 templates/ja.subject.txt create mode 100644 templates/single.html create mode 100644 templates/single.subject.txt create mode 100644 templates/zh-CN.html create mode 100644 templates/zh-CN.subject.txt create mode 100644 templates/zh-TW.html create mode 100644 templates/zh-TW.subject.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..47eb288 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +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 兜底),老库没有该列也能发 diff --git a/__pycache__/broadcast.cpython-313.pyc b/__pycache__/broadcast.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d0b5d0d76d5f67171a56210cac6a1b54ffd58ce5 GIT binary patch literal 97225 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.html / .subject.txt / .plain.txt), + 想改文案直接编辑对应 HTML,无需碰任何 Python 代码 + +占位符(模板中直接写,发送时逐用户替换): + {{name}} 用户名(空则用「用户」) + {{domain}} 用户个性域名(可能为空) + {{box_link}} 用户的提问箱链接:{site-url}{box-prefix}{domain};无个性域名时退回站点首页 + {{email}} 收件邮箱 + {{site}} 站点地址(--site-url 参数,未传则回退 app.ini external_url) + {{site_title}} 站点名(app.ini [app] title) + 任意自定义:--var old_domain=box.tama.guru --var expire_date=2026-12-24 + (模板里写 {{old_domain}}、{{expire_date}} 即可引用) + 注意:占位符若无对应值,发送时会替换为空串并在控制台列出缺失键,便于发现模板笔误。 + +安全设计(面向「全体用户」群发): + - 默认 dry-run:只列出收件人,绝不真正发信 + - 发送前打印数据库统计(总/已注销/未注销/有邮箱),人工输入 yes 确认(--yes 跳过) + - --to 测试地址:仅发一封;命中数据库用户则套用其姓名/域名/语言 + - --limit N 小批量试水;--delay 间隔;--pause-every/--pause-for 防限流 + - 按语言分群发送,组间 --group-pause 秒 + - 断点续发:状态文件记录已发邮箱,重跑自动跳过;--reset-state 清空重发 + - 监控报告:每次运行写 JSON 报告(--report / --no-report) + - SMTP 拒收捕获:send_message 返回的 refused 计入失败 + +用法示例: + # 1) 演练:列出收件人 + 统计(不发信) + python3 broadcast.py -c /path/to/conf/app.ini --dry-run \ + --site-url https://box.shiroko.one \ + --var old_domain=box.tama.guru --var expire_date=2026-12-24 + + # 2) 发一封到测试地址(先验证模板渲染 + SMTP 链路) + python3 broadcast.py -c /path/to/conf/app.ini --send --to you@example.com \ + --site-url https://box.shiroko.one \ + --var old_domain=box.tama.guru --var expire_date=2026-12-24 + + # 3) 正式全员群发(建议先 --limit 50 试水) + python3 broadcast.py -c /path/to/conf/app.ini --send --yes \ + --site-url https://box.shiroko.one \ + --var old_domain=box.tama.guru --var expire_date=2026-12-24 + +依赖:仅 Python 3 标准库。数据库默认调用系统 psql/mysql 客户端(零 pip 依赖), + 客户端不存在时可回退 psycopg2/pymysql(需自行 pip install)。 +""" + +import argparse +import configparser +import email.utils +import imaplib +import json +import os +import re +import shutil +import subprocess +import tempfile +from datetime import datetime, timedelta +from html import escape # noqa: F401 (模板由用户维护,脚本不做 HTML 转义,仅保留占位符替换) +from html.parser import HTMLParser +import smtplib +import ssl +import sys +import time +from email.header import Header, decode_header +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formataddr, make_msgid +from urllib.parse import quote_plus + +SCRIPT_VERSION = "2026-09-07.bouncecheck.v2" + +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") +DEFAULT_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") +DEFAULT_PARAMS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "params.ini") +DEFAULT_BOUNCE_SEEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "broadcast_bounces_seen.json") + +# 语言展示标签(未知语言直接显示语言码) +LANG_LABEL = { + "zh-CN": "简体中文", + "zh-TW": "繁體中文", + "en": "English", + "ja": "日本語", +} +# 常见语言在分群里靠前的固定顺序(其余语言排在后面) +LANG_ORDER = ["zh-CN", "zh-TW", "en", "ja"] + + +# --------------------------------------------------------------------------- +# 配置读取:兼容 ini 行内注释,并去包围引号 +# --------------------------------------------------------------------------- +def load_config(path): + if not os.path.isfile(path): + sys.exit(f"[FATAL] 配置文件不存在: {path}") + + cp = configparser.ConfigParser( + comment_prefixes=(";", "#"), + inline_comment_prefixes=(";",), + interpolation=None, + strict=False, # 允许重复键(取后者) + ) + cp.optionxform = str # 保留键大小写 + try: + cp.read(path, encoding="utf-8") + except Exception as e: # noqa: BLE001 + sys.exit(f"[FATAL] 解析配置文件失败: {e}") + + def get(section, key, default=""): + try: + v = cp.get(section, key) + except (configparser.NoSectionError, configparser.NoOptionError): + return default + if v is None: + return default + v = v.strip() + if len(v) >= 2 and v[0] == '"' and v[-1] == '"': + v = v[1:-1] + return v + + cfg = { + "app_external_url": get("app", "external_url").rstrip("/"), + "app_title": get("app", "title") or "TamaBox", + "app_default_lang": (get("app", "default_lang") or "zh-CN").strip(), + "db_type": get("database", "type") or "postgres", + "db_user": get("database", "user"), + "db_password": get("database", "password"), + "db_host": get("database", "host") or "127.0.0.1", + "db_port": int(get("database", "port") or 5432), + "db_name": get("database", "name"), + "db_schema": get("database", "schema"), + "db_sslmode": get("database", "sslmode") or "disable", + "mail_account": get("mail", "account"), + "mail_password": get("mail", "password"), + "mail_port": int(get("mail", "port") or 465), + "mail_smtp": get("mail", "smtp"), + "mail_skip_tls_verify": get("mail", "skip_tls_verify").lower() in ("1", "true", "yes"), + } + return cfg + + +# --------------------------------------------------------------------------- +# 数据库访问(零依赖优先:系统 psql/mysql 客户端;回退 psycopg2/pymysql) +# --------------------------------------------------------------------------- +def _build_pg_uri(cfg): + user = quote_plus(cfg["db_user"]) + pw = quote_plus(cfg["db_password"]) + sslmode = cfg.get("db_sslmode") or "disable" + return ( + f"postgresql://{user}:{pw}@{cfg['db_host']}:{cfg['db_port']}" + f"/{quote_plus(cfg['db_name'])}?sslmode={sslmode}" + ) + + +def _choose_driver(cfg, args): + want = (getattr(args, "db_driver", "auto") or "auto").lower() + db_type = cfg["db_type"] + + def cli_or_lib(cli_name, lib_import): + if shutil.which(cli_name): + return cli_name + try: + __import__(lib_import) + return "lib" + except ImportError: + return cli_name # 后续执行时会给出清晰报错 + + if want == "psql": + return "psql" + if want == "mysql": + return "mysql" + if want in ("psycopg2", "pymysql", "lib"): + return "lib" + if db_type == "postgres": + return cli_or_lib("psql", "psycopg2") + return cli_or_lib("mysql", "pymysql") + + +def _psql_query(cfg, sql): + uri = _build_pg_uri(cfg) + sep = "\x1f" # 单元分隔符,数据里几乎不可能出现 + cmd = ["psql", uri, "-X", "-t", "-A", "-F", sep, "-P", "null=\\N", + "-P", "footer=off", "-c", sql] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + except FileNotFoundError: + sys.exit("[FATAL] 未找到 psql 客户端:请安装 PostgreSQL 客户端(如 apt install postgresql-client)," + "或用 --db-driver psycopg2 并 pip install psycopg2-binary") + if r.returncode != 0: + sys.exit(f"[FATAL] psql 执行失败: {r.stderr.strip()[:500]}") + rows = [] + for line in r.stdout.splitlines(): + if not line: + continue + parts = [None if p == "\\N" else p for p in line.split(sep)] + rows.append(parts) + return rows + + +def _mysql_query(cfg, sql): + fd, cnf = tempfile.mkstemp(suffix=".cnf", prefix="tb_my_") + try: + with os.fdopen(fd, "w") as f: + f.write("[client]\npassword=%s\n" % cfg["db_password"]) + os.chmod(cnf, 0o600) + cmd = ["mysql", f"-h{cfg['db_host']}", f"-P{cfg['db_port']}", + f"-u{cfg['db_user']}", f"--defaults-extra-file={cnf}", + "-D", cfg["db_name"], "-N", "-B", + "--default-character-set=utf8mb4", "-e", sql] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + except FileNotFoundError: + sys.exit("[FATAL] 未找到 mysql 客户端:请安装 MySQL 客户端(如 apt install mysql-client)," + "或用 --db-driver pymysql 并 pip install pymysql") + if r.returncode != 0: + sys.exit(f"[FATAL] mysql 执行失败: {r.stderr.strip()[:500]}") + finally: + try: + os.remove(cnf) + except OSError: + pass + rows = [] + for line in r.stdout.splitlines(): + if not line: + continue + parts = [None if p == "\\N" else p for p in line.split("\t")] + rows.append(parts) + return rows + + +def _lib_query(cfg, sql): + db_type = cfg["db_type"] + if db_type == "postgres": + try: + import psycopg2 + except ImportError: + sys.exit("[FATAL] 需要 psycopg2 驱动:pip install psycopg2-binary") + kwargs = dict(host=cfg["db_host"], port=cfg["db_port"], user=cfg["db_user"], + password=cfg["db_password"], dbname=cfg["db_name"], + sslmode=cfg.get("db_sslmode") or "disable", connect_timeout=10) + if cfg["db_schema"]: + kwargs["options"] = f"-c search_path={cfg['db_schema']}" + try: + conn = psycopg2.connect(**kwargs) + except Exception as e: # noqa: BLE001 + sys.exit(f"[FATAL] 连接 PostgreSQL 失败: {e}") + else: + try: + import pymysql + except ImportError: + sys.exit("[FATAL] 需要 pymysql 驱动:pip install pymysql") + try: + conn = pymysql.connect(host=cfg["db_host"], port=cfg["db_port"], user=cfg["db_user"], + password=cfg["db_password"], database=cfg["db_name"], + connect_timeout=10, charset="utf8mb4") + except Exception as e: # noqa: BLE001 + sys.exit(f"[FATAL] 连接 MySQL 失败: {e}") + try: + with conn.cursor() as cur: + cur.execute(sql) + rows = cur.fetchall() + finally: + conn.close() + return [list(r) for r in rows] + + +def run_query(cfg, sql, args): + drv = _choose_driver(cfg, args) + if drv == "psql": + return _psql_query(cfg, sql) + if drv == "mysql": + return _mysql_query(cfg, sql) + return _lib_query(cfg, sql) + + +# --------------------------------------------------------------------------- +# users 表定位:同库不同 schema 可能有同名 users 表,不限定 schema 时 +# psql 会按 search_path 命中错误的空表(表现为统计全 0、语言列探测失败)。 +# 枚举所有 schema 的 users 表并计数,选有数据的那个(其次带 language 列)。 +# --------------------------------------------------------------------------- +def _qid(s): + return '"' + s.replace('"', '""') + '"' + + +def resolve_users_table(cfg, args): + """返回 (限定表名, schema 名, 信息或 None)。非 postgres / 探测失败时退回裸 users。""" + if cfg["db_type"] != "postgres": + return "users", "", None + sql = ( + "SELECT table_schema FROM information_schema.tables " + "WHERE table_name = 'users' " + "AND table_schema NOT LIKE 'pg\\_%' ESCAPE '\\' " + "AND table_schema <> 'information_schema' ORDER BY table_schema" + ) + try: + rows = run_query(cfg, sql, args) + except SystemExit: + raise + except Exception: # noqa: BLE001 + return "users", "", None + schemas = [str(r[0]) for r in rows if r and r[0]] + if not schemas: + return "users", "", None + + stats = [] + for s in schemas: + n = 0 + try: + cnt_rows = run_query(cfg, f"SELECT COUNT(*) FROM {_qid(s)}.users", args) + if cnt_rows and cnt_rows[0] and cnt_rows[0][0]: + n = int(cnt_rows[0][0]) + except Exception: # noqa: BLE001 + n = 0 + lang_col = None + try: + lrows = run_query( + cfg, + "SELECT column_name FROM information_schema.columns " + "WHERE table_name = 'users' AND table_schema = " + "'" + s.replace("'", "''") + "' " + "AND column_name IN ('language', 'lang') ORDER BY column_name DESC", + args, + ) + if lrows and lrows[0] and lrows[0][0]: + lang_col = str(lrows[0][0]) + except Exception: # noqa: BLE001 + pass + stats.append((s, n, lang_col)) + + stats.sort(key=lambda st: (1 if st[1] > 0 else 0, 1 if st[2] else 0, st[1]), reverse=True) + s, n, lang = stats[0] + return f"{_qid(s)}.users", s, {"rows": n, "lang_col": lang, "all": stats} + + +def detect_lang_column(cfg, args, tbl="users"): + """返回 (sql 表达式, 列名或 None)。 + + 显式 --lang-column 时直接采用;否则真实试跑 language → lang → NULL, + NULL 兜底必然成功(全员回退默认语言),不会因列缺失 FATAL。 + """ + specified = getattr(args, "lang_column", "auto") + if specified not in (None, "", "auto"): + return specified, specified + + last_exc = None + for cand in ("language", "lang", None): + expr = cand if cand else "NULL" + probe = ( + f"SELECT name, domain, email, {expr} FROM {tbl} " + "WHERE deleted_at IS NULL AND email IS NOT NULL AND email <> '' " + "ORDER BY id LIMIT 1" + ) + try: + run_query(cfg, probe, args) + return expr, cand + except (SystemExit, Exception) as e: # noqa: BLE001 + last_exc = e + continue + sys.exit(f"[FATAL] 语言列探测全失败(含 NULL 兜底),疑似 users 表不存在或权限不足: {last_exc}") + + +def fetch_recipients(cfg, args, lang_expr=None, tbl="users"): + if lang_expr is None: + lang_expr, _ = detect_lang_column(cfg, args, tbl) + sql = ( + f"SELECT name, domain, email, {lang_expr} FROM {tbl} " + "WHERE deleted_at IS NULL AND email IS NOT NULL AND email <> '' " + "ORDER BY id" + ) + try: + rows = run_query(cfg, sql, args) + except SystemExit: + raise + except Exception as e: # noqa: BLE001 + sys.exit(f"[FATAL] 查询 users 失败: {e}") + + recipients = [] + for row in rows: + name, domain, email_addr, lang = (list(row) + [None, None, None, None])[:4] + recipients.append( + { + "name": (name or "").strip(), + "domain": (domain or "").strip(), + "email": (email_addr or "").strip(), + "language": (lang or "").strip(), + } + ) + return recipients + + +def fetch_counts(cfg, args, tbl="users"): + if cfg["db_type"] == "postgres": + sql = ( + "SELECT COUNT(*), " + "COUNT(*) FILTER (WHERE deleted_at IS NOT NULL), " + "COUNT(*) FILTER (WHERE deleted_at IS NULL), " + "COUNT(*) FILTER (WHERE deleted_at IS NULL AND email <> ''), " + "COUNT(*) FILTER (WHERE deleted_at IS NULL AND (email IS NULL OR email = '')) " + f"FROM {tbl}" + ) + else: + sql = ( + "SELECT COUNT(*), " + "SUM(CASE WHEN deleted_at IS NOT NULL THEN 1 ELSE 0 END), " + "SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END), " + "SUM(CASE WHEN deleted_at IS NULL AND email <> '' THEN 1 ELSE 0 END), " + "SUM(CASE WHEN deleted_at IS NULL AND (email IS NULL OR email = '') THEN 1 ELSE 0 END) " + f"FROM {tbl}" + ) + try: + rows = run_query(cfg, sql, args) + except SystemExit: + raise + except Exception as e: # noqa: BLE001 + sys.exit(f"[FATAL] 统计 users 失败: {e}") + if not rows: + sys.exit("[FATAL] 统计 users 返回空结果") + vals = [int(v) if v is not None else 0 for v in rows[0]] + total, deactivated, active, active_with_email, active_no_email = (vals + [0] * 5)[:5] + return { + "total": total, + "deactivated": deactivated, + "active": active, + "active_with_email": active_with_email, + "active_no_email": active_no_email, + } + + +# --------------------------------------------------------------------------- +# 模板加载:templates/.html(必需)、.subject.txt(必需)、 +# .plain.txt(可选,缺省由 HTML 自动抽取纯文本) +# --------------------------------------------------------------------------- +class _PlainExtractor(HTMLParser): + """把 HTML 正文抽成可读纯文本(跳过 style/script,块级标签转换行)。""" + + _BLOCK = ("p", "div", "tr", "li", "h1", "h2", "h3", "h4", "table", "section") + + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts = [] + self._skip = 0 + + def handle_starttag(self, tag, attrs): + if tag in ("style", "script"): + self._skip += 1 + elif tag == "br": + self.parts.append("\n") + elif tag in self._BLOCK: + self.parts.append("\n") + + def handle_endtag(self, tag): + if tag in ("style", "script"): + if self._skip: + self._skip -= 1 + elif tag in self._BLOCK: + self.parts.append("\n") + + def handle_data(self, data): + if not self._skip: + self.parts.append(data) + + +def html_to_plain(html_text): + p = _PlainExtractor() + try: + p.feed(html_text) + p.close() + except Exception: # noqa: BLE001 + return html_text + lines = [ln.strip() for ln in "".join(p.parts).splitlines()] + out, blank = [], 0 + for ln in lines: + if ln: + out.append(ln) + blank = 0 + else: + blank += 1 + if blank == 1: + out.append("") + return "\n".join(out).strip() + "\n" + + +# 单文件通用模板的保留文件名(不作为语言参与多语言扫描) +SINGLE_TPL_NAME = "single" +RESERVED_TEMPLATES = {SINGLE_TPL_NAME} + + +def load_templates(templates_dir): + """扫描模板目录,返回 {lang: {subject, html, plain}};同时返回发现顺序。 + reserved(single.*)不作为语言,单独用 load_single_template 加载。""" + if not os.path.isdir(templates_dir): + sys.exit(f"[FATAL] 模板目录不存在: {templates_dir}") + langs = [] + for fn in sorted(os.listdir(templates_dir)): + if fn.endswith(".html"): + lang = fn[: -len(".html")] + if lang and lang not in RESERVED_TEMPLATES and lang not in langs: + langs.append(lang) + if not langs: + sys.exit(f"[FATAL] 模板目录里没有找到任何 <语言>.html 文件: {templates_dir}") + + templates = {} + for lang in langs: + html_path = os.path.join(templates_dir, lang + ".html") + subj_path = os.path.join(templates_dir, lang + ".subject.txt") + plain_path = os.path.join(templates_dir, lang + ".plain.txt") + with open(html_path, "r", encoding="utf-8") as f: + html = f.read() + if os.path.isfile(subj_path): + with open(subj_path, "r", encoding="utf-8") as f: + subject = f.read().strip() + else: + print(f"[警告] 缺少 {lang}.subject.txt,回退使用通用主题") + subject = "【{{site_title}}】站内通知" + if os.path.isfile(plain_path): + with open(plain_path, "r", encoding="utf-8") as f: + plain = f.read() + else: + plain = html_to_plain(html) + templates[lang] = {"subject": subject, "html": html, "plain": plain} + return templates, langs + + +def load_single_template(templates_dir): + """加载单文件通用模板 templates/single.html(+ single.subject.txt)。 + 不存在返回 None;plain 缺省由 HTML 抽取。""" + html_path = os.path.join(templates_dir, SINGLE_TPL_NAME + ".html") + if not os.path.isfile(html_path): + return None + with open(html_path, "r", encoding="utf-8") as f: + html = f.read() + subj_path = os.path.join(templates_dir, SINGLE_TPL_NAME + ".subject.txt") + if os.path.isfile(subj_path): + with open(subj_path, "r", encoding="utf-8") as f: + subject = f.read().strip() + else: + print(f"[提示] 缺少 {SINGLE_TPL_NAME}.subject.txt,主题回退为「【{{{{site_title}}}}】站内通知」") + subject = "【{{site_title}}】站内通知" + plain_path = os.path.join(templates_dir, SINGLE_TPL_NAME + ".plain.txt") + if os.path.isfile(plain_path): + with open(plain_path, "r", encoding="utf-8") as f: + plain = f.read() + else: + plain = html_to_plain(html) + return {"subject": subject, "html": html, "plain": plain} + + +def render(template, ctx): + """{{key}} 占位符替换;返回 (结果, 未提供的键列表)。""" + missing = [] + + def repl(m): + key = m.group(1).strip() + if key in ctx: + return ctx[key] + missing.append(key) + return "" # 缺失占位符替换为空串,避免邮件里出现 {{xxx}} + + out = _PLACEHOLDER_RE.sub(repl, template) + return out, missing + + +import re # noqa: E402 +_PLACEHOLDER_RE = re.compile(r"\{\{\s*([A-Za-z0-9_\-]+)\s*\}\}") + + +def render_strict(template, ctx): + out, missing = render(template, ctx) + return out, sorted(set(missing)) + + +def resolve_lang(lang_field, default_lang, available): + """解析收件人语言:空 -> zh-CN;未知码 -> default_lang -> zh-CN -> 任一可用。""" + lang = (lang_field or "").strip() + if not lang: + lang = "zh-CN" + if lang not in available: + lang = (default_lang or "").strip() + if lang not in available: + lang = "zh-CN" + if lang not in available: + lang = available[0] + return lang + + +def build_message(cfg, to_addr, ctx, subject, html, plain): + subject, miss1 = render_strict(subject, ctx) + html, miss2 = render_strict(html, ctx) + plain, miss3 = render_strict(plain, ctx) + + msg = MIMEMultipart("alternative") + from_user = cfg.get("app_title") or "" + from_addr = cfg["mail_account"] + # RFC5322/RFC2047:非 ASCII 显示名必须先编码再放进 From, + # 否则 QQ 邮箱等会以 "From header is missing or invalid" 550 拒收。 + msg["From"] = formataddr((str(Header(from_user, "utf-8")), from_addr)) + msg["To"] = to_addr + msg["Subject"] = Header(subject, "utf-8") + msg["Date"] = email.utils.formatdate(localtime=True) + msg["Message-ID"] = make_msgid(domain=from_addr.split("@")[-1]) + msg.attach(MIMEText(plain, "plain", "utf-8")) + msg.attach(MIMEText(html, "html", "utf-8")) + return msg, sorted(set(miss1 + miss2 + miss3)) + + +def smtp_login(cfg): + port = cfg["mail_port"] + context = ssl.create_default_context() + if cfg["mail_skip_tls_verify"]: + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + if port == 465: + server = smtplib.SMTP_SSL(cfg["mail_smtp"], port, timeout=30, context=context) + else: + server = smtplib.SMTP(cfg["mail_smtp"], port, timeout=30) + server.ehlo() + server.starttls(context=context) + server.ehlo() + server.login(cfg["mail_account"], cfg["mail_password"]) + return server + + +# --------------------------------------------------------------------------- +# 断点续发状态(JSONL 追加写,崩溃安全) +# --------------------------------------------------------------------------- +def load_sent_state(state_path): + sent = {} + if not os.path.isfile(state_path): + return sent + try: + with open(state_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: # noqa: BLE001 + continue + em = (obj.get("email") or "").strip().lower() + if em: + sent[em] = obj + except Exception as e: # noqa: BLE001 + print(f"[警告] 读取断点续发清单失败,将视为空清单继续:{e}") + return sent + + +def record_sent(state_path, entry): + try: + with open(state_path, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception as e: # noqa: BLE001 + print(f"[警告] 写入断点续发清单失败(不影响本次发送):{e}") + + +def reset_state(state_path): + try: + if os.path.isfile(state_path): + os.remove(state_path) + except Exception as e: # noqa: BLE001 + print(f"[警告] 清除断点续发清单失败:{e}") + + +# --------------------------------------------------------------------------- +# 退信核查(IMAP):SMTP 250 ≠ 真正送达。企业邮箱(如阿里云)对限流、不存在的 +# 收件人常「先收下、后异步退信」,退信通知会投到发件账号的收件箱。这里用标准库 +# imaplib 登录 IMAP 扫描退信,解析出真正投递失败的收件人;配合 --prune-state +# 把这些用户从断点续发清单剔除,下次重跑自动补发。 +# --------------------------------------------------------------------------- +BOUNCE_SUBJECT_KEYWORDS = ( + "退信", "投递失败", "无法投递", "邮件被退回", "投递状态", "发送失败", + "undeliver", "delivery status", "returned mail", "failure notice", + "mail delivery subsystem", "mail could not be delivered", "delivery failure", +) +EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}") +# 中文退信常见「标签:地址」写法 + RFC DSN 的 Final-Recipient 等 +# ((?[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})", + r"(?:Final-Recipient|Original-Recipient|X-Failed-Recipients)" + r"[^\r\n@;]*[;:]\s*(?:rfc822\s*;?\s*)?" + r"(?[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})", +) +SYSTEM_LOCALPARTS = ("mailer-daemon", "postmaster", "mail-daemon", "noreply", "no-reply") + + +def _decode_hdr(value): + """解码 MIME 编码的邮件头(Subject 等)为可读文本。""" + if not value: + return "" + try: + return "".join( + frag.decode(cs or "utf-8", "replace") if isinstance(frag, bytes) else frag + for frag, cs in decode_header(str(value)) + ) + except Exception: # noqa: BLE001 + return str(value) + + +def _part_text(msg, limit=30000): + """抽取邮件正文文本(plain / html / DSN 原始块),拼接后截断。""" + chunks = [] + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + if ct in ("text/plain", "text/html"): + try: + payload = part.get_payload(decode=True) + if payload: + chunks.append(payload.decode(part.get_content_charset() or "utf-8", "replace")) + except Exception: # noqa: BLE001 + pass + elif ct == "message/delivery-status": + blocks = part.get_payload() + if isinstance(blocks, list): + for blk in blocks: + try: + for k, v in blk.items(): + chunks.append(f"{k}: {v}") + except Exception: # noqa: BLE001 + pass + if sum(len(c) for c in chunks) > limit: + break + else: + try: + payload = msg.get_payload(decode=True) + if payload: + chunks.append(payload.decode(msg.get_content_charset() or "utf-8", "replace")) + except Exception: # noqa: BLE001 + pass + return "\n".join(chunks)[:limit] + + +def _dsn_failed_recipients(msg): + """标准 DSN(message/delivery-status)中 Action: failed 的收件人。""" + out = [] + for part in msg.walk(): + if part.get_content_type() != "message/delivery-status": + continue + blocks = part.get_payload() + if not isinstance(blocks, list): + continue + for blk in blocks: + try: + final = str(blk.get("Final-Recipient") or blk.get("Original-Recipient") or "") + action = str(blk.get("Action") or "failed").lower() + except Exception: # noqa: BLE001 + continue + m = EMAIL_RE.search(final) + if m and "failed" in action: + out.append(m.group(0).strip(".").lower()) + return out + + +def _own_ok(addr, own_addr): + a = addr.strip(".").lower() + if not a or a == own_addr.lower(): + return False + local = a.split("@", 1)[0].lower() + return local not in SYSTEM_LOCALPARTS + + +def is_bounce_message(msg): + """判断一封邮件是否退信通知:主题关键词 或 multipart/report 送达报告。""" + subject = _decode_hdr(msg.get("Subject", "")).lower() + if any(k in subject for k in BOUNCE_SUBJECT_KEYWORDS): + return True + if msg.get_content_type() == "multipart/report": + report_type = str(msg.get_param("Report-Type", "")).lower() + if "delivery" in report_type or "status" in report_type: + return True + return False + + +def extract_bounced_recipients(msg, own_addr): + """从退信中解析投递失败的收件人列表(已去重、剔除发件人与系统地址)。""" + if not is_bounce_message(msg): + return [] + recipients = [] + # 1) 标准 DSN 最可靠 + for r in _dsn_failed_recipients(msg): + if _own_ok(r, own_addr) and r not in recipients: + recipients.append(r) + # 2) 中文退信的「标签:地址」写法 + if not recipients: + text = _part_text(msg) + for pat in LABELED_PATTERNS: + for m in re.finditer(pat, text, re.IGNORECASE): + e = m.group("e") + if _own_ok(e, own_addr) and e.lower() not in recipients: + recipients.append(e.lower()) + # 3) 兜底:正文里出现的其他地址(剔除发件人自身与 daemon/postmaster) + if not recipients: + for e in EMAIL_RE.findall(_part_text(msg)): + if _own_ok(e, own_addr) and e.lower() not in recipients: + recipients.append(e.lower()) + return recipients + + +def prune_state(state_path, emails): + """从断点续发清单剔除指定邮箱(实际未送达,重跑会补发)。返回剔除条数。""" + if not os.path.isfile(state_path): + return 0 + targets = {e.strip().lower() for e in emails} + kept, removed = [], 0 + with open(state_path, "r", encoding="utf-8") as f: + for line in f: + s = line.strip() + if not s: + continue + try: + obj = json.loads(s) + em = (obj.get("email") or "").strip().lower() + except Exception: # noqa: BLE001 + kept.append(s) + continue + if em in targets: + removed += 1 + else: + kept.append(s) + if removed: + tmp = state_path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write("\n".join(kept) + ("\n" if kept else "")) + os.replace(tmp, state_path) + return removed + + +def resolve_scan_start(args): + """退信扫描起点:--since 显式日期 > auto 时取断点清单最早一条发送记录的 + 时间(精确到该时刻,不加余量)> 无记录时回退 --since-days。 + 返回 (datetime, 说明文字)。""" + since_arg = (getattr(args, "since", "auto") or "auto").strip() + if since_arg and since_arg.lower() != "auto": + try: + dt = datetime.strptime(since_arg, "%Y-%m-%d") + return dt, f"--since 指定日期 {since_arg}" + except ValueError: + print(f"[警告] --since 格式应为 YYYY-MM-DD,已忽略:{since_arg}(改用 auto)") + sent = load_sent_state(args.state) + ts_list = [obj.get("ts") for obj in sent.values() + if isinstance(obj, dict) and obj.get("ts")] + if ts_list: + start = datetime.fromtimestamp(min(ts_list)) + return start, f"群发首条记录时间 {start.strftime('%Y-%m-%d %H:%M')}" + return (datetime.now() - timedelta(days=args.since_days), + f"断点清单为空,回退扫描近 {args.since_days} 天") + + +def check_bounces(cfg, args): + """登录发件邮箱 IMAP,扫描近 N 天的退信并解析失败收件人。 + 返回 {email: (subject, date_str)};已处理过的邮件(Message-ID)记录在 + broadcast_bounces_seen.json,重复核查不会重复报告。""" + smtp_host = cfg["mail_smtp"] or "" + imap_host = (args.imap_host or "").strip() + if not imap_host: + imap_host = smtp_host.replace("smtp.", "imap.", 1) if smtp_host else "" + if not imap_host: + sys.exit("[FATAL] 无法推导 IMAP 服务器地址,请用 --imap-host 指定") + account = cfg["mail_account"] + password = cfg["mail_password"] + start_dt, start_desc = resolve_scan_start(args) + since = start_dt.strftime("%d-%b-%Y") + + print(f" [IMAP] {imap_host}:{args.imap_port} 账号 {account}") + print(f" [扫描起点] {start_desc} → 自 {since} 起的邮件") + try: + conn = imaplib.IMAP4_SSL(imap_host, args.imap_port) + conn.login(account, password) + conn.select("INBOX", readonly=True) + except Exception as e: # noqa: BLE001 + sys.exit(f"[FATAL] IMAP 登录失败(请确认邮箱已开启 IMAP,密码用邮箱登录密码): {e}") + + typ, data = conn.search(None, f'(SINCE "{since}")') + if typ != "OK": + sys.exit("[FATAL] IMAP search 失败") + ids = data[0].split() + print(f" [IMAP] 扫描范围内共 {len(ids)} 封待扫描") + + seen = set() + if os.path.isfile(DEFAULT_BOUNCE_SEEN): + try: + with open(DEFAULT_BOUNCE_SEEN, "r", encoding="utf-8") as f: + seen = set(json.load(f)) + except Exception: # noqa: BLE001 + seen = set() + + bounced = {} # email -> (subject, date) + scanned, bounce_cnt, newly_seen = 0, 0, [] + for i, mid in enumerate(ids, 1): + uid = mid.decode() if isinstance(mid, bytes) else str(mid) + # 两段式:先取头部(省流量),命中退信特征再取全文 + typ, hdata = conn.fetch(mid, "(BODY.PEEK[HEADER.FIELDS (SUBJECT MESSAGE-ID FROM CONTENT-TYPE)])") + if typ != "OK" or not hdata or hdata[0] is None: + continue + try: + head = email.message_from_bytes(hdata[0][1]) + except Exception: # noqa: BLE001 + continue + msg_id = (head.get("Message-ID") or "").strip() or f"uid:{uid}" + if msg_id in seen: + continue + scanned += 1 + if not is_bounce_message(head): + continue + typ, fdata = conn.fetch(mid, "(RFC822)") + if typ != "OK" or not fdata or fdata[0] is None: + continue + try: + full = email.message_from_bytes(fdata[0][1]) + except Exception: # noqa: BLE001 + continue + subject = _decode_hdr(full.get("Subject", "")) or "(无主题)" + date = _decode_hdr(full.get("Date", "")) + rcpts = extract_bounced_recipients(full, account) + bounce_cnt += 1 + newly_seen.append(msg_id) + for r in rcpts: + bounced.setdefault(r, (subject, date)) + print(f" [退信 {i}/{len(ids)}] {subject[:40]} → {', '.join(rcpts) if rcpts else '(未解析出收件人)'}") + + try: + conn.logout() + except Exception: # noqa: BLE001 + pass + + print("\n========================================================") + print(f"退信核查完成:扫描 {scanned} 封未处理邮件,其中退信 {bounce_cnt} 封," + f"涉及 {len(bounced)} 个收件人。") + if bounced: + for r, (subj, dt) in sorted(bounced.items()): + print(f" - {r} ({dt} · {subj[:36]})") + print("\n这些用户实际未收到邮件。") + if args.prune_state: + n = prune_state(args.state, bounced.keys()) + print(f"[剔除] 已从断点续发清单剔除 {n} 条记录:{args.state}") + if getattr(args, "send", False): + print(" 本次发送将继续进行,这些用户会被包含在内(剔除并补发)。") + else: + print(" 本次不发送;下次重跑(同参数)会自动给这些用户补发。") + else: + print("可选后续:--prune-state 仅剔除不发送;--prune-state --send 剔除后立即补发;") + print(" 或交互模式运行方式 4 里选择对应核查方式。") + else: + print("未发现新的退信,此前发送均正常送达(至少未被退回)。") + + if newly_seen: + try: + with open(DEFAULT_BOUNCE_SEEN, "w", encoding="utf-8") as f: + json.dump(sorted(set(newly_seen) | seen), f, ensure_ascii=False) + except Exception as e: # noqa: BLE001 + print(f"[警告] 记录已核查邮件失败(不影响本次结果):{e}") + return bounced + + +# --------------------------------------------------------------------------- +# 主流程 +# --------------------------------------------------------------------------- +def parse_var(items): + extra = {} + for it in items or []: + if "=" not in it: + sys.exit(f"[FATAL] --var 格式应为 key=value:{it}") + k, v = it.split("=", 1) + k = k.strip() + if not k: + sys.exit(f"[FATAL] --var 的键不能为空:{it}") + extra[k] = v + return extra + + +# --------------------------------------------------------------------------- +# 参数文件(params.ini):运行时自动读取,CLI 参数优先级更高。 +# 注意:出于安全考虑,参数文件只能提供内容性参数(站点地址/自定义占位符/ +# 限速/测试邮箱等),绝不会提供 --send / --yes——正式发送永远需要 +# 命令行显式指定并在终端确认。 +# --------------------------------------------------------------------------- +def load_params(path): + """读取参数文件;文件不存在返回空 dict(不报错,属可选配置)。""" + 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 as e: # noqa: BLE001 + sys.exit(f"[FATAL] 解析参数文件失败: {e}") + + data = {"config": "", "site_url": "", "box_prefix": "", "vars": {}, "to": "", + "limit": None, "delay": None, "group_pause": 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() + if cp.has_section("vars"): + data["vars"] = {k: v.strip() for k, v in cp.items("vars")} + data["to"] = cp.get("send", "to", fallback="").strip() + for key, cast in (("limit", int), ("delay", float), ("group_pause", float)): + raw = cp.get("send", key, fallback="").strip() + if raw: + try: + data[key] = cast(raw) + except ValueError: + print(f"[警告] params.ini [send] {key}={raw!r} 不是合法数字,已忽略") + return data + + +def save_params(path, config, site_url, box_prefix, extra_vars, lang_labels=None): + """把参数写回 params.ini(覆盖写;vars 逐行 key = value)。""" + lines = ["; broadcast.py 参数文件:每次运行自动读取;命令行参数优先级更高", + "; 注意:本文件不控制 --send/--yes,正式发送仍需命令行显式指定", ""] + lines.append("[path]") + lines.append("; conf/app.ini 路径(相对路径按运行脚本时的当前目录解析)") + lines.append(f"config = {config}") + lines.append("") + lines.append("[site]") + lines.append(f"site_url = {site_url}") + lines.append(f"box_prefix = {box_prefix}") + lines.append("") + lines.append("; 自定义占位符:模板里用 {{键名}} 引用") + lines.append("[vars]") + for k in sorted(extra_vars): + lines.append(f"{k} = {extra_vars[k]}") + lines.append("") + lines.append("[send]") + lines.append("; to = 可选:默认测试收件邮箱(正式群发用命令行 --send,不加 --to)") + lines.append("delay = 1.0") + lines.append("group_pause = 3.0") + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + +def collect_template_placeholders(templates, builtin_keys): + """扫描全部模板里出现的 {{占位符}},返回非内置的键集合(用于交互模式逐项询问)。""" + keys = set() + for t in templates.values(): + for txt in (t["subject"], t["html"], t["plain"]): + keys.update(m.group(1) for m in _PLACEHOLDER_RE.finditer(txt)) + return keys - builtin_keys + + +def ask(prompt, default=""): + """终端问答;非交互环境(EOF)返回默认值。""" + try: + suffix = f" [{default}]" if default != "" else "" + ans = input(f"{prompt}{suffix}: ").strip() + except EOFError: + print() + return default + return ans if ans else default + + +def run_interactive(cfg, templates, extra_vars, args, config_path): + """交互模式:只补全缺失项——已有值的参数(params.ini / 命令行 / app.ini) + 直接沿用不再询问;然后选择模板模式与运行方式。 + 返回 (site_url, box_prefix, extra_vars);args.send / args.to / args.yes / + args.limit / args.single 会按用户选择就地写入 args。""" + site_url = (args.site_url or "").strip().rstrip("/") + box_prefix = (args.box_prefix or "/_/").strip() + + # ---- 摘要:已有值直接沿用;只有缺的才问 ---- + print("\n[参数确认] 以下直接沿用 params.ini / 命令行 / app.ini 的值,不再逐项询问" + "(要改请编辑 params.ini,或本次直接写死到模板 html 里):") + print(f" site_url = {site_url or '(空)'}") + print(f" box_prefix = {box_prefix}") + for k in sorted(extra_vars): + print(f" {{{{{k}}}}} = {extra_vars[k]}") + + # 站点地址:只有完全没值时才问(值链已在 main 里兜底过 external_url) + if not site_url: + site_url = ask("站点地址 site_url(如 https://box.shiroko.one)", "") + + # 自定义占位符:只询问模板用到但还没有值的 + builtin = {"name", "domain", "email", "box_link", "site", "site_title", "year"} + need = [k for k in sorted(collect_template_placeholders(templates, builtin)) + if not str(extra_vars.get(k, "")).strip()] + for key in need: + extra_vars[key] = ask(f"占位符 {{{{{key}}}}}(模板用到但还没有值;直接回车 = 留空)", "") + + # ---- 运行方式:在这里选择,而不是靠命令行 --send/--to/--yes ---- + print("\n请选择本次运行方式:") + print(" 1) 演练 —— 只列收件人、统计与渲染预览,不发信") + print(" 2) 测试 —— 只给一个邮箱发一封,验证模板与发信链路") + print(" 3) 群发 —— 向全体未注销用户发送(发送前仍会打印统计确认)") + print(" 4) 退信核查 —— 登录发件箱扫描退信通知,核对哪些用户实际没收到") + while True: + try: + mode = input("运行方式 [1/2/3/4](直接回车 = 1): ").strip() + except EOFError: + mode = "" + if mode in ("", "1", "2", "3", "4"): + break + print(" 请输入 1、2、3 或 4") + + if mode == "4": + args.check_bounces = True + print("\n退信核查方式:") + print(" 1) 仅核查 —— 只报告哪些用户没收到,不改动断点清单(默认)") + print(" 2) 仅剔除 —— 把退信用户从断点清单剔除后结束,本次不发送") + print(" 3) 剔除并补发 —— 剔除后继续正常发送流程(发送前仍有统计与确认)") + while True: + try: + sub = input("核查方式 [1/2/3](直接回车 = 1): ").strip() + except EOFError: + sub = "" + if sub in ("", "1", "2", "3"): + break + print(" 请输入 1、2 或 3") + if sub == "2": + args.prune_state = True + elif sub == "3": + args.prune_state = True + args.send = True + return site_url, box_prefix, extra_vars + + # ---- 模板模式:多语言 / 单文件通用 ---- + print("\n请选择模板模式:") + print(" 1) 多语言 —— 按 users.language 使用各自语言的模板发送(默认)") + print(f" 2) 单文件 —— 所有人发送 templates/{SINGLE_TPL_NAME}.html 通用模板(以后复用改这一个文件)") + while True: + try: + tm = input("模板模式 [1/2](直接回车 = 1): ").strip() + except EOFError: + tm = "" + if tm in ("", "1", "2"): + break + print(" 请输入 1 或 2") + args.single = (tm == "2") + + if mode == "2": + args.send = True + args.to = ask("测试收件邮箱", args.to or "") + if not args.to: + print(" [提示] 未填测试邮箱,本次退回演练模式") + args.send = False + elif mode == "3": + args.send = True + lim = ask("最多发送 N 封(直接回车 = 不限制;建议先小量试水)", "") + if lim.isdigit() and int(lim) > 0: + args.limit = int(lim) + try: + skip = input("发送前是否保留最终确认(打印统计后输入 yes)?(Y/n): ").strip().lower() + except EOFError: + skip = "" + args.yes = skip in ("n", "no") # n = 跳过最终确认,直接发 + + # 询问是否保存回 params.ini + try: + save_ans = input("把以上参数保存到 params.ini 供下次直接使用?(y/N): ").strip().lower() + except EOFError: + save_ans = "" + if save_ans in ("y", "yes"): + save_params(DEFAULT_PARAMS, config_path, site_url, box_prefix, extra_vars) + print(f"[交互模式] 已保存到 {DEFAULT_PARAMS}") + return site_url, box_prefix, extra_vars + + +def main(): + parser = argparse.ArgumentParser( + description="TamaBox 站内信群发工具(读取 conf/app.ini,模板在 templates/ 目录,按用户语言发送)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""模板占位符: + {{name}} {{domain}} {{box_link}} {{email}} {{site}} {{site_title}} + 自定义:--var key=value 后模板里即可用 {{key}} + 个性域名链接规则:box_link = {site-url}{box-prefix}{domain};无个性域名时为站点首页 +""", + ) + parser.add_argument( + "-c", "--config", + default=None, + help="conf/app.ini 路径(默认依次取:params.ini [path] config -> 环境变量 " + "TAMABOX_CONFIG_PATH -> ./conf/app.ini;交互模式会先询问)", + ) + parser.add_argument("--templates-dir", default=DEFAULT_TEMPLATES_DIR, + help="模板目录(默认脚本同目录 templates/),需含 <语言>.html / <语言>.subject.txt") + parser.add_argument("--site-url", default="", + help="站点地址(如 https://box.shiroko.one)。未传则回退 app.ini 的 external_url") + parser.add_argument("--box-prefix", default="/_/", + help="个性域名链接前缀,默认 /_/(box_link = site-url + box-prefix + domain)") + parser.add_argument("--var", action="append", metavar="KEY=VALUE", + help="自定义占位符(可多次),如 --var old_domain=box.tama.guru") + parser.add_argument("--dry-run", action="store_true", + help="只列出收件人 + 统计,不真正发信(默认行为;与 --send 互斥)") + parser.add_argument("--send", action="store_true", + help="真正发信(不指定则仅 dry-run)") + parser.add_argument("--to", metavar="EMAIL", + help="只发给该测试地址(验证模板与发信链路),覆盖收件人列表") + parser.add_argument("--limit", type=int, default=0, + help="最多发送 N 封(0 表示不限制)") + parser.add_argument("--delay", type=float, default=1.0, + help="每封之间的间隔秒数,默认 1.0") + parser.add_argument("--pause-every", type=int, default=0, + help="每发送 N 封后额外长暂停一次(0 表示不启用)") + parser.add_argument("--pause-for", type=float, default=15.0, + help="--pause-every 触发时的长暂停秒数,默认 15.0") + parser.add_argument("--group-pause", type=float, default=3.0, + help="不同语言分组之间的额外暂停秒数,默认 3.0(0 关闭)") + parser.add_argument("--db-driver", default="auto", + choices=["auto", "psql", "mysql", "psycopg2", "pymysql"], + help="DB 驱动:auto(默认,优先系统 psql/mysql 客户端)/ psycopg2 / pymysql") + parser.add_argument("--lang-column", default="auto", + help="users 表语言列名。auto=自动探测(language/lang),都不存在则全员回退默认语言") + parser.add_argument("--state", default=DEFAULT_STATE, + help="断点续发状态文件路径(JSONL);默认脚本同目录 broadcast_state.json") + parser.add_argument("--reset-state", action="store_true", + help="发送前清空断点续发状态,从头全量发送(慎用);仅在 --send 时生效") + parser.add_argument("--report", default=DEFAULT_REPORT, + help="监控报告文件路径(JSON);默认脚本同目录 broadcast_report.json") + parser.add_argument("--no-report", action="store_true", + help="关闭监控报告写入") + parser.add_argument("--yes", action="store_true", + help="跳过发送前的人工确认(非交互/自动化用)") + parser.add_argument("--params", default=None, metavar="FILE", + help=f"参数文件路径(默认自动读取脚本同目录 params.ini,不存在则跳过;CLI 参数优先级更高)") + parser.add_argument("--no-params", action="store_true", + help="不读取 params.ini 参数文件") + parser.add_argument("--interactive", "-i", action="store_true", default=None, + help="交互模式(默认开启:不带任何运行参数运行脚本时自动进入)。" + "运行后逐项询问参数,并直接选择运行方式(演练/测试一封/群发)") + parser.add_argument("--no-interactive", action="store_true", + help="关闭交互模式,按命令行/params.ini 参数直接执行(脚本化用)") + parser.add_argument("--single", action="store_true", + help="单文件模式:所有人不论语言都发送 templates/single.html 通用模板" + "(交互模式里也可选择)") + parser.add_argument("--check-bounces", action="store_true", + help="退信核查:登录发件邮箱 IMAP 扫描退信通知,找出实际未送达的收件人" + "(SMTP 250 不代表送达;限流等退信是异步投到发件箱的)") + parser.add_argument("--prune-state", action="store_true", + help="配合 --check-bounces:把退信用户从断点续发清单剔除后结束(仅剔除," + "本次不发送);再加 --send 则剔除后立即补发") + parser.add_argument("--imap-host", default="", + help="IMAP 服务器地址(默认由 SMTP 域名推导:smtp.xxx → imap.xxx)") + parser.add_argument("--imap-port", type=int, default=993, + help="IMAP 端口(默认 993,SSL)") + parser.add_argument("--since", default="auto", metavar="auto|YYYY-MM-DD", + help="退信扫描起点:auto(默认)= 从群发记录(断点清单)最早一条的" + "时间开始,只核查本次群发相关的退信;" + "也可显式指定日期如 2026-09-07") + parser.add_argument("--since-days", type=int, default=3, + help="断点清单为空时的回退扫描范围(天),默认 3 天") + parser.add_argument("--subject", default=None, + help="全局覆盖邮件主题(忽略各语言 subject.txt;支持占位符)") + parser.add_argument("--html", metavar="FILE", + help="全局覆盖 HTML 正文模板文件(忽略按语言模板;调试用)") + parser.add_argument("--plain", metavar="FILE", + help="全局覆盖纯文本正文模板文件(调试用)") + global args + args = parser.parse_args() + + # 默认交互模式:没给任何运行方式相关参数(--send/--to/--yes/--dry-run)就自动进入交互; + # 显式 --no-interactive 强制关闭(脚本化/定时任务用) + if args.no_interactive: + args.interactive = False + elif args.interactive is None: + args.interactive = not (args.send or args.to or args.yes or args.dry_run + or args.check_bounces) + + # 载入模板 + templates, lang_list = load_templates(args.templates_dir) + single_tpl = load_single_template(args.templates_dir) + if args.single and single_tpl is None: + sys.exit(f"[FATAL] 指定了 --single,但模板目录里没有 {SINGLE_TPL_NAME}.html: {args.templates_dir}") + override_html = override_plain = None + if args.html: + with open(args.html, "r", encoding="utf-8") as f: + override_html = f.read() + if args.plain: + with open(args.plain, "r", encoding="utf-8") as f: + override_plain = f.read() + + # ---- 参数文件:自动读取 params.ini(CLI 参数优先级更高)---- + params = {} + params_path = None + if not args.no_params: + params_path = args.params or DEFAULT_PARAMS + params = load_params(params_path) + if params: + print(f"[参数文件] 已读取:{params_path}") + elif args.params: + print(f"[警告] 指定的参数文件不存在,已跳过:{params_path}") + if not args.site_url and params.get("site_url"): + args.site_url = params["site_url"] + if params.get("box_prefix"): + args.box_prefix = params["box_prefix"] # 文件值覆盖默认 /_/ + if not args.to and params.get("to"): + args.to = params["to"] + if args.limit == 0 and params.get("limit") is not None: + args.limit = params["limit"] + if args.delay == 1.0 and params.get("delay") is not None: + args.delay = params["delay"] + if args.group_pause == 3.0 and params.get("group_pause") is not None: + args.group_pause = params["group_pause"] + extra_vars = {**params.get("vars", {}), **parse_var(args.var)} # CLI --var 覆盖文件 + + # ---- conf/app.ini 路径解析:CLI > params.ini > 环境变量 > ./conf/app.ini ---- + config_path = (args.config or params.get("config") + or os.environ.get("TAMABOX_CONFIG_PATH") or "conf/app.ini") + config_explicit = bool(args.config or params.get("config")) + + # ---- 交互模式:配置路径已明确指定(CLI/params.ini)则不问,仅默认猜测时补问 ---- + if args.interactive: + if config_explicit: + print(f"[交互模式] 配置文件沿用:{config_path}") + else: + config_path = ask("配置文件路径(conf/app.ini)", config_path) + + cfg = load_config(config_path) + + site_url = (args.site_url or "").strip().rstrip("/") + # site_url 兜底链:CLI/params.ini > app.ini external_url(交互与非交互一致, + # 有值时交互模式不再询问 site_url) + if not site_url: + site_url = (cfg["app_external_url"] or "").strip().rstrip("/") + if site_url: + print(f"[提示] 未显式提供 site_url,回退使用 app.ini external_url = {site_url}") + elif not args.interactive: + print("[警告] site_url 为空:{{site}} 与无个性域名用户的 {{box_link}} 将为空串") + + if args.interactive: + site_url, args.box_prefix, extra_vars = run_interactive( + cfg, templates, extra_vars, args, config_path) + + # ---- 退信核查模式:不查数据库、不发信;扫描(含可选剔除)后结束, + # 除非同时指定 --send(剔除并补发),此时继续走正常发送流程 ---- + if args.check_bounces: + print("\n[退信核查] SMTP 250 不代表真正送达(限流/无效地址常被异步退回发件箱)") + bounced = check_bounces(cfg, args) + if args.send: + print("\n[退信核查] 处理完成,继续执行发送流程(被剔除的退信用户会包含在本次发送中)...") + else: + if bounced and not args.prune_state: + print("\n[提示] --prune-state 仅剔除不发送;--prune-state --send 剔除后立即补发。") + sys.exit(0) + + print("=" * 56) + print(f"已解析配置(站内信群发工具 v{SCRIPT_VERSION})") + print("=" * 56) + print(f" [配置] 文件 = {config_path}") + print(f" [模板] 目录 = {args.templates_dir}") + print(f" [模板] 语言 = {', '.join(lang_list)}({', '.join(LANG_LABEL.get(l, l) for l in lang_list)})") + print(f" [模板] 模式 = {'单文件通用 single.html(不按语言)' if args.single else '多语言(按 users.language)'}") + print(f" [站点] site_url = {site_url or '(空)'}") + print(f" [站点] box_prefix = {args.box_prefix}") + if extra_vars: + for k in sorted(extra_vars): + print(f" [自定义] {k:<12} = {extra_vars[k]}") + print(f" [app] title = {cfg['app_title']}") + print(f" [app] default_lang = {cfg['app_default_lang']}") + print(f" [db] type = {cfg['db_type']}") + 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" [断点续发] state_file = {args.state}") + print("=" * 56) + + if not cfg["mail_account"] or not cfg["mail_smtp"]: + sys.exit("[FATAL] [mail] account/smtp 为空,无法发信,已中止。") + + drv = _choose_driver(cfg, args) + print(f" [DB 驱动] {drv}") + + # 连接诊断:确认 psql 实际连到的库和用户 + if drv == "psql": + try: + cur = run_query(cfg, "SELECT current_database(), current_user", args) + if cur and cur[0]: + print(f" [DB 连接] database={cur[0][0]} user={cur[0][1]}") + except Exception: # noqa: BLE001 + pass + + # 定位 users 表(避免 search_path 命中别的 schema 里的同名空表) + users_table, schema_name, schema_info = resolve_users_table(cfg, args) + if schema_name: + others = "、".join( + f"{s}({c}行)" for s, c, _ in schema_info["all"] if s != schema_name + ) or "无" + print( + f" [users 表] {schema_name}.users" + f"({schema_info['rows']} 行,语言列={schema_info['lang_col'] or '无'});" + f"其他同名表:{others}" + ) + if schema_info["rows"] == 0: + print(" [警告] 所有 schema 的 users 表均为空,请确认库名/连接串是否指向生产库!") + else: + print(" [users 表] users(未做 schema 定位,按默认 search_path)") + + lang_expr, lang_name = detect_lang_column(cfg, args, users_table) + print(f" [语言列] {lang_name if lang_name else '(无,全员回退默认语言 ' + str(cfg['app_default_lang']) + ')'}") + recipients = fetch_recipients(cfg, args, lang_expr=lang_expr, tbl=users_table) + counts = fetch_counts(cfg, args, tbl=users_table) + + print("数据库用户统计:") + print(f" 总用户数(含已注销) : {counts['total']}") + print(f" 已注销用户数 : {counts['deactivated']}") + print(f" 未注销用户数 : {counts['active']}") + print(f" 未注销且有邮箱(应发送) : {counts['active_with_email']}") + print(f" 未注销但无邮箱(跳过) : {counts['active_no_email']}") + + lang_dist = {} + for r in recipients: + lg = resolve_lang(r.get("language"), cfg["app_default_lang"], lang_list) + lang_dist[lg] = lang_dist.get(lg, 0) + 1 + dist_parts = [] + for lg in [l for l in LANG_ORDER if l in lang_list]: + if lang_dist.get(lg): + dist_parts.append(f"{lg}({LANG_LABEL.get(lg, lg)}):{lang_dist[lg]}") + for lg in lang_dist: + if lg not in lang_list or lg not in LANG_ORDER: + dist_parts.append(f"{lg}:{lang_dist[lg]}") + print(f" 按语言分布(应发送) : {', '.join(dist_parts) if dist_parts else '-'}") + + if args.to: + test_email = args.to.strip() + match = None + for r in recipients: + if (r.get("email") or "").strip().lower() == test_email.lower(): + match = r + break + if match: + recipients = [match] + resolved = f"{site_url}{args.box_prefix}{match['domain']}" if match["domain"] else (site_url or "(空)") + print(f"[测试模式] 命中数据库用户:{match['name'] or '(无名)'} " + f"(lang={match['language'] or '空'}, domain={match['domain'] or '空'})," + f"套用该用户数据发送,box_link={resolved}") + else: + recipients = [{"name": "测试", "domain": "", "email": test_email, "language": ""}] + print(f"[测试模式] 测试邮箱 {test_email} 在数据库中未找到,按简体中文(zh-CN)发送") + + skipped = 0 + had_state = False + if not args.to: + sent_set = load_sent_state(args.state) + had_state = bool(sent_set) + if args.reset_state: + if args.send: + reset_state(args.state) + sent_set = {} + print(" [断点续发] 已按 --reset-state 清空状态,将从头全量发送。") + else: + print(" [断点续发] --reset-state 仅在 --send 时生效,dry-run 未清空。") + if sent_set: + before = len(recipients) + recipients = [ + r for r in recipients + if (r["email"] or "").strip().lower() not in sent_set + ] + skipped = before - len(recipients) + if skipped: + print(f" [断点续发] 已跳过 {skipped} 封(此前已发送,见状态文件)") + + # --limit 必须在断点续发过滤之后应用:先剔除已发,再取前 N 封, + # 否则会永远截到名单头部那批已发用户,跳过后剩 0 封。 + if args.limit and args.limit > 0: + if args.to: + print("[注意] --to 与 --limit 同时指定时,--limit 不生效(仅 1 个测试地址)。") + else: + recipients = recipients[: args.limit] + print(f"[限制] 仅发送前 {len(recipients)} 封(已剔除此前已发送的 {skipped} 封)。") + + if not args.to and had_state: + print(f" [断点续发] 剩余待发送:{len(recipients)} 封") + + print(f"\n本次将发送:{len(recipients)} 封邮件。") + + report = { + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "script_version": SCRIPT_VERSION, + "mode": "send" if args.send else "dry-run", + "template_mode": "single" if args.single else "multilang", + "config": { + "site_url": site_url, + "box_prefix": args.box_prefix, + "extra_vars": extra_vars, + "app_title": cfg["app_title"], + "mail_account": cfg["mail_account"] or "(空)", + "mail_smtp": cfg["mail_smtp"] or "(空)", + "db_type": cfg["db_type"], + "templates_dir": args.templates_dir, + }, + "db_stats": counts, + "lang_distribution": dict(lang_dist), + "target": len(recipients), + "sent": 0, + "failed": 0, + "smtp_rejected": 0, + "skipped_already_sent": skipped, + "failed_list": [], + "missing_placeholders": [], + "test_mode": bool(args.to), + "test_email": args.to or None, + "state_file": args.state, + "report_file": None if args.no_report else args.report, + } + + def write_report_now(): + if args.no_report: + return + try: + with open(args.report, "w", encoding="utf-8") as f: + json.dump(report, f, ensure_ascii=False, indent=2) + print(f"\n[监控报告] 已写入:{args.report}") + except Exception as e: # noqa: BLE001 + print(f"[警告] 写入监控报告失败:{e}") + + if not recipients: + report["note"] = "没有可发送的收件人" + write_report_now() + print("没有可发送的收件人,结束。") + return + + # 预览前 3 条(邮箱打码),并展示解析出的 box_link,便于发现模板/链接问题 + print("预览收件人(前 3 条,邮箱打码):") + for r in recipients[:3]: + e = r["email"] + masked = (e[:2] + "***" + e[e.rfind("@"):]) if "@" in e else e + link = f"{site_url}{args.box_prefix}{r['domain']}" if r["domain"] else (site_url or "(空)") + print(f" - {r['name'] or '(无名)'} <{masked}> domain={r['domain'] or '-'} " + f"lang={resolve_lang(r.get('language'), cfg['app_default_lang'], lang_list)} box_link={link}") + + if not args.send: + write_report_now() + print("\n[dry-run] 未指定 --send,仅演练,未发送任何邮件。") + print("如需正式发送,请追加 --send(会先要求输入 yes 确认;可先 --to 测试 / --limit 小批量)。") + return + + if not args.yes: + try: + ans = input( + f"\n请确认向以上 {len(recipients)} 位用户发送邮件(输入 yes 并回车继续,其他任意输入取消):" + ).strip().lower() + except EOFError: + print("\n[中止] 非交互环境未提供确认,已取消发送。如需自动确认请加 --yes。") + report["note"] = "非交互环境未确认,已取消" + write_report_now() + return + if ans not in ("yes", "y"): + print("[已取消] 未确认,未发送任何邮件。") + report["note"] = "用户取消" + write_report_now() + return + + sent, failed = 0, [] + smtp_rejected = 0 + total = len(recipients) + all_missing = set() + print(f"\n[发送中] 目标 {total} 封,每封间隔 {args.delay}s" + + (f",每组间隔 {args.group_pause}s" if args.group_pause > 0 else "") + + (f",每 {args.pause_every} 封暂停 {args.pause_for}s" if args.pause_every > 0 else "") + " ...") + + if args.single: + # 单文件模式:不按语言分组,所有人同一模板 + groups = {"single": list(recipients)} + group_order = ["single"] + else: + groups = {} + for r in recipients: + lg = resolve_lang(r.get("language"), cfg["app_default_lang"], lang_list) + groups.setdefault(lg, []).append(r) + group_order = [lg for lg in lang_list if lg in groups] + [lg for lg in groups if lg not in lang_list] + + try: + server = smtp_login(cfg) + except Exception as e: # noqa: BLE001 + sys.exit(f"[FATAL] SMTP 登录失败: {e}") + + try: + for gi, lg in enumerate(group_order, 1): + grp = groups[lg] + if args.single: + label = "通用模板 single.html" + tpl = single_tpl + else: + label = LANG_LABEL.get(lg, lg) + tpl = templates[lg] + print(f"\n[语言分组 {gi}/{len(group_order)}] {lg} ({label}) : 本组 {len(grp)} 封") + for r in grp: + box_link = f"{site_url}{args.box_prefix}{r['domain']}" if r["domain"] else site_url + ctx = { + "name": r["name"] or "用户", + "domain": r["domain"] or "", + "email": r["email"], + "box_link": box_link, + "site": site_url, + "site_title": cfg["app_title"], + "year": datetime.now().strftime("%Y"), + **extra_vars, + } + subject = args.subject if args.subject else tpl["subject"] + html = override_html if override_html is not None else tpl["html"] + plain = override_plain if override_plain is not None else tpl["plain"] + msg, missing = build_message(cfg, r["email"], ctx, subject, html, plain) + if missing: + all_missing.update(missing) + try: + refused = server.send_message(msg) + if refused: + refused_desc = "; ".join(f"{a} {c}:{err}" for a, (c, err) in refused.items()) + failed.append((r["email"], f"SMTP 拒收 {refused_desc}")) + smtp_rejected += 1 + print(f" [SMTP 拒收] {r['email']}: {refused_desc}") + else: + sent += 1 + if not args.to: + record_sent(args.state, {"email": r["email"], "lang": lg, "ts": time.time()}) + if sent % 25 == 0 or sent == total: + print(f" 进度 {sent}/{total} 已成功 {sent}") + except Exception as e: # noqa: BLE001 + failed.append((r["email"], str(e))) + print(f" [失败] {r['email']}: {e}") + if sent < total: + time.sleep(max(0.0, args.delay)) + if args.pause_every > 0 and sent % args.pause_every == 0: + print(f" [间隔] 已发 {sent} 封,暂停 {args.pause_for}s 防限流 ...") + time.sleep(max(0.0, args.pause_for)) + if args.group_pause > 0 and gi < len(group_order): + print(f" [组间间隔] 下一组前暂停 {args.group_pause}s ...") + time.sleep(max(0.0, args.group_pause)) + finally: + try: + server.quit() + except Exception: # noqa: BLE001 + pass + + report["sent"] = sent + report["failed"] = len(failed) + report["smtp_rejected"] = smtp_rejected + report["failed_list"] = [{"email": e, "error": err} for e, err in failed] + report["missing_placeholders"] = sorted(all_missing) + write_report_now() + + print("\n" + "=" * 56) + print(f"发送完成:成功 {sent} 封,失败 {len(failed)} 封(其中 SMTP 拒收 {smtp_rejected} 封)。") + if all_missing: + print(f"[注意] 模板中有未提供值的占位符(已替换为空串):{', '.join(sorted(all_missing))}") + if not args.to and args.send: + print(f"断点续发状态已写入:{args.state}(重跑脚本会自动跳过已发用户)") + print("[提示] SMTP 250 不代表一定送达(限流常被异步退回发件箱)。" + "发送完过几分钟可运行「python3 broadcast.py --check-bounces --prune-state」" + "核查退信并让失败用户下次自动补发。") + if failed: + print("失败清单:") + for e, err in failed: + print(f" - {e}: {err}") + print("=" * 56) + + +if __name__ == "__main__": + main() diff --git a/params.ini b/params.ini new file mode 100644 index 0000000..8e8a871 --- /dev/null +++ b/params.ini @@ -0,0 +1,20 @@ +; broadcast.py 参数文件:每次运行自动读取;命令行参数优先级更高 +; 注意:本文件不控制 --send/--yes,正式发送仍需命令行显式指定 + +[path] +; conf/app.ini 路径(相对路径按运行脚本时的当前目录解析) +config = ./auto/conf/app.ini + +[site] +site_url = https://box.shiroko.one +box_prefix = /_/ + +; 自定义占位符:模板里用 {{键名}} 引用 +[vars] +expire_date = 2026-12-24 +old_domain = box.tama.guru + +[send] +; to = 可选:默认测试收件邮箱(正式群发用命令行 --send,不加 --to) +delay = 1.0 +group_pause = 3.0 diff --git a/templates/en.html b/templates/en.html new file mode 100644 index 0000000..81e5e7a --- /dev/null +++ b/templates/en.html @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+
+
+ {{site_title}} domain change notice +
+
+ Hi {{name}}, this is a bulk notification sent to all registered users. +
+
+
+
+
Our new domain
+
{{site}}
+
+ +

First of all, thank you for using {{site_title}}. Our site has officially moved to the new domain {{site}} (previously {{old_domain}}).

+ +

Why the change?

+

The .guru domain has kept raising its renewal price in recent years, so we have migrated to the more stable .one domain.

+ +

What happens to the old domain?

+

The old domain will expire on {{expire_date}}. Until then, visits to the old domain will be redirected automatically to the new one via a permanent 301 redirect — your account and data stay unchanged. After that date, the old domain will be retired completely.

+ +

What should I do?

+

1. Remember the new domain {{site}} and use it from now on.

+

2. If you have shared your question box via a QR code before, please replace it with a new QR code generated for the new domain.

+ +

Your personal question box link (bookmark it):

+
+
+ + Open my question box + +
+ {{box_link}} +
+
+
+
+
+
+ This is an automated notification sent to all registered users. Please do not reply directly.
+ Thank you for your continued support.
+ © {{year}} {{site_title}} +
+
+
+
+
+ + diff --git a/templates/en.subject.txt b/templates/en.subject.txt new file mode 100644 index 0000000..8568e67 --- /dev/null +++ b/templates/en.subject.txt @@ -0,0 +1 @@ +【{{site_title}}】Important: we've moved to {{site}} \ No newline at end of file diff --git a/templates/ja.html b/templates/ja.html new file mode 100644 index 0000000..b0f37a5 --- /dev/null +++ b/templates/ja.html @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+
+
+ {{site_title}} ドメイン変更のお知らせ +
+
+ {{name}} 様、これは全登録ユーザーへの一括通知メールです。 +
+
+
+
+
新しいドメイン
+
{{site}}
+
+ +

日頃より {{site_title}} をご利用いただき、ありがとうございます。当サイトは新しいドメイン {{site}}(旧ドメイン:{{old_domain}})へ正式に移行しました。

+ +

なぜドメインを変更するのですか?

+

.guru ドメインは近年更新料金が連続して値上げしており、維持コストが高騰しているため、より安定した .one ドメインへ移行しました。

+ +

旧ドメインはどうなりますか?

+

旧ドメインは {{expire_date}} に失効します。失効までの間、旧ドメインへのアクセスは 301 永久リダイレクトにより自動的に新ドメインへ転送されます。アカウントやデータに変更はありません。失効後、旧ドメインは完全に使用されなくなります。

+ +

何をすればよいですか?

+

1. 新しいドメイン {{site}} を覚えて、今後はそちらにアクセスしてください。

+

2. 以前 QR コードで質問箱を共有していた場合は、新しいドメインに対応した QR コードに張り替えてください。

+ +

あなたの質問箱のアドレス(ブックマーク推奨):

+
+
+ + マイ質問箱を開く + +
+ {{box_link}} +
+
+
+
+
+
+ このメールは全登録ユーザーへ一括送信されたシステム通知です。直接の返信はできません。
+ これからも応援よろしくお願いいたします。
+ © {{year}} {{site_title}} +
+
+
+
+
+ + diff --git a/templates/ja.subject.txt b/templates/ja.subject.txt new file mode 100644 index 0000000..72a3cb2 --- /dev/null +++ b/templates/ja.subject.txt @@ -0,0 +1 @@ +【{{site_title}}】重要:サイトは {{site}} に移転しました \ No newline at end of file diff --git a/templates/single.html b/templates/single.html new file mode 100644 index 0000000..0b54e33 --- /dev/null +++ b/templates/single.html @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+
+
+ {{site_title}} 站内通知 +
+
+ 你好,{{name}}!这是一封群发的站内通知邮件 +
+
+
+ +

感谢你一直以来使用 {{site_title}}。这里是本次通知的正文内容——发送前请编辑 templates/single.html,把本段替换成你想要告知用户的内容。

+ +

小标题(可选,可删)

+

需要分段就复制这些 <p> 段落;需要强调框可以复制下面这个浅灰框。

+ +
+ 强调框示例(可删):重要信息放在这里,比如时间、地址或注意事项。 +
+ + +

你的提问箱地址(建议收藏):

+
+
+ + 访问我的提问箱 + +
+ {{box_link}} +
+
+
+
+
+
+ 这是一封系统通知邮件,由站点统一发送给全体注册用户,请勿直接回复。
+ © {{year}} {{site_title}} +
+
+
+
+
+ + diff --git a/templates/single.subject.txt b/templates/single.subject.txt new file mode 100644 index 0000000..6829f2a --- /dev/null +++ b/templates/single.subject.txt @@ -0,0 +1 @@ +【{{site_title}}】站内通知 diff --git a/templates/zh-CN.html b/templates/zh-CN.html new file mode 100644 index 0000000..7c7bc9b --- /dev/null +++ b/templates/zh-CN.html @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+
+
+ {{site_title}} 域名变更通知 +
+
+ 你好,{{name}}!这是一封群发的站内通知邮件 +
+
+
+
+
本站新域名
+
{{site}}
+
+ +

首先,感谢你一直以来使用 {{site_title}}。本站已正式切换到新域名 {{site}}(原域名:{{old_domain}})。

+ +

为什么换域名?

+

.guru 域名近年来连续涨价,续费成本越来越高,因此我们迁移到了更稳定的 .one 域名。

+ +

原域名会怎样?

+

原域名将在 {{expire_date}} 到期。到期前访问原域名会通过 301 永久重定向自动跳转到新域名,你的账号和数据都不变;到期之后,旧域名将彻底不再使用。

+ +

你该做什么?

+

1. 记住新域名 {{site}},以后直接访问这里。

+

2. 如果之前通过二维码分享过自己的提问箱,记得把二维码替换成新域名对应的新二维码。

+ +

你的提问箱地址(建议收藏):

+
+
+ + 打开我的提问箱 + +
+ {{box_link}} +
+
+
+
+
+
+ 这是一封系统通知邮件,由站点统一发送给全体注册用户,请勿直接回复。
+ 感谢你一直以来的陪伴与支持。
+ © {{year}} {{site_title}} +
+
+
+
+
+ + diff --git a/templates/zh-CN.subject.txt b/templates/zh-CN.subject.txt new file mode 100644 index 0000000..8da5f0c --- /dev/null +++ b/templates/zh-CN.subject.txt @@ -0,0 +1 @@ +【{{site_title}}】重要通知:本站已更换域名为 {{site}} \ No newline at end of file diff --git a/templates/zh-TW.html b/templates/zh-TW.html new file mode 100644 index 0000000..f9fd7bd --- /dev/null +++ b/templates/zh-TW.html @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+
+
+ {{site_title}} 網域變更通知 +
+
+ 你好,{{name}}!這是一封群發的站內通知郵件 +
+
+
+
+
本站新網域
+
{{site}}
+
+ +

首先,感謝你一直以來使用 {{site_title}}。本站已正式切換到新網域 {{site}}(原網域:{{old_domain}})。

+ +

為什麼換網域?

+

.guru 網域近年來連續漲價,續費成本越來越高,因此我們遷移到了更穩定的 .one 網域。

+ +

原網域會怎樣?

+

原網域將於 {{expire_date}} 到期。到期前造訪原網域會透過 301 永久重新導向自動跳轉到新網域,你的帳號和資料都不變;到期之後,舊網域將徹底不再使用。

+ +

你該做什麼?

+

1. 記住新網域 {{site}},以後直接造訪這裡。

+

2. 如果之前透過 QR Code 分享過自己的提問箱,記得把 QR Code 換成新網域對應的新 QR Code。

+ +

你的提問箱地址(建議收藏):

+
+
+ + 開啟我的提問箱 + +
+ {{box_link}} +
+
+
+
+
+
+ 這是一封系統通知郵件,由站點統一發送給全體註冊用戶,請勿直接回覆。
+ 感謝你一直以來的陪伴與支持。
+ © {{year}} {{site_title}} +
+
+
+
+
+ + diff --git a/templates/zh-TW.subject.txt b/templates/zh-TW.subject.txt new file mode 100644 index 0000000..89d3933 --- /dev/null +++ b/templates/zh-TW.subject.txt @@ -0,0 +1 @@ +【{{site_title}}】重要通知:本站已更換域名為 {{site}} \ No newline at end of file