send_keys.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. send_keys.py
  5. ------------
  6. Read an Excel exported by fetch_requests.py, allocate Steam keys from a TXT pool (one per line),
  7. and send HTML emails to curators using a fixed template.
  8. This version:
  9. - Logs ONE ROW PER KEY (not per email).
  10. - Log column order (front):
  11. 1) No.
  12. 2) Send Date (Beijing, YYYY-MM-DD)
  13. 3) Channel (auto from social link; default "Steam")
  14. 4) Curator/Name
  15. 5) Purpose (fixed "评测")
  16. 6) Social Link
  17. - Other columns kept (and adjusted for per-key row):
  18. Key, Mailbox Key, To, Requested Key Count, Subject, Status, Sent At, Test Mode, Actual Recipient
  19. - Subject uses: --subject + " RE: " + original email subject (if present)
  20. - Test mode does NOT consume keys; dry-run does not send/consume.
  21. - Optional external HTML template via --template (fallback to built-in).
  22. Usage examples:
  23. python send_keys.py --excel curator_requests.xlsx --keys key_pool.txt --out send_log.xlsx \
  24. --subject "Steam Keys for Such A Guy" --dry-run --limit 2
  25. python send_keys.py --excel curator_requests.xlsx --keys key_pool.txt --out send_log.xlsx \
  26. --subject "Steam Keys for Such A Guy" --test --test-email krystic@such-one.com --limit 1
  27. """
  28. import os
  29. import sys
  30. import json
  31. import argparse
  32. import smtplib
  33. import ssl
  34. import imaplib
  35. from email.message import EmailMessage
  36. from typing import List, Dict, Any, Optional, Tuple
  37. import pandas as pd
  38. from openpyxl import load_workbook
  39. from datetime import datetime
  40. from zoneinfo import ZoneInfo
  41. DEFAULT_CONFIG_PATH = os.path.join("configs", "settings.json")
  42. def load_settings(config_path: Optional[str]) -> Dict[str, Any]:
  43. path = config_path or DEFAULT_CONFIG_PATH
  44. if not os.path.isabs(path):
  45. path = os.path.abspath(path)
  46. if not os.path.exists(path):
  47. print(f"ERROR: settings file not found: {path}", file=sys.stderr)
  48. sys.exit(2)
  49. try:
  50. with open(path, "r", encoding="utf-8") as f:
  51. data = json.load(f)
  52. except Exception as e:
  53. print(f"ERROR: failed to read settings.json: {e}", file=sys.stderr)
  54. sys.exit(2)
  55. files = data.get("files", {})
  56. normalized = {}
  57. for key, val in files.items():
  58. if not os.path.isabs(val):
  59. normalized[key] = os.path.abspath(val)
  60. else:
  61. normalized[key] = val
  62. data["files"] = normalized
  63. return data
  64. def resolve_path(arg_value: Optional[str], default_path: str) -> str:
  65. default_dir = os.path.dirname(default_path)
  66. if arg_value is None or arg_value == "":
  67. target = default_path
  68. else:
  69. if os.path.isabs(arg_value) or os.path.dirname(arg_value):
  70. target = arg_value
  71. else:
  72. base = default_dir if default_dir else "."
  73. target = os.path.join(base, arg_value)
  74. return os.path.abspath(target)
  75. def ensure_parent_dir(path: str) -> None:
  76. directory = os.path.dirname(path)
  77. if directory:
  78. os.makedirs(directory, exist_ok=True)
  79. # Built-in fallback template (can be overridden by --template file)
  80. FALLBACK_TEMPLATE = """\
  81. <html>
  82. <body>
  83. <p>Hi <b>{curator_name}</b>,</p>
  84. <p>Thank you for your interest in <b>Such A Guy</b>!
  85. We’re happy to provide you with <b>{key_num} Steam key(s)</b>:</p>
  86. <p><b>{keys_block}</b></p>
  87. <p><b>Included materials:</b><br/>
  88. Press kit (capsules, screenshots, short trailer):<br/>
  89. <a href="https://drive.google.com/drive/folders/15h5IQWy0AD1TFBz2Jgd2lNp9CkA3szY8?usp=sharing">Google Drive Press Kit</a><br/>
  90. Review notes (features, estimated length, content warnings):<br/>
  91. <a href="https://docs.google.com/document/d/1aTPTiDxCbsd3Ie4tNK47LUcYYzIZw5Yf4nveJG4KT7s/edit?usp=sharing">Google Docs Review Notes</a></p>
  92. <p>We’d really appreciate your honest impressions after you’ve tried the game —
  93. both on your <b>Steam Curator page</b> and as a <b>store review</b> on Steam.
  94. If you enjoyed the experience, even a short recommendation would help more players discover it. 💫</p>
  95. <p>Best,<br/><b>Krystic</b><br/>SUCH ONE STUDIO</p>
  96. </body>
  97. </html>
  98. """
  99. def load_template(path: Optional[str]) -> str:
  100. if path and os.path.exists(path):
  101. with open(path, "r", encoding="utf-8") as f:
  102. return f.read()
  103. return FALLBACK_TEMPLATE
  104. def load_keys(path: str) -> List[str]:
  105. with open(path, "r", encoding="utf-8") as f:
  106. keys = [line.strip() for line in f if line.strip()]
  107. return keys
  108. def save_remaining_keys(path: str, keys: List[str]) -> None:
  109. ensure_parent_dir(path)
  110. tmp = path + ".tmp"
  111. with open(tmp, "w", encoding="utf-8") as f:
  112. for k in keys:
  113. f.write(k + "\n")
  114. os.replace(tmp, path)
  115. def render_email_html(template_html: str, curator_name: str, key_num: int, keys: List[str]) -> str:
  116. keys_lines = "<br/>\n".join([k for k in keys])
  117. return template_html.format(curator_name=curator_name or "there",
  118. key_num=key_num,
  119. keys_block=keys_lines)
  120. def send_email(smtp_host: str, smtp_port: int, smtp_user: str, smtp_pass: str,
  121. from_name: str, from_email: str, original_message_id: Optional[str],
  122. to_email: str, subject: str, html: str, dry_run: bool = False) -> None:
  123. if dry_run:
  124. print(f"\n--- DRY RUN (no send) ---\nTO: {to_email}\nSUBJECT: {subject}\nHTML:\n{html}\n-------------------------\n")
  125. return
  126. msg = EmailMessage()
  127. msg["Subject"] = subject
  128. msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
  129. msg["To"] = to_email
  130. if original_message_id:
  131. msg["In-Reply-To"] = original_message_id
  132. msg["References"] = original_message_id
  133. msg.set_content("HTML email - please view in a mail client that supports HTML.")
  134. msg.add_alternative(html, subtype="html")
  135. context = ssl.create_default_context()
  136. with smtplib.SMTP(smtp_host, smtp_port) as server:
  137. server.ehlo()
  138. if smtp_port == 587:
  139. server.starttls(context=context)
  140. server.ehlo()
  141. server.login(smtp_user, smtp_pass)
  142. server.send_message(msg)
  143. def parse_channel_and_link(links_str: str) -> Tuple[str, str]:
  144. """Return (channel, link) based on first recognizable URL; default ('Steam','')."""
  145. if not isinstance(links_str, str) or not links_str.strip():
  146. return ("Steam", "")
  147. # Take first URL if comma-separated
  148. first = links_str.split(",")[0].strip()
  149. low = first.lower()
  150. mapping = [
  151. ("store.steampowered.com/curator", "Steam"),
  152. ("steamcommunity.com", "Steam"),
  153. ("youtu.be", "YouTube"),
  154. ("youtube.com", "YouTube"),
  155. ("x.com", "Twitter"),
  156. ("twitter.com", "Twitter"),
  157. ("twitch.tv", "Twitch"),
  158. ("discord.gg", "Discord"),
  159. ("discord.com", "Discord"),
  160. ("facebook.com", "Facebook"),
  161. ("instagram.com", "Instagram"),
  162. ("tiktok.com", "TikTok"),
  163. ("bilibili.com", "Bilibili"),
  164. ("weibo.com", "Weibo"),
  165. ]
  166. for needle, label in mapping:
  167. if needle in low:
  168. return (label, first)
  169. return ("Steam", first) # default channel name
  170. def load_global_state(path: str) -> Dict[str, Any]:
  171. if not os.path.exists(path):
  172. return {"mailboxes": {}, "sent_emails": []}
  173. try:
  174. with open(path, "r", encoding="utf-8") as f:
  175. data = json.load(f)
  176. except Exception:
  177. return {"mailboxes": {}, "sent_emails": []}
  178. if "mailboxes" not in data or not isinstance(data["mailboxes"], dict):
  179. data["mailboxes"] = {}
  180. if "sent_emails" not in data or not isinstance(data["sent_emails"], list):
  181. data["sent_emails"] = []
  182. return data
  183. def save_global_state(path: str, data: Dict[str, Any]) -> None:
  184. ensure_parent_dir(path)
  185. tmp = path + ".tmp"
  186. with open(tmp, "w", encoding="utf-8") as f:
  187. json.dump(data, f, ensure_ascii=False, indent=2)
  188. os.replace(tmp, path)
  189. def norm_email(addr: str) -> str:
  190. return (addr or "").strip().lower()
  191. def cleanup_log_sheets(log_path: str, keep_only_real: bool = False) -> None:
  192. if not os.path.exists(log_path):
  193. return
  194. try:
  195. wb = load_workbook(log_path)
  196. except Exception:
  197. return
  198. removed = False
  199. for name in list(wb.sheetnames):
  200. upper = name.upper()
  201. if (("DRYRUN" in upper) or ("TEST" in upper)) and keep_only_real:
  202. ws = wb[name]
  203. wb.remove(ws)
  204. removed = True
  205. if removed:
  206. wb.save(log_path)
  207. wb.close()
  208. def _norm_uid(u) -> str:
  209. if isinstance(u, (bytes, bytearray)):
  210. return u.decode("utf-8", errors="ignore").strip()
  211. return str(u).strip()
  212. def _chunked(seq, n):
  213. for i in range(0, len(seq), n):
  214. yield seq[i:i+n]
  215. def imap_mark_answered_batch(host: str, port: int, user: str, pwd: str, mailbox: str,
  216. uids: list[str], batch_size: int = 500) -> int:
  217. """把一批 UID 标记为 \\Answered,返回成功提交的数量。"""
  218. uids = [_norm_uid(u) for u in uids if _norm_uid(u)]
  219. if not uids:
  220. return 0
  221. M = imaplib.IMAP4_SSL(host, port)
  222. M.login(user, pwd)
  223. typ, _ = M.select(mailbox, readonly=False)
  224. if typ != "OK":
  225. try:
  226. M.close()
  227. except Exception:
  228. pass
  229. M.logout()
  230. return 0
  231. total_ok = 0
  232. for batch in _chunked(uids, batch_size):
  233. seqset = ",".join(batch) # e.g. "444,445,446"
  234. typ1, _ = M.uid("STORE", seqset, "+FLAGS.SILENT", r"(\Answered)")
  235. if typ1 != "OK":
  236. typ2, resp2 = M.uid("STORE", seqset, "+FLAGS", r"(\Answered)")
  237. if typ2 == "OK":
  238. total_ok += len(batch)
  239. else:
  240. # 逐封回退
  241. for uid in batch:
  242. t3, _ = M.uid("STORE", uid, "+FLAGS.SILENT", r"(\Answered)")
  243. if t3 == "OK":
  244. total_ok += 1
  245. else:
  246. t4, _ = M.uid("STORE", uid, "+FLAGS", r"(\Answered)")
  247. if t4 == "OK":
  248. total_ok += 1
  249. else:
  250. total_ok += len(batch)
  251. try:
  252. M.close()
  253. except Exception:
  254. pass
  255. M.logout()
  256. return total_ok
  257. def main():
  258. parser = argparse.ArgumentParser(description="Bulk send Steam keys to curators from Excel (one row per key).")
  259. parser.add_argument("--config", help="Path to settings.json (default: configs/settings.json).")
  260. parser.add_argument("--excel", help="Path to input Excel (default from settings).")
  261. parser.add_argument("--keys", help="Path to key pool TXT (default from settings).")
  262. parser.add_argument("--out", help="Path to output Excel log (default from settings).")
  263. parser.add_argument("--subject", help="Base email subject (default from settings).")
  264. parser.add_argument("--sheet", default=None, help="Worksheet name to read within the Excel file (default: first sheet).")
  265. parser.add_argument("--template", help="Path to HTML email template file (default from settings).")
  266. parser.add_argument("--limit", type=int, default=None, help="Max rows to process from Excel.")
  267. parser.add_argument("--dry-run", action="store_true", help="Render emails only; do not send.")
  268. parser.add_argument("--test", action="store_true", help="Send to test address instead of recipients.")
  269. parser.add_argument("--test-email", default=os.environ.get("TEST_EMAIL", ""), help="Test recipient (with --test).")
  270. parser.add_argument("--skip-sent", action="store_true", help="Skip rows already present (by UID) in the output log.")
  271. parser.add_argument("--no-sentemail", action="store_true", help="Do not record sent emails into STATE_FILE (useful for testing).")
  272. parser.add_argument("--no-consume", action="store_true", help="Do not modify key pool file (do not remove used keys).")
  273. # SMTP config (env or CLI)
  274. parser.add_argument("--smtp-host")
  275. parser.add_argument("--smtp-port", type=int)
  276. parser.add_argument("--smtp-user")
  277. parser.add_argument("--smtp-pass")
  278. parser.add_argument("--from-name")
  279. parser.add_argument("--from-email")
  280. # IMAP config for optional marking as answered
  281. parser.add_argument("--mark-answered", action="store_true",
  282. help="After real sends, mark original messages as \\Answered via IMAP.")
  283. parser.add_argument("--imap-host")
  284. parser.add_argument("--imap-port", type=int)
  285. parser.add_argument("--imap-user")
  286. parser.add_argument("--imap-pass")
  287. parser.add_argument("--imap-mailbox")
  288. args = parser.parse_args()
  289. config_path = args.config or os.environ.get("SETTINGS_FILE") or DEFAULT_CONFIG_PATH
  290. settings = load_settings(config_path)
  291. files_conf = settings.get("files", {})
  292. required_files = ["excel", "keys", "log", "template", "state"]
  293. missing_files = [k for k in required_files if k not in files_conf]
  294. if missing_files:
  295. print(f"ERROR: settings.json missing file paths for: {', '.join(missing_files)}", file=sys.stderr)
  296. sys.exit(2)
  297. args.excel = resolve_path(args.excel, files_conf["excel"])
  298. args.keys = resolve_path(args.keys, files_conf["keys"])
  299. args.out = resolve_path(args.out, files_conf["log"])
  300. args.template = resolve_path(args.template, files_conf["template"])
  301. state_file = os.path.abspath(files_conf["state"])
  302. subject_default = settings.get("subject", "")
  303. args.subject = args.subject if args.subject is not None else subject_default
  304. smtp_conf = settings.get("smtp", {})
  305. args.smtp_host = args.smtp_host or os.environ.get("SMTP_HOST") or smtp_conf.get("host", "")
  306. if args.smtp_port is None:
  307. env_port = os.environ.get("SMTP_PORT")
  308. if env_port:
  309. try:
  310. args.smtp_port = int(env_port)
  311. except Exception:
  312. pass
  313. if args.smtp_port is None:
  314. args.smtp_port = int(smtp_conf.get("port", 587))
  315. args.smtp_user = args.smtp_user or os.environ.get("SMTP_USER") or smtp_conf.get("user", "")
  316. args.smtp_pass = args.smtp_pass or os.environ.get("SMTP_PASS") or smtp_conf.get("pass", "")
  317. args.from_name = args.from_name or os.environ.get("FROM_NAME") or smtp_conf.get("from_name", "Krystic")
  318. args.from_email = args.from_email or os.environ.get("FROM_EMAIL") or smtp_conf.get("from_email", args.smtp_user)
  319. imap_conf = settings.get("imap", {})
  320. args.imap_host = args.imap_host or os.environ.get("IMAP_HOST") or imap_conf.get("host", "")
  321. if args.imap_port is None:
  322. env_imap_port = os.environ.get("IMAP_PORT")
  323. if env_imap_port:
  324. try:
  325. args.imap_port = int(env_imap_port)
  326. except Exception:
  327. pass
  328. if args.imap_port is None:
  329. args.imap_port = int(imap_conf.get("port", 993))
  330. args.imap_user = args.imap_user or os.environ.get("EMAIL_USER") or imap_conf.get("user", args.smtp_user)
  331. args.imap_pass = args.imap_pass or os.environ.get("EMAIL_PASS") or imap_conf.get("pass", args.smtp_pass)
  332. args.imap_mailbox = args.imap_mailbox or os.environ.get("MAILBOX") or imap_conf.get("mailbox", "INBOX")
  333. global_state = load_global_state(state_file)
  334. sent_emails_global = set()
  335. for existing_email in global_state.get("sent_emails", []):
  336. normalized = norm_email(existing_email)
  337. if normalized:
  338. sent_emails_global.add(normalized)
  339. run_sent_emails: set[str] = set()
  340. state_emails_run: set[str] = set()
  341. if not args.dry_run and not args.test:
  342. cleanup_log_sheets(args.out, keep_only_real=True)
  343. # Validate SMTP when not dry-run
  344. if not args.dry_run:
  345. for vname in ["smtp_host", "smtp_user", "smtp_pass", "from_email"]:
  346. if not getattr(args, vname):
  347. print(f"ERROR: Missing SMTP config --{vname.replace('_','-')} (or env var). Use --dry-run to preview.", file=sys.stderr)
  348. sys.exit(2)
  349. # Load template (external file if exists, otherwise fallback)
  350. template_html = load_template(args.template)
  351. # Load Excel
  352. sheet_to_read: Optional[str | int]
  353. if args.sheet:
  354. sheet_to_read = args.sheet
  355. else:
  356. sheet_to_read = 0
  357. df = pd.read_excel(args.excel, sheet_name=sheet_to_read)
  358. required_cols = ["Mailbox Key", "Email", "Curator/Name", "Requested Key Count", "Subject", "Curator/Social Links"]
  359. for c in required_cols:
  360. if c not in df.columns:
  361. print(f"ERROR: Excel missing column: {c}", file=sys.stderr)
  362. sys.exit(3)
  363. # Load existing log to support --skip-sent (by UID)
  364. sent_uids = set()
  365. if args.skip_sent and os.path.exists(args.out):
  366. try:
  367. logdfs = pd.read_excel(args.out, sheet_name=None)
  368. for _, logdf in logdfs.items():
  369. if "Mailbox Key" in logdf.columns:
  370. sent_uids.update(str(x) for x in logdf["Mailbox Key"].astype(str).tolist())
  371. except Exception:
  372. pass
  373. # Load key pool
  374. pool = load_keys(args.keys)
  375. # Prepare per-key logging
  376. log_rows: List[Dict[str, Any]] = []
  377. row_no = 1
  378. processed = 0
  379. # ===== 进度与汇总统计 =====
  380. # 估算计划处理的“邮件行”总数(考虑 --limit 与 --skip-sent)
  381. if args.limit is not None:
  382. total_target = min(len(df), args.limit)
  383. else:
  384. total_target = len(df)
  385. if args.skip_sent and os.path.exists(args.out):
  386. try:
  387. # 粗略估算,已发过的行会被跳过(只是估算,实际略有出入也没关系)
  388. total_target = max(0, total_target - len(sent_uids))
  389. except Exception:
  390. pass
  391. attempt_rows = 0 # 实际尝试发送的“邮件行”(有成功分配到 key 才算一次尝试)
  392. emails_ok = 0 # 发送成功(SENT 或 SENT_TEST)
  393. emails_fail = 0 # 失败/跳过(ERROR 或 SKIPPED_NO_KEYS 等)
  394. keys_assigned_total = 0 # 实际分配(写进邮件里的)key 数(dry-run/test 也会统计)
  395. duplicate_skipped = 0 # 因邮箱重复而跳过的行数
  396. uids_to_mark_answered: list[str] = []
  397. interrupted = False
  398. # Iterate over Excel rows (one email row)
  399. try:
  400. for idx, row in df.iterrows():
  401. try:
  402. uid = str(row.get("Mailbox Key", "")).strip()
  403. email_to = str(row.get("Email", "")).strip()
  404. email_norm = norm_email(email_to)
  405. msg_id_val = row.get("Original Message-ID", "")
  406. if pd.isna(msg_id_val):
  407. original_message_id = ""
  408. else:
  409. original_message_id = str(msg_id_val).strip()
  410. name_val = row.get("Curator/Name", "")
  411. if pd.isna(name_val) or not str(name_val).strip():
  412. curator_name = "Curator"
  413. else:
  414. curator_name = str(name_val).strip()
  415. if args.test:
  416. curator_name = curator_name + "(" + email_to + ")"
  417. req_num = row.get("Requested Key Count")
  418. try:
  419. key_num = int(req_num) if pd.notna(req_num) else 2
  420. except Exception:
  421. key_num = 2
  422. if key_num <= 0:
  423. key_num = 2
  424. if args.skip_sent and uid and uid in sent_uids:
  425. continue
  426. if args.limit is not None and processed >= args.limit:
  427. break
  428. # Channel & link detection (robust against NaN)
  429. val = row.get("Curator/Social Links", "")
  430. if pd.isna(val):
  431. links_str = ""
  432. else:
  433. links_str = str(val).strip()
  434. channel, chosen_link = parse_channel_and_link(links_str)
  435. # ✅ 一律将 None/NaN/空白 归一为 ""
  436. safe_link = "" if (chosen_link is None or (isinstance(chosen_link, float) and pd.isna(chosen_link)) or not str(chosen_link).strip()) else str(chosen_link).strip()
  437. orig_subject = str(row.get("Subject", "")).strip()
  438. final_subject = args.subject
  439. assigned: List[str] = []
  440. to_addr = ""
  441. duplicate_email = bool(email_norm and (email_norm in sent_emails_global or email_norm in run_sent_emails))
  442. if duplicate_email:
  443. status = "SKIPPED_DUP_EMAIL"
  444. duplicate_skipped += 1
  445. elif len(pool) < key_num:
  446. print(f"WARNING: Not enough keys left for UID {uid}. Needed {key_num}, have {len(pool)}. Skipping.", file=sys.stderr)
  447. status = "SKIPPED_NO_KEYS"
  448. else:
  449. assigned = pool[:key_num]
  450. if orig_subject:
  451. final_subject = f"{args.subject} RE: {orig_subject}"
  452. html = render_email_html(template_html, curator_name, key_num, assigned)
  453. # Decide recipient
  454. to_addr = args.test_email if args.test and args.test_email else (os.environ.get("TEST_EMAIL") if args.test else email_to)
  455. if args.test and not to_addr:
  456. print("ERROR: --test specified but no test email provided. Use --test-email or TEST_EMAIL env.", file=sys.stderr)
  457. sys.exit(4)
  458. # Send (or dry-run)
  459. send_email(
  460. smtp_host=args.smtp_host, smtp_port=args.smtp_port,
  461. smtp_user=args.smtp_user, smtp_pass=args.smtp_pass,
  462. from_name=args.from_name, from_email=args.from_email,
  463. original_message_id=original_message_id or None,
  464. to_email=to_addr, subject=final_subject, html=html,
  465. dry_run=args.dry_run
  466. )
  467. status = "SENT_TEST" if args.test else ("DRY_RUN" if args.dry_run else "SENT")
  468. if email_norm and status in ("SENT", "SENT_TEST", "DRY_RUN"):
  469. run_sent_emails.add(email_norm)
  470. if email_norm and status in ("SENT", "SENT_TEST"):
  471. state_emails_run.add(email_norm)
  472. if status == "SENT":
  473. uids_to_mark_answered.append(uid)
  474. if assigned:
  475. attempt_rows += 1
  476. if status in ("SENT", "SENT_TEST"):
  477. emails_ok += 1
  478. keys_assigned_total += len(assigned)
  479. else:
  480. emails_fail += 1
  481. if not args.dry_run:
  482. pct = (processed / total_target * 100) if total_target else 0
  483. print(f"[{processed}/{total_target} | {pct:.1f}%] {status} UID={uid} to={to_addr or email_to} keys={len(assigned)}")
  484. if not args.no_consume:
  485. pool = pool[key_num:]
  486. # Prepare BJ timestamps
  487. now_bj = datetime.now(ZoneInfo("Asia/Shanghai"))
  488. send_date = now_bj.strftime("%Y-%m-%d") # YYYY-MM-DD
  489. sent_at = now_bj.strftime("%Y-%m-%d %H:%M:%S") # detailed
  490. # Log ONE ROW PER KEY
  491. if assigned:
  492. for k in assigned:
  493. log_rows.append({
  494. "No.": row_no,
  495. "Send Date": send_date,
  496. "Channel": channel or "Steam",
  497. "Curator/Name": curator_name,
  498. "Purpose": "评测",
  499. "Social Link": safe_link,
  500. "Key": k,
  501. "Mailbox Key": uid,
  502. "To": email_to,
  503. "Requested Key Count": key_num,
  504. "Subject": final_subject,
  505. "Status": status,
  506. "Sent At": sent_at,
  507. "Test Mode": bool(args.test),
  508. "Actual Recipient": to_addr if (args.test or args.dry_run) else email_to,
  509. })
  510. row_no += 1
  511. else:
  512. # Even if skipped/no keys, write a single row for traceability (without Key)
  513. log_rows.append({
  514. "No.": row_no,
  515. "Send Date": send_date,
  516. "Channel": channel or "Steam",
  517. "Curator/Name": curator_name,
  518. "Purpose": "评测",
  519. "Social Link": chosen_link,
  520. "Key": "",
  521. "Mailbox Key": uid,
  522. "To": email_to,
  523. "Requested Key Count": key_num,
  524. "Subject": final_subject,
  525. "Status": status,
  526. "Sent At": sent_at,
  527. "Test Mode": bool(args.test),
  528. "Actual Recipient": to_addr if (args.test or args.dry_run) else email_to,
  529. })
  530. row_no += 1
  531. processed += 1
  532. except Exception as e:
  533. now_bj = datetime.now(ZoneInfo("Asia/Shanghai"))
  534. log_rows.append({
  535. "No.": row_no,
  536. "Send Date": now_bj.strftime("%Y-%m-%d"),
  537. "Channel": "Steam",
  538. "Curator/Name": str(row.get("Curator/Name", "")),
  539. "Purpose": "评测",
  540. "Social Link": str(row.get("Curator/Social Links", "")),
  541. "Key": "",
  542. "Mailbox Key": str(row.get("Mailbox Key", "")),
  543. "To": str(row.get("Email", "")),
  544. "Requested Key Count": row.get("Requested Key Count"),
  545. "Subject": str(row.get("Subject", "")),
  546. "Status": f"ERROR:{e}",
  547. "Sent At": now_bj.strftime("%Y-%m-%d %H:%M:%S"),
  548. "Test Mode": bool(args.test),
  549. "Actual Recipient": "",
  550. })
  551. row_no += 1
  552. print(f"ERROR processing row {idx}: {e}", file=sys.stderr)
  553. continue
  554. except KeyboardInterrupt:
  555. interrupted = True
  556. print("\n[INTERRUPTED] Sending loop stopped by user. Finalizing current progress.")
  557. if args.mark_answered and not args.dry_run and not args.test and uids_to_mark_answered:
  558. done = imap_mark_answered_batch(
  559. host=args.imap_host, port=args.imap_port,
  560. user=args.imap_user, pwd=args.imap_pass,
  561. mailbox=args.imap_mailbox, uids=uids_to_mark_answered, batch_size=500
  562. )
  563. print(f"[INFO] Marked {done} message(s) as \\Answered.")
  564. # Build DataFrame with desired column order
  565. columns = [
  566. "No.", "Send Date", "Channel", "Curator/Name", "Purpose", "Social Link",
  567. "Key", "Mailbox Key", "To", "Requested Key Count", "Subject",
  568. "Status", "Sent At", "Test Mode", "Actual Recipient"
  569. ]
  570. log_df = pd.DataFrame(log_rows, columns=columns)
  571. log_path = os.path.abspath(args.out)
  572. ensure_parent_dir(log_path)
  573. date_tag = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
  574. sheet_prefix = f"sendlog_{date_tag}"
  575. if args.dry_run:
  576. sheet_prefix += "_DRYRUN"
  577. elif args.test:
  578. sheet_prefix += "_TEST"
  579. existing_sheets = set()
  580. if os.path.exists(log_path):
  581. try:
  582. wb = load_workbook(log_path, read_only=True)
  583. existing_sheets = set(wb.sheetnames)
  584. wb.close()
  585. except Exception:
  586. existing_sheets = set()
  587. suffix = 1
  588. while True:
  589. sheet_name = f"{sheet_prefix}{suffix:02d}"
  590. if sheet_name not in existing_sheets:
  591. break
  592. suffix += 1
  593. writer_args: Dict[str, Any] = {"engine": "openpyxl"}
  594. if os.path.exists(log_path):
  595. writer_args["mode"] = "a"
  596. with pd.ExcelWriter(log_path, **writer_args) as writer:
  597. log_df.to_excel(writer, sheet_name=sheet_name, index=False)
  598. book = writer.book
  599. if sheet_name in book.sheetnames and book.sheetnames[0] != sheet_name:
  600. ws = book[sheet_name]
  601. sheets = book._sheets # type: ignore[attr-defined]
  602. sheets.insert(0, sheets.pop(sheets.index(ws)))
  603. # ===== 汇总报告 =====
  604. mode = "REAL"
  605. if args.dry_run:
  606. mode = "DRY-RUN"
  607. elif args.test:
  608. mode = "TEST"
  609. print("\n=== RUN SUMMARY ===")
  610. print(f"Mode: {mode}")
  611. print(f"Email rows processed: {processed}")
  612. print(f"Attempted sends: {attempt_rows} (rows that had keys assigned)")
  613. print(f"Successful sends: {emails_ok}")
  614. print(f"Failed/Skipped: {emails_fail}")
  615. if duplicate_skipped:
  616. print(f"Skipped duplicates: {duplicate_skipped}")
  617. print(f"Keys assigned total: {keys_assigned_total}")
  618. print(f"Log file: {log_path}")
  619. if interrupted:
  620. print("[INTERRUPTED] Run stopped early; review the log for partial results.")
  621. if (not args.dry_run) and state_emails_run and (not args.no_sentemail):
  622. merged_emails = sorted(sent_emails_global.union(state_emails_run))
  623. global_state["sent_emails"] = merged_emails
  624. save_global_state(state_file, global_state)
  625. # Save remaining keys if consuming (not dry-run/test)
  626. if not args.no_consume and not args.dry_run and not args.test:
  627. save_remaining_keys(args.keys, pool)
  628. print(f"Done. Processed {processed} email row(s). Logged {len(log_df)} key row(s). File: {log_path}")
  629. if not args.no_consume:
  630. print(f"Current in-memory remaining keys (not saved if test/dry-run): {len(pool)}")
  631. if __name__ == "__main__":
  632. main()