SMTP 断线自动重连:修掉大 delay 下整批 please run connect() first
现象:--delay 调到 60s/封、发 305 人时,第一封报 Server not connected,
之后每一封都是 please run connect() first,全军覆没。
根因:SMTP 服务器会掐掉空闲连接(QQ/163/阿里云常见 5~15 分钟,有的更短)。
脚本只在开头 smtp_login 一次,之后从不重连——连接一旦被服务器关掉,
同一个死掉的连接对象会被反复使用,除第一封外全是 connect() 报错。
delay 越大必然越早触发;以前默认 1s/封时几乎等不到超时,所以没暴露过。
改动:
- 新增 SmtpSession 包装连接:
* 空闲超过 smtp_idle_reconnect 秒(默认 30,可 0 关闭)就主动断开重连,
不必每封都先吃一发失败
* 仍遇到断线类异常(ServerDisconnected/ConnectError/OSError)立即重连
重试,最多 3 次(间隔 2s/4s);耗尽后抛原始异常、该收件人如实计失败
* 只重试连接类异常;SMTP 拒收(5xx/4xx 回话)不重试,避免重复投递
* 「Server not connected」不会被误判为限流,不会误触发发送中止
- 新增 --smtp-idle-reconnect / params.ini [send] smtp_idle_reconnect
- 发送循环改用 SmtpSession,结束统一 close
This commit is contained in:
@@ -41,6 +41,7 @@ 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 空闲超时秒数,超时就重连
|
||||||
|
|
||||||
[bounce] ; 退信核查 / 发送中限流巡检(见下节)
|
[bounce] ; 退信核查 / 发送中限流巡检(见下节)
|
||||||
watch_bounces = 1 ; 发送中巡检限流退信,发现即中止
|
watch_bounces = 1 ; 发送中巡检限流退信,发现即中止
|
||||||
@@ -257,6 +258,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 命中别的同名空表)
|
||||||
|
|||||||
+83
-8
@@ -637,6 +637,76 @@ 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_message(self, msg):
|
||||||
|
"""带重连的 send_message;失败时抛最后一次的原始异常。"""
|
||||||
|
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()
|
||||||
|
refused = self.server.send_message(msg)
|
||||||
|
self.last_used = time.time()
|
||||||
|
return refused
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 断点续发状态(JSONL 追加写,崩溃安全)
|
# 断点续发状态(JSONL 追加写,崩溃安全)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1353,7 +1423,7 @@ 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,
|
||||||
"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 +1433,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)):
|
||||||
raw = cp.get("send", key, fallback="").strip()
|
raw = cp.get("send", key, fallback="").strip()
|
||||||
if raw:
|
if raw:
|
||||||
try:
|
try:
|
||||||
@@ -1662,6 +1733,10 @@ 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("--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 +1863,9 @@ 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"])
|
||||||
|
|
||||||
# 退信核查 / 发送中巡检:命令行没给(None)时才用 params.ini [bounce]
|
# 退信核查 / 发送中巡检:命令行没给(None)时才用 params.ini [bounce]
|
||||||
if args.watch_bounces is None:
|
if args.watch_bounces is None:
|
||||||
@@ -2106,7 +2184,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}")
|
||||||
|
|
||||||
@@ -2140,7 +2218,7 @@ def main():
|
|||||||
all_missing.update(missing)
|
all_missing.update(missing)
|
||||||
smtp_err = ""
|
smtp_err = ""
|
||||||
try:
|
try:
|
||||||
refused = server.send_message(msg)
|
refused = session.send_message(msg)
|
||||||
if refused:
|
if refused:
|
||||||
refused_desc = "; ".join(f"{a} {c}:{err}" for a, (c, err) in refused.items())
|
refused_desc = "; ".join(f"{a} {c}:{err}" for a, (c, err) in refused.items())
|
||||||
smtp_err = refused_desc
|
smtp_err = refused_desc
|
||||||
@@ -2208,10 +2286,7 @@ def main():
|
|||||||
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)
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ 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
|
||||||
|
|
||||||
; 退信核查 / 发送中限流巡检开关
|
; 退信核查 / 发送中限流巡检开关
|
||||||
; 这些都能在 params.ini 里长期配置,命令行同名参数可临时覆盖
|
; 这些都能在 params.ini 里长期配置,命令行同名参数可临时覆盖
|
||||||
|
|||||||
Reference in New Issue
Block a user