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.
1270 lines
62 KiB
Python
1270 lines
62 KiB
Python
#!/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()
|