Files
tamabox/dev/notify_domain_change.py
T
tamakyi e3e59e7c69 chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
2026-09-10 10:56:27 +08:00

1270 lines
62 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
TamaBox 域名变更 · 全员邮件通知脚本(独立维护脚本,不依赖、不修改项目源码)
直接复用生产环境 conf/app.ini 中的 [database] 与 [mail] 配置:
- 连接数据库,查询 users 表中「未注销」(deleted_at IS NULL) 且已填写邮箱的用户
- 通过生产 SMTP 账号,给每位用户发送一封域名变更通知邮件(含个性化提问箱链接)
- 新旧域名(box.shiroko.one / box.tama.guru)与到期日(2026-12-24)在脚本中硬编码,已确认无误
与线上发信行为保持一致(参见 internal/mail/mail.go):
- 发件人: "{site_title} <mail.account>"
- 端口 465 → 隐式 SSL(SMTP_SSL);其他端口 → STARTTLS
- 支持 [mail] skip_tls_verify=true 跳过证书校验(自签名/内网 SMTP)
邮件正文按收件人语言(users.language,取值 zh-CN/zh-TW/en/ja)自动选择对应模板,
回退顺序:用户语言 → [app] default_lang → zh-CN(用户无语言信息时直接按 zh-CN);--subject/--html/--plain 为全局覆盖。
安全设计(面向「全体用户」群发):
- 默认 --dry-run:只列出收件人,绝不真正发信
- 发送前先打印数据库用户统计(总用户 / 已注销 / 未注销 / 未注销且有邮箱),便于核对应发数量
- 真正发信前必须人工确认(终端输入 yes),除非显式加 --yes(非交互/自动化用)
- --to 测试地址:仅向该邮箱发一封,用于验证模板与发信链路。发送前会在数据库里查找该邮箱:
命中 → 直接套用该用户的姓名/域名/语言(最贴近真实用户的渲染效果)
未命中 → 按简体中文(zh-CN)发送
- 监控报告:每次运行结束会把统计与发送结果写入报告文件(默认脚本同目录 notify_report.json),
可用 --report 改路径、--no-report 关闭;内容含 DB 统计、各语言分布、成功/失败/SMTP 拒收/已跳过数、失败清单,便于事后核查
- --limit N:限制发送条数,便于先小批量试水
- 不在一次发完:每封间隔 --delay 秒(默认 1.0);可加 --pause-every N --pause-for S 分批长暂停防限流
- 按语言分群发送:同一语言的用户归为一组依次发送,组间间隔 --group-pause 秒(默认 3.0),便于分群控制节奏与监控
- 断点续发:已发送邮箱记录在状态文件(--state,默认脚本同目录 notify_state.json,JSONL),重跑脚本自动跳过已发用户;--reset-state 清空后从头全量发送
- 单条失败不影响整体,结束汇总成功/失败数并打印失败清单
用法示例:
# 1) 演练:列出收件人 + 数据库统计(不连 SMTP)
python3 notify_domain_change.py --config /path/to/conf/app.ini --dry-run
# 2) 发一封到测试地址,验证模板 + SMTP 链路(仍需 yes 确认)
python3 notify_domain_change.py --config /path/to/conf/app.ini --to [email protected] --send
# 3) 正式全员群发(建议先用 --limit 50 小批量试水)
python3 notify_domain_change.py --config /path/to/conf/app.ini --send --limit 50
python3 notify_domain_change.py --config /path/to/conf/app.ini --send
# 4) 非交互环境自动确认(请自行确保数量无误)
python3 notify_domain_change.py --config /path/to/conf/app.ini --send --yes
# 5) 断点续发:中断后重跑会自动跳过已发用户(状态默认脚本同目录 notify_state.json)
# 如需从头全量重发,追加 --reset-state(会清状态并重新发给所有人,慎用)
python3 notify_domain_change.py --config /path/to/conf/app.ini --send --reset-state
依赖(默认零依赖):
- 默认优先调用系统自带的 psql(PostgreSQL)/ mysql(MySQL)命令行客户端,纯 Python 标准库
subprocess,无需 pip install 任何包;
- 仅当服务器上没有对应命令行客户端时,才需要 pip install psycopg2-binary(Postgres)或
pymysql(MySQL),可用 --db-driver 显式指定;
- SMTP 使用 Python 标准库 smtplib,无需额外依赖。
"""
import argparse
import configparser
import email.utils
import json
import os
import shutil
import subprocess
import tempfile
from datetime import datetime
import smtplib
import ssl
import sys
import time
from email.header import 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
# ---------------------------------------------------------------------------
# 硬编码的域名迁移信息(两个域名均已确认无误)
# ---------------------------------------------------------------------------
NEW_DOMAIN = "box.shiroko.one" # 新域名(站点已切换至此)
OLD_DOMAIN = "box.tama.guru" # 原域名(2026-12-24 到期)
EXPIRE_DATE = "2026-12-24" # 原域名到期日
# 脚本版本标记:每次重新部署后运行,若输出未出现此行,说明跑的不是最新副本
SCRIPT_VERSION = "2026-09-07.rfc5322.schema.v4"
# 断点续发状态文件:默认与脚本同目录,记录已发送邮箱(JSONL,每行一条),重跑可跳过已发用户
DEFAULT_STATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "notify_state.json")
# 监控报告文件:默认与脚本同目录,记录每次运行的统计与发送结果,便于事后核查/监控
DEFAULT_REPORT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "notify_report.json")
# ---------------------------------------------------------------------------
# 配置读取:兼容 ini 行内注释(与项目 IgnoreInlineComment:true 一致),并去包围引号
# ---------------------------------------------------------------------------
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,
# 与生产 ini 库一致:允许重复键(取后者),例如示例 [upload] 段存在两个 image_endpoint
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()
# 去掉 ini 值可能带的两侧双引号(如 title = "NekoBox")
if len(v) >= 2 and v[0] == '"' and v[-1] == '"':
v = v[1:-1]
return v
cfg = {
# [app]
"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(),
# [database]
"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]
"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 命令行客户端,
# 纯标准库 subprocess,无需 pip install 任何包;若客户端不可用再回退到
# psycopg2 / pymysql 等 Python 驱动(需 pip install)。)
# ---------------------------------------------------------------------------
def _build_pg_uri(cfg):
"""拼接 PostgreSQL 连接 URI(密码做 URL 编码,避免特殊字符破坏连接串)。"""
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):
"""选择 DB 驱动:auto 时优先用命令行客户端(零依赖),否则回退 Python 库。"""
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"
# auto 或未知取值
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="neko_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):
"""统一查询入口:按所选驱动执行 SQL,返回 list[list](NULL 为 None)。"""
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)
# ---------------------------------------------------------------------------
# 语言列探测:不同版本数据库 schema 下该列可能叫 language / lang,
# 老库甚至可能根本没有这一列。
# 注意:不能查 information_schema 来探测——它会跨 schema 误报(同名的 users
# 表在别的 schema 带 language 列,但当前查询命中的 users 没有)。所以这里用
# 「真实执行探测」:依次试 language / lang / NULL,哪个能跑通用哪个。
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# users 表定位:同库不同 schema 可能有同名 users 表(GORM 实际用的 schema
# 与 public 里的旧表不同),不限定 schema 时 psql 会按 search_path 命中错误
# 的空表(表现为统计全 0、语言列探测失败)。这里枚举所有 schema 的 users
# 表并计数,选有数据的那个;多个有数据时优先带 language/lang 列、其次行数多。
# ---------------------------------------------------------------------------
def _qid(s):
"""SQL 标识符安全引用(schema 名)。"""
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))
# 选择:有数据 > 带 language 列 > 行数多
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
# 连 NULL 兜底都失败:说明 users 表不存在 / 无权限,已无回退空间
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)
# 软删除列 deleted_at 为 NULL 表示未注销(gorm.Model / MySQL datetime 同义)
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:
# MySQL 不支持 FILTER,改用 SUM(CASE ...)
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,
}
# ---------------------------------------------------------------------------
# 邮件内容模板(占位符用 {{key}},避免与 HTML/CSS 的大括号冲突)
# 占位符:{{name}} {{domain}} {{box_link}} {{site}} {{site_title}}
# {{old_domain}} {{expire_date}}
# ---------------------------------------------------------------------------
DEFAULT_SUBJECT = "【{{site_title}}】重要通知:本站已更换域名为 {{site}}"
DEFAULT_HTML = """<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang SC','Microsoft YaHei',sans-serif;color:#1d1d1f;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f7;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08);">
<tr><td style="background:#6f42c1;padding:24px 28px;color:#fff;font-size:20px;font-weight:600;">
{{site_title}} 域名变更通知
</td></tr>
<tr><td style="padding:28px;">
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">你好,{{name}}:</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">首先,感谢你一直以来使用 {{site_title}}(狼的提问箱)。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">这是一封群发的站内通知邮件,由站点统一发送给全体注册用户,请放心查阅。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">
本站已正式切换到新域名:<strong style="color:#6f42c1;">{{site}}</strong>(原域名:{{old_domain}})。
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>为什么换域名?</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">
.guru 域名近年来连续涨价,续费成本越来越高,因此我们迁移到了更稳定的 .one 域名。
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>原域名会怎样?</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">
原域名将在 <strong>{{expire_date}}</strong> 到期。到期前,访问原域名会通过 301 永久重定向自动跳转到新域名,
你的账号和数据都不变;到期之后,旧域名将彻底不再使用。
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>我该做什么?</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;color:#444;">1. 记住新域名 {{site}},以后直接访问这里。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">2. 如果你之前通过二维码分享过自己的提问箱,记得把二维码替换成新域名对应的新二维码。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;">你的提问箱地址(建议收藏):</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">
<a href="{{box_link}}" style="color:#6f42c1;word-break:break-all;">{{box_link}}</a>
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 4px;">感谢一直以来的陪伴与支持</p>
</td></tr>
<tr><td style="padding:16px 28px;background:#fafafa;color:#86868b;font-size:12px;border-top:1px solid #eee;">
这是一封系统通知邮件,由站点管理员发送给全体注册用户。<br>
© 2026 狼的提问箱
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
"""
DEFAULT_PLAIN = """你好,{{name}}:
首先,感谢你一直以来使用 {{site_title}}(狼的提问箱)。
这是一封群发的站内通知邮件,由站点统一发送给全体注册用户,请放心查阅。
本站已正式切换到新域名:{{site}}(原域名:{{old_domain}})。
为什么换域名?
.guru 域名近年来连续涨价,续费成本越来越高,因此我们迁移到了更稳定的 .one 域名。
原域名会怎样?
原域名将在 {{expire_date}} 到期。到期前,访问原域名会通过 301 永久重定向自动跳转到新域名,你的账号和数据都不变;到期之后,旧域名将彻底不再使用。
我该做什么?
1. 记住新域名 {{site}},以后直接访问这里。
2. 如果你之前通过二维码分享过自己的提问箱,记得把二维码替换成新域名对应的新二维码。
你的提问箱地址(建议收藏):
{{box_link}}
感谢一直以来的陪伴与支持。
—— {{site_title}}
© 2026 狼的提问箱
"""
# 多语言模板:按 users.language 选择;zh-CN 即上方 DEFAULT_*,其余为翻译版。
# 占位符统一为 {{name}}/{{domain}}/{{box_link}}/{{site}}/{{site_title}}/{{old_domain}}/{{expire_date}}
ZH_TW_HTML = """<!doctype html>
<html lang="zh-TW">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang TC','Microsoft JhengHei',sans-serif;color:#1d1d1f;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f7;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08);">
<tr><td style="background:#6f42c1;padding:24px 28px;color:#fff;font-size:20px;font-weight:600;">
{{site_title}} 域名變更通知
</td></tr>
<tr><td style="padding:28px;">
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">你好,{{name}}:</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">首先,感謝你一直以來使用 {{site_title}}(狼的提問箱)。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">這是一封群發的站內通知郵件,由站點統一發送給全體註冊用戶,請放心查閱。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">
本站已正式切換到新域名:<strong style="color:#6f42c1;">{{site}}</strong>(原域名:{{old_domain}})。
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>為什麼換域名?</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">
.guru 域名近年來連續漲價,續費成本越來越高,因此我們遷移到了更穩定的 .one 域名。
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>原域名會怎樣?</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">
原域名將在 <strong>{{expire_date}}</strong> 到期。到期前,訪問原域名會通過 301 永久重定向自動跳轉到新域名,
你的帳號和資料都不變;到期之後,舊域名將徹底不再使用。
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>我該做什麼?</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;color:#444;">1. 記住新域名 {{site}},以後直接訪問這裡。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">2. 如果你之前透過 QR 碼分享過自己的提問箱,記得把 QR 碼替換成新域名對應的新 QR 碼。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;">你的提問箱地址(建議收藏):</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">
<a href="{{box_link}}" style="color:#6f42c1;word-break:break-all;">{{box_link}}</a>
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 4px;">感謝一直以來的陪伴與支持</p>
</td></tr>
<tr><td style="padding:16px 28px;background:#fafafa;color:#86868b;font-size:12px;border-top:1px solid #eee;">
這是一封系統通知郵件,由站點管理員發送給全體註冊用戶。<br>
© 2026 狼的提問箱
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
"""
ZH_TW_PLAIN = """你好,{{name}}:
首先,感謝你一直以來使用 {{site_title}}(狼的提問箱)。
這是一封群發的站內通知郵件,由站點統一發送給全體註冊用戶,請放心查閱。
本站已正式切換到新域名:{{site}}(原域名:{{old_domain}})。
為什麼換域名?
.guru 域名近年來連續漲價,續費成本越來越高,因此我們遷移到了更穩定的 .one 域名。
原域名會怎樣?
原域名將在 {{expire_date}} 到期。到期前,訪問原域名會通過 301 永久重定向自動跳轉到新域名,你的帳號和資料都不變;到期之後,舊域名將徹底不再使用。
我該做什麼?
1. 記住新域名 {{site}},以後直接訪問這裡。
2. 如果你之前透過 QR 碼分享過自己的提問箱,記得把 QR 碼替換成新域名對應的新 QR 碼。
你的提問箱地址(建議收藏):
{{box_link}}
感謝一直以來的陪伴與支持。
—— {{site_title}}
© 2026 狼的提問箱
"""
EN_HTML = """<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1d1d1f;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f7;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08);">
<tr><td style="background:#6f42c1;padding:24px 28px;color:#fff;font-size:20px;font-weight:600;">
{{site_title}} Domain change notice
</td></tr>
<tr><td style="padding:28px;">
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">Hello, {{name}}:</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">First of all, thank you for using {{site_title}} (Wolf's Question Box).</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">This is a mass internal notification email sent by the site to all registered users. Please read it with confidence.</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">
Our site has officially moved to a new domain: <strong style="color:#6f42c1;">{{site}}</strong> (previously: {{old_domain}}).
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>Why the move?</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">
The .guru domain has kept raising its renewal price year after year, so we've migrated to the more stable .one domain.
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>What happens to the old domain?</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">
The old domain will expire on <strong>{{expire_date}}</strong>. Until then, visiting the old domain will automatically
301-redirect to the new one, and your account and data stay exactly the same. After that date, the old domain will be retired completely.
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>What do you need to do?</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;color:#444;">1. Remember the new domain {{site}} and use it from now on.</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">2. If you previously shared your question box via a QR code, please replace it with a new QR code pointing to the new domain.</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;">Your question box (recommended to bookmark):</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">
<a href="{{box_link}}" style="color:#6f42c1;word-break:break-all;">{{box_link}}</a>
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 4px;">Thank you for being with us.</p>
</td></tr>
<tr><td style="padding:16px 28px;background:#fafafa;color:#86868b;font-size:12px;border-top:1px solid #eee;">
This is a system notification sent by the site administrator to all registered users.<br>
© 2026 Wolf's Question Box
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
"""
EN_PLAIN = """Hello, {{name}}:
First of all, thank you for using {{site_title}} (Wolf's Question Box).
This is a mass internal notification email sent by the site to all registered users. Please read it with confidence.
Our site has officially moved to a new domain: {{site}} (previously: {{old_domain}}).
Why the move?
The .guru domain has kept raising its renewal price year after year, so we've migrated to the more stable .one domain.
What happens to the old domain?
The old domain will expire on {{expire_date}}. Until then, visiting the old domain will automatically 301-redirect to the new one, and your account and data stay exactly the same. After that date, the old domain will be retired completely.
What do you need to do?
1. Remember the new domain {{site}} and use it from now on.
2. If you previously shared your question box via a QR code, please replace it with a new QR code pointing to the new domain.
Your question box (recommended to bookmark):
{{box_link}}
Thank you for being with us.
—— {{site_title}}
© 2026 Wolf's Question Box
"""
JA_HTML = """<!doctype html>
<html lang="ja">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Hiragino Sans','Yu Gothic',Meiryo,sans-serif;color:#1d1d1f;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f7;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08);">
<tr><td style="background:#6f42c1;padding:24px 28px;color:#fff;font-size:20px;font-weight:600;">
{{site_title}} ドメイン移転のお知らせ
</td></tr>
<tr><td style="padding:28px;">
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">{{name}} さん、こんにちは:</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">まず、いつも {{site_title}}(狼の質問箱)をご利用いただき、ありがとうございます。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">本メールはサイトから全登録ユーザーへ一斉送信されるシステム内通知です。安心してご覧ください。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">
サイトは新しいドメインへ正式に移転しました:<strong style="color:#6f42c1;">{{site}}</strong>(旧ドメイン:{{old_domain}})。
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>なぜ移転したのですか?</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">
.guru ドメインの更新料金がこの数年で連続して値上がりし、維持コストがかさむため、より安定した .one ドメインへ移行しました。
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>旧ドメインはどうなるのですか?</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">
旧ドメインは <strong>{{expire_date}}</strong> に失効します。失効までは旧ドメインへのアクセスが 301 リダイレクトで新ドメインに自動転送され、アカウントやデータはそのまま引き継がれます。失効後は旧ドメインは完全に利用できなくなります。
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;"><strong>あなたにご対応いただきたいこと:</strong></p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;color:#444;">1. 新ドメイン {{site}} を覚えて、今後はこちらをご利用ください。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#444;">2. これまで QR コードで質問箱を共有されていた場合は、新ドメインの QR コードに差し替えてください。</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 6px;">あなたの質問箱(ブックマーク推奨):</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 16px;">
<a href="{{box_link}}" style="color:#6f42c1;word-break:break-all;">{{box_link}}</a>
</p>
<p style="font-size:15px;line-height:1.7;margin:0 0 4px;">いつもご利用いただきありがとうございます。</p>
</td></tr>
<tr><td style="padding:16px 28px;background:#fafafa;color:#86868b;font-size:12px;border-top:1px solid #eee;">
本メールはサイト管理者から全登録ユーザーへ送信されるシステム通知です。<br>
© 2026 狼の質問箱
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
"""
JA_PLAIN = """{{name}} さん、こんにちは:
まず、いつも {{site_title}}(狼の質問箱)をご利用いただき、ありがとうございます。
本メールはサイトから全登録ユーザーへ一斉送信されるシステム内通知です。安心してご覧ください。
サイトは新しいドメインへ正式に移転しました:{{site}}(旧ドメイン:{{old_domain}})。
なぜ移転したのですか?
.guru ドメインの更新料金がこの数年で連続して値上がりし、維持コストがかさむため、より安定した .one ドメインへ移行しました。
旧ドメインはどうなるのですか?
旧ドメインは {{expire_date}} に失効します。失効までは旧ドメインへのアクセスが 301 リダイレクトで新ドメインに自動転送され、アカウントやデータはそのまま引き継がれます。失効後は旧ドメインは完全に利用できなくなります。
あなたにご対応いただきたいこと:
1. 新ドメイン {{site}} を覚えて、今後はこちらをご利用ください。
2. これまで QR コードで質問箱を共有されていた場合は、新ドメインの QR コードに差し替えてください。
あなたの質問箱(ブックマーク推奨):
{{box_link}}
いつもご利用いただきありがとうございます。
—— {{site_title}}
© 2026 狼の質問箱
"""
LANG_TEMPLATES = {
"zh-CN": {"subject": DEFAULT_SUBJECT, "html": DEFAULT_HTML, "plain": DEFAULT_PLAIN},
"zh-TW": {"subject": "【{{site_title}}】重要通知:本站已更換域名為 {{site}}", "html": ZH_TW_HTML, "plain": ZH_TW_PLAIN},
"en": {"subject": "【{{site_title}}】Important: we've moved to {{site}}", "html": EN_HTML, "plain": EN_PLAIN},
"ja": {"subject": "【{{site_title}}】重要:サイトは {{site}} に移転しました", "html": JA_HTML, "plain": JA_PLAIN},
}
# 语言分组发送的固定顺序 + 展示标签
LANG_ORDER = ["zh-CN", "zh-TW", "en", "ja"]
LANG_LABEL = {
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
"en": "English",
"ja": "日本語",
}
def render(template, ctx):
for k, v in ctx.items():
template = template.replace("{{" + k + "}}", v)
return template
def resolve_lang(lang_field, default_lang):
"""解析收件人语言:空 -> zh-CN;未知语言码 -> default_lang -> zh-CN。"""
lang = (lang_field or "").strip()
if not lang:
lang = "zh-CN"
if lang not in LANG_TEMPLATES:
lang = (default_lang or "").strip() or "zh-CN"
if lang not in LANG_TEMPLATES:
lang = "zh-CN"
return lang
def effective_templates(lang, args):
"""按语言选择模板;--subject/--html/--plain 为全局覆盖,优先级高于按语言默认模板。"""
base = LANG_TEMPLATES.get(lang) or LANG_TEMPLATES["zh-CN"]
subject = args.subject if args.subject != DEFAULT_SUBJECT else base["subject"]
html = args.html_template if args.html else base["html"]
plain = args.plain_template if args.plain else base["plain"]
return subject, html, plain
def build_message(cfg, to_addr, ctx, subject, html, plain):
subject = render(subject, ctx)
html = render(html, ctx)
plain = render(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
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):
"""读取已发送清单(JSONL),返回 {email_lower: entry}。"""
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):
"""追加一条已发送记录(JSONL 追加写,崩溃安全)。"""
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}")
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="TamaBox 域名变更全员邮件通知(读取生产 conf/app.ini)",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"-c", "--config",
default=os.environ.get("NEKOBOX_CONFIG_PATH", "conf/app.ini"),
help="conf/app.ini 路径(默认取环境变量 NEKOBOX_CONFIG_PATH,否则 conf/app.ini)",
)
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,避免触发 SMTP 限流")
parser.add_argument("--pause-every", type=int, default=0,
help="每发送 N 封后额外长暂停一次(0 表示不启用),配合 --pause-for 防限流")
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 客户端,零依赖)/ "
"psql / mysql(命令行客户端)/ psycopg2 / pymysql(Python 库,需 pip install)")
parser.add_argument("--lang-column", default="auto",
help="users 表中存储界面语言的列名。auto(默认)=自动探测(language/lang),"
"若都不存在则全员回退默认语言;也可手动指定如 --lang-column=lang")
parser.add_argument("--state", default=DEFAULT_STATE,
help="断点续发状态文件路径(JSONL,记录已发送邮箱);默认与脚本同目录 notify_state.json")
parser.add_argument("--reset-state", action="store_true",
help="发送前清空断点续发状态文件,从头全量发送(慎用:会重新发给所有人);仅在 --send 时生效")
parser.add_argument("--report", default=DEFAULT_REPORT,
help="监控报告文件路径(JSON,记录统计与发送结果);默认脚本同目录 notify_report.json")
parser.add_argument("--no-report", action="store_true",
help="关闭监控报告写入(默认会写 notify_report.json)")
parser.add_argument("--yes", action="store_true",
help="跳过发送前的人工确认(非交互/自动化用,请自行确保数量无误)")
parser.add_argument("--subject", default=DEFAULT_SUBJECT,
help="邮件主题(支持 {{site_title}}/{{site}} 占位符)")
parser.add_argument("--html", metavar="FILE",
help="自定义 HTML 正文模板文件路径(支持 {{name}}/{{domain}}/{{box_link}}/{{site}}/{{site_title}}/{{old_domain}}/{{expire_date}})")
parser.add_argument("--plain", metavar="FILE",
help="自定义纯文本正文模板文件路径(占位符同上)")
global args
args = parser.parse_args()
# 载入自定义模板文件
args.html_template = DEFAULT_HTML
args.plain_template = DEFAULT_PLAIN
if args.html:
with open(args.html, "r", encoding="utf-8") as f:
args.html_template = f.read()
if args.plain:
with open(args.plain, "r", encoding="utf-8") as f:
args.plain_template = f.read()
cfg = load_config(args.config)
# 透明输出已解析的配置(不打密码)
print("=" * 56)
print("已解析配置(来自生产 conf/app.ini)")
print("=" * 56)
print(f" [版本] SCRIPT_VERSION = {SCRIPT_VERSION}")
print(f" [app] external_url = {cfg['app_external_url'] or '(空)'}")
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" [mail] skip_tls = {cfg['mail_skip_tls_verify']}")
print(f" [硬编码] new_domain = {NEW_DOMAIN}")
print(f" [硬编码] old_domain = {OLD_DOMAIN}")
print(f" [硬编码] expire_date = {EXPIRE_DATE}")
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_dist[lg] = lang_dist.get(lg, 0) + 1
dist_parts = []
for lg in LANG_ORDER:
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_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]
print(f"[测试模式] 命中数据库用户:{match['name'] or '(无名)'} "
f"(lang={match['language'] or '空'}),套用该用户姓名/域名/语言发送")
else:
recipients = [{"name": "测试", "domain": "", "email": test_email, "language": "zh-CN"}]
print(f"[测试模式] 测试邮箱 {test_email} 在数据库中未找到,按简体中文(zh-CN)发送")
if args.limit and args.limit > 0:
if args.to:
print(f"[注意] --to 与 --limit 同时指定时,--limit 不生效(仅 1 个测试地址)。")
else:
recipients = recipients[: args.limit]
print(f"[限制] 仅发送前 {len(recipients)} 封。")
# 断点续发:读取已发清单,过滤掉已发送用户(测试 --to 模式不应用去重,避免污染清单)
skipped = 0
if not args.to:
sent_set = load_sent_state(args.state)
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} 封(此前已发送,见状态文件)")
print(f" [断点续发] 剩余待发送:{len(recipients)} 封")
print(f"\n本次将发送:{len(recipients)} 封邮件。")
# ---- 监控报告骨架:运行结束写入报告文件,便于事后核查 / 监控 ----
report = {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"mode": "send" if args.send else "dry-run",
"config": {
"app_title": cfg["app_title"],
"mail_account": cfg["mail_account"] or "(空)",
"mail_smtp": cfg["mail_smtp"] or "(空)",
"db_type": cfg["db_type"],
"new_domain": NEW_DOMAIN,
"old_domain": OLD_DOMAIN,
"expire_date": EXPIRE_DATE,
},
"db_stats": counts,
"lang_distribution": dict(lang_dist),
"target": len(recipients),
"sent": 0,
"failed": 0,
"smtp_rejected": 0,
"skipped_already_sent": skipped,
"failed_list": [],
"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 条(邮箱打码)
print("预览收件人(前 3 条,邮箱打码):")
for r in recipients[:3]:
e = r["email"]
masked = (e[:2] + "***" + e[e.rfind("@"):]) if "@" in e else e
print(f" - {r['name'] or '(无名)'} <{masked}> domain={r['domain'] or '-'} lang={r.get('language') or '-'}")
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)
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 "") + " ...")
# 按语言分组,顺序固定:zh-CN, zh-TW, en, ja, 其他
groups = {}
for r in recipients:
lg = resolve_lang(r.get("language"), cfg["app_default_lang"])
groups.setdefault(lg, []).append(r)
group_order = [lg for lg in LANG_ORDER if lg in groups] + [lg for lg in groups if lg not in LANG_ORDER]
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]
label = LANG_LABEL.get(lg, lg)
print(f"\n[语言分组 {gi}/{len(group_order)}] {lg} ({label}) : 本组 {len(grp)} 封")
for r in grp:
box_link = f"https://{NEW_DOMAIN}/_/{r['domain']}" if r["domain"] else f"https://{NEW_DOMAIN}"
subject, html, plain = effective_templates(lg, args)
ctx = {
"name": r["name"] or "用户",
"domain": r["domain"],
"box_link": box_link,
"site": f"https://{NEW_DOMAIN}",
"site_title": cfg["app_title"],
"old_domain": OLD_DOMAIN,
"expire_date": EXPIRE_DATE,
}
msg = build_message(cfg, r["email"], ctx, subject, html, plain)
try:
refused = server.send_message(msg)
if refused:
# RCPT TO 阶段有收件人被 SMTP 拒收:send_message 不抛异常,
# 而是返回 {地址: (code, error)};这种应计入失败而非成功
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
# 记录断点续发状态(测试 --to 模式不记录,避免污染清单)
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]
write_report_now()
print("\n" + "=" * 56)
print(f"发送完成:成功 {sent} 封,失败 {len(failed)} 封(其中 SMTP 拒收 {smtp_rejected} 封)。")
if not args.to and args.send:
print(f"断点续发状态已写入:{args.state}(重跑脚本会自动跳过已发用户)")
if failed:
print("失败清单:")
for e, err in failed:
print(f" - {e}: {err}")
print("=" * 56)
if __name__ == "__main__":
main()