Compare commits
5
Commits
6dc33604d1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a433720074 | ||
|
|
72c3a81571 | ||
|
|
35e9a7231a | ||
|
|
1407fa9fb6 | ||
|
|
d4e5c611b0 |
@@ -249,9 +249,63 @@ conf/app.ini 路径的解析优先级:
|
|||||||
- 用的是 conf/app.ini [mail] 的账号与密码(需邮箱已开启 IMAP,密码为邮箱登录密码)
|
- 用的是 conf/app.ini [mail] 的账号与密码(需邮箱已开启 IMAP,密码为邮箱登录密码)
|
||||||
- 交互模式的运行方式「4) 退信核查」里同样三选一:仅核查 / 仅剔除 / 剔除并补发,
|
- 交互模式的运行方式「4) 退信核查」里同样三选一:仅核查 / 仅剔除 / 剔除并补发,
|
||||||
选择「仅剔除 / 剔除并补发」时会再问一次是否连未分类的一起补发
|
选择「仅剔除 / 剔除并补发」时会再问一次是否连未分类的一起补发
|
||||||
- 已核查过的退信(按 Message-ID)记录在 broadcast_bounces_seen.json,
|
- 已核查过的退信(按 Message-ID)与**上次扫描位置**(INBOX UID 游标 +
|
||||||
重复核查不会重复报告/重复剔除
|
UIDVALIDITY)都记录在 broadcast_bounces_seen.json:
|
||||||
- 每次核查另写一份明细报告 broadcast_bounce_report.json(含每个收件人的类别与判定依据)
|
默认从上次位置续扫(UID n+1:*),只拉新邮件的头部,重复核查不会
|
||||||
|
重复报告/重复剔除,也不会随邮箱邮件增多越扫越慢
|
||||||
|
- 以下情况自动回退为按日期窗口从头扫(扫完位置照常更新):
|
||||||
|
首次核查(无位置记录)、邮箱 UIDVALIDITY 变化(换号/重建邮箱)、
|
||||||
|
显式指定 --since、加 --full-scan
|
||||||
|
- 发送中巡检不推进扫描位置(它只看本次运行之后新到的退信,
|
||||||
|
范围内的历史邮件不标记「已扫」),位置只由 --check-bounces 推进
|
||||||
|
- 每次核查另写一份明细报告 broadcast_bounce_report.json(含每个收件人的
|
||||||
|
类别与判定依据,以及 scan_mode / scan_cursor)
|
||||||
|
|
||||||
|
发信账号来源:app.ini / 程序自带 SMTP
|
||||||
|
------------------------------------
|
||||||
|
默认用 conf/app.ini [mail] 的 SMTP(站内信跟随站点自己的邮箱)。切到程序
|
||||||
|
自带的一套后,发信与退信核查(IMAP)都走这套账号:
|
||||||
|
|
||||||
|
[mail]
|
||||||
|
source = app ; app(默认)= conf/app.ini [mail];own = 下面这套
|
||||||
|
account = bot@other.com
|
||||||
|
password = env:MY_SMTP_PASSWORD ; 支持 env:变量名,推荐
|
||||||
|
smtp = smtp.other.com
|
||||||
|
port = 465
|
||||||
|
skip_tls_verify = 0
|
||||||
|
|
||||||
|
临时切换(优先级高于 params.ini):`--mail-source own` / `--mail-source app`
|
||||||
|
交互模式里若 [mail] 配齐了,会多问一次(`1` = app.ini,`2` = 程序自带;
|
||||||
|
默认值取 params.ini 已配置的 source,直接回车即沿用)。
|
||||||
|
只有交互里显式选过才会写回 params.ini;命令行临时切换不会被保存成永久配置。
|
||||||
|
|
||||||
|
安全提醒:
|
||||||
|
- params.ini 会被 git 跟踪,**不要把密码明文写进去**;用 `env:变量名`
|
||||||
|
从环境变量读取,或把 params.ini 加入 .gitignore
|
||||||
|
- 明文写死时脚本会打印警告,但不会阻止运行
|
||||||
|
- 切到 own 但 [mail] 缺 smtp/account/password 时直接 FATAL,不会用半套配置
|
||||||
|
|
||||||
|
合并信封批量发送(可选)
|
||||||
|
------------------------
|
||||||
|
默认逐人一封(一个 SMTP 信封 = 1 个 RCPT TO)。开启 batch_size 后,
|
||||||
|
「渲染后内容完全相同」的收件人合并发送:一个信封 = 1×MAIL FROM +
|
||||||
|
N×RCPT TO + 1×DATA,发送次数从「人数」降到「信封数」(如 305 人、
|
||||||
|
每 50 人一封 → 约 7 次发信)。
|
||||||
|
|
||||||
|
开启方式(0=关闭):
|
||||||
|
broadcast.py --send --single --batch-size 25
|
||||||
|
或 params.ini [send] batch_size = 25
|
||||||
|
|
||||||
|
与限流的关系(要点):
|
||||||
|
- 服务商按「发信次数」计频率 → 合并后成倍降低触发概率(主要收益)
|
||||||
|
- 服务商按「单位时间收件人总数」计数 → 无缓解,仍靠 delay/pause 控制节奏
|
||||||
|
- 单封收件人数上限常见 50~100:超限的 RCPT 会被 452 拒收,脚本自动把
|
||||||
|
batch_size 砍半、被拒者重试,不会中止也不会误判为限流
|
||||||
|
- 信封收件人对其他收件人不可见;批量信封的 To: 头显示为「站点名+发件邮箱」
|
||||||
|
|
||||||
|
适用的前提是内容逐字相同:模板含 {{name}}/{{box_link}} 等个人化占位符时,
|
||||||
|
渲染结果逐人不同,会自动落回逐封,不会错合。断点续发按人记录(DATA 被
|
||||||
|
服务器接收即整批入账,被拒的除外);发送中限流巡检照常按收件人数计数。
|
||||||
|
|
||||||
安全机制
|
安全机制
|
||||||
--------
|
--------
|
||||||
@@ -264,6 +318,7 @@ conf/app.ini 路径的解析优先级:
|
|||||||
连接空闲超过 `smtp_idle_reconnect` 秒(默认 30,0 关闭)就主动重连;
|
连接空闲超过 `smtp_idle_reconnect` 秒(默认 30,0 关闭)就主动重连;
|
||||||
仍遇到断线则立即重试最多 3 次(间隔 2s/4s),失败才会记为该收件人失败
|
仍遇到断线则立即重试最多 3 次(间隔 2s/4s),失败才会记为该收件人失败
|
||||||
- SMTP 拒收(refused)计入失败并写入报告
|
- SMTP 拒收(refused)计入失败并写入报告
|
||||||
|
- 批量信封遇 452「收件人数超限」自动砍半拆批重试;限流类拒收仍立即中止
|
||||||
- From/Subject 头自动做 RFC2047 编码(中文显示名不会被 QQ 邮箱 550 拒收)
|
- From/Subject 头自动做 RFC2047 编码(中文显示名不会被 QQ 邮箱 550 拒收)
|
||||||
- 自动定位 users 表所在 schema(避免 psql 命中别的同名空表)
|
- 自动定位 users 表所在 schema(避免 psql 命中别的同名空表)
|
||||||
- 语言列自动试跑探测(language → lang → NULL 兜底),老库没有该列也能发
|
- 语言列自动试跑探测(language → lang → NULL 兜底),老库没有该列也能发
|
||||||
|
|||||||
+270
-31
@@ -83,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.batchenv.v4"
|
SCRIPT_VERSION = "2026-09-08.mailsource.v6"
|
||||||
|
|
||||||
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")
|
||||||
@@ -1249,18 +1249,59 @@ def _parse_mail_date(value):
|
|||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _load_bounce_seen():
|
||||||
|
"""读取已核查记录文件(DEFAULT_BOUNCE_SEEN)。
|
||||||
|
|
||||||
|
旧格式:Message-ID 列表;新格式:{"seen": [...], "cursor": {...}}。
|
||||||
|
返回 (seen 集合, cursor dict 或 None)。
|
||||||
|
cursor = {"uidvalidity": int, "last_uid": int},即上次扫描在 INBOX 里
|
||||||
|
扫到的位置;UIDVALIDITY 对不上(邮箱重建/换号)时游标作废回退全扫。
|
||||||
|
"""
|
||||||
|
if not os.path.isfile(DEFAULT_BOUNCE_SEEN):
|
||||||
|
return set(), None
|
||||||
|
try:
|
||||||
|
with open(DEFAULT_BOUNCE_SEEN, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return set(), None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
seen = set(data.get("seen") or [])
|
||||||
|
cur = data.get("cursor")
|
||||||
|
if (isinstance(cur, dict)
|
||||||
|
and isinstance(cur.get("uidvalidity"), int)
|
||||||
|
and isinstance(cur.get("last_uid"), int)):
|
||||||
|
return seen, cur
|
||||||
|
return seen, None
|
||||||
|
return set(data or []), None
|
||||||
|
|
||||||
|
|
||||||
|
def _save_bounce_seen(seen, cursor=None):
|
||||||
|
try:
|
||||||
|
with open(DEFAULT_BOUNCE_SEEN, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"seen": sorted(seen), "cursor": cursor}, f, ensure_ascii=False)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"[警告] 记录已核查邮件/扫描位置失败(不影响本次结果):{e}")
|
||||||
|
|
||||||
|
|
||||||
def _scan_imap_bounces(cfg, args, start_dt, skip_ids=None, not_before=None,
|
def _scan_imap_bounces(cfg, args, start_dt, skip_ids=None, not_before=None,
|
||||||
start_desc="", verbose=True):
|
start_desc="", verbose=True, resume=None):
|
||||||
"""连 IMAP 扫描退信并逐收件人分类(check_bounces 与发送中巡检共用)。
|
"""连 IMAP 扫描退信并逐收件人分类(check_bounces 与发送中巡检共用)。
|
||||||
|
|
||||||
skip_ids 已处理过的 Message-ID(seen 文件 / 本次运行内已见过),跳过不重复报
|
skip_ids 已处理过的 Message-ID(seen 文件 / 本次运行内已见过),跳过不重复报
|
||||||
not_before 只统计 Date 头晚于该时刻的退信(发送中巡检用:只看本次运行之后
|
not_before 只统计 Date 头晚于该时刻的退信(发送中巡检用:只看本次运行之后
|
||||||
新到的退信,避免拿历史退信误触发中止);Date 解析不了则放行
|
新到的退信,避免拿历史退信误触发中止);Date 解析不了则放行
|
||||||
返回 dict:bounced / new_ids / scanned / touched_ids
|
resume 上次扫描位置 {"uidvalidity": int, "last_uid": int};UIDVALIDITY
|
||||||
|
一致时从 last_uid 之后续扫(UID 检索),否则回退按日期窗口全扫
|
||||||
|
(全新 / 邮箱 UID 代际变更 / 显式 --since / --full-scan)。
|
||||||
|
只由 check_bounces 传入推进——发送中巡检的 not_before 会过滤掉
|
||||||
|
范围内的历史邮件,若推进游标会把没处理过的邮件标记成「已扫」。
|
||||||
|
返回 dict:bounced / new_ids / scanned / touched_ids / last_uid / uidvalidity
|
||||||
bounced {email: {"subject","date","category","reason","detail"}}
|
bounced {email: {"subject","date","category","reason","detail"}}
|
||||||
new_ids 本次计入的退信 Message-ID(是否落盘由调用方决定)
|
new_ids 本次计入的退信 Message-ID(是否落盘由调用方决定)
|
||||||
touched_ids 本次扫到的所有退信 Message-ID(含被 not_before 过滤掉的),
|
touched_ids 本次扫到的所有退信 Message-ID(含被 not_before 过滤掉的),
|
||||||
供巡检做运行期去重,避免每轮重复拉取同一封全文
|
供巡检做运行期去重,避免每轮重复拉取同一封全文
|
||||||
|
last_uid 本次检查到的最大 UID(供调用方更新扫描位置)
|
||||||
|
uidvalidity 当前邮箱的 UIDVALIDITY(读不到为 None,调用方据此不落游标)
|
||||||
"""
|
"""
|
||||||
smtp_host = cfg["mail_smtp"] or ""
|
smtp_host = cfg["mail_smtp"] or ""
|
||||||
imap_host = (args.imap_host or "").strip()
|
imap_host = (args.imap_host or "").strip()
|
||||||
@@ -1280,33 +1321,58 @@ def _scan_imap_bounces(cfg, args, start_dt, skip_ids=None, not_before=None,
|
|||||||
try:
|
try:
|
||||||
conn.login(account, password)
|
conn.login(account, password)
|
||||||
conn.select("INBOX", readonly=True)
|
conn.select("INBOX", readonly=True)
|
||||||
typ, data = conn.search(None, f'(SINCE "{since}")')
|
try:
|
||||||
|
uv = int(conn.response("UIDVALIDITY")[1][0])
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
uv = None
|
||||||
|
resume_uid = None
|
||||||
|
if resume:
|
||||||
|
if uv is not None and uv == resume.get("uidvalidity"):
|
||||||
|
resume_uid = int(resume["last_uid"])
|
||||||
|
if verbose:
|
||||||
|
print(f" [扫描位置] 续扫:从 UID {resume_uid} 之后开始"
|
||||||
|
f"(uidvalidity {uv})")
|
||||||
|
elif verbose:
|
||||||
|
print(" [扫描位置] 邮箱 UIDVALIDITY 已变化(或无法读取),"
|
||||||
|
"本次回退按日期窗口全量扫描")
|
||||||
|
if resume_uid is not None:
|
||||||
|
typ, data = conn.uid("SEARCH", None, f"UID {resume_uid + 1}:*")
|
||||||
|
else:
|
||||||
|
typ, data = conn.uid("SEARCH", None, f'(SINCE "{since}")')
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
raise RuntimeError("IMAP search 失败")
|
raise RuntimeError("IMAP search 失败")
|
||||||
ids = data[0].split()
|
ids = data[0].split()
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f" [IMAP] 扫描范围内共 {len(ids)} 封待扫描")
|
print(f" [IMAP] 扫描范围内共 {len(ids)} 封"
|
||||||
|
f"({'续扫' if resume_uid is not None else '日期窗口'})")
|
||||||
|
|
||||||
skip_ids = skip_ids or set()
|
skip_ids = skip_ids or set()
|
||||||
bounced, new_ids, touched, scanned = {}, [], [], 0
|
bounced, new_ids, touched, scanned = {}, [], [], 0
|
||||||
|
last_uid = resume_uid or 0
|
||||||
for i, mid in enumerate(ids, 1):
|
for i, mid in enumerate(ids, 1):
|
||||||
uid = mid.decode() if isinstance(mid, bytes) else str(mid)
|
uid = int(mid)
|
||||||
|
if uid <= last_uid:
|
||||||
|
# 防御:UID n:* 在 n 超过邮箱最大 UID 时会返回最后一封
|
||||||
|
continue
|
||||||
|
last_uid = uid
|
||||||
|
ustr = str(uid)
|
||||||
# 两段式:先取头部(省流量),命中退信特征再取全文
|
# 两段式:先取头部(省流量),命中退信特征再取全文
|
||||||
typ, hdata = conn.fetch(
|
typ, hdata = conn.uid(
|
||||||
mid, "(BODY.PEEK[HEADER.FIELDS (SUBJECT MESSAGE-ID FROM CONTENT-TYPE)])")
|
"fetch", ustr,
|
||||||
|
"(BODY.PEEK[HEADER.FIELDS (SUBJECT MESSAGE-ID FROM CONTENT-TYPE)])")
|
||||||
if typ != "OK" or not hdata or hdata[0] is None:
|
if typ != "OK" or not hdata or hdata[0] is None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
head = email.message_from_bytes(hdata[0][1])
|
head = email.message_from_bytes(hdata[0][1])
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
continue
|
continue
|
||||||
msg_id = (head.get("Message-ID") or "").strip() or f"uid:{uid}"
|
msg_id = (head.get("Message-ID") or "").strip() or f"uid:{ustr}"
|
||||||
if msg_id in skip_ids:
|
if msg_id in skip_ids:
|
||||||
continue
|
continue
|
||||||
scanned += 1
|
scanned += 1
|
||||||
if not is_bounce_message(head):
|
if not is_bounce_message(head):
|
||||||
continue
|
continue
|
||||||
typ, fdata = conn.fetch(mid, "(RFC822)")
|
typ, fdata = conn.uid("fetch", ustr, "(RFC822)")
|
||||||
if typ != "OK" or not fdata or fdata[0] is None:
|
if typ != "OK" or not fdata or fdata[0] is None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -1333,7 +1399,8 @@ def _scan_imap_bounces(cfg, args, start_dt, skip_ids=None, not_before=None,
|
|||||||
for r in rcpts) if rcpts else "(未解析出收件人)"
|
for r in rcpts) if rcpts else "(未解析出收件人)"
|
||||||
print(f" [退信 {i}/{len(ids)}] {subject[:40]} → {desc}")
|
print(f" [退信 {i}/{len(ids)}] {subject[:40]} → {desc}")
|
||||||
return {"bounced": bounced, "new_ids": new_ids,
|
return {"bounced": bounced, "new_ids": new_ids,
|
||||||
"scanned": scanned, "touched_ids": touched}
|
"scanned": scanned, "touched_ids": touched,
|
||||||
|
"last_uid": last_uid, "uidvalidity": uv}
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
conn.logout()
|
conn.logout()
|
||||||
@@ -1342,29 +1409,43 @@ def _scan_imap_bounces(cfg, args, start_dt, skip_ids=None, not_before=None,
|
|||||||
|
|
||||||
|
|
||||||
def check_bounces(cfg, args):
|
def check_bounces(cfg, args):
|
||||||
"""登录发件邮箱 IMAP,扫描近 N 天的退信并解析失败收件人。
|
"""登录发件邮箱 IMAP,扫描退信并解析失败收件人。
|
||||||
|
|
||||||
返回 {email: {"subject","date","category","reason","detail"}};已处理过的
|
返回 {email: {"subject","date","category","reason","detail"}};已处理过的
|
||||||
邮件(Message-ID)记录在 broadcast_bounces_seen.json,重复核查不会重复报告。
|
邮件(Message-ID)与上次扫描位置(UID 游标)都记录在
|
||||||
|
broadcast_bounces_seen.json:默认从上次位置续扫,只查新到的邮件,
|
||||||
|
游标不存在/作废、显式 --since 或 --full-scan 时才按日期窗口从头扫。
|
||||||
category 见 CAT_RATE / CAT_HARD / CAT_UNKNOWN。"""
|
category 见 CAT_RATE / CAT_HARD / CAT_UNKNOWN。"""
|
||||||
start_dt, start_desc = resolve_scan_start(args)
|
start_dt, start_desc = resolve_scan_start(args)
|
||||||
|
|
||||||
seen = set()
|
seen, cursor = _load_bounce_seen()
|
||||||
if os.path.isfile(DEFAULT_BOUNCE_SEEN):
|
since_explicit = ((getattr(args, "since", "auto") or "auto").strip().lower()
|
||||||
try:
|
not in ("", "auto"))
|
||||||
with open(DEFAULT_BOUNCE_SEEN, "r", encoding="utf-8") as f:
|
full_scan = bool(getattr(args, "full_scan", False))
|
||||||
seen = set(json.load(f))
|
resume = None
|
||||||
except Exception: # noqa: BLE001
|
if full_scan:
|
||||||
seen = set()
|
print("[扫描位置] --full-scan:忽略上次扫描位置,从头全量扫描")
|
||||||
|
elif since_explicit:
|
||||||
|
print("[扫描位置] 显式 --since 指定窗口,本次忽略上次扫描位置")
|
||||||
|
elif cursor is None:
|
||||||
|
print("[扫描位置] 全新扫描(尚无上次位置记录),按日期窗口全扫")
|
||||||
|
else:
|
||||||
|
resume = cursor
|
||||||
|
print(f"[扫描位置] 从上次扫到的 UID {cursor['last_uid']} 之后续扫"
|
||||||
|
f"(uidvalidity {cursor['uidvalidity']})")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = _scan_imap_bounces(cfg, args, start_dt, skip_ids=seen, start_desc=start_desc)
|
res = _scan_imap_bounces(cfg, args, start_dt, skip_ids=seen,
|
||||||
|
start_desc=start_desc, resume=resume)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
sys.exit(f"[FATAL] IMAP 扫描失败(请确认邮箱已开启 IMAP,密码用邮箱登录密码): {e}")
|
sys.exit(f"[FATAL] IMAP 扫描失败(请确认邮箱已开启 IMAP,密码用邮箱登录密码): {e}")
|
||||||
|
|
||||||
bounced = res["bounced"]
|
bounced = res["bounced"]
|
||||||
newly_seen, scanned = res["new_ids"], res["scanned"]
|
newly_seen, scanned = res["new_ids"], res["scanned"]
|
||||||
bounce_cnt = len(newly_seen)
|
bounce_cnt = len(newly_seen)
|
||||||
|
scan_mode = "resume" if resume is not None else "full"
|
||||||
|
scan_cursor = ({"uidvalidity": res["uidvalidity"], "last_uid": res["last_uid"]}
|
||||||
|
if res.get("uidvalidity") is not None and res.get("last_uid") else None)
|
||||||
|
|
||||||
# ---- 按类别分组处理:只有「可重试」的才剔除补发 ----
|
# ---- 按类别分组处理:只有「可重试」的才剔除补发 ----
|
||||||
groups = {CAT_RATE: [], CAT_HARD: [], CAT_UNKNOWN: []}
|
groups = {CAT_RATE: [], CAT_HARD: [], CAT_UNKNOWN: []}
|
||||||
@@ -1428,6 +1509,8 @@ def check_bounces(cfg, args):
|
|||||||
"script_version": SCRIPT_VERSION,
|
"script_version": SCRIPT_VERSION,
|
||||||
"scan_from": start_dt.strftime("%Y-%m-%d %H:%M:%S"),
|
"scan_from": start_dt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"scan_from_desc": start_desc,
|
"scan_from_desc": start_desc,
|
||||||
|
"scan_mode": scan_mode,
|
||||||
|
"scan_cursor": scan_cursor,
|
||||||
"scanned": scanned,
|
"scanned": scanned,
|
||||||
"bounce_messages": bounce_cnt,
|
"bounce_messages": bounce_cnt,
|
||||||
"counts": {c: len(groups.get(c, [])) for c in (CAT_RATE, CAT_HARD, CAT_UNKNOWN)},
|
"counts": {c: len(groups.get(c, [])) for c in (CAT_RATE, CAT_HARD, CAT_UNKNOWN)},
|
||||||
@@ -1437,12 +1520,10 @@ def check_bounces(cfg, args):
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print(f"[警告] 写入退信报告失败(不影响本次结果):{e}")
|
print(f"[警告] 写入退信报告失败(不影响本次结果):{e}")
|
||||||
|
|
||||||
if newly_seen:
|
# 落盘:已核查 Message-ID + 新的扫描位置
|
||||||
try:
|
# (本次读不到 uidvalidity 时沿用旧游标:只可能往回多扫几封头部,不会漏)
|
||||||
with open(DEFAULT_BOUNCE_SEEN, "w", encoding="utf-8") as f:
|
if newly_seen or scan_cursor or cursor:
|
||||||
json.dump(sorted(set(newly_seen) | seen), f, ensure_ascii=False)
|
_save_bounce_seen(set(newly_seen) | seen, scan_cursor or cursor)
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
print(f"[警告] 记录已核查邮件失败(不影响本次结果):{e}")
|
|
||||||
return bounced
|
return bounced
|
||||||
|
|
||||||
|
|
||||||
@@ -1486,7 +1567,7 @@ def load_params(path):
|
|||||||
|
|
||||||
data = {"config": "", "site_url": "", "box_prefix": "", "vars": {}, "to": "",
|
data = {"config": "", "site_url": "", "box_prefix": "", "vars": {}, "to": "",
|
||||||
"limit": None, "delay": None, "group_pause": None, "smtp_idle_reconnect": None,
|
"limit": None, "delay": None, "group_pause": None, "smtp_idle_reconnect": None,
|
||||||
"batch_size": None,
|
"batch_size": None, "mail": {},
|
||||||
"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}
|
||||||
@@ -1519,9 +1600,77 @@ def load_params(path):
|
|||||||
data[key] = cast(raw)
|
data[key] = cast(raw)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print(f"[警告] params.ini [bounce] {key}={raw!r} 不是合法整数,已忽略")
|
print(f"[警告] params.ini [bounce] {key}={raw!r} 不是合法整数,已忽略")
|
||||||
|
|
||||||
|
# [mail] 程序自带 SMTP(source=own 时取代 conf/app.ini 的 [mail])
|
||||||
|
if cp.has_section("mail"):
|
||||||
|
m = {}
|
||||||
|
m["source"] = cp.get("mail", "source", fallback="").strip().lower() or "app"
|
||||||
|
for key in ("account", "password", "smtp"):
|
||||||
|
m[key] = cp.get("mail", key, fallback="").strip()
|
||||||
|
m["skip_tls_verify"] = bool(_parse_bool(
|
||||||
|
cp.get("mail", "skip_tls_verify", fallback="").strip(), "skip_tls_verify"))
|
||||||
|
raw = cp.get("mail", "port", fallback="").strip()
|
||||||
|
try:
|
||||||
|
m["port"] = int(raw) if raw else 465
|
||||||
|
except ValueError:
|
||||||
|
print(f"[警告] params.ini [mail] port={raw!r} 不是合法整数,已忽略")
|
||||||
|
m["port"] = 465
|
||||||
|
data["mail"] = m
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_mail_password(raw):
|
||||||
|
"""SMTP 密码:支持 env:变量名 从环境变量取值,避免明文写进配置文件。"""
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
if raw.lower().startswith("env:"):
|
||||||
|
name = raw[4:].strip()
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
return (os.environ.get(name) or "").strip()
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def apply_mail_source(cfg, args, params, verbose=True):
|
||||||
|
"""发信账号来源切换:app(conf/app.ini [mail],默认)/ own(params.ini [mail])。
|
||||||
|
|
||||||
|
就地覆盖 cfg 的 mail_* 键,后续 SMTP 发信与 IMAP 退信核查都跟着走这套。
|
||||||
|
优先级:命令行 --mail-source > params.ini [mail] source > app。
|
||||||
|
返回实际生效的来源。
|
||||||
|
"""
|
||||||
|
m = (params or {}).get("mail") or {}
|
||||||
|
src = (getattr(args, "mail_source", None) or "").strip().lower()
|
||||||
|
if src not in ("app", "own"):
|
||||||
|
src = (m.get("source") or "app").strip().lower()
|
||||||
|
if src != "own":
|
||||||
|
if verbose:
|
||||||
|
print(f"[发信账号] 来源 = conf/app.ini [mail]"
|
||||||
|
f"({cfg['mail_smtp']} / {cfg['mail_account']})")
|
||||||
|
return "app"
|
||||||
|
|
||||||
|
smtp = (m.get("smtp") or "").strip()
|
||||||
|
account = (m.get("account") or "").strip()
|
||||||
|
raw_pw = (m.get("password") or "").strip()
|
||||||
|
password = resolve_mail_password(raw_pw)
|
||||||
|
missing = [k for k, v in (("smtp", smtp), ("account", account), ("password", password))
|
||||||
|
if not v]
|
||||||
|
if missing:
|
||||||
|
sys.exit(f"[FATAL] params.ini [mail] 缺少或不完整:{', '.join(missing)}。"
|
||||||
|
f"补全后重试,或改用 --mail-source app(用 conf/app.ini 的 SMTP)")
|
||||||
|
if raw_pw and not raw_pw.lower().startswith("env:"):
|
||||||
|
print("[警告] [mail] password 以明文写在 params.ini(该文件通常会被 git 跟踪),"
|
||||||
|
"建议改成 env:变量名 从环境变量读取,或把 params.ini 加入 .gitignore")
|
||||||
|
|
||||||
|
cfg["mail_smtp"] = smtp
|
||||||
|
cfg["mail_account"] = account
|
||||||
|
cfg["mail_password"] = password
|
||||||
|
cfg["mail_port"] = int(m.get("port") or 465)
|
||||||
|
cfg["mail_skip_tls_verify"] = bool(m.get("skip_tls_verify"))
|
||||||
|
if verbose:
|
||||||
|
print(f"[发信账号] 来源 = 程序自带 params.ini [mail]"
|
||||||
|
f"({smtp}:{cfg['mail_port']} / {account})")
|
||||||
|
return "own"
|
||||||
|
|
||||||
|
|
||||||
def _parse_bool(raw, key=""):
|
def _parse_bool(raw, key=""):
|
||||||
"""解析 1/0、yes/no、true/false、on/off;空串返回 None(表示未配置)。"""
|
"""解析 1/0、yes/no、true/false、on/off;空串返回 None(表示未配置)。"""
|
||||||
low = (raw or "").strip().lower()
|
low = (raw or "").strip().lower()
|
||||||
@@ -1536,7 +1685,27 @@ def _parse_bool(raw, key=""):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _preserve_extra_sections(path, managed=("path", "site", "vars", "send", "bounce")):
|
def _read_mail_section(path):
|
||||||
|
"""读取已有 params.ini 的 [mail] 小节(保存时回写,避免被覆盖丢弃)。"""
|
||||||
|
out = {}
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
return out
|
||||||
|
cp = configparser.ConfigParser(comment_prefixes=(";", "#"),
|
||||||
|
inline_comment_prefixes=(";", "#"),
|
||||||
|
interpolation=None, strict=False)
|
||||||
|
cp.optionxform = str
|
||||||
|
try:
|
||||||
|
cp.read(path, encoding="utf-8")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return out
|
||||||
|
if not cp.has_section("mail"):
|
||||||
|
return out
|
||||||
|
for k in ("source", "account", "password", "smtp", "port", "skip_tls_verify"):
|
||||||
|
out[k] = cp.get("mail", k, fallback="").strip()
|
||||||
|
return {k: v for k, v in out.items() if v}
|
||||||
|
|
||||||
|
|
||||||
|
def _preserve_extra_sections(path, managed=("path", "site", "vars", "send", "bounce", "mail")):
|
||||||
"""读取已有 params.ini 里非托管的小节,原样保留(避免保存时丢掉手写的配置)。"""
|
"""读取已有 params.ini 里非托管的小节,原样保留(避免保存时丢掉手写的配置)。"""
|
||||||
if not path or not os.path.isfile(path):
|
if not path or not os.path.isfile(path):
|
||||||
return []
|
return []
|
||||||
@@ -1560,9 +1729,11 @@ def _preserve_extra_sections(path, managed=("path", "site", "vars", "send", "bou
|
|||||||
|
|
||||||
|
|
||||||
def save_params(path, config, site_url, box_prefix, extra_vars, lang_labels=None,
|
def save_params(path, config, site_url, box_prefix, extra_vars, lang_labels=None,
|
||||||
bounce=None):
|
bounce=None, batch_size=0, smtp_idle_reconnect=30.0, mail=None):
|
||||||
"""把参数写回 params.ini(覆盖写;vars 逐行 key = value)。
|
"""把参数写回 params.ini(覆盖写;vars 逐行 key = value)。
|
||||||
bounce:当前生效的退信/巡检设置,回写进 [bounce] 小节以便下次直接沿用。"""
|
bounce:当前生效的退信/巡检设置,回写进 [bounce] 小节以便下次直接沿用。
|
||||||
|
batch_size / smtp_idle_reconnect / mail:同属 [send]/[mail] 小节,必须回写,
|
||||||
|
否则覆盖写会把手工加的键抹掉(mail 的小节值从原文件读回,只覆盖传入项)。"""
|
||||||
lines = ["; broadcast.py 参数文件:每次运行自动读取;命令行参数优先级更高",
|
lines = ["; broadcast.py 参数文件:每次运行自动读取;命令行参数优先级更高",
|
||||||
"; 注意:本文件不控制 --send/--yes,正式发送仍需命令行显式指定", ""]
|
"; 注意:本文件不控制 --send/--yes,正式发送仍需命令行显式指定", ""]
|
||||||
lines.append("[path]")
|
lines.append("[path]")
|
||||||
@@ -1582,6 +1753,10 @@ def save_params(path, config, site_url, box_prefix, extra_vars, lang_labels=None
|
|||||||
lines.append("; to = 可选:默认测试收件邮箱(正式群发用命令行 --send,不加 --to)")
|
lines.append("; to = 可选:默认测试收件邮箱(正式群发用命令行 --send,不加 --to)")
|
||||||
lines.append("delay = 1.0")
|
lines.append("delay = 1.0")
|
||||||
lines.append("group_pause = 3.0")
|
lines.append("group_pause = 3.0")
|
||||||
|
lines.append("; SMTP 连接空闲超过该秒数就主动重连再发(delay 调大时必开,0 关闭)")
|
||||||
|
lines.append(f"smtp_idle_reconnect = {float(smtp_idle_reconnect):g}")
|
||||||
|
lines.append("; 合并信封批量发送:渲染内容完全相同的收件人每 N 人共用一个信封(0=逐封)")
|
||||||
|
lines.append(f"batch_size = {int(batch_size)}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("; 退信核查 / 发送中限流巡检(均可用同名命令行参数临时覆盖)")
|
lines.append("; 退信核查 / 发送中限流巡检(均可用同名命令行参数临时覆盖)")
|
||||||
lines.append("[bounce]")
|
lines.append("[bounce]")
|
||||||
@@ -1604,6 +1779,24 @@ def save_params(path, config, site_url, box_prefix, extra_vars, lang_labels=None
|
|||||||
lines.append("; since = auto 表示从断点清单最早一条记录的时间开始扫描")
|
lines.append("; since = auto 表示从断点清单最早一条记录的时间开始扫描")
|
||||||
lines.append(f"since = {b.get('since', 'auto')}")
|
lines.append(f"since = {b.get('since', 'auto')}")
|
||||||
lines.append(f"since_days = {b.get('since_days', 3)}")
|
lines.append(f"since_days = {b.get('since_days', 3)}")
|
||||||
|
lines.append("")
|
||||||
|
# [mail]:值从原文件读回(含 env: 形式的密码),再用传入的覆盖项更新
|
||||||
|
mv = {"source": "app", "account": "", "password": "", "smtp": "", "port": "465",
|
||||||
|
"skip_tls_verify": "0"}
|
||||||
|
mv.update(_read_mail_section(path))
|
||||||
|
for k, v in (mail or {}).items():
|
||||||
|
if v not in (None, ""):
|
||||||
|
mv[k] = str(v)
|
||||||
|
lines.append("; 发信账号来源:app = 用 conf/app.ini [mail] 的 SMTP(默认);")
|
||||||
|
lines.append("; own = 用下面这套程序自带 SMTP(--mail-source 可临时覆盖)")
|
||||||
|
lines.append("[mail]")
|
||||||
|
lines.append(f"source = {mv['source'] or 'app'}")
|
||||||
|
lines.append(f"account = {mv['account']}")
|
||||||
|
lines.append("; password 支持 env:变量名(推荐),避免明文写进会被 git 跟踪的文件")
|
||||||
|
lines.append(f"password = {mv['password']}")
|
||||||
|
lines.append(f"smtp = {mv['smtp']}")
|
||||||
|
lines.append(f"port = {mv['port'] or 465}")
|
||||||
|
lines.append(f"skip_tls_verify = {mv['skip_tls_verify'] or 0}")
|
||||||
lines.extend(_preserve_extra_sections(path))
|
lines.extend(_preserve_extra_sections(path))
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
f.write("\n".join(lines) + "\n")
|
f.write("\n".join(lines) + "\n")
|
||||||
@@ -1648,6 +1841,23 @@ def run_interactive(cfg, templates, extra_vars, args, config_path):
|
|||||||
f"(每 {args.watch_every} 封一次)"
|
f"(每 {args.watch_every} 封一次)"
|
||||||
f" 来源:params.ini [bounce] / 命令行,改值请编辑 params.ini")
|
f" 来源:params.ini [bounce] / 命令行,改值请编辑 params.ini")
|
||||||
|
|
||||||
|
# 发信账号来源:只有 params.ini [mail] 配齐了才提供切换。
|
||||||
|
# 默认取 params.ini 已配置的 source(而不是硬编码 app),并且同时接受
|
||||||
|
# 数字选型——UI 里其它选项都是 1/2/3/4,只认 "own" 会让人输入 2 时静默回落。
|
||||||
|
if getattr(args, "_own_mail_ready", False):
|
||||||
|
cur = (getattr(args, "mail_source", None)
|
||||||
|
or getattr(args, "_mail_source_default", "app") or "app").strip().lower()
|
||||||
|
if cur not in ("app", "own"):
|
||||||
|
cur = "app"
|
||||||
|
default_choice = "2" if cur == "own" else "1"
|
||||||
|
print("\n发信账号来源:")
|
||||||
|
print(" 1) conf/app.ini 的 SMTP(站点自己的邮箱)")
|
||||||
|
print(" 2) 程序自带 params.ini [mail] 的 SMTP")
|
||||||
|
src = ask("选择 [1/2]", default_choice).strip().lower()
|
||||||
|
args.mail_source = "own" if src in ("2", "own") else "app"
|
||||||
|
args._mail_source_chosen = True # 本轮显式选过,保存时才写回文件
|
||||||
|
print(f" 发信账号 = {'程序自带 params.ini [mail]' if args.mail_source == 'own' else 'conf/app.ini [mail]'}")
|
||||||
|
|
||||||
# 站点地址:只有完全没值时才问(值链已在 main 里兜底过 external_url)
|
# 站点地址:只有完全没值时才问(值链已在 main 里兜底过 external_url)
|
||||||
if not site_url:
|
if not site_url:
|
||||||
site_url = ask("站点地址 site_url(如 https://box.shiroko.one)", "")
|
site_url = ask("站点地址 site_url(如 https://box.shiroko.one)", "")
|
||||||
@@ -1741,6 +1951,10 @@ def run_interactive(cfg, templates, extra_vars, args, config_path):
|
|||||||
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,
|
batch_size=getattr(args, "batch_size", 0) or 0,
|
||||||
smtp_idle_reconnect=getattr(args, "smtp_idle_reconnect", 30.0) or 30.0,
|
smtp_idle_reconnect=getattr(args, "smtp_idle_reconnect", 30.0) or 30.0,
|
||||||
|
# 只有本轮在交互里显式选过来源才写回文件,否则保留原值:
|
||||||
|
# 命令行临时 --mail-source 不该被保存成永久配置
|
||||||
|
mail=({"source": args.mail_source}
|
||||||
|
if getattr(args, "_mail_source_chosen", False) else None),
|
||||||
bounce={
|
bounce={
|
||||||
"watch_bounces": args.watch_bounces,
|
"watch_bounces": args.watch_bounces,
|
||||||
"watch_every": args.watch_every,
|
"watch_every": args.watch_every,
|
||||||
@@ -1827,6 +2041,12 @@ def build_arg_parser():
|
|||||||
help=f"参数文件路径(默认自动读取脚本同目录 params.ini,不存在则跳过;CLI 参数优先级更高)")
|
help=f"参数文件路径(默认自动读取脚本同目录 params.ini,不存在则跳过;CLI 参数优先级更高)")
|
||||||
parser.add_argument("--no-params", action="store_true",
|
parser.add_argument("--no-params", action="store_true",
|
||||||
help="不读取 params.ini 参数文件")
|
help="不读取 params.ini 参数文件")
|
||||||
|
parser.add_argument("--mail-source", default=None, choices=["app", "own"],
|
||||||
|
help="发信账号来源:app = conf/app.ini [mail](默认);"
|
||||||
|
"own = 程序自带 SMTP(读 params.ini [mail] 的"
|
||||||
|
" account/password/smtp/port,password 支持 env:变量名)。"
|
||||||
|
"命令行优先于 params.ini [mail] source。切换后发信与"
|
||||||
|
"退信核查(IMAP)都走这套账号")
|
||||||
parser.add_argument("--interactive", "-i", action="store_true", default=None,
|
parser.add_argument("--interactive", "-i", action="store_true", default=None,
|
||||||
help="交互模式(默认开启:不带任何运行参数运行脚本时自动进入)。"
|
help="交互模式(默认开启:不带任何运行参数运行脚本时自动进入)。"
|
||||||
"运行后逐项询问参数,并直接选择运行方式(演练/测试一封/群发)")
|
"运行后逐项询问参数,并直接选择运行方式(演练/测试一封/群发)")
|
||||||
@@ -1845,6 +2065,9 @@ def build_arg_parser():
|
|||||||
parser.add_argument("--prune-unknown", action="store_true", default=None,
|
parser.add_argument("--prune-unknown", action="store_true", default=None,
|
||||||
help="配合 --check-bounces:无法分类的退信也按「可重试」处理,一并剔除补发"
|
help="配合 --check-bounces:无法分类的退信也按「可重试」处理,一并剔除补发"
|
||||||
"(默认不剔除,避免给死信地址反复重发;也可在 params.ini [bounce] 设置)")
|
"(默认不剔除,避免给死信地址反复重发;也可在 params.ini [bounce] 设置)")
|
||||||
|
parser.add_argument("--full-scan", action="store_true",
|
||||||
|
help="退信核查忽略上次扫描位置,按日期窗口从头全量扫描"
|
||||||
|
"(默认记住上次扫到的位置只查新邮件;显式 --since 也会忽略位置)")
|
||||||
parser.add_argument("--invalid-file", default=None, metavar="FILE",
|
parser.add_argument("--invalid-file", default=None, metavar="FILE",
|
||||||
help=f"无效地址清单(硬退信地址)路径;默认脚本同目录 broadcast_invalid.json。"
|
help=f"无效地址清单(硬退信地址)路径;默认脚本同目录 broadcast_invalid.json。"
|
||||||
f"清单里的地址每次运行都会跳过,即使 --reset-state")
|
f"清单里的地址每次运行都会跳过,即使 --reset-state")
|
||||||
@@ -1987,10 +2210,22 @@ def main():
|
|||||||
elif not args.interactive:
|
elif not args.interactive:
|
||||||
print("[警告] site_url 为空:{{site}} 与无个性域名用户的 {{box_link}} 将为空串")
|
print("[警告] site_url 为空:{{site}} 与无个性域名用户的 {{box_link}} 将为空串")
|
||||||
|
|
||||||
|
# 程序自带 SMTP 是否已配齐(交互模式据此提供来源切换);默认来源取自
|
||||||
|
# params.ini [mail] source,否则交互里回车会把已配好的 own 静默改成 app
|
||||||
|
_m = params.get("mail") or {}
|
||||||
|
args._own_mail_ready = bool((_m.get("smtp") or "").strip()
|
||||||
|
and (_m.get("account") or "").strip()
|
||||||
|
and (_m.get("password") or "").strip())
|
||||||
|
args._mail_source_default = (_m.get("source") or "app").strip().lower()
|
||||||
|
|
||||||
if args.interactive:
|
if args.interactive:
|
||||||
site_url, args.box_prefix, extra_vars = run_interactive(
|
site_url, args.box_prefix, extra_vars = run_interactive(
|
||||||
cfg, templates, extra_vars, args, config_path)
|
cfg, templates, extra_vars, args, config_path)
|
||||||
|
|
||||||
|
# ---- 发信账号来源切换(app.ini / 程序自带 params.ini [mail])----
|
||||||
|
# 必须在交互模式之后(交互里可能改选)、退信核查与发信之前(两者都用 cfg)
|
||||||
|
mail_source = apply_mail_source(cfg, args, params)
|
||||||
|
|
||||||
# ---- 退信核查模式:不查数据库、不发信;扫描(含可选剔除)后结束,
|
# ---- 退信核查模式:不查数据库、不发信;扫描(含可选剔除)后结束,
|
||||||
# 除非同时指定 --send(剔除并补发),此时继续走正常发送流程 ----
|
# 除非同时指定 --send(剔除并补发),此时继续走正常发送流程 ----
|
||||||
if args.check_bounces:
|
if args.check_bounces:
|
||||||
@@ -2021,6 +2256,8 @@ def main():
|
|||||||
print(f" [db] host:port = {cfg['db_host']}:{cfg['db_port']} db={cfg['db_name'] or '(空)'}")
|
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] smtp:port = {cfg['mail_smtp']}:{cfg['mail_port']}")
|
||||||
print(f" [mail] account = {cfg['mail_account'] or '(空)'}")
|
print(f" [mail] account = {cfg['mail_account'] or '(空)'}")
|
||||||
|
print(f" [mail] 来源 = "
|
||||||
|
f"{'程序自带 params.ini [mail]' if mail_source == 'own' else 'conf/app.ini [mail]'}")
|
||||||
print(f" [断点续发] state_file = {args.state}")
|
print(f" [断点续发] state_file = {args.state}")
|
||||||
print("=" * 56)
|
print("=" * 56)
|
||||||
|
|
||||||
@@ -2281,6 +2518,8 @@ def main():
|
|||||||
f"(如 {sorted(watch_rate)[0]}:"
|
f"(如 {sorted(watch_rate)[0]}:"
|
||||||
f"{watch_rate[sorted(watch_rate)[0]]['reason']})")
|
f"{watch_rate[sorted(watch_rate)[0]]['reason']})")
|
||||||
|
|
||||||
|
print(f"\n[发信账号] {cfg['mail_account']} @ {cfg['mail_smtp']}:{cfg['mail_port']}"
|
||||||
|
f"(来源:{'程序自带 params.ini [mail]' if mail_source == 'own' else 'conf/app.ini [mail]'})")
|
||||||
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 "")
|
||||||
|
|||||||
+12
@@ -32,6 +32,18 @@ smtp_idle_reconnect = 30
|
|||||||
; 模板含 {{name}} 等个人化占位符时内容逐人不同,自动回退逐封,不会错合。
|
; 模板含 {{name}} 等个人化占位符时内容逐人不同,自动回退逐封,不会错合。
|
||||||
batch_size = 0
|
batch_size = 0
|
||||||
|
|
||||||
|
; 发信账号来源:app = 用 conf/app.ini [mail] 的 SMTP(默认,站内信跟随站点邮箱)
|
||||||
|
; own = 用下面这套程序自带 SMTP(命令行 --mail-source own 可临时覆盖)
|
||||||
|
; 切到 own 后,发信与退信核查(IMAP)都走这套账号
|
||||||
|
[mail]
|
||||||
|
source = app
|
||||||
|
account =
|
||||||
|
; password 支持 env:变量名(推荐),避免明文写进会被 git 跟踪的文件
|
||||||
|
password =
|
||||||
|
smtp =
|
||||||
|
port = 465
|
||||||
|
skip_tls_verify = 0
|
||||||
|
|
||||||
; 退信核查 / 发送中限流巡检开关
|
; 退信核查 / 发送中限流巡检开关
|
||||||
; 这些都能在 params.ini 里长期配置,命令行同名参数可临时覆盖
|
; 这些都能在 params.ini 里长期配置,命令行同名参数可临时覆盖
|
||||||
[bounce]
|
[bounce]
|
||||||
|
|||||||
Reference in New Issue
Block a user