Просмотр исходного кода

Refine workflow with config-driven setup

Krystic Cong 1 месяц назад
Родитель
Сommit
a775a890f3
12 измененных файлов с 860 добавлено и 295 удалено
  1. 17 9
      .gitignore
  2. 115 0
      README.md
  3. 127 0
      backfill_sent_emails.py
  4. 376 165
      bulk_send_keys.py
  5. 27 0
      configs/settings.template.json
  6. BIN
      curator_requests.xlsx
  7. BIN
      curator_requests.xlsx.bak.xlsx
  8. 0 7
      curator_state.json
  9. 198 98
      imap_curator_export.py
  10. BIN
      send_log.xlsx
  11. 0 16
      steam_key.txt
  12. 0 0
      templates/email_template.html

+ 17 - 9
.gitignore

@@ -1,4 +1,4 @@
-# ---> macOS
+# ---> macOS
 .DS_Store
 .DS_Store
 .AppleDouble
 .AppleDouble
 .LSOverride
 .LSOverride
@@ -24,16 +24,24 @@ Icon
 Network Trash Folder
 Network Trash Folder
 Temporary Items
 Temporary Items
 .apdisk
 .apdisk
-
-# ---> VisualStudioCode
-.settings
-
-
-# ---> Vim
-[._]*.s[a-w][a-z]
-[._]s[a-w][a-z]
+
+# ---> VisualStudioCode
+.settings
+
+
+# ---> Vim
+[._]*.s[a-w][a-z]
+[._]s[a-w][a-z]
 *.un~
 *.un~
 Session.vim
 Session.vim
 .netrwhist
 .netrwhist
 *~
 *~
 
 
+.mailenv
+
+# Generated/updated by automation scripts
+records/curator_requests.xlsx
+records/send_log.xlsx
+records/steam_key.txt
+configs/curator_state.json
+configs/settings.json

+ 115 - 0
README.md

@@ -1,2 +1,117 @@
 # support-email
 # support-email
 
 
+## 项目概览
+这是一组自动化脚本,用来:
+1. 通过 `imap_curator_export.py` 登录 IMAP 邮箱,筛选 Steam Curator 索要激活码的邮件,导出 `curator_requests.xlsx`;
+2. 再用 `bulk_send_keys.py` 读取导出的 Excel 和 `steam_key.txt` 里的激活码池,批量发送 HTML 邮件,并记录每个 key 的派发日志。
+
+## 环境准备
+1. 安装 Python 3.10 或更新版本(macOS 可用 `brew install python@3.10`,Windows 直接从 python.org 下载)。
+2. 建议使用虚拟环境隔离依赖:
+   ```bash
+   python3 -m venv .venv
+   source .venv/bin/activate      # Windows 里是 .venv\Scripts\activate
+   ```
+3. 安装依赖:
+   ```bash
+   pip install -r requirements.txt
+   ```
+   若首次安装 pip 版本过旧,可先 `python -m pip install --upgrade pip`。
+4. 目录布局(已建好):
+   - `configs/settings.json`:统一配置 IMAP/SMTP、默认文件路径(records/……)、主题、起始日期、最小 UID 等(仓库里只放模板 `configs/settings.template.json`,自己复制为 `settings.json` 使用);
+   - `configs/curator_state.json`:脚本自动维护的书签与已发送邮箱;
+   - `templates/`:HTML 邮件模板(默认使用 `templates/email_template.html`);
+   - `records/`:输入/输出数据文件(默认 `curator_requests.xlsx`、`send_log.xlsx`、`steam_key.txt` 都在这里)。
+
+`configs/settings.template.json` 示例(复制为 settings.json 后填入真实凭证):
+```jsonc
+{
+  "subject": "Steam Keys for Such A Guy -",
+  "start_date": "18-Oct-2025",
+  "min_uid": 125,
+  "files": {
+    "excel": "records/curator_requests.xlsx",
+    "keys": "records/steam_key.txt",
+    "log": "records/send_log.xlsx",
+    "template": "templates/email_template.html",
+    "state": "configs/curator_state.json"
+  },
+  "smtp": {
+    "host": "smtp.feishu.cn",
+    "port": 587,
+    "user": "support@suchone.com.cn",
+    "pass": "your-app-pass",
+    "from_name": "SUCH ONE STUDIO",
+    "from_email": "support@such-one.com"
+  },
+  "imap": {
+    "host": "imap.feishu.cn",
+    "port": 993,
+    "user": "support@suchone.com.cn",
+    "pass": "your-app-pass",
+    "mailbox": "INBOX"
+  }
+}
+```
+命令行如果只写文件名(如 `--excel curator_requests.xlsx`),脚本会自动拼接对应目录(records/templates/config)。
+
+## `imap_curator_export.py`
+### 作用
+登录 IMAP 邮箱、抓取自上次书签之后的邮件,排除回复/转发,提取 Curator 名称、邮箱、社交链接、所需 key 数等信息写入 `records/curator_requests.xlsx`。脚本会把已处理的邮箱/UID 存在 `configs/curator_state.json`,下次只扫描新的邮件。
+
+IMAP/起始日期/输出路径等全部在 `configs/settings.json` 中维护(运行时可改用 `--config path/to/settings.json`)。
+
+导出的 Excel 列包含:`No.`、`Mailbox Key`、`Requested Key Count`、`Date`、`Curator/Name`、`Email`、`Subject`、`Curator/Social Links`、`Body (preview)`、`Original Message-ID`。最后一列用于让发送脚本把 `In-Reply-To/References` 指向原邮件,确保邮件客户端能按对话显示。
+
+### 常用命令
+```bash
+# 1. 连通性测试(只确认能否登录,不读写状态)
+python imap_curator_export.py --config configs/settings.json --test
+
+# 2. 正式导出(使用/更新书签)
+python imap_curator_export.py --config configs/settings.json
+
+# 3. 重置书签后再导出(强制从 START_DATE 开始)
+python imap_curator_export.py --config configs/settings.json --reset-state
+
+# 4. 导出后顺便把匹配的邮件标记为已读
+python imap_curator_export.py --config configs/settings.json --mark-read
+```
+
+运行完成后会在 `records/curator_requests.xlsx` 中追加 `curtor_YYYYMMDD##` 的表格,`configs/curator_state.json` 记录最新 `last_uid`。脚本结束时,还会贴心打印三条推荐命令(Dry-run / Test / Real)。
+
+## `bulk_send_keys.py`
+### 作用
+读取 `records/curator_requests.xlsx` 与 `records/steam_key.txt` 中待分发的激活码,按请求数量配给 key,使用 `templates/email_template.html`(若不存在则用脚本内置模板)渲染 HTML 邮件,通过 SMTP 发送,并在 `records/send_log.xlsx` 里记录“每个 key 一行”的详细日志(每次运行会新增 `sendlog_YYYYMMDD##` 的 sheet 并置顶;真实发送开始前会自动清理旧的 DRYRUN/TEST sheet)。脚本还会在 `configs/curator_state.json` 里维护 `sent_emails` 列表,防止向已发过 key 的邮箱重复派发。支持 Dry-run、Test 模式、跳过已处理 UID、发送成功后把原邮件标记 `\Answered`。如果 Excel 中包含 `Original Message-ID`,回复邮件会自动带上 `In-Reply-To/References`,方便邮件客户端按对话展示。
+
+### 关键参数
+默认配置全部来自 `configs/settings.json`,命令行参数只在需要覆盖默认值时使用。常见参数:
+- `--config`:使用其他 settings.json;
+- `--excel`/`--keys`/`--out`/`--template`:覆盖默认文件,若仅写文件名会自动落在对应目录;
+- `--subject`:覆盖设置文件中的主题前缀(发送时依旧会拼接原邮件主题);
+- `--sheet`:指定 Excel 中的工作表;
+- `--limit`:只处理前 N 条;`--dry-run`:仅渲染预览;
+- `--test`:把邮件发送到测试邮箱,默认会记录 `sent_emails`;若只想验证流程又不想污染状态,可加 `--no-sentemail`,并常配合 `--no-consume`;
+- `--skip-sent`:按照 send_log 里记录的 UID 跳过;
+- `--mark-answered`:真实发送后将原邮件标记 `\Answered`(IMAP 参数可来自 settings.json,也可用 CLI 覆盖)。
+
+### 示例
+```bash
+# 1. 预览模式:只渲染邮件,不发送、不消耗 key
+python bulk_send_keys.py --config configs/settings.json \
+  --sheet curtor_2025111907 \
+  --dry-run --limit 5
+
+# 2. 测试发送:发给指定测试邮箱,记录日志但不扣除 key
+python bulk_send_keys.py --config configs/settings.json \
+  --sheet curtor_2025111907 \
+  --test --test-email your_test@example.com \
+  --no-consume --no-sentemail
+
+# 3. 正式批量发送,并把成功邮件标记 \Answered
+python bulk_send_keys.py --config configs/settings.json \
+  --sheet curtor_2025111907 \
+  --mark-answered
+```
+
+成功发送(非 dry-run/test)后,脚本会更新 `records/steam_key.txt` 以移除已使用的 key,写入 `records/send_log.xlsx`(`sendlog_YYYYMMDD##` sheet),并在真实发送启动前自动清理旧的 DRYRUN/TEST sheet;记得在真实发送前先用 `--dry-run` 或 `--test` 确认内容。

+ 127 - 0
backfill_sent_emails.py

@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+临时脚本:读取 configs/settings.json 里的 IMAP 配置,从指定的 Sent 邮箱抓取所有邮件,
+把其中出现过的收件人邮箱写入 configs/curator_state.json 的 sent_emails 字段。
+
+使用方式(在仓库根目录):
+  python backfill_sent_emails.py
+"""
+
+import os
+import json
+import sys
+import imaplib
+import email
+from email.utils import getaddresses
+
+SETTINGS_PATH = os.path.join("configs", "settings.json")
+
+def load_settings(path: str) -> dict:
+    if not os.path.exists(path):
+        print(f"ERROR: settings file not found: {path}", file=sys.stderr)
+        sys.exit(2)
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+    files = data.get("files", {})
+    for key, val in list(files.items()):
+        if not os.path.isabs(val):
+            files[key] = os.path.abspath(val)
+    data["files"] = files
+    return data
+
+def load_state(path: str) -> dict:
+    if not os.path.exists(path):
+        return {"_path": path, "mailboxes": {}, "sent_emails": []}
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+    if "sent_emails" not in data or not isinstance(data["sent_emails"], list):
+        data["sent_emails"] = []
+    data["_path"] = path
+    return data
+
+def save_state(state: dict) -> None:
+    path = state.get("_path")
+    if not path:
+        return
+    os.makedirs(os.path.dirname(path), exist_ok=True)
+    tmp = path + ".tmp"
+    with open(tmp, "w", encoding="utf-8") as f:
+        json.dump({k: v for k, v in state.items() if k != "_path"}, f, ensure_ascii=False, indent=2)
+    os.replace(tmp, path)
+
+def fetch_sent_addresses(imap_conf: dict) -> set[str]:
+    host = imap_conf.get("host")
+    port = int(imap_conf.get("port", 993))
+    user = imap_conf.get("user")
+    pwd = imap_conf.get("pass")
+    mailbox = imap_conf.get("sent_mailbox") or imap_conf.get("mailbox") or "Sent"
+    if not (host and user and pwd):
+        print("ERROR: settings.json 缺少 IMAP 登录配置", file=sys.stderr)
+        sys.exit(2)
+
+    conn = imaplib.IMAP4_SSL(host, port)
+    conn.login(user, pwd)
+    typ, _ = conn.select(mailbox, readonly=True)
+    if typ != "OK":
+        print(f"ERROR: 无法选择邮箱 {mailbox}", file=sys.stderr)
+        conn.logout()
+        sys.exit(3)
+
+    typ, data = conn.search(None, "ALL")
+    if typ != "OK" or not data or not data[0]:
+        conn.close(); conn.logout()
+        return set()
+
+    addresses: set[str] = set()
+    uids = data[0].split()
+    for idx, uid in enumerate(uids, 1):
+        typ, msg_data = conn.fetch(uid, "(RFC822.HEADER)")
+        if typ != "OK" or not msg_data:
+            continue
+        raw = msg_data[0][1]
+        msg = email.message_from_bytes(raw)
+        fields = []
+        target_headers = [
+            "To", "Cc", "Bcc", "Resent-To", "Resent-Cc", "Resent-Bcc",
+            "X-Original-To", "Delivered-To", "X-Real-To", "X-Forwarded-To"
+        ]
+        for field in target_headers:
+            values = msg.get_all(field, [])
+            if not values:
+                continue
+            if isinstance(values, str):
+                fields.append(values)
+            else:
+                fields.extend(values)
+        for _, addr in getaddresses(fields):
+            if addr:
+                addresses.add(addr.strip().lower())
+        if idx % 200 == 0:
+            print(f"[INFO] processed {idx}/{len(uids)} messages ...")
+
+    try:
+        conn.close()
+    except Exception:
+        pass
+    conn.logout()
+    return addresses
+
+def main() -> None:
+    settings = load_settings(SETTINGS_PATH)
+    files = settings.get("files", {})
+    state_path = os.path.abspath(files.get("state", os.path.join("configs", "curator_state.json")))
+    imap_conf = settings.get("imap", {})
+
+    new_addresses = fetch_sent_addresses(imap_conf)
+    print(f"[INFO] collected {len(new_addresses)} address(es) from Sent mailbox.")
+
+    state = load_state(state_path)
+    existing = {addr.strip().lower() for addr in state.get("sent_emails", []) if addr}
+    merged = sorted(existing.union(new_addresses))
+    state["sent_emails"] = merged
+    save_state(state)
+    print(f"[DONE] sent_emails updated to {len(merged)} unique entries in {state_path}.")
+
+if __name__ == "__main__":
+    main()

+ 376 - 165
bulk_send_keys.py

@@ -31,6 +31,7 @@ Usage examples:
 
 
 import os
 import os
 import sys
 import sys
+import json
 import argparse
 import argparse
 import smtplib
 import smtplib
 import ssl
 import ssl
@@ -38,9 +39,52 @@ import imaplib
 from email.message import EmailMessage
 from email.message import EmailMessage
 from typing import List, Dict, Any, Optional, Tuple
 from typing import List, Dict, Any, Optional, Tuple
 import pandas as pd
 import pandas as pd
+from openpyxl import load_workbook
 from datetime import datetime
 from datetime import datetime
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
 
 
+DEFAULT_CONFIG_PATH = os.path.join("configs", "settings.json")
+
+def load_settings(config_path: Optional[str]) -> Dict[str, Any]:
+    path = config_path or DEFAULT_CONFIG_PATH
+    if not os.path.isabs(path):
+        path = os.path.abspath(path)
+    if not os.path.exists(path):
+        print(f"ERROR: settings file not found: {path}", file=sys.stderr)
+        sys.exit(2)
+    try:
+        with open(path, "r", encoding="utf-8") as f:
+            data = json.load(f)
+    except Exception as e:
+        print(f"ERROR: failed to read settings.json: {e}", file=sys.stderr)
+        sys.exit(2)
+    files = data.get("files", {})
+    normalized = {}
+    for key, val in files.items():
+        if not os.path.isabs(val):
+            normalized[key] = os.path.abspath(val)
+        else:
+            normalized[key] = val
+    data["files"] = normalized
+    return data
+
+def resolve_path(arg_value: Optional[str], default_path: str) -> str:
+    default_dir = os.path.dirname(default_path)
+    if arg_value is None or arg_value == "":
+        target = default_path
+    else:
+        if os.path.isabs(arg_value) or os.path.dirname(arg_value):
+            target = arg_value
+        else:
+            base = default_dir if default_dir else "."
+            target = os.path.join(base, arg_value)
+    return os.path.abspath(target)
+
+def ensure_parent_dir(path: str) -> None:
+    directory = os.path.dirname(path)
+    if directory:
+        os.makedirs(directory, exist_ok=True)
+
 # Built-in fallback template (can be overridden by --template file)
 # Built-in fallback template (can be overridden by --template file)
 FALLBACK_TEMPLATE = """\
 FALLBACK_TEMPLATE = """\
 <html>
 <html>
@@ -74,6 +118,7 @@ def load_keys(path: str) -> List[str]:
     return keys
     return keys
 
 
 def save_remaining_keys(path: str, keys: List[str]) -> None:
 def save_remaining_keys(path: str, keys: List[str]) -> None:
+    ensure_parent_dir(path)
     tmp = path + ".tmp"
     tmp = path + ".tmp"
     with open(tmp, "w", encoding="utf-8") as f:
     with open(tmp, "w", encoding="utf-8") as f:
         for k in keys:
         for k in keys:
@@ -87,7 +132,7 @@ def render_email_html(template_html: str, curator_name: str, key_num: int, keys:
                                 keys_block=keys_lines)
                                 keys_block=keys_lines)
 
 
 def send_email(smtp_host: str, smtp_port: int, smtp_user: str, smtp_pass: str,
 def send_email(smtp_host: str, smtp_port: int, smtp_user: str, smtp_pass: str,
-               from_name: str, from_email: str, replyto: str,
+               from_name: str, from_email: str, original_message_id: Optional[str],
                to_email: str, subject: str, html: str, dry_run: bool = False) -> None:
                to_email: str, subject: str, html: str, dry_run: bool = False) -> None:
     if dry_run:
     if dry_run:
         print(f"\n--- DRY RUN (no send) ---\nTO: {to_email}\nSUBJECT: {subject}\nHTML:\n{html}\n-------------------------\n")
         print(f"\n--- DRY RUN (no send) ---\nTO: {to_email}\nSUBJECT: {subject}\nHTML:\n{html}\n-------------------------\n")
@@ -96,7 +141,9 @@ def send_email(smtp_host: str, smtp_port: int, smtp_user: str, smtp_pass: str,
     msg["Subject"] = subject
     msg["Subject"] = subject
     msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
     msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
     msg["To"] = to_email
     msg["To"] = to_email
-    msg["In-Reply-To"] = replyto
+    if original_message_id:
+        msg["In-Reply-To"] = original_message_id
+        msg["References"] = original_message_id
     msg.set_content("HTML email - please view in a mail client that supports HTML.")
     msg.set_content("HTML email - please view in a mail client that supports HTML.")
     msg.add_alternative(html, subtype="html")
     msg.add_alternative(html, subtype="html")
 
 
@@ -137,6 +184,48 @@ def parse_channel_and_link(links_str: str) -> Tuple[str, str]:
             return (label, first)
             return (label, first)
     return ("Steam", first)  # default channel name
     return ("Steam", first)  # default channel name
 
 
+def load_global_state(path: str) -> Dict[str, Any]:
+    if not os.path.exists(path):
+        return {"mailboxes": {}, "sent_emails": []}
+    try:
+        with open(path, "r", encoding="utf-8") as f:
+            data = json.load(f)
+    except Exception:
+        return {"mailboxes": {}, "sent_emails": []}
+    if "mailboxes" not in data or not isinstance(data["mailboxes"], dict):
+        data["mailboxes"] = {}
+    if "sent_emails" not in data or not isinstance(data["sent_emails"], list):
+        data["sent_emails"] = []
+    return data
+
+def save_global_state(path: str, data: Dict[str, Any]) -> None:
+    ensure_parent_dir(path)
+    tmp = path + ".tmp"
+    with open(tmp, "w", encoding="utf-8") as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+    os.replace(tmp, path)
+
+def norm_email(addr: str) -> str:
+    return (addr or "").strip().lower()
+
+def cleanup_log_sheets(log_path: str, keep_only_real: bool = False) -> None:
+    if not os.path.exists(log_path):
+        return
+    try:
+        wb = load_workbook(log_path)
+    except Exception:
+        return
+    removed = False
+    for name in list(wb.sheetnames):
+        upper = name.upper()
+        if (("DRYRUN" in upper) or ("TEST" in upper)) and keep_only_real:
+            ws = wb[name]
+            wb.remove(ws)
+            removed = True
+    if removed:
+        wb.save(log_path)
+    wb.close()
+
 def _norm_uid(u) -> str:
 def _norm_uid(u) -> str:
     if isinstance(u, (bytes, bytearray)):
     if isinstance(u, (bytes, bytearray)):
         return u.decode("utf-8", errors="ignore").strip()
         return u.decode("utf-8", errors="ignore").strip()
@@ -194,37 +283,101 @@ def imap_mark_answered_batch(host: str, port: int, user: str, pwd: str, mailbox:
 
 
 def main():
 def main():
     parser = argparse.ArgumentParser(description="Bulk send Steam keys to curators from Excel (one row per key).")
     parser = argparse.ArgumentParser(description="Bulk send Steam keys to curators from Excel (one row per key).")
-    parser.add_argument("--excel", required=True, help="Path to input Excel (curator_requests.xlsx).")
-    parser.add_argument("--keys", required=True, help="Path to key pool TXT (one key per line).")
-    parser.add_argument("--out", default="send_log.xlsx", help="Path to output Excel log (per key rows).")
-    parser.add_argument("--subject", required=True, help="Base email subject (will prefix ' RE: original subject').")
-    parser.add_argument("--template", default="email_template.html", help="Path to HTML email template file (optional).")
+    parser.add_argument("--config", help="Path to settings.json (default: configs/settings.json).")
+    parser.add_argument("--excel", help="Path to input Excel (default from settings).")
+    parser.add_argument("--keys", help="Path to key pool TXT (default from settings).")
+    parser.add_argument("--out", help="Path to output Excel log (default from settings).")
+    parser.add_argument("--subject", help="Base email subject (default from settings).")
+    parser.add_argument("--sheet", default=None, help="Worksheet name to read within the Excel file (default: first sheet).")
+    parser.add_argument("--template", help="Path to HTML email template file (default from settings).")
     parser.add_argument("--limit", type=int, default=None, help="Max rows to process from Excel.")
     parser.add_argument("--limit", type=int, default=None, help="Max rows to process from Excel.")
     parser.add_argument("--dry-run", action="store_true", help="Render emails only; do not send.")
     parser.add_argument("--dry-run", action="store_true", help="Render emails only; do not send.")
     parser.add_argument("--test", action="store_true", help="Send to test address instead of recipients.")
     parser.add_argument("--test", action="store_true", help="Send to test address instead of recipients.")
     parser.add_argument("--test-email", default=os.environ.get("TEST_EMAIL", ""), help="Test recipient (with --test).")
     parser.add_argument("--test-email", default=os.environ.get("TEST_EMAIL", ""), help="Test recipient (with --test).")
     parser.add_argument("--skip-sent", action="store_true", help="Skip rows already present (by UID) in the output log.")
     parser.add_argument("--skip-sent", action="store_true", help="Skip rows already present (by UID) in the output log.")
+    parser.add_argument("--no-sentemail", action="store_true", help="Do not record sent emails into STATE_FILE (useful for testing).")
     parser.add_argument("--no-consume", action="store_true", help="Do not modify key pool file (do not remove used keys).")
     parser.add_argument("--no-consume", action="store_true", help="Do not modify key pool file (do not remove used keys).")
 
 
     # SMTP config (env or CLI)
     # SMTP config (env or CLI)
-    parser.add_argument("--smtp-host", default=os.environ.get("SMTP_HOST", ""))
-    parser.add_argument("--smtp-port", type=int, default=int(os.environ.get("SMTP_PORT", "587")))
-    parser.add_argument("--smtp-user", default=os.environ.get("SMTP_USER", ""))
-    parser.add_argument("--smtp-pass", default=os.environ.get("SMTP_PASS", ""))
-    parser.add_argument("--from-name", default=os.environ.get("FROM_NAME", "Krystic"))
-    parser.add_argument("--from-email", default=os.environ.get("FROM_EMAIL", os.environ.get("SMTP_USER", "")))
+    parser.add_argument("--smtp-host")
+    parser.add_argument("--smtp-port", type=int)
+    parser.add_argument("--smtp-user")
+    parser.add_argument("--smtp-pass")
+    parser.add_argument("--from-name")
+    parser.add_argument("--from-email")
 
 
     # IMAP config for optional marking as answered
     # IMAP config for optional marking as answered
     parser.add_argument("--mark-answered", action="store_true",
     parser.add_argument("--mark-answered", action="store_true",
     help="After real sends, mark original messages as \\Answered via IMAP.")
     help="After real sends, mark original messages as \\Answered via IMAP.")
-    parser.add_argument("--imap-host", default=os.environ.get("IMAP_HOST", ""))
-    parser.add_argument("--imap-port", type=int, default=int(os.environ.get("IMAP_PORT", "993")))
-    parser.add_argument("--imap-user", default=os.environ.get("EMAIL_USER", os.environ.get("SMTP_USER","")))
-    parser.add_argument("--imap-pass", default=os.environ.get("EMAIL_PASS", os.environ.get("SMTP_PASS", "")))
-    parser.add_argument("--imap-mailbox", default=os.environ.get("MAILBOX", "INBOX"))
+    parser.add_argument("--imap-host")
+    parser.add_argument("--imap-port", type=int)
+    parser.add_argument("--imap-user")
+    parser.add_argument("--imap-pass")
+    parser.add_argument("--imap-mailbox")
 
 
     args = parser.parse_args()
     args = parser.parse_args()
 
 
+    config_path = args.config or os.environ.get("SETTINGS_FILE") or DEFAULT_CONFIG_PATH
+    settings = load_settings(config_path)
+    files_conf = settings.get("files", {})
+    required_files = ["excel", "keys", "log", "template", "state"]
+    missing_files = [k for k in required_files if k not in files_conf]
+    if missing_files:
+        print(f"ERROR: settings.json missing file paths for: {', '.join(missing_files)}", file=sys.stderr)
+        sys.exit(2)
+
+    args.excel = resolve_path(args.excel, files_conf["excel"])
+    args.keys = resolve_path(args.keys, files_conf["keys"])
+    args.out = resolve_path(args.out, files_conf["log"])
+    args.template = resolve_path(args.template, files_conf["template"])
+    state_file = os.path.abspath(files_conf["state"])
+
+    subject_default = settings.get("subject", "")
+    args.subject = args.subject if args.subject is not None else subject_default
+
+    smtp_conf = settings.get("smtp", {})
+    args.smtp_host = args.smtp_host or os.environ.get("SMTP_HOST") or smtp_conf.get("host", "")
+    if args.smtp_port is None:
+        env_port = os.environ.get("SMTP_PORT")
+        if env_port:
+            try:
+                args.smtp_port = int(env_port)
+            except Exception:
+                pass
+    if args.smtp_port is None:
+        args.smtp_port = int(smtp_conf.get("port", 587))
+    args.smtp_user = args.smtp_user or os.environ.get("SMTP_USER") or smtp_conf.get("user", "")
+    args.smtp_pass = args.smtp_pass or os.environ.get("SMTP_PASS") or smtp_conf.get("pass", "")
+    args.from_name = args.from_name or os.environ.get("FROM_NAME") or smtp_conf.get("from_name", "Krystic")
+    args.from_email = args.from_email or os.environ.get("FROM_EMAIL") or smtp_conf.get("from_email", args.smtp_user)
+
+    imap_conf = settings.get("imap", {})
+    args.imap_host = args.imap_host or os.environ.get("IMAP_HOST") or imap_conf.get("host", "")
+    if args.imap_port is None:
+        env_imap_port = os.environ.get("IMAP_PORT")
+        if env_imap_port:
+            try:
+                args.imap_port = int(env_imap_port)
+            except Exception:
+                pass
+    if args.imap_port is None:
+        args.imap_port = int(imap_conf.get("port", 993))
+    args.imap_user = args.imap_user or os.environ.get("EMAIL_USER") or imap_conf.get("user", args.smtp_user)
+    args.imap_pass = args.imap_pass or os.environ.get("EMAIL_PASS") or imap_conf.get("pass", args.smtp_pass)
+    args.imap_mailbox = args.imap_mailbox or os.environ.get("MAILBOX") or imap_conf.get("mailbox", "INBOX")
+
+    global_state = load_global_state(state_file)
+    sent_emails_global = set()
+    for existing_email in global_state.get("sent_emails", []):
+        normalized = norm_email(existing_email)
+        if normalized:
+            sent_emails_global.add(normalized)
+    run_sent_emails: set[str] = set()
+    state_emails_run: set[str] = set()
+
+    if not args.dry_run and not args.test:
+        cleanup_log_sheets(args.out, keep_only_real=True)
+
     # Validate SMTP when not dry-run
     # Validate SMTP when not dry-run
     if not args.dry_run:
     if not args.dry_run:
         for vname in ["smtp_host", "smtp_user", "smtp_pass", "from_email"]:
         for vname in ["smtp_host", "smtp_user", "smtp_pass", "from_email"]:
@@ -236,7 +389,12 @@ def main():
     template_html = load_template(args.template)
     template_html = load_template(args.template)
 
 
     # Load Excel
     # Load Excel
-    df = pd.read_excel(args.excel, sheet_name=0)
+    sheet_to_read: Optional[str | int]
+    if args.sheet:
+        sheet_to_read = args.sheet
+    else:
+        sheet_to_read = 0
+    df = pd.read_excel(args.excel, sheet_name=sheet_to_read)
     required_cols = ["Mailbox Key", "Email", "Curator/Name", "Requested Key Count", "Subject", "Curator/Social Links"]
     required_cols = ["Mailbox Key", "Email", "Curator/Name", "Requested Key Count", "Subject", "Curator/Social Links"]
     for c in required_cols:
     for c in required_cols:
         if c not in df.columns:
         if c not in df.columns:
@@ -247,9 +405,10 @@ def main():
     sent_uids = set()
     sent_uids = set()
     if args.skip_sent and os.path.exists(args.out):
     if args.skip_sent and os.path.exists(args.out):
         try:
         try:
-            logdf = pd.read_excel(args.out, sheet_name=0)
-            if "Mailbox Key" in logdf.columns:
-                sent_uids = set(str(x) for x in logdf["Mailbox Key"].astype(str).tolist())
+            logdfs = pd.read_excel(args.out, sheet_name=None)
+            for _, logdf in logdfs.items():
+                if "Mailbox Key" in logdf.columns:
+                    sent_uids.update(str(x) for x in logdf["Mailbox Key"].astype(str).tolist())
         except Exception:
         except Exception:
             pass
             pass
 
 
@@ -278,125 +437,156 @@ def main():
     emails_ok = 0           # 发送成功(SENT 或 SENT_TEST)
     emails_ok = 0           # 发送成功(SENT 或 SENT_TEST)
     emails_fail = 0         # 失败/跳过(ERROR 或 SKIPPED_NO_KEYS 等)
     emails_fail = 0         # 失败/跳过(ERROR 或 SKIPPED_NO_KEYS 等)
     keys_assigned_total = 0 # 实际分配(写进邮件里的)key 数(dry-run/test 也会统计)
     keys_assigned_total = 0 # 实际分配(写进邮件里的)key 数(dry-run/test 也会统计)
+    duplicate_skipped = 0   # 因邮箱重复而跳过的行数
 
 
     uids_to_mark_answered: list[str] = []
     uids_to_mark_answered: list[str] = []
+    interrupted = False
 
 
     # Iterate over Excel rows (one email row)
     # Iterate over Excel rows (one email row)
-    for idx, row in df.iterrows():
-        try:
-            uid = str(row.get("Mailbox Key", "")).strip()
-            email_to = str(row.get("Email", "")).strip()
-
-            name_val = row.get("Curator/Name", "")
-            if pd.isna(name_val) or not str(name_val).strip():
-                curator_name = "Curator"
-            else:
-                curator_name = str(name_val).strip()
-            
-            if args.test:
-                curator_name = curator_name + "(" + email_to + ")"
-
-            req_num = row.get("Requested Key Count")
+    try:
+        for idx, row in df.iterrows():
             try:
             try:
-                key_num = int(req_num) if pd.notna(req_num) else 2
-            except Exception:
-                key_num = 2
-            if key_num <= 0:
-                key_num = 2
-
-            if args.skip_sent and uid and uid in sent_uids:
-                continue
-
-            if args.limit is not None and processed >= args.limit:
-                break
-
-            # Channel & link detection (robust against NaN)
-            val = row.get("Curator/Social Links", "")
-            if pd.isna(val):
-                links_str = ""
-            else:
-                links_str = str(val).strip()
-
-            channel, chosen_link = parse_channel_and_link(links_str)
+                uid = str(row.get("Mailbox Key", "")).strip()
+                email_to = str(row.get("Email", "")).strip()
+                email_norm = norm_email(email_to)
+                msg_id_val = row.get("Original Message-ID", "")
+                if pd.isna(msg_id_val):
+                    original_message_id = ""
+                else:
+                    original_message_id = str(msg_id_val).strip()
+
+                name_val = row.get("Curator/Name", "")
+                if pd.isna(name_val) or not str(name_val).strip():
+                    curator_name = "Curator"
+                else:
+                    curator_name = str(name_val).strip()
+                
+                if args.test:
+                    curator_name = curator_name + "(" + email_to + ")"
+
+                req_num = row.get("Requested Key Count")
+                try:
+                    key_num = int(req_num) if pd.notna(req_num) else 2
+                except Exception:
+                    key_num = 2
+                if key_num <= 0:
+                    key_num = 2
+
+                if args.skip_sent and uid and uid in sent_uids:
+                    continue
+
+                if args.limit is not None and processed >= args.limit:
+                    break
+
+                # Channel & link detection (robust against NaN)
+                val = row.get("Curator/Social Links", "")
+                if pd.isna(val):
+                    links_str = ""
+                else:
+                    links_str = str(val).strip()
+
+                channel, chosen_link = parse_channel_and_link(links_str)
+
+                # ✅ 一律将 None/NaN/空白 归一为 ""
+                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()
 
 
-            # ✅ 一律将 None/NaN/空白 归一为 ""
-            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()
-
-            # Build email
-            if len(pool) < key_num:
-                print(f"WARNING: Not enough keys left for UID {uid}. Needed {key_num}, have {len(pool)}. Skipping.", file=sys.stderr)
-                status = "SKIPPED_NO_KEYS"
-                assigned = []
-                to_addr = ""
-                final_subject = args.subject
-            else:
-                assigned = pool[:key_num]
-
-                # subject: --subject + ' RE: ' + original
                 orig_subject = str(row.get("Subject", "")).strip()
                 orig_subject = str(row.get("Subject", "")).strip()
                 final_subject = args.subject
                 final_subject = args.subject
-                if orig_subject:
-                    final_subject = f"{args.subject} RE: {orig_subject}"
-
-                html = render_email_html(template_html, curator_name, key_num, assigned)
-
-                # Decide recipient
-                to_addr = args.test_email if args.test and args.test_email else (os.environ.get("TEST_EMAIL") if args.test else email_to)
-                if args.test and not to_addr:
-                    print("ERROR: --test specified but no test email provided. Use --test-email or TEST_EMAIL env.", file=sys.stderr)
-                    sys.exit(4)
-
-                # Send (or dry-run)
-                send_email(
-                    smtp_host=args.smtp_host, smtp_port=args.smtp_port,
-                    smtp_user=args.smtp_user, smtp_pass=args.smtp_pass,
-                    from_name=args.from_name, from_email=args.from_email,
-                    replyto=uid,
-                    to_email=to_addr, subject=final_subject, html=html,
-                    dry_run=args.dry_run
-                )
-
-                status = "SENT_TEST" if args.test else ("DRY_RUN" if args.dry_run else "SENT")
-
-                if status == "SENT":
-                    uids_to_mark_answered.append(uid)
+                assigned: List[str] = []
+                to_addr = ""
 
 
-                # ===== 进度统计(有分配到 key 才算一次尝试)=====
+                duplicate_email = bool(email_norm and (email_norm in sent_emails_global or email_norm in run_sent_emails))
+                if duplicate_email:
+                    status = "SKIPPED_DUP_EMAIL"
+                    duplicate_skipped += 1
+                elif len(pool) < key_num:
+                    print(f"WARNING: Not enough keys left for UID {uid}. Needed {key_num}, have {len(pool)}. Skipping.", file=sys.stderr)
+                    status = "SKIPPED_NO_KEYS"
+                else:
+                    assigned = pool[:key_num]
+
+                    if orig_subject:
+                        final_subject = f"{args.subject} RE: {orig_subject}"
+
+                    html = render_email_html(template_html, curator_name, key_num, assigned)
+
+                    # Decide recipient
+                    to_addr = args.test_email if args.test and args.test_email else (os.environ.get("TEST_EMAIL") if args.test else email_to)
+                    if args.test and not to_addr:
+                        print("ERROR: --test specified but no test email provided. Use --test-email or TEST_EMAIL env.", file=sys.stderr)
+                        sys.exit(4)
+
+                    # Send (or dry-run)
+                    send_email(
+                        smtp_host=args.smtp_host, smtp_port=args.smtp_port,
+                        smtp_user=args.smtp_user, smtp_pass=args.smtp_pass,
+                        from_name=args.from_name, from_email=args.from_email,
+                        original_message_id=original_message_id or None,
+                        to_email=to_addr, subject=final_subject, html=html,
+                        dry_run=args.dry_run
+                    )
+
+                    status = "SENT_TEST" if args.test else ("DRY_RUN" if args.dry_run else "SENT")
+
+                    if email_norm and status in ("SENT", "SENT_TEST", "DRY_RUN"):
+                        run_sent_emails.add(email_norm)
+                    if email_norm and status in ("SENT", "SENT_TEST"):
+                        state_emails_run.add(email_norm)
+
+                    if status == "SENT":
+                        uids_to_mark_answered.append(uid)
+
+                    if assigned:
+                        attempt_rows += 1
+                        if status in ("SENT", "SENT_TEST"):
+                            emails_ok += 1
+                            keys_assigned_total += len(assigned)
+                        else:
+                            emails_fail += 1
+
+                    if not args.dry_run:
+                        pct = (processed / total_target * 100) if total_target else 0
+                        print(f"[{processed}/{total_target} | {pct:.1f}%] {status}  UID={uid}  to={to_addr or email_to}  keys={len(assigned)}")
+
+                    if not args.no_consume:
+                        pool = pool[key_num:]
+
+                # Prepare BJ timestamps
+                now_bj = datetime.now(ZoneInfo("Asia/Shanghai"))
+                send_date = now_bj.strftime("%Y-%m-%d")                # YYYY-MM-DD
+                sent_at = now_bj.strftime("%Y-%m-%d %H:%M:%S")         # detailed
+
+                # Log ONE ROW PER KEY
                 if assigned:
                 if assigned:
-                    attempt_rows += 1
-                    if status in ("SENT", "SENT_TEST"):
-                        emails_ok += 1
-                        keys_assigned_total += len(assigned)
-                    else:
-                        emails_fail += 1
-
-                # ===== 实时进度反馈(不是 dry-run 就打印)=====
-                if not args.dry_run:
-                    # processed 是你脚本里原本就有的计数器:处理了多少“邮件行”
-                    # total_target 是开始前估算的目标处理量(用于百分比展示)
-                    pct = (processed / total_target * 100) if total_target else 0
-                    print(f"[{processed}/{total_target} | {pct:.1f}%] {status}  UID={uid}  to={to_addr or email_to}  keys={len(assigned)}")
-
-                # Consume keys ONLY when real send (not dry-run, not test) and not --no-consume
-                if not args.no_consume:
-                    pool = pool[key_num:]
-
-            # Prepare BJ timestamps
-            now_bj = datetime.now(ZoneInfo("Asia/Shanghai"))
-            send_date = now_bj.strftime("%Y-%m-%d")                # YYYY-MM-DD
-            sent_at = now_bj.strftime("%Y-%m-%d %H:%M:%S")         # detailed
-
-            # Log ONE ROW PER KEY
-            if assigned:
-                for k in assigned:
+                    for k in assigned:
+                        log_rows.append({
+                            "No.": row_no,
+                            "Send Date": send_date,
+                            "Channel": channel or "Steam",
+                            "Curator/Name": curator_name,
+                            "Purpose": "评测",
+                            "Social Link": safe_link,
+                            "Key": k,
+                            "Mailbox Key": uid,
+                            "To": email_to,
+                            "Requested Key Count": key_num,
+                            "Subject": final_subject,
+                            "Status": status,
+                            "Sent At": sent_at,
+                            "Test Mode": bool(args.test),
+                            "Actual Recipient": to_addr if (args.test or args.dry_run) else email_to,
+                        })
+                        row_no += 1
+                else:
+                    # Even if skipped/no keys, write a single row for traceability (without Key)
                     log_rows.append({
                     log_rows.append({
                         "No.": row_no,
                         "No.": row_no,
                         "Send Date": send_date,
                         "Send Date": send_date,
                         "Channel": channel or "Steam",
                         "Channel": channel or "Steam",
                         "Curator/Name": curator_name,
                         "Curator/Name": curator_name,
                         "Purpose": "评测",
                         "Purpose": "评测",
-                        "Social Link": safe_link,
-                        "Key": k,
+                        "Social Link": chosen_link,
+                        "Key": "",
                         "Mailbox Key": uid,
                         "Mailbox Key": uid,
                         "To": email_to,
                         "To": email_to,
                         "Requested Key Count": key_num,
                         "Requested Key Count": key_num,
@@ -407,51 +597,34 @@ def main():
                         "Actual Recipient": to_addr if (args.test or args.dry_run) else email_to,
                         "Actual Recipient": to_addr if (args.test or args.dry_run) else email_to,
                     })
                     })
                     row_no += 1
                     row_no += 1
-            else:
-                # Even if skipped/no keys, write a single row for traceability (without Key)
+
+                processed += 1
+
+            except Exception as e:
+                now_bj = datetime.now(ZoneInfo("Asia/Shanghai"))
                 log_rows.append({
                 log_rows.append({
                     "No.": row_no,
                     "No.": row_no,
-                    "Send Date": send_date,
-                    "Channel": channel or "Steam",
-                    "Curator/Name": curator_name,
+                    "Send Date": now_bj.strftime("%Y-%m-%d"),
+                    "Channel": "Steam",
+                    "Curator/Name": str(row.get("Curator/Name", "")),
                     "Purpose": "评测",
                     "Purpose": "评测",
-                    "Social Link": chosen_link,
+                    "Social Link": str(row.get("Curator/Social Links", "")),
                     "Key": "",
                     "Key": "",
-                    "Mailbox Key": uid,
-                    "To": email_to,
-                    "Requested Key Count": key_num,
-                    "Subject": final_subject,
-                    "Status": status,
-                    "Sent At": sent_at,
+                    "Mailbox Key": str(row.get("Mailbox Key", "")),
+                    "To": str(row.get("Email", "")),
+                    "Requested Key Count": row.get("Requested Key Count"),
+                    "Subject": str(row.get("Subject", "")),
+                    "Status": f"ERROR:{e}",
+                    "Sent At": now_bj.strftime("%Y-%m-%d %H:%M:%S"),
                     "Test Mode": bool(args.test),
                     "Test Mode": bool(args.test),
-                    "Actual Recipient": to_addr if (args.test or args.dry_run) else email_to,
+                    "Actual Recipient": "",
                 })
                 })
                 row_no += 1
                 row_no += 1
-
-            processed += 1
-
-        except Exception as e:
-            now_bj = datetime.now(ZoneInfo("Asia/Shanghai"))
-            log_rows.append({
-                "No.": row_no,
-                "Send Date": now_bj.strftime("%Y-%m-%d"),
-                "Channel": "Steam",
-                "Curator/Name": str(row.get("Curator/Name", "")),
-                "Purpose": "评测",
-                "Social Link": str(row.get("Curator/Social Links", "")),
-                "Key": "",
-                "Mailbox Key": str(row.get("Mailbox Key", "")),
-                "To": str(row.get("Email", "")),
-                "Requested Key Count": row.get("Requested Key Count"),
-                "Subject": str(row.get("Subject", "")),
-                "Status": f"ERROR:{e}",
-                "Sent At": now_bj.strftime("%Y-%m-%d %H:%M:%S"),
-                "Test Mode": bool(args.test),
-                "Actual Recipient": "",
-            })
-            row_no += 1
-            print(f"ERROR processing row {idx}: {e}", file=sys.stderr)
-            continue
+                print(f"ERROR processing row {idx}: {e}", file=sys.stderr)
+                continue
+    except KeyboardInterrupt:
+        interrupted = True
+        print("\n[INTERRUPTED] Sending loop stopped by user. Finalizing current progress.")
 
 
     if args.mark_answered and not args.dry_run and not args.test and uids_to_mark_answered:
     if args.mark_answered and not args.dry_run and not args.test and uids_to_mark_answered:
         done = imap_mark_answered_batch(
         done = imap_mark_answered_batch(
@@ -469,8 +642,37 @@ def main():
     ]
     ]
     log_df = pd.DataFrame(log_rows, columns=columns)
     log_df = pd.DataFrame(log_rows, columns=columns)
     log_path = os.path.abspath(args.out)
     log_path = os.path.abspath(args.out)
-    with pd.ExcelWriter(log_path, engine="openpyxl") as writer:
-        log_df.to_excel(writer, sheet_name="SendLog", index=False)
+    ensure_parent_dir(log_path)
+    date_tag = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
+    sheet_prefix = f"sendlog_{date_tag}"
+    if args.dry_run:
+        sheet_prefix += "_DRYRUN"
+    elif args.test:
+        sheet_prefix += "_TEST"
+    existing_sheets = set()
+    if os.path.exists(log_path):
+        try:
+            wb = load_workbook(log_path, read_only=True)
+            existing_sheets = set(wb.sheetnames)
+            wb.close()
+        except Exception:
+            existing_sheets = set()
+    suffix = 1
+    while True:
+        sheet_name = f"{sheet_prefix}{suffix:02d}"
+        if sheet_name not in existing_sheets:
+            break
+        suffix += 1
+    writer_args: Dict[str, Any] = {"engine": "openpyxl"}
+    if os.path.exists(log_path):
+        writer_args["mode"] = "a"
+    with pd.ExcelWriter(log_path, **writer_args) as writer:
+        log_df.to_excel(writer, sheet_name=sheet_name, index=False)
+        book = writer.book
+        if sheet_name in book.sheetnames and book.sheetnames[0] != sheet_name:
+            ws = book[sheet_name]
+            sheets = book._sheets  # type: ignore[attr-defined]
+            sheets.insert(0, sheets.pop(sheets.index(ws)))
 
 
     # ===== 汇总报告 =====
     # ===== 汇总报告 =====
     mode = "REAL"
     mode = "REAL"
@@ -484,8 +686,17 @@ def main():
     print(f"Attempted sends:      {attempt_rows}  (rows that had keys assigned)")
     print(f"Attempted sends:      {attempt_rows}  (rows that had keys assigned)")
     print(f"Successful sends:     {emails_ok}")
     print(f"Successful sends:     {emails_ok}")
     print(f"Failed/Skipped:       {emails_fail}")
     print(f"Failed/Skipped:       {emails_fail}")
+    if duplicate_skipped:
+        print(f"Skipped duplicates:   {duplicate_skipped}")
     print(f"Keys assigned total:  {keys_assigned_total}")
     print(f"Keys assigned total:  {keys_assigned_total}")
     print(f"Log file:             {log_path}")
     print(f"Log file:             {log_path}")
+    if interrupted:
+        print("[INTERRUPTED] Run stopped early; review the log for partial results.")
+
+    if (not args.dry_run) and state_emails_run and (not args.no_sentemail):
+        merged_emails = sorted(sent_emails_global.union(state_emails_run))
+        global_state["sent_emails"] = merged_emails
+        save_global_state(state_file, global_state)
 
 
 
 
     # Save remaining keys if consuming (not dry-run/test)
     # Save remaining keys if consuming (not dry-run/test)

+ 27 - 0
configs/settings.template.json

@@ -0,0 +1,27 @@
+{
+  "subject": "Steam Keys for Such A Guy -",
+  "start_date": "18-Oct-2025",
+  "min_uid": 125,
+  "files": {
+    "excel": "records/curator_requests.xlsx",
+    "keys": "records/steam_key.txt",
+    "log": "records/send_log.xlsx",
+    "template": "templates/email_template.html",
+    "state": "configs/curator_state.json"
+  },
+  "smtp": {
+    "host": "smtp.example.com",
+    "port": 587,
+    "user": "support@example.com",
+    "pass": "your_smtp_password",
+    "from_name": "SUCH ONE STUDIO",
+    "from_email": "support@example.com"
+  },
+  "imap": {
+    "host": "imap.example.com",
+    "port": 993,
+    "user": "support@example.com",
+    "pass": "your_imap_password",
+    "mailbox": "INBOX"
+  }
+}

BIN
curator_requests.xlsx


BIN
curator_requests.xlsx.bak.xlsx


+ 0 - 7
curator_state.json

@@ -1,7 +0,0 @@
-{
-  "mailboxes": {
-    "imap.feishu.cn|support@suchone.com.cn|INBOX": {
-      "last_uid": "569"
-    }
-  }
-}

+ 198 - 98
imap_curator_export.py

@@ -71,6 +71,7 @@ from typing import List, Dict, Any, Optional
 
 
 # Third-party
 # Third-party
 from bs4 import BeautifulSoup  # for HTML -> text
 from bs4 import BeautifulSoup  # for HTML -> text
+from openpyxl import load_workbook
 import pandas as pd
 import pandas as pd
 
 
 import re
 import re
@@ -98,6 +99,7 @@ KEY_REQUEST_PATTERNS = [
     r"\brequest(?:ing)?\s*(\d{1,3})\s*keys?\b",
     r"\brequest(?:ing)?\s*(\d{1,3})\s*keys?\b",
     r"\b(\d{1,3})\s*x\s*keys?\b",
     r"\b(\d{1,3})\s*x\s*keys?\b",
     r"\b(\d{1,3})-(\d{1,3})\s*keys?\b",
     r"\b(\d{1,3})-(\d{1,3})\s*keys?\b",
+    r"\b(\d{1,3})\s*(?:steam\s*)?(?:activation\s+(?:codes?|keys?))\b",
 ]
 ]
 
 
 CURATOR_KEYWORDS = [
 CURATOR_KEYWORDS = [
@@ -112,6 +114,33 @@ REPLY_FWD_PREFIX = re.compile(
     re.IGNORECASE
     re.IGNORECASE
 )
 )
 
 
+DEFAULT_CONFIG_PATH = os.path.join("configs", "settings.json")
+
+def load_settings(config_path: Optional[str]) -> Dict[str, Any]:
+    path = config_path or DEFAULT_CONFIG_PATH
+    if not os.path.isabs(path):
+        path = os.path.abspath(path)
+    if not os.path.exists(path):
+        print(f"ERROR: settings file not found: {path}", file=sys.stderr)
+        sys.exit(2)
+    try:
+        with open(path, "r", encoding="utf-8") as f:
+            data = json.load(f)
+    except Exception as e:
+        print(f"ERROR: failed to read settings.json: {e}", file=sys.stderr)
+        sys.exit(2)
+    files = data.get("files", {})
+    for key in ["excel", "state", "template", "keys", "log"]:
+        if key in files and not os.path.isabs(files[key]):
+            files[key] = os.path.abspath(files[key])
+    data["files"] = files
+    return data
+
+def ensure_parent_dir(path: str) -> None:
+    directory = os.path.dirname(path)
+    if directory:
+        os.makedirs(directory, exist_ok=True)
+
 def extract_name_from_body(body: str) -> str:
 def extract_name_from_body(body: str) -> str:
     """
     """
     从正文中根据常见短语提取名字:
     从正文中根据常见短语提取名字:
@@ -182,35 +211,27 @@ def mark_seen_batch(M, mailbox: str, uids: list[str], batch_size: int = 500) ->
     return total
     return total
 
 
 
 
-def env_get(name: str, default: Optional[str] = None, required: bool = False) -> str:
-    v = os.environ.get(name, default)
-    if required and not v:
-        print(f"ERROR: Missing environment variable {name}", file=sys.stderr)
+def imap_login(conf: Dict[str, Any]) -> imaplib.IMAP4_SSL:
+    host = conf.get("host")
+    port = int(conf.get("port", 993))
+    user = conf.get("user")
+    pwd  = conf.get("pass")
+    if not (host and user and pwd):
+        print("ERROR: Missing IMAP configuration in settings.json", file=sys.stderr)
         sys.exit(1)
         sys.exit(1)
-    return v
-
-
-def imap_login() -> imaplib.IMAP4_SSL:
-    host = env_get("IMAP_HOST", required=True)
-    port = int(env_get("IMAP_PORT", "993"))
-    user = env_get("EMAIL_USER", required=True)
-    pwd  = env_get("EMAIL_PASS", required=True)
-
     M = imaplib.IMAP4_SSL(host, port)
     M = imaplib.IMAP4_SSL(host, port)
     M.login(user, pwd)
     M.login(user, pwd)
     return M
     return M
 
 
 
 
-def select_mailbox(M: imaplib.IMAP4_SSL) -> str:
-    mailbox = env_get("MAILBOX", "INBOX")
+def select_mailbox(M: imaplib.IMAP4_SSL, mailbox: str) -> str:
     typ, data = M.select(mailbox, readonly=True)
     typ, data = M.select(mailbox, readonly=True)
     if typ != "OK":
     if typ != "OK":
         raise RuntimeError(f"Cannot select mailbox {mailbox}: {typ} {data}")
         raise RuntimeError(f"Cannot select mailbox {mailbox}: {typ} {data}")
     return mailbox
     return mailbox
 
 
 
 
-def imap_date_string() -> str:
-    start_date = env_get("START_DATE", "18-Oct-2025")
+def imap_date_string(start_date: str) -> str:
     try:
     try:
         datetime.strptime(start_date, "%d-%b-%Y")
         datetime.strptime(start_date, "%d-%b-%Y")
     except Exception:
     except Exception:
@@ -219,8 +240,7 @@ def imap_date_string() -> str:
     return start_date
     return start_date
 
 
 
 
-def load_state() -> Dict[str, Any]:
-    path = env_get("STATE_FILE", "curator_state.json")
+def load_state(path: str) -> Dict[str, Any]:
     if not os.path.exists(path):
     if not os.path.exists(path):
         return {"_path": path, "mailboxes": {}}
         return {"_path": path, "mailboxes": {}}
     try:
     try:
@@ -235,7 +255,10 @@ def load_state() -> Dict[str, Any]:
 
 
 
 
 def save_state(state: Dict[str, Any]) -> None:
 def save_state(state: Dict[str, Any]) -> None:
-    path = state.get("_path", env_get("STATE_FILE", "curator_state.json"))
+    path = state.get("_path")
+    if not path:
+        return
+    ensure_parent_dir(path)
     tmp = path + ".tmp"
     tmp = path + ".tmp"
     with open(tmp, "w", encoding="utf-8") as f:
     with open(tmp, "w", encoding="utf-8") as f:
         json.dump({k: v for k, v in state.items() if k != "_path"}, f, ensure_ascii=False, indent=2)
         json.dump({k: v for k, v in state.items() if k != "_path"}, f, ensure_ascii=False, indent=2)
@@ -436,6 +459,7 @@ def fetch_and_parse(M: imaplib.IMAP4_SSL, uid: bytes) -> Dict[str, Any]:
     subject = get_subject(msg)
     subject = get_subject(msg)
     date_local = parse_msg_date_bj(msg)
     date_local = parse_msg_date_bj(msg)
     reply_flag = is_reply_or_forward(msg, subject)
     reply_flag = is_reply_or_forward(msg, subject)
+    message_id = (msg.get("Message-ID") or "").strip()
 
 
     plain, html = get_payload_text(msg)
     plain, html = get_payload_text(msg)
     merged_text = plain.strip()
     merged_text = plain.strip()
@@ -454,6 +478,7 @@ def fetch_and_parse(M: imaplib.IMAP4_SSL, uid: bytes) -> Dict[str, Any]:
         "from_name": name or "",
         "from_name": name or "",
         "from_email": addr or "",
         "from_email": addr or "",
         "subject": subject,
         "subject": subject,
+        "message_id": message_id,
         "date_local": date_local,
         "date_local": date_local,
         "body_preview": (merged_text[:3000] + ("..." if len(merged_text) > 3000 else "")),
         "body_preview": (merged_text[:3000] + ("..." if len(merged_text) > 3000 else "")),
         "curator_links": curator_links,
         "curator_links": curator_links,
@@ -462,11 +487,13 @@ def fetch_and_parse(M: imaplib.IMAP4_SSL, uid: bytes) -> Dict[str, Any]:
     }
     }
 
 
 
 
-def connectivity_test() -> int:
+def connectivity_test(settings: Dict[str, Any]) -> int:
     try:
     try:
-        M = imap_login()
+        imap_conf = settings.get("imap", {})
+        mailbox_name = imap_conf.get("mailbox", "INBOX")
+        M = imap_login(imap_conf)
         try:
         try:
-            mailbox = select_mailbox(M)
+            mailbox = select_mailbox(M, mailbox_name)
             uids = uid_search(M, "ALL")
             uids = uid_search(M, "ALL")
             count = len(uids)
             count = len(uids)
             if count == 0:
             if count == 0:
@@ -495,16 +522,21 @@ def connectivity_test() -> int:
         return 2
         return 2
 
 
 
 
-def run_export(reset_state: bool = False, mark_read: bool = False) -> int:
-    since_date = imap_date_string()
-    host = env_get("IMAP_HOST", required=True)
-    user = env_get("EMAIL_USER", required=True)
-    min_uid = int(env_get("MIN_UID", "125"))
+def run_export(settings: Dict[str, Any], reset_state: bool = False, mark_read: bool = False) -> int:
+    files_conf = settings.get("files", {})
+    state_path = os.path.abspath(files_conf.get("state", os.path.join("config", "curator_state.json")))
+    out_path = os.path.abspath(files_conf.get("excel", os.path.join("records", "curator_requests.xlsx")))
+    start_date = imap_date_string(settings.get("start_date", "18-Oct-2025"))
+    imap_conf = settings.get("imap", {})
+    mailbox_name = imap_conf.get("mailbox", "INBOX")
+    host = imap_conf.get("host", "")
+    user = imap_conf.get("user", "")
+    min_uid = int(settings.get("min_uid", 125))
 
 
-    M = imap_login()
-    state = load_state()
+    M = imap_login(imap_conf)
+    state = load_state(state_path)
     try:
     try:
-        mailbox = select_mailbox(M)
+        mailbox = select_mailbox(M, mailbox_name)
         mailbox_key = get_mailbox_key(host, user, mailbox)
         mailbox_key = get_mailbox_key(host, user, mailbox)
 
 
         if reset_state and mailbox_key in state["mailboxes"]:
         if reset_state and mailbox_key in state["mailboxes"]:
@@ -521,8 +553,8 @@ def run_export(reset_state: bool = False, mark_read: bool = False) -> int:
             uids = uid_search(M, criterion)
             uids = uid_search(M, criterion)
             print(f"[SCAN] Using bookmark last_uid={last_uid}; new UIDs found: {len(uids)}")
             print(f"[SCAN] Using bookmark last_uid={last_uid}; new UIDs found: {len(uids)}")
         else:
         else:
-            uids = search_initial(M, since_date)
-            print(f"[SCAN] Initial run since {since_date}; candidate UIDs: {len(uids)}")
+            uids = search_initial(M, start_date)
+            print(f"[SCAN] Initial run since {start_date}; candidate UIDs: {len(uids)}")
 
 
         rows: List[Dict[str, Any]] = []
         rows: List[Dict[str, Any]] = []
         all_uids_for_mark: list[str] = []
         all_uids_for_mark: list[str] = []
@@ -532,66 +564,71 @@ def run_export(reset_state: bool = False, mark_read: bool = False) -> int:
         best_by_email: dict[str, dict] = {}
         best_by_email: dict[str, dict] = {}
         dup_skipped = 0
         dup_skipped = 0
 
 
-        for i, uid in enumerate(uids, 1):
-            try:
-                uid_int = int(uid)
-                if uid_int < min_uid:
-                    continue
-
-                rec = fetch_and_parse(M, uid)
-                if uid_int > max_seen_uid:
-                    max_seen_uid = uid_int
-
-                # Exclude replies/forwards
-                if rec["is_reply"]:
-                    continue
-
-                if looks_like_curator_request(rec["subject"], rec["body_preview"]):
-                    has_links = bool(rec["curator_links"])
-                    curator_email = norm_email(rec["from_email"])
-                    dedup_key = curator_email if curator_email else f"uid:{rec['uid']}"
-
-
-                    # 首选:邮件头中的名字
-                    if rec["from_name"]:
-                        curator_name = rec["from_name"].strip()
-                    else:
-                        # 尝试从正文里提取名字
-                        extracted = extract_name_from_body(rec["body_preview"])
-                        if extracted:
-                            curator_name = extracted
-                        #else:
-                        #    # 兜底用邮箱前缀
-                        #    curator_name = "Curator"
-
-                    candidate = {
-                        "uid_int": int(uid),
-                        "record": {
-                            "Mailbox Key": rec["uid"],                 # UID
-                            "Requested Key Count": rec["key_count"],
-                            "Date": rec["date_local"],
-                            "Curator/Name": curator_name,
-                            "Email": rec["from_email"],
-                            "Subject": rec["subject"],
-                            "Curator/Social Links": ", ".join(rec["curator_links"]) if has_links else "",
-                            "Body (preview)": rec["body_preview"],
-                            "_has_links": has_links,
+        interrupted = False
+        try:
+            for i, uid in enumerate(uids, 1):
+                try:
+                    uid_int = int(uid)
+                    if uid_int < min_uid:
+                        continue
+
+                    rec = fetch_and_parse(M, uid)
+                    if uid_int > max_seen_uid:
+                        max_seen_uid = uid_int
+
+                    # Exclude replies/forwards
+                    if rec["is_reply"]:
+                        continue
+
+                    has_links = False
+                    if looks_like_curator_request(rec["subject"], rec["body_preview"]):
+                        has_links = bool(rec["curator_links"])
+                        curator_email = norm_email(rec["from_email"])
+                        dedup_key = curator_email if curator_email else f"uid:{rec['uid']}"
+
+
+                        # 首选:邮件头中的名字
+                        if rec["from_name"]:
+                            curator_name = rec["from_name"].strip()
+                        else:
+                            # 尝试从正文里提取名字
+                            extracted = extract_name_from_body(rec["body_preview"])
+                            if extracted:
+                                curator_name = extracted
+                            else:
+                                curator_name = "Curator"
+
+                        candidate = {
+                            "uid_int": int(uid),
+                            "record": {
+                                "Mailbox Key": rec["uid"],                 # UID
+                                "Requested Key Count": rec["key_count"],
+                                "Date": rec["date_local"],
+                                "Curator/Name": curator_name,
+                                "Email": rec["from_email"],
+                                "Subject": rec["subject"],
+                                "Curator/Social Links": ", ".join(rec["curator_links"]) if has_links else "",
+                                "Body (preview)": rec["body_preview"],
+                                "Original Message-ID": rec.get("message_id", ""),
+                                "_has_links": has_links,
+                            }
                         }
                         }
-                    }
-                    
-                    all_uids_for_mark.append(int(uid))
-                    prev = best_by_email.get(dedup_key)
-                    if prev is None or candidate["uid_int"] > prev["uid_int"]:
-                        best_by_email[dedup_key] = candidate
-                    else:
-                        dup_skipped += 1
-                    
-                if i % 10 == 0:
-                    pct = (i / len(uids)) * 100
-                    print(f"  Processed {pct:.1f}% ({i}/{len(uids)})")
-            except Exception as e:
-                print(f"[WARN] Failed to parse UID {uid!r}: {e}", file=sys.stderr)
-                continue
+                        all_uids_for_mark.append(int(uid))
+                        prev = best_by_email.get(dedup_key)
+                        if prev is None or candidate["uid_int"] > prev["uid_int"]:
+                            best_by_email[dedup_key] = candidate
+                        else:
+                            dup_skipped += 1
+
+                        if i % 10 == 0:
+                            pct = (i / len(uids)) * 100
+                            print(f"  Processed {pct:.1f}% ({i}/{len(uids)})")
+                except Exception as e:
+                    print(f"[WARN] Failed to parse UID {uid!r}: {e}", file=sys.stderr)
+                    continue
+        except KeyboardInterrupt:
+            interrupted = True
+            print("\n[INTERRUPTED] Stopping scan early. Partial results will be written.")
 
 
         if mark_read:
         if mark_read:
             done = mark_seen_batch(M, mailbox, all_uids_for_mark, batch_size=500)
             done = mark_seen_batch(M, mailbox, all_uids_for_mark, batch_size=500)
@@ -613,6 +650,7 @@ def run_export(reset_state: bool = False, mark_read: bool = False) -> int:
                 "Subject": rec["Subject"],
                 "Subject": rec["Subject"],
                 "Curator/Social Links": rec["Curator/Social Links"],
                 "Curator/Social Links": rec["Curator/Social Links"],
                 "Body (preview)": rec["Body (preview)"],
                 "Body (preview)": rec["Body (preview)"],
+                "Original Message-ID": rec["Original Message-ID"],
                 "_has_links": rec["_has_links"],
                 "_has_links": rec["_has_links"],
             })
             })
             selected_uids_for_mark.append(rec["Mailbox Key"])
             selected_uids_for_mark.append(rec["Mailbox Key"])
@@ -625,7 +663,7 @@ def run_export(reset_state: bool = False, mark_read: bool = False) -> int:
 
 
         columns = [
         columns = [
             "No.", "Mailbox Key", "Requested Key Count", "Date", "Curator/Name",
             "No.", "Mailbox Key", "Requested Key Count", "Date", "Curator/Name",
-            "Email", "Subject", "Curator/Social Links", "Body (preview)"
+            "Email", "Subject", "Curator/Social Links", "Body (preview)", "Original Message-ID"
         ]
         ]
 
 
         if not rows:
         if not rows:
@@ -641,19 +679,78 @@ def run_export(reset_state: bool = False, mark_read: bool = False) -> int:
             # drop helper
             # drop helper
             if "_has_links" in df.columns:
             if "_has_links" in df.columns:
                 df = df.drop(columns=["_has_links"])
                 df = df.drop(columns=["_has_links"])
+            for col in columns:
+                if col not in df.columns:
+                    df[col] = ""
             df = df[columns]
             df = df[columns]
 
 
-        out_path = os.path.abspath("curator_requests.xlsx")
-        with pd.ExcelWriter(out_path, engine="openpyxl") as writer:
-            df.to_excel(writer, sheet_name="Requests", index=False)
+        ensure_parent_dir(out_path)
+        date_tag = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
+        sheet_prefix = f"curtor_{date_tag}"
+        existing_sheets = set()
+        if os.path.exists(out_path):
+            try:
+                wb = load_workbook(out_path, read_only=True)
+                existing_sheets = set(wb.sheetnames)
+                wb.close()
+            except Exception:
+                existing_sheets = set()
+
+        suffix = 1
+        while True:
+            sheet_name = f"{sheet_prefix}{suffix:02d}"
+            if sheet_name not in existing_sheets:
+                break
+            suffix += 1
+
+        writer_args: Dict[str, Any] = {"engine": "openpyxl"}
+        if os.path.exists(out_path):
+            writer_args["mode"] = "a"
+        with pd.ExcelWriter(out_path, **writer_args) as writer:
+            df.to_excel(writer, sheet_name=sheet_name, index=False)
+            book = writer.book
+            if sheet_name in book.sheetnames and book.sheetnames[0] != sheet_name:
+                ws = book[sheet_name]
+                sheets = book._sheets  # type: ignore[attr-defined]
+                sheets.insert(0, sheets.pop(sheets.index(ws)))
 
 
         # Summary printout
         # Summary printout
         print("\n=== SUMMARY ===")
         print("\n=== SUMMARY ===")
         print(f"Total requested keys: {total_keys}")
         print(f"Total requested keys: {total_keys}")
         print(f"With social links:    {with_links}")
         print(f"With social links:    {with_links}")
         print(f"Without social links: {without_links}")
         print(f"Without social links: {without_links}")
+        if interrupted:
+            print("[INTERRUPTED] Export stopped early; only the processed portion is included.")
 
 
         print(f"\nExported {len(df)} row(s) to {out_path}")
         print(f"\nExported {len(df)} row(s) to {out_path}")
+        print("\nDRY-RUN 命令:")
+        print(f"  python bulk_send_keys.py \\")
+        print(f"    --excel {os.path.basename(out_path)} \\")
+        print(f"    --sheet {sheet_name} \\")
+        print(f"    --template \"email_template.html\" \\")
+        print(f"    --keys steam_key.txt \\")
+        print(f"    --out send_log.xlsx \\")
+        print(f"    --subject \"Steam Keys for Such A Guy - \" \\")
+        print(f"    --dry-run")
+        print("\nTEST 命令:")
+        print(f"  python bulk_send_keys.py \\")
+        print(f"    --excel {os.path.basename(out_path)} \\")
+        print(f"    --sheet {sheet_name} \\")
+        print(f"    --template \"email_template.html\" \\")
+        print(f"    --keys steam_key.txt \\")
+        print(f"    --out send_log.xlsx \\")
+        print(f"    --subject \"Steam Keys for Such A Guy -\" \\")
+        print(f"    --test \\")
+        print(f"    --no-consume --no-sentemail")
+        print("\n正式执行命令:")
+        print(f"  python bulk_send_keys.py \\")
+        print(f"    --excel {os.path.basename(out_path)} \\")
+        print(f"    --sheet {sheet_name} \\")
+        print(f"    --template \"email_template.html\" \\")
+        print(f"    --keys steam_key.txt \\")
+        print(f"    --out send_log.xlsx \\")
+        print(f"    --subject \"Steam Keys for Such A Guy -\" \\")
+        print(f"    --mark-answered")
         return 0
         return 0
     finally:
     finally:
         try:
         try:
@@ -664,6 +761,7 @@ def run_export(reset_state: bool = False, mark_read: bool = False) -> int:
 
 
 def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
 def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
     p = argparse.ArgumentParser(description="Export Steam curator key requests to Excel via IMAP.")
     p = argparse.ArgumentParser(description="Export Steam curator key requests to Excel via IMAP.")
+    p.add_argument("--config", help="Path to settings.json (default: configs/settings.json).")
     p.add_argument("--test", action="store_true", help="Run a quick IMAP connectivity test and exit (does not read/write state).")
     p.add_argument("--test", action="store_true", help="Run a quick IMAP connectivity test and exit (does not read/write state).")
     p.add_argument("--reset-state", action="store_true", help="Reset stored UID bookmark before running.")
     p.add_argument("--reset-state", action="store_true", help="Reset stored UID bookmark before running.")
     p.add_argument("--mark-read", action="store_true", help="After exporting, mark those emails as read on the IMAP server.")
     p.add_argument("--mark-read", action="store_true", help="After exporting, mark those emails as read on the IMAP server.")
@@ -672,9 +770,11 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
 
 
 def main(argv: Optional[List[str]] = None) -> int:
 def main(argv: Optional[List[str]] = None) -> int:
     args = parse_args(argv)
     args = parse_args(argv)
+    config_path = args.config or os.environ.get("SETTINGS_FILE") or DEFAULT_CONFIG_PATH
+    settings = load_settings(config_path)
     if args.test:
     if args.test:
-        return connectivity_test()   # no state read/write
-    return run_export(reset_state=args.reset_state, mark_read=args.mark_read)
+        return connectivity_test(settings)   # no state read/write
+    return run_export(settings=settings, reset_state=args.reset_state, mark_read=args.mark_read)
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":


+ 0 - 16
steam_key.txt

@@ -1,16 +0,0 @@
-B39EY-A2L0H-7MA9M
-DD6QQ-DJ2I6-23QHX
-VGGYH-AWL30-W6I36
-CADFP-79IYH-6RR94
-5BGHW-7A578-LNWJF
-ZWY7E-R46HT-03AND
-YDE2L-5A5WT-G0R68
-F8AKR-9H4VM-LE6KP
-V60BM-QA49Z-DHQV8
-JWBAM-RRQ4W-T3M9K
-J4ZIR-45NL9-LMNAB
-L72VD-VNP3J-AER78
-AIQII-RT4IE-XTY0W
-KN5HE-38CF2-Z5B7H
-R5ZGL-GTEBR-EPPW9
-QBEGD-T297Y-JIMK8

+ 0 - 0
email_template.html → templates/email_template.html