Compare commits
2
Commits
f83ea3e29a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dc33604d1 | ||
|
|
5823b5fbb3 |
@@ -41,6 +41,8 @@ README — TamaBox 站内信群发工具(mail-broadcast)
|
|||||||
to = ; 可选:默认测试收件邮箱
|
to = ; 可选:默认测试收件邮箱
|
||||||
delay = 1.0
|
delay = 1.0
|
||||||
group_pause = 3.0
|
group_pause = 3.0
|
||||||
|
smtp_idle_reconnect = 30 ; SMTP 空闲超时秒数,超时就重连
|
||||||
|
batch_size = 0 ; 合并信封:内容相同的收件人每 N 人一封(0=逐封)
|
||||||
|
|
||||||
[bounce] ; 退信核查 / 发送中限流巡检(见下节)
|
[bounce] ; 退信核查 / 发送中限流巡检(见下节)
|
||||||
watch_bounces = 1 ; 发送中巡检限流退信,发现即中止
|
watch_bounces = 1 ; 发送中巡检限流退信,发现即中止
|
||||||
@@ -257,6 +259,10 @@ conf/app.ini 路径的解析优先级:
|
|||||||
- 发送前打印数据库统计与语言分布,人工核对
|
- 发送前打印数据库统计与语言分布,人工核对
|
||||||
- 每封间隔 --delay 秒(默认 1.0);--pause-every/--pause-for 防限流
|
- 每封间隔 --delay 秒(默认 1.0);--pause-every/--pause-for 防限流
|
||||||
- 按语言分群发送,组间 --group-pause 秒(默认 3.0)
|
- 按语言分群发送,组间 --group-pause 秒(默认 3.0)
|
||||||
|
- SMTP 断线自动重连:服务器会掐掉空闲连接,--delay 调大(如 60s/封)时
|
||||||
|
必现「Server not connected / please run connect() first」,整批失败。
|
||||||
|
连接空闲超过 `smtp_idle_reconnect` 秒(默认 30,0 关闭)就主动重连;
|
||||||
|
仍遇到断线则立即重试最多 3 次(间隔 2s/4s),失败才会记为该收件人失败
|
||||||
- SMTP 拒收(refused)计入失败并写入报告
|
- SMTP 拒收(refused)计入失败并写入报告
|
||||||
- From/Subject 头自动做 RFC2047 编码(中文显示名不会被 QQ 邮箱 550 拒收)
|
- From/Subject 头自动做 RFC2047 编码(中文显示名不会被 QQ 邮箱 550 拒收)
|
||||||
- 自动定位 users 表所在 schema(避免 psql 命中别的同名空表)
|
- 自动定位 users 表所在 schema(避免 psql 命中别的同名空表)
|
||||||
|
|||||||
+312
-76
@@ -62,6 +62,7 @@ TamaBox 站内信群发工具(独立项目,零依赖,不依赖、不修改
|
|||||||
import argparse
|
import argparse
|
||||||
import configparser
|
import configparser
|
||||||
import email.utils
|
import email.utils
|
||||||
|
import hashlib
|
||||||
import imaplib
|
import imaplib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -82,7 +83,7 @@ from email.mime.text import MIMEText
|
|||||||
from email.utils import formataddr, make_msgid, parsedate_to_datetime
|
from email.utils import formataddr, make_msgid, parsedate_to_datetime
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
SCRIPT_VERSION = "2026-09-07.bounceclass.v3"
|
SCRIPT_VERSION = "2026-09-07.batchenv.v4"
|
||||||
|
|
||||||
DEFAULT_STATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "broadcast_state.json")
|
DEFAULT_STATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "broadcast_state.json")
|
||||||
DEFAULT_REPORT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "broadcast_report.json")
|
DEFAULT_REPORT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "broadcast_report.json")
|
||||||
@@ -599,24 +600,43 @@ def resolve_lang(lang_field, default_lang, available):
|
|||||||
return lang
|
return lang
|
||||||
|
|
||||||
|
|
||||||
def build_message(cfg, to_addr, ctx, subject, html, plain):
|
def render_content(subject, html, plain, ctx):
|
||||||
subject, miss1 = render_strict(subject, ctx)
|
"""渲染 subject/html/plain 三段;返回 (渲染结果三元组, 缺失占位符列表)。
|
||||||
html, miss2 = render_strict(html, ctx)
|
|
||||||
plain, miss3 = render_strict(plain, ctx)
|
|
||||||
|
|
||||||
|
拆出来是为了批量发送:先渲染、按内容指纹分桶,内容完全相同的收件人
|
||||||
|
才共用一个 SMTP 信封;逐封路径与批量路径共用同一份渲染结果。
|
||||||
|
"""
|
||||||
|
subject, m1 = render_strict(subject, ctx)
|
||||||
|
html, m2 = render_strict(html, ctx)
|
||||||
|
plain, m3 = render_strict(plain, ctx)
|
||||||
|
return subject, html, plain, sorted(set(m1 + m2 + m3))
|
||||||
|
|
||||||
|
|
||||||
|
def build_message_from(cfg, to_header, subject, html, plain):
|
||||||
|
"""用已渲染好的内容构造 MIME 邮件(不再做占位符替换)。
|
||||||
|
|
||||||
|
to_header 只影响收件人看到的「收件人」显示,与实际投递无关:
|
||||||
|
批量信封的真实收件人走 SMTP 信封(RCPT TO),互相不可见。
|
||||||
|
"""
|
||||||
msg = MIMEMultipart("alternative")
|
msg = MIMEMultipart("alternative")
|
||||||
from_user = cfg.get("app_title") or ""
|
from_user = cfg.get("app_title") or ""
|
||||||
from_addr = cfg["mail_account"]
|
from_addr = cfg["mail_account"]
|
||||||
# RFC5322/RFC2047:非 ASCII 显示名必须先编码再放进 From,
|
# RFC5322/RFC2047:非 ASCII 显示名必须先编码再放进 From,
|
||||||
# 否则 QQ 邮箱等会以 "From header is missing or invalid" 550 拒收。
|
# 否则 QQ 邮箱等会以 "From header is missing or invalid" 550 拒收。
|
||||||
msg["From"] = formataddr((str(Header(from_user, "utf-8")), from_addr))
|
msg["From"] = formataddr((str(Header(from_user, "utf-8")), from_addr))
|
||||||
msg["To"] = to_addr
|
msg["To"] = to_header
|
||||||
msg["Subject"] = Header(subject, "utf-8")
|
msg["Subject"] = Header(subject, "utf-8")
|
||||||
msg["Date"] = email.utils.formatdate(localtime=True)
|
msg["Date"] = email.utils.formatdate(localtime=True)
|
||||||
msg["Message-ID"] = make_msgid(domain=from_addr.split("@")[-1])
|
msg["Message-ID"] = make_msgid(domain=from_addr.split("@")[-1])
|
||||||
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
||||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||||
return msg, sorted(set(miss1 + miss2 + miss3))
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(cfg, to_addr, ctx, subject, html, plain):
|
||||||
|
subject, html, plain, missing = render_content(subject, html, plain, ctx)
|
||||||
|
msg = build_message_from(cfg, to_addr, subject, html, plain)
|
||||||
|
return msg, missing
|
||||||
|
|
||||||
|
|
||||||
def smtp_login(cfg):
|
def smtp_login(cfg):
|
||||||
@@ -637,6 +657,118 @@ def smtp_login(cfg):
|
|||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
# 断线重连需要重试的异常:连接被服务器掐掉 / 压根没连上 / 网络层故障。
|
||||||
|
# 注意 SMTP 拒收(SMTPRecipientsRefused / SMTPDataError 等)不在其中——
|
||||||
|
# 那是服务器明确回了话,重试同样会失败,还会重复投递。
|
||||||
|
SMTP_TRANSIENT_ERRORS = (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError, OSError)
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpSession:
|
||||||
|
"""维持一条 SMTP 连接,断线自动重连。
|
||||||
|
|
||||||
|
为什么必须有:服务器会掐掉空闲连接(QQ/163/阿里云常见 5~15 分钟,
|
||||||
|
有的更短)。群发一旦把 --delay 调大(比如 60s/封),两封之间连接空闲
|
||||||
|
整整一分钟,第一封就报「Server not connected」,之后每封都是
|
||||||
|
「please run connect() first」——脚本此前只在开头登录一次,从不重连。
|
||||||
|
|
||||||
|
策略:
|
||||||
|
- 发送前若距上次活动超过 idle_reconnect 秒,主动断开重连(省得每次
|
||||||
|
都先吃一发失败)
|
||||||
|
- 仍遇到断线类异常则立即重连重试,最多 max_retries 次(重试间隔
|
||||||
|
2s/4s,给服务器喘息)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cfg, max_retries=3, idle_reconnect=30):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.max_retries = max(1, max_retries)
|
||||||
|
self.idle_reconnect = max(0, idle_reconnect)
|
||||||
|
self.server = None
|
||||||
|
self.last_used = 0.0
|
||||||
|
self.reconnects = 0
|
||||||
|
self.server = self._connect() # 启动时就连,账号/网络问题第一时间暴露
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
self.server = smtp_login(self.cfg)
|
||||||
|
self.last_used = time.time()
|
||||||
|
return self.server
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.server is not None:
|
||||||
|
try:
|
||||||
|
self.server.quit()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self.server = None
|
||||||
|
|
||||||
|
def send_to(self, msg, rcpt_addrs=None):
|
||||||
|
"""带重连的发送;rcpt_addrs 给定时做批量信封(1×MAIL FROM + N×RCPT TO + 1×DATA)。
|
||||||
|
|
||||||
|
- rcpt_addrs=None:按邮件头解析收件人(等价旧的逐封 send_message)
|
||||||
|
- rcpt_addrs=[...]:信封收件人列表,To: 头仅作显示(互相不可见)
|
||||||
|
返回 refused dict {地址: (码, 错误)}。整信封全部被拒时
|
||||||
|
SMTPRecipientsRefused 也转为 dict 返回——那是服务器明确回话,
|
||||||
|
不属于连接故障,不重试。连接类异常仍走重连重试,耗尽后抛原始异常。
|
||||||
|
"""
|
||||||
|
if (self.idle_reconnect and self.server is not None
|
||||||
|
and time.time() - self.last_used > self.idle_reconnect):
|
||||||
|
self.close()
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(1, self.max_retries + 1):
|
||||||
|
try:
|
||||||
|
if self.server is None:
|
||||||
|
self.reconnects += 1
|
||||||
|
print(f" [重连] SMTP 连接已断开,正在重新登录"
|
||||||
|
f"(第 {self.reconnects} 次)...")
|
||||||
|
self._connect()
|
||||||
|
if rcpt_addrs is None:
|
||||||
|
refused = self.server.send_message(msg)
|
||||||
|
else:
|
||||||
|
refused = self.server.send_message(
|
||||||
|
msg, from_addr=self.cfg["mail_account"],
|
||||||
|
to_addrs=list(rcpt_addrs))
|
||||||
|
self.last_used = time.time()
|
||||||
|
return refused
|
||||||
|
except smtplib.SMTPRecipientsRefused as e:
|
||||||
|
self.last_used = time.time()
|
||||||
|
return dict(e.recipients or {})
|
||||||
|
except SMTP_TRANSIENT_ERRORS as e: # noqa: PERF203
|
||||||
|
last_err = e
|
||||||
|
self.close()
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
wait = 2 * attempt
|
||||||
|
print(f" [重试] SMTP 连接异常({e}),{wait}s 后重试"
|
||||||
|
f"({attempt}/{self.max_retries - 1})")
|
||||||
|
time.sleep(wait)
|
||||||
|
raise last_err
|
||||||
|
|
||||||
|
def send_message(self, msg):
|
||||||
|
"""逐封发送(兼容旧调用),委托给 send_to。"""
|
||||||
|
return self.send_to(msg, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_too_many_rcpts(err):
|
||||||
|
"""判断拒收是否为「单封收件人数超限」类(452 / too many recipients)。
|
||||||
|
|
||||||
|
这类拒绝可以砍小批次重试解决,不能按限流中止、也不能按硬退信处理。
|
||||||
|
"""
|
||||||
|
low = str(err).strip().lower()
|
||||||
|
if low.startswith("452"):
|
||||||
|
return True
|
||||||
|
if "too many recipients" in low:
|
||||||
|
return True
|
||||||
|
if "收件人" in low and ("上限" in low or "超" in low or "过多" in low):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_smtp_result(item):
|
||||||
|
"""把 smtplib refused dict 的值 (code, msg_bytes) 规整成可读字符串。"""
|
||||||
|
code, err = item
|
||||||
|
if isinstance(err, bytes):
|
||||||
|
err = err.decode("utf-8", "replace").strip()
|
||||||
|
return f"{code} {err}".strip()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 断点续发状态(JSONL 追加写,崩溃安全)
|
# 断点续发状态(JSONL 追加写,崩溃安全)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1353,7 +1485,8 @@ def load_params(path):
|
|||||||
sys.exit(f"[FATAL] 解析参数文件失败: {e}")
|
sys.exit(f"[FATAL] 解析参数文件失败: {e}")
|
||||||
|
|
||||||
data = {"config": "", "site_url": "", "box_prefix": "", "vars": {}, "to": "",
|
data = {"config": "", "site_url": "", "box_prefix": "", "vars": {}, "to": "",
|
||||||
"limit": None, "delay": None, "group_pause": None,
|
"limit": None, "delay": None, "group_pause": None, "smtp_idle_reconnect": None,
|
||||||
|
"batch_size": None,
|
||||||
"watch_bounces": None, "watch_every": None, "prune_unknown": None,
|
"watch_bounces": None, "watch_every": None, "prune_unknown": None,
|
||||||
"no_invalid_list": None, "invalid_file": "", "bounce_report": "",
|
"no_invalid_list": None, "invalid_file": "", "bounce_report": "",
|
||||||
"imap_host": "", "imap_port": None, "since": "", "since_days": None}
|
"imap_host": "", "imap_port": None, "since": "", "since_days": None}
|
||||||
@@ -1363,7 +1496,8 @@ def load_params(path):
|
|||||||
if cp.has_section("vars"):
|
if cp.has_section("vars"):
|
||||||
data["vars"] = {k: v.strip() for k, v in cp.items("vars")}
|
data["vars"] = {k: v.strip() for k, v in cp.items("vars")}
|
||||||
data["to"] = cp.get("send", "to", fallback="").strip()
|
data["to"] = cp.get("send", "to", fallback="").strip()
|
||||||
for key, cast in (("limit", int), ("delay", float), ("group_pause", float)):
|
for key, cast in (("limit", int), ("delay", float), ("group_pause", float),
|
||||||
|
("smtp_idle_reconnect", float), ("batch_size", int)):
|
||||||
raw = cp.get("send", key, fallback="").strip()
|
raw = cp.get("send", key, fallback="").strip()
|
||||||
if raw:
|
if raw:
|
||||||
try:
|
try:
|
||||||
@@ -1605,6 +1739,8 @@ def run_interactive(cfg, templates, extra_vars, args, config_path):
|
|||||||
save_ans = ""
|
save_ans = ""
|
||||||
if save_ans in ("y", "yes"):
|
if save_ans in ("y", "yes"):
|
||||||
save_params(DEFAULT_PARAMS, config_path, site_url, box_prefix, extra_vars,
|
save_params(DEFAULT_PARAMS, config_path, site_url, box_prefix, extra_vars,
|
||||||
|
batch_size=getattr(args, "batch_size", 0) or 0,
|
||||||
|
smtp_idle_reconnect=getattr(args, "smtp_idle_reconnect", 30.0) or 30.0,
|
||||||
bounce={
|
bounce={
|
||||||
"watch_bounces": args.watch_bounces,
|
"watch_bounces": args.watch_bounces,
|
||||||
"watch_every": args.watch_every,
|
"watch_every": args.watch_every,
|
||||||
@@ -1662,6 +1798,16 @@ def build_arg_parser():
|
|||||||
help="--pause-every 触发时的长暂停秒数,默认 15.0")
|
help="--pause-every 触发时的长暂停秒数,默认 15.0")
|
||||||
parser.add_argument("--group-pause", type=float, default=3.0,
|
parser.add_argument("--group-pause", type=float, default=3.0,
|
||||||
help="不同语言分组之间的额外暂停秒数,默认 3.0(0 关闭)")
|
help="不同语言分组之间的额外暂停秒数,默认 3.0(0 关闭)")
|
||||||
|
parser.add_argument("--smtp-idle-reconnect", type=float, default=None, metavar="SEC",
|
||||||
|
help="SMTP 连接空闲超过该秒数就主动重连再发(默认 30)。"
|
||||||
|
"--delay 调大时(如 60s/封)服务器会掐掉空闲连接,"
|
||||||
|
"不重连的话每封都会先吃一发 Server not connected")
|
||||||
|
parser.add_argument("--batch-size", type=int, default=None, metavar="N",
|
||||||
|
help="合并信封批量发送(0=逐封,默认):渲染内容完全相同的收件人"
|
||||||
|
"每 N 人共用一个 SMTP 信封,发送次数从「人数」降到「信封数」,"
|
||||||
|
"可显著减少触发「外发频率超过限制」类限流(服务商按收件人计数"
|
||||||
|
"则无效)。单封收件人有上限(常见 50~100),被 452 拒收时脚本"
|
||||||
|
"自动砍半拆批重试,不会中止")
|
||||||
parser.add_argument("--db-driver", default="auto",
|
parser.add_argument("--db-driver", default="auto",
|
||||||
choices=["auto", "psql", "mysql", "psycopg2", "pymysql"],
|
choices=["auto", "psql", "mysql", "psycopg2", "pymysql"],
|
||||||
help="DB 驱动:auto(默认,优先系统 psql/mysql 客户端)/ psycopg2 / pymysql")
|
help="DB 驱动:auto(默认,优先系统 psql/mysql 客户端)/ psycopg2 / pymysql")
|
||||||
@@ -1788,6 +1934,11 @@ def main():
|
|||||||
args.delay = params["delay"]
|
args.delay = params["delay"]
|
||||||
if args.group_pause == 3.0 and params.get("group_pause") is not None:
|
if args.group_pause == 3.0 and params.get("group_pause") is not None:
|
||||||
args.group_pause = params["group_pause"]
|
args.group_pause = params["group_pause"]
|
||||||
|
if args.smtp_idle_reconnect is None:
|
||||||
|
args.smtp_idle_reconnect = (30.0 if params.get("smtp_idle_reconnect") is None
|
||||||
|
else params["smtp_idle_reconnect"])
|
||||||
|
if args.batch_size is None:
|
||||||
|
args.batch_size = params.get("batch_size") or 0
|
||||||
|
|
||||||
# 退信核查 / 发送中巡检:命令行没给(None)时才用 params.ini [bounce]
|
# 退信核查 / 发送中巡检:命令行没给(None)时才用 params.ini [bounce]
|
||||||
if args.watch_bounces is None:
|
if args.watch_bounces is None:
|
||||||
@@ -2081,6 +2232,9 @@ def main():
|
|||||||
attempted = 0
|
attempted = 0
|
||||||
total = len(recipients)
|
total = len(recipients)
|
||||||
all_missing = set()
|
all_missing = set()
|
||||||
|
batch_size = max(0, int(getattr(args, "batch_size", 0) or 0))
|
||||||
|
envelopes = 0 # SMTP 信封数(批量模式下 < 发送人数)
|
||||||
|
watch_mark = 0
|
||||||
|
|
||||||
# 发送中巡检:限流退信是异步投到发件箱的,边发边看才能及时止损
|
# 发送中巡检:限流退信是异步投到发件箱的,边发边看才能及时止损
|
||||||
watch_active = bool(getattr(args, "watch_bounces", True)) and not args.to
|
watch_active = bool(getattr(args, "watch_bounces", True)) and not args.to
|
||||||
@@ -2089,9 +2243,48 @@ def main():
|
|||||||
known_ids, watch_fails = set(), 0
|
known_ids, watch_fails = set(), 0
|
||||||
watch_rate, watch_hard, abort_reason = {}, {}, ""
|
watch_rate, watch_hard, abort_reason = {}, {}, ""
|
||||||
|
|
||||||
|
def note_smtp_err(email, err_str, subject_label):
|
||||||
|
"""SMTP 拒收按退信分类处理:硬退信记无效清单;返回限流中止理由(否则空串)。"""
|
||||||
|
cat, why = classify_bounce("", err_str, "")
|
||||||
|
if cat == CAT_HARD and not args.no_invalid_list:
|
||||||
|
record_invalid(args.invalid_file, {email: {
|
||||||
|
"reason": why, "detail": err_str[:160], "subject": subject_label,
|
||||||
|
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}})
|
||||||
|
if cat == CAT_RATE:
|
||||||
|
return f"SMTP 返回疑似限流({why}):{err_str[:100]}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def run_patrol():
|
||||||
|
"""周期巡检收件箱:命中限流退信则给出中止理由,连续两轮失败则关巡检。"""
|
||||||
|
nonlocal watch_fails, watch_active, watch_hard, abort_reason
|
||||||
|
nb, ok = watch_bounces(cfg, args, watch_start, known_ids)
|
||||||
|
if not ok:
|
||||||
|
watch_fails += 1
|
||||||
|
if watch_fails >= 2:
|
||||||
|
print(" [退信巡检] IMAP 连续不可用,本次已关闭巡检"
|
||||||
|
"(不影响发送;发完请手动跑一次 --check-bounces)")
|
||||||
|
watch_active = False
|
||||||
|
return
|
||||||
|
watch_fails = 0
|
||||||
|
for em, info in nb.items():
|
||||||
|
if info["category"] == CAT_RATE:
|
||||||
|
watch_rate[em] = info
|
||||||
|
elif info["category"] == CAT_HARD:
|
||||||
|
watch_hard[em] = info
|
||||||
|
if watch_hard:
|
||||||
|
if not args.no_invalid_list:
|
||||||
|
record_invalid(args.invalid_file, watch_hard)
|
||||||
|
watch_hard = {}
|
||||||
|
if watch_rate and not abort_reason:
|
||||||
|
abort_reason = (
|
||||||
|
f"收件箱出现 {len(watch_rate)} 条限流退信"
|
||||||
|
f"(如 {sorted(watch_rate)[0]}:"
|
||||||
|
f"{watch_rate[sorted(watch_rate)[0]]['reason']})")
|
||||||
|
|
||||||
print(f"\n[发送中] 目标 {total} 封,每封间隔 {args.delay}s"
|
print(f"\n[发送中] 目标 {total} 封,每封间隔 {args.delay}s"
|
||||||
+ (f",每组间隔 {args.group_pause}s" if args.group_pause > 0 else "")
|
+ (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 "")
|
+ (f",每 {args.pause_every} 封暂停 {args.pause_for}s" if args.pause_every > 0 else "")
|
||||||
|
+ (f",合并信封每 {batch_size} 人/封" if batch_size > 0 else "")
|
||||||
+ (f",每 {watch_every} 封巡检一次退信" if watch_active else "") + " ...")
|
+ (f",每 {watch_every} 封巡检一次退信" if watch_active else "") + " ...")
|
||||||
|
|
||||||
if args.single:
|
if args.single:
|
||||||
@@ -2106,7 +2299,7 @@ def main():
|
|||||||
group_order = [lg for lg in lang_list if lg in groups] + [lg for lg in groups if lg not in lang_list]
|
group_order = [lg for lg in lang_list if lg in groups] + [lg for lg in groups if lg not in lang_list]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
server = smtp_login(cfg)
|
session = SmtpSession(cfg, idle_reconnect=getattr(args, "smtp_idle_reconnect", 30))
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
sys.exit(f"[FATAL] SMTP 登录失败: {e}")
|
sys.exit(f"[FATAL] SMTP 登录失败: {e}")
|
||||||
|
|
||||||
@@ -2120,6 +2313,15 @@ def main():
|
|||||||
label = LANG_LABEL.get(lg, lg)
|
label = LANG_LABEL.get(lg, lg)
|
||||||
tpl = templates[lg]
|
tpl = templates[lg]
|
||||||
print(f"\n[语言分组 {gi}/{len(group_order)}] {lg} ({label}) : 本组 {len(grp)} 封")
|
print(f"\n[语言分组 {gi}/{len(group_order)}] {lg} ({label}) : 本组 {len(grp)} 封")
|
||||||
|
|
||||||
|
subject_raw = args.subject if args.subject else tpl["subject"]
|
||||||
|
html_raw = override_html if override_html is not None else tpl["html"]
|
||||||
|
plain_raw = override_plain if override_plain is not None else tpl["plain"]
|
||||||
|
|
||||||
|
# 渲染并按内容指纹分桶:只有渲染结果完全相同的收件人才共用信封。
|
||||||
|
# 模板含 {{name}}/{{box_link}} 等个人化占位符时结果逐人不同,
|
||||||
|
# 自然落回逐人一封,不会错合。
|
||||||
|
buckets, bucket_map = [], {}
|
||||||
for r in grp:
|
for r in grp:
|
||||||
box_link = f"{site_url}{args.box_prefix}{r['domain']}" if r["domain"] else site_url
|
box_link = f"{site_url}{args.box_prefix}{r['domain']}" if r["domain"] else site_url
|
||||||
ctx = {
|
ctx = {
|
||||||
@@ -2132,90 +2334,124 @@ def main():
|
|||||||
"year": datetime.now().strftime("%Y"),
|
"year": datetime.now().strftime("%Y"),
|
||||||
**extra_vars,
|
**extra_vars,
|
||||||
}
|
}
|
||||||
subject = args.subject if args.subject else tpl["subject"]
|
s, h, p, missing = render_content(subject_raw, html_raw, plain_raw, ctx)
|
||||||
html = override_html if override_html is not None else tpl["html"]
|
|
||||||
plain = override_plain if override_plain is not None else tpl["plain"]
|
|
||||||
msg, missing = build_message(cfg, r["email"], ctx, subject, html, plain)
|
|
||||||
if missing:
|
if missing:
|
||||||
all_missing.update(missing)
|
all_missing.update(missing)
|
||||||
smtp_err = ""
|
digest = hashlib.sha256(
|
||||||
try:
|
"\x00".join((s, h, p)).encode("utf-8", "replace")).hexdigest()
|
||||||
refused = server.send_message(msg)
|
bkt = bucket_map.get(digest)
|
||||||
if refused:
|
if bkt is None:
|
||||||
refused_desc = "; ".join(f"{a} {c}:{err}" for a, (c, err) in refused.items())
|
bkt = {"content": (s, h, p), "queue": []}
|
||||||
smtp_err = refused_desc
|
bucket_map[digest] = bkt
|
||||||
failed.append((r["email"], f"SMTP 拒收 {refused_desc}"))
|
buckets.append(bkt)
|
||||||
smtp_rejected += 1
|
bkt["queue"].append(r)
|
||||||
print(f" [SMTP 拒收] {r['email']}: {refused_desc}")
|
del bucket_map
|
||||||
|
|
||||||
|
for bkt in buckets:
|
||||||
|
subj_r, html_r, plain_r = bkt["content"]
|
||||||
|
queue = bkt["queue"]
|
||||||
|
while queue and not abort_reason:
|
||||||
|
take = batch_size if batch_size > 0 else 1
|
||||||
|
batch, queue = queue[:take], queue[take:]
|
||||||
|
rcpts = [r["email"] for r in batch]
|
||||||
|
|
||||||
|
if len(batch) == 1:
|
||||||
|
to_header = batch[0]["email"]
|
||||||
else:
|
else:
|
||||||
|
# 批量信封:To: 头只作显示(站点名+发件邮箱),真实收件人
|
||||||
|
# 走 RCPT TO 信封,互相不可见
|
||||||
|
to_header = formataddr((str(Header(cfg.get("app_title") or "", "utf-8")),
|
||||||
|
cfg["mail_account"]))
|
||||||
|
msg = build_message_from(cfg, to_header, subj_r, html_r, plain_r)
|
||||||
|
|
||||||
|
envelopes += 1
|
||||||
|
try:
|
||||||
|
refused = session.send_to(msg, rcpts if len(batch) > 1 else None)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
# 整信封异常(重连耗尽 / DATA 被拒等):多收件人时无法
|
||||||
|
# 区分到人,只记失败;若像限流(DATA 4xx/552)则立即中止。
|
||||||
|
for r in batch:
|
||||||
|
failed.append((r["email"], str(e)))
|
||||||
|
attempted += len(batch)
|
||||||
|
print(f" [失败] 本信封 {len(batch)} 人: {e}")
|
||||||
|
if len(batch) == 1:
|
||||||
|
ab = note_smtp_err(batch[0]["email"], str(e), subj_r)
|
||||||
|
else:
|
||||||
|
cat, why = classify_bounce("", str(e), "")
|
||||||
|
ab = f"SMTP 返回疑似限流({why}):{str(e)[:100]}" if cat == CAT_RATE else ""
|
||||||
|
if ab and not abort_reason:
|
||||||
|
abort_reason = ab
|
||||||
|
continue
|
||||||
|
|
||||||
|
err_map = {a.lower(): _fmt_smtp_result(v)
|
||||||
|
for a, v in (refused or {}).items()}
|
||||||
|
ok_n = 0
|
||||||
|
for r in batch:
|
||||||
|
if r["email"].lower() in err_map:
|
||||||
|
continue
|
||||||
|
ok_n += 1
|
||||||
sent += 1
|
sent += 1
|
||||||
if not args.to:
|
if not args.to:
|
||||||
record_sent(args.state, {"email": r["email"], "lang": lg, "ts": time.time()})
|
record_sent(args.state, {"email": r["email"], "lang": lg,
|
||||||
if sent % 25 == 0 or sent == total:
|
"ts": time.time()})
|
||||||
print(f" 进度 {sent}/{total} 已成功 {sent}")
|
attempted += len(batch)
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
smtp_err = str(e)
|
|
||||||
failed.append((r["email"], smtp_err))
|
|
||||||
print(f" [失败] {r['email']}: {e}")
|
|
||||||
|
|
||||||
attempted += 1
|
# 「单封收件人数超限」的拒绝:砍半批次重试,被拒的人排回队首;
|
||||||
# SMTP 当场就返回限流信号 → 继续发只会更糟,立即中止
|
# 未被拒的收件人已随本次 DATA 投出,照常入账
|
||||||
if smtp_err:
|
oversized = [r for r in batch
|
||||||
cat, why = classify_bounce("", smtp_err, "")
|
if _is_too_many_rcpts(err_map.get(r["email"].lower(), ""))]
|
||||||
if cat == CAT_HARD and not args.no_invalid_list:
|
if oversized:
|
||||||
record_invalid(args.invalid_file, {r["email"]: {
|
batch_size = max(1, batch_size // 2)
|
||||||
"reason": why, "detail": smtp_err[:160], "subject": subject,
|
queue[:0] = oversized
|
||||||
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}})
|
over_set = {r["email"].lower() for r in oversized}
|
||||||
elif cat == CAT_RATE and not abort_reason:
|
print(f" [拆批] {len(oversized)} 人被拒(单封收件人数超限),"
|
||||||
abort_reason = f"SMTP 返回疑似限流({why}):{smtp_err[:100]}"
|
f"batch_size 降为 {batch_size} 后重试")
|
||||||
|
|
||||||
# 周期巡检收件箱:异步退信才是限流最常见的表现
|
|
||||||
if watch_active and not abort_reason and attempted % watch_every == 0:
|
|
||||||
nb, ok = watch_bounces(cfg, args, watch_start, known_ids)
|
|
||||||
if not ok:
|
|
||||||
watch_fails += 1
|
|
||||||
if watch_fails >= 2:
|
|
||||||
print(" [退信巡检] IMAP 连续不可用,本次已关闭巡检"
|
|
||||||
"(不影响发送;发完请手动跑一次 --check-bounces)")
|
|
||||||
watch_active = False
|
|
||||||
else:
|
else:
|
||||||
watch_fails = 0
|
over_set = set()
|
||||||
for em, info in nb.items():
|
|
||||||
if info["category"] == CAT_RATE:
|
|
||||||
watch_rate[em] = info
|
|
||||||
elif info["category"] == CAT_HARD:
|
|
||||||
watch_hard[em] = info
|
|
||||||
if watch_hard:
|
|
||||||
if not args.no_invalid_list:
|
|
||||||
record_invalid(args.invalid_file, watch_hard)
|
|
||||||
watch_hard = {}
|
|
||||||
if watch_rate:
|
|
||||||
abort_reason = (
|
|
||||||
f"收件箱出现 {len(watch_rate)} 条限流退信"
|
|
||||||
f"(如 {sorted(watch_rate)[0]}:"
|
|
||||||
f"{watch_rate[sorted(watch_rate)[0]]['reason']})")
|
|
||||||
|
|
||||||
if abort_reason:
|
for r in batch:
|
||||||
break
|
em_low = r["email"].lower()
|
||||||
if sent < total:
|
e = err_map.get(em_low)
|
||||||
time.sleep(max(0.0, args.delay))
|
if e is None or em_low in over_set:
|
||||||
if args.pause_every > 0 and sent % args.pause_every == 0:
|
continue
|
||||||
print(f" [间隔] 已发 {sent} 封,暂停 {args.pause_for}s 防限流 ...")
|
failed.append((r["email"], f"SMTP 拒收 {e}"))
|
||||||
time.sleep(max(0.0, args.pause_for))
|
smtp_rejected += 1
|
||||||
|
print(f" [SMTP 拒收] {r['email']}: {e}")
|
||||||
|
ab = note_smtp_err(r["email"], e, subj_r)
|
||||||
|
if ab and not abort_reason:
|
||||||
|
abort_reason = ab
|
||||||
|
|
||||||
|
if len(batch) > 1:
|
||||||
|
print(f" [信封] 一封投递 {len(batch)} 人:"
|
||||||
|
f"接收 {ok_n},拒收 {len(err_map) - len(over_set)}")
|
||||||
|
if sent % 25 == 0 or sent == total:
|
||||||
|
print(f" 进度 {sent}/{total} 已成功 {sent}")
|
||||||
|
|
||||||
|
# 周期巡检收件箱:按收件人计数,异步退信才是限流最常见表现
|
||||||
|
if watch_active and not abort_reason and attempted >= watch_mark + watch_every:
|
||||||
|
watch_mark = attempted
|
||||||
|
run_patrol()
|
||||||
|
|
||||||
|
if abort_reason:
|
||||||
|
break
|
||||||
|
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):
|
if args.group_pause > 0 and gi < len(group_order):
|
||||||
print(f" [组间间隔] 下一组前暂停 {args.group_pause}s ...")
|
print(f" [组间间隔] 下一组前暂停 {args.group_pause}s ...")
|
||||||
time.sleep(max(0.0, args.group_pause))
|
time.sleep(max(0.0, args.group_pause))
|
||||||
if abort_reason:
|
if abort_reason:
|
||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
try:
|
session.close()
|
||||||
server.quit()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
|
|
||||||
report["sent"] = sent
|
report["sent"] = sent
|
||||||
report["failed"] = len(failed)
|
report["failed"] = len(failed)
|
||||||
report["smtp_rejected"] = smtp_rejected
|
report["smtp_rejected"] = smtp_rejected
|
||||||
|
report["batch_size"] = batch_size
|
||||||
|
report["envelopes"] = envelopes
|
||||||
report["aborted"] = abort_reason or None
|
report["aborted"] = abort_reason or None
|
||||||
report["abort_rate_bounces"] = sorted(watch_rate)
|
report["abort_rate_bounces"] = sorted(watch_rate)
|
||||||
report["watch_bounces"] = watch_active
|
report["watch_bounces"] = watch_active
|
||||||
|
|||||||
+13
@@ -18,6 +18,19 @@ old_domain = box.tama.guru
|
|||||||
; to = 可选:默认测试收件邮箱(正式群发用命令行 --send,不加 --to)
|
; to = 可选:默认测试收件邮箱(正式群发用命令行 --send,不加 --to)
|
||||||
delay = 1.0
|
delay = 1.0
|
||||||
group_pause = 3.0
|
group_pause = 3.0
|
||||||
|
; SMTP 连接空闲超过该秒数就主动重连再发。
|
||||||
|
; delay 调大时(比如 60s/封)服务器会掐掉空闲连接,出现整批
|
||||||
|
; 「please run connect() first」;保持默认 30 即可自动重连,设 0 关闭
|
||||||
|
smtp_idle_reconnect = 30
|
||||||
|
|
||||||
|
; 合并信封批量发送(0=逐封)。渲染内容完全相同的收件人每 N 人共用一个
|
||||||
|
; SMTP 信封:发送次数从「人数」降到「信封数」(如 305 人、每 50 人一封
|
||||||
|
; → 约 7 次发信),可显著降低触发「外发频率超过邮件系统限制」类限流的
|
||||||
|
; 概率;若服务商按收件人计数则无缓解。
|
||||||
|
; 注意:单封收件人有服务商上限(常见 50~100),超限被 452 拒收时脚本会
|
||||||
|
; 自动砍半拆批重试,不会中止;delay 在批量模式下的语义是「每批之间」间隔。
|
||||||
|
; 模板含 {{name}} 等个人化占位符时内容逐人不同,自动回退逐封,不会错合。
|
||||||
|
batch_size = 0
|
||||||
|
|
||||||
; 退信核查 / 发送中限流巡检开关
|
; 退信核查 / 发送中限流巡检开关
|
||||||
; 这些都能在 params.ini 里长期配置,命令行同名参数可临时覆盖
|
; 这些都能在 params.ini 里长期配置,命令行同名参数可临时覆盖
|
||||||
|
|||||||
Reference in New Issue
Block a user