bulk_send_keys.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. bulk_send_keys.py
  5. -----------------
  6. Read an Excel exported by imap_curator_export.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 bulk_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 bulk_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 argparse
  31. import smtplib
  32. import ssl
  33. import imaplib
  34. from email.message import EmailMessage
  35. from typing import List, Dict, Any, Optional, Tuple
  36. import pandas as pd
  37. from datetime import datetime
  38. from zoneinfo import ZoneInfo
  39. # Built-in fallback template (can be overridden by --template file)
  40. FALLBACK_TEMPLATE = """\
  41. <html>
  42. <body>
  43. <p>Hi <b>{curator_name}</b>,</p>
  44. <p>Thank you for your interest in <b>Such A Guy</b>!
  45. We’re happy to provide you with <b>{key_num} Steam key(s)</b>:</p>
  46. <p><b>{keys_block}</b></p>
  47. <p><b>Included materials:</b><br/>
  48. Press kit (capsules, screenshots, short trailer):<br/>
  49. <a href="https://drive.google.com/drive/folders/15h5IQWy0AD1TFBz2Jgd2lNp9CkA3szY8?usp=sharing">Google Drive Press Kit</a><br/>
  50. Review notes (features, estimated length, content warnings):<br/>
  51. <a href="https://docs.google.com/document/d/1aTPTiDxCbsd3Ie4tNK47LUcYYzIZw5Yf4nveJG4KT7s/edit?usp=sharing">Google Docs Review Notes</a></p>
  52. <p>We’d really appreciate your honest impressions after you’ve tried the game —
  53. both on your <b>Steam Curator page</b> and as a <b>store review</b> on Steam.
  54. If you enjoyed the experience, even a short recommendation would help more players discover it. 💫</p>
  55. <p>Best,<br/><b>Krystic</b><br/>SUCH ONE STUDIO</p>
  56. </body>
  57. </html>
  58. """
  59. def load_template(path: Optional[str]) -> str:
  60. if path and os.path.exists(path):
  61. with open(path, "r", encoding="utf-8") as f:
  62. return f.read()
  63. return FALLBACK_TEMPLATE
  64. def load_keys(path: str) -> List[str]:
  65. with open(path, "r", encoding="utf-8") as f:
  66. keys = [line.strip() for line in f if line.strip()]
  67. return keys
  68. def save_remaining_keys(path: str, keys: List[str]) -> None:
  69. tmp = path + ".tmp"
  70. with open(tmp, "w", encoding="utf-8") as f:
  71. for k in keys:
  72. f.write(k + "\n")
  73. os.replace(tmp, path)
  74. def render_email_html(template_html: str, curator_name: str, key_num: int, keys: List[str]) -> str:
  75. keys_lines = "<br/>\n".join([k for k in keys])
  76. return template_html.format(curator_name=curator_name or "there",
  77. key_num=key_num,
  78. keys_block=keys_lines)
  79. def send_email(smtp_host: str, smtp_port: int, smtp_user: str, smtp_pass: str,
  80. from_name: str, from_email: str, replyto: str,
  81. to_email: str, subject: str, html: str, dry_run: bool = False) -> None:
  82. if dry_run:
  83. print(f"\n--- DRY RUN (no send) ---\nTO: {to_email}\nSUBJECT: {subject}\nHTML:\n{html}\n-------------------------\n")
  84. return
  85. msg = EmailMessage()
  86. msg["Subject"] = subject
  87. msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
  88. msg["To"] = to_email
  89. msg["In-Reply-To"] = replyto
  90. msg.set_content("HTML email - please view in a mail client that supports HTML.")
  91. msg.add_alternative(html, subtype="html")
  92. context = ssl.create_default_context()
  93. with smtplib.SMTP(smtp_host, smtp_port) as server:
  94. server.ehlo()
  95. if smtp_port == 587:
  96. server.starttls(context=context)
  97. server.ehlo()
  98. server.login(smtp_user, smtp_pass)
  99. server.send_message(msg)
  100. def parse_channel_and_link(links_str: str) -> Tuple[str, str]:
  101. """Return (channel, link) based on first recognizable URL; default ('Steam','')."""
  102. if not isinstance(links_str, str) or not links_str.strip():
  103. return ("Steam", "")
  104. # Take first URL if comma-separated
  105. first = links_str.split(",")[0].strip()
  106. low = first.lower()
  107. mapping = [
  108. ("store.steampowered.com/curator", "Steam"),
  109. ("steamcommunity.com", "Steam"),
  110. ("youtu.be", "YouTube"),
  111. ("youtube.com", "YouTube"),
  112. ("x.com", "Twitter"),
  113. ("twitter.com", "Twitter"),
  114. ("twitch.tv", "Twitch"),
  115. ("discord.gg", "Discord"),
  116. ("discord.com", "Discord"),
  117. ("facebook.com", "Facebook"),
  118. ("instagram.com", "Instagram"),
  119. ("tiktok.com", "TikTok"),
  120. ("bilibili.com", "Bilibili"),
  121. ("weibo.com", "Weibo"),
  122. ]
  123. for needle, label in mapping:
  124. if needle in low:
  125. return (label, first)
  126. return ("Steam", first) # default channel name
  127. def _norm_uid(u) -> str:
  128. if isinstance(u, (bytes, bytearray)):
  129. return u.decode("utf-8", errors="ignore").strip()
  130. return str(u).strip()
  131. def _chunked(seq, n):
  132. for i in range(0, len(seq), n):
  133. yield seq[i:i+n]
  134. def imap_mark_answered_batch(host: str, port: int, user: str, pwd: str, mailbox: str,
  135. uids: list[str], batch_size: int = 500) -> int:
  136. """把一批 UID 标记为 \\Answered,返回成功提交的数量。"""
  137. uids = [_norm_uid(u) for u in uids if _norm_uid(u)]
  138. if not uids:
  139. return 0
  140. M = imaplib.IMAP4_SSL(host, port)
  141. M.login(user, pwd)
  142. typ, _ = M.select(mailbox, readonly=False)
  143. if typ != "OK":
  144. try:
  145. M.close()
  146. except Exception:
  147. pass
  148. M.logout()
  149. return 0
  150. total_ok = 0
  151. for batch in _chunked(uids, batch_size):
  152. seqset = ",".join(batch) # e.g. "444,445,446"
  153. typ1, _ = M.uid("STORE", seqset, "+FLAGS.SILENT", r"(\Answered)")
  154. if typ1 != "OK":
  155. typ2, resp2 = M.uid("STORE", seqset, "+FLAGS", r"(\Answered)")
  156. if typ2 == "OK":
  157. total_ok += len(batch)
  158. else:
  159. # 逐封回退
  160. for uid in batch:
  161. t3, _ = M.uid("STORE", uid, "+FLAGS.SILENT", r"(\Answered)")
  162. if t3 == "OK":
  163. total_ok += 1
  164. else:
  165. t4, _ = M.uid("STORE", uid, "+FLAGS", r"(\Answered)")
  166. if t4 == "OK":
  167. total_ok += 1
  168. else:
  169. total_ok += len(batch)
  170. try:
  171. M.close()
  172. except Exception:
  173. pass
  174. M.logout()
  175. return total_ok
  176. def main():
  177. parser = argparse.ArgumentParser(description="Bulk send Steam keys to curators from Excel (one row per key).")
  178. parser.add_argument("--excel", required=True, help="Path to input Excel (curator_requests.xlsx).")
  179. parser.add_argument("--keys", required=True, help="Path to key pool TXT (one key per line).")
  180. parser.add_argument("--out", default="send_log.xlsx", help="Path to output Excel log (per key rows).")
  181. parser.add_argument("--subject", required=True, help="Base email subject (will prefix ' RE: original subject').")
  182. parser.add_argument("--template", default="email_template.html", help="Path to HTML email template file (optional).")
  183. parser.add_argument("--limit", type=int, default=None, help="Max rows to process from Excel.")
  184. parser.add_argument("--dry-run", action="store_true", help="Render emails only; do not send.")
  185. parser.add_argument("--test", action="store_true", help="Send to test address instead of recipients.")
  186. parser.add_argument("--test-email", default=os.environ.get("TEST_EMAIL", ""), help="Test recipient (with --test).")
  187. parser.add_argument("--skip-sent", action="store_true", help="Skip rows already present (by UID) in the output log.")
  188. parser.add_argument("--no-consume", action="store_true", help="Do not modify key pool file (do not remove used keys).")
  189. # SMTP config (env or CLI)
  190. parser.add_argument("--smtp-host", default=os.environ.get("SMTP_HOST", ""))
  191. parser.add_argument("--smtp-port", type=int, default=int(os.environ.get("SMTP_PORT", "587")))
  192. parser.add_argument("--smtp-user", default=os.environ.get("SMTP_USER", ""))
  193. parser.add_argument("--smtp-pass", default=os.environ.get("SMTP_PASS", ""))
  194. parser.add_argument("--from-name", default=os.environ.get("FROM_NAME", "Krystic"))
  195. parser.add_argument("--from-email", default=os.environ.get("FROM_EMAIL", os.environ.get("SMTP_USER", "")))
  196. # IMAP config for optional marking as answered
  197. parser.add_argument("--mark-answered", action="store_true",
  198. help="After real sends, mark original messages as \\Answered via IMAP.")
  199. parser.add_argument("--imap-host", default=os.environ.get("IMAP_HOST", ""))
  200. parser.add_argument("--imap-port", type=int, default=int(os.environ.get("IMAP_PORT", "993")))
  201. parser.add_argument("--imap-user", default=os.environ.get("EMAIL_USER", os.environ.get("SMTP_USER","")))
  202. parser.add_argument("--imap-pass", default=os.environ.get("EMAIL_PASS", os.environ.get("SMTP_PASS", "")))
  203. parser.add_argument("--imap-mailbox", default=os.environ.get("MAILBOX", "INBOX"))
  204. args = parser.parse_args()
  205. # Validate SMTP when not dry-run
  206. if not args.dry_run:
  207. for vname in ["smtp_host", "smtp_user", "smtp_pass", "from_email"]:
  208. if not getattr(args, vname):
  209. print(f"ERROR: Missing SMTP config --{vname.replace('_','-')} (or env var). Use --dry-run to preview.", file=sys.stderr)
  210. sys.exit(2)
  211. # Load template (external file if exists, otherwise fallback)
  212. template_html = load_template(args.template)
  213. # Load Excel
  214. df = pd.read_excel(args.excel, sheet_name=0)
  215. required_cols = ["Mailbox Key", "Email", "Curator/Name", "Requested Key Count", "Subject", "Curator/Social Links"]
  216. for c in required_cols:
  217. if c not in df.columns:
  218. print(f"ERROR: Excel missing column: {c}", file=sys.stderr)
  219. sys.exit(3)
  220. # Load existing log to support --skip-sent (by UID)
  221. sent_uids = set()
  222. if args.skip_sent and os.path.exists(args.out):
  223. try:
  224. logdf = pd.read_excel(args.out, sheet_name=0)
  225. if "Mailbox Key" in logdf.columns:
  226. sent_uids = set(str(x) for x in logdf["Mailbox Key"].astype(str).tolist())
  227. except Exception:
  228. pass
  229. # Load key pool
  230. pool = load_keys(args.keys)
  231. # Prepare per-key logging
  232. log_rows: List[Dict[str, Any]] = []
  233. row_no = 1
  234. processed = 0
  235. # ===== 进度与汇总统计 =====
  236. # 估算计划处理的“邮件行”总数(考虑 --limit 与 --skip-sent)
  237. if args.limit is not None:
  238. total_target = min(len(df), args.limit)
  239. else:
  240. total_target = len(df)
  241. if args.skip_sent and os.path.exists(args.out):
  242. try:
  243. # 粗略估算,已发过的行会被跳过(只是估算,实际略有出入也没关系)
  244. total_target = max(0, total_target - len(sent_uids))
  245. except Exception:
  246. pass
  247. attempt_rows = 0 # 实际尝试发送的“邮件行”(有成功分配到 key 才算一次尝试)
  248. emails_ok = 0 # 发送成功(SENT 或 SENT_TEST)
  249. emails_fail = 0 # 失败/跳过(ERROR 或 SKIPPED_NO_KEYS 等)
  250. keys_assigned_total = 0 # 实际分配(写进邮件里的)key 数(dry-run/test 也会统计)
  251. uids_to_mark_answered: list[str] = []
  252. # Iterate over Excel rows (one email row)
  253. for idx, row in df.iterrows():
  254. try:
  255. uid = str(row.get("Mailbox Key", "")).strip()
  256. email_to = str(row.get("Email", "")).strip()
  257. name_val = row.get("Curator/Name", "")
  258. if pd.isna(name_val) or not str(name_val).strip():
  259. curator_name = "Curator"
  260. else:
  261. curator_name = str(name_val).strip()
  262. if args.test:
  263. curator_name = curator_name + "(" + email_to + ")"
  264. req_num = row.get("Requested Key Count")
  265. try:
  266. key_num = int(req_num) if pd.notna(req_num) else 2
  267. except Exception:
  268. key_num = 2
  269. if key_num <= 0:
  270. key_num = 2
  271. if args.skip_sent and uid and uid in sent_uids:
  272. continue
  273. if args.limit is not None and processed >= args.limit:
  274. break
  275. # Channel & link detection (robust against NaN)
  276. val = row.get("Curator/Social Links", "")
  277. if pd.isna(val):
  278. links_str = ""
  279. else:
  280. links_str = str(val).strip()
  281. channel, chosen_link = parse_channel_and_link(links_str)
  282. # ✅ 一律将 None/NaN/空白 归一为 ""
  283. 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()
  284. # Build email
  285. if len(pool) < key_num:
  286. print(f"WARNING: Not enough keys left for UID {uid}. Needed {key_num}, have {len(pool)}. Skipping.", file=sys.stderr)
  287. status = "SKIPPED_NO_KEYS"
  288. assigned = []
  289. to_addr = ""
  290. final_subject = args.subject
  291. else:
  292. assigned = pool[:key_num]
  293. # subject: --subject + ' RE: ' + original
  294. orig_subject = str(row.get("Subject", "")).strip()
  295. final_subject = args.subject
  296. if orig_subject:
  297. final_subject = f"{args.subject} RE: {orig_subject}"
  298. html = render_email_html(template_html, curator_name, key_num, assigned)
  299. # Decide recipient
  300. to_addr = args.test_email if args.test and args.test_email else (os.environ.get("TEST_EMAIL") if args.test else email_to)
  301. if args.test and not to_addr:
  302. print("ERROR: --test specified but no test email provided. Use --test-email or TEST_EMAIL env.", file=sys.stderr)
  303. sys.exit(4)
  304. # Send (or dry-run)
  305. send_email(
  306. smtp_host=args.smtp_host, smtp_port=args.smtp_port,
  307. smtp_user=args.smtp_user, smtp_pass=args.smtp_pass,
  308. from_name=args.from_name, from_email=args.from_email,
  309. replyto=uid,
  310. to_email=to_addr, subject=final_subject, html=html,
  311. dry_run=args.dry_run
  312. )
  313. status = "SENT_TEST" if args.test else ("DRY_RUN" if args.dry_run else "SENT")
  314. if status == "SENT":
  315. uids_to_mark_answered.append(uid)
  316. # ===== 进度统计(有分配到 key 才算一次尝试)=====
  317. if assigned:
  318. attempt_rows += 1
  319. if status in ("SENT", "SENT_TEST"):
  320. emails_ok += 1
  321. keys_assigned_total += len(assigned)
  322. else:
  323. emails_fail += 1
  324. # ===== 实时进度反馈(不是 dry-run 就打印)=====
  325. if not args.dry_run:
  326. # processed 是你脚本里原本就有的计数器:处理了多少“邮件行”
  327. # total_target 是开始前估算的目标处理量(用于百分比展示)
  328. pct = (processed / total_target * 100) if total_target else 0
  329. print(f"[{processed}/{total_target} | {pct:.1f}%] {status} UID={uid} to={to_addr or email_to} keys={len(assigned)}")
  330. # Consume keys ONLY when real send (not dry-run, not test) and not --no-consume
  331. if not args.no_consume:
  332. pool = pool[key_num:]
  333. # Prepare BJ timestamps
  334. now_bj = datetime.now(ZoneInfo("Asia/Shanghai"))
  335. send_date = now_bj.strftime("%Y-%m-%d") # YYYY-MM-DD
  336. sent_at = now_bj.strftime("%Y-%m-%d %H:%M:%S") # detailed
  337. # Log ONE ROW PER KEY
  338. if assigned:
  339. for k in assigned:
  340. log_rows.append({
  341. "No.": row_no,
  342. "Send Date": send_date,
  343. "Channel": channel or "Steam",
  344. "Curator/Name": curator_name,
  345. "Purpose": "评测",
  346. "Social Link": safe_link,
  347. "Key": k,
  348. "Mailbox Key": uid,
  349. "To": email_to,
  350. "Requested Key Count": key_num,
  351. "Subject": final_subject,
  352. "Status": status,
  353. "Sent At": sent_at,
  354. "Test Mode": bool(args.test),
  355. "Actual Recipient": to_addr if (args.test or args.dry_run) else email_to,
  356. })
  357. row_no += 1
  358. else:
  359. # Even if skipped/no keys, write a single row for traceability (without Key)
  360. log_rows.append({
  361. "No.": row_no,
  362. "Send Date": send_date,
  363. "Channel": channel or "Steam",
  364. "Curator/Name": curator_name,
  365. "Purpose": "评测",
  366. "Social Link": chosen_link,
  367. "Key": "",
  368. "Mailbox Key": uid,
  369. "To": email_to,
  370. "Requested Key Count": key_num,
  371. "Subject": final_subject,
  372. "Status": status,
  373. "Sent At": sent_at,
  374. "Test Mode": bool(args.test),
  375. "Actual Recipient": to_addr if (args.test or args.dry_run) else email_to,
  376. })
  377. row_no += 1
  378. processed += 1
  379. except Exception as e:
  380. now_bj = datetime.now(ZoneInfo("Asia/Shanghai"))
  381. log_rows.append({
  382. "No.": row_no,
  383. "Send Date": now_bj.strftime("%Y-%m-%d"),
  384. "Channel": "Steam",
  385. "Curator/Name": str(row.get("Curator/Name", "")),
  386. "Purpose": "评测",
  387. "Social Link": str(row.get("Curator/Social Links", "")),
  388. "Key": "",
  389. "Mailbox Key": str(row.get("Mailbox Key", "")),
  390. "To": str(row.get("Email", "")),
  391. "Requested Key Count": row.get("Requested Key Count"),
  392. "Subject": str(row.get("Subject", "")),
  393. "Status": f"ERROR:{e}",
  394. "Sent At": now_bj.strftime("%Y-%m-%d %H:%M:%S"),
  395. "Test Mode": bool(args.test),
  396. "Actual Recipient": "",
  397. })
  398. row_no += 1
  399. print(f"ERROR processing row {idx}: {e}", file=sys.stderr)
  400. continue
  401. if args.mark_answered and not args.dry_run and not args.test and uids_to_mark_answered:
  402. done = imap_mark_answered_batch(
  403. host=args.imap_host, port=args.imap_port,
  404. user=args.imap_user, pwd=args.imap_pass,
  405. mailbox=args.imap_mailbox, uids=uids_to_mark_answered, batch_size=500
  406. )
  407. print(f"[INFO] Marked {done} message(s) as \\Answered.")
  408. # Build DataFrame with desired column order
  409. columns = [
  410. "No.", "Send Date", "Channel", "Curator/Name", "Purpose", "Social Link",
  411. "Key", "Mailbox Key", "To", "Requested Key Count", "Subject",
  412. "Status", "Sent At", "Test Mode", "Actual Recipient"
  413. ]
  414. log_df = pd.DataFrame(log_rows, columns=columns)
  415. log_path = os.path.abspath(args.out)
  416. with pd.ExcelWriter(log_path, engine="openpyxl") as writer:
  417. log_df.to_excel(writer, sheet_name="SendLog", index=False)
  418. # ===== 汇总报告 =====
  419. mode = "REAL"
  420. if args.dry_run:
  421. mode = "DRY-RUN"
  422. elif args.test:
  423. mode = "TEST"
  424. print("\n=== RUN SUMMARY ===")
  425. print(f"Mode: {mode}")
  426. print(f"Email rows processed: {processed}")
  427. print(f"Attempted sends: {attempt_rows} (rows that had keys assigned)")
  428. print(f"Successful sends: {emails_ok}")
  429. print(f"Failed/Skipped: {emails_fail}")
  430. print(f"Keys assigned total: {keys_assigned_total}")
  431. print(f"Log file: {log_path}")
  432. # Save remaining keys if consuming (not dry-run/test)
  433. if not args.no_consume and not args.dry_run and not args.test:
  434. save_remaining_keys(args.keys, pool)
  435. print(f"Done. Processed {processed} email row(s). Logged {len(log_df)} key row(s). File: {log_path}")
  436. if not args.no_consume:
  437. print(f"Current in-memory remaining keys (not saved if test/dry-run): {len(pool)}")
  438. if __name__ == "__main__":
  439. main()