commit 912facaf2cc2b9c58491921f4858356a9e11732d Author: tamakyi Date: Mon Sep 7 18:04:51 2026 +0800 first init 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 0000000..d0b5d0d Binary files /dev/null and b/__pycache__/broadcast.cpython-313.pyc differ diff --git a/broadcast.py b/broadcast.py new file mode 100644 index 0000000..2caff8a --- /dev/null +++ b/broadcast.py @@ -0,0 +1,1660 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +TamaBox 站内信群发工具(独立项目,零依赖,不依赖、不修改 TamaBox 源码) + +功能: + - 复用生产 conf/app.ini 的 [database] 与 [mail] 配置 + - 查询 users 表中「未注销」(deleted_at IS NULL) 且已填写邮箱的用户 + - 按用户语言(users.language:zh-CN/zh-TW/en/ja)选择 templates/ 下的对应模板发送 + - 模板全部是独立文件(templates/<语言>.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