Explorar o código

Refine workflow with config-driven setup

Krystic Cong hai 1 mes
pai
achega
a775a890f3
Modificáronse 12 ficheiros con 860 adicións e 295 borrados
  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=BIN
      curator_requests.xlsx
  7. BIN=BIN
      curator_requests.xlsx.bak.xlsx
  8. 0 7
      curator_state.json
  9. 198 98
      imap_curator_export.py
  10. BIN=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
 .AppleDouble
 .LSOverride
@@ -24,16 +24,24 @@ Icon
 Network Trash Folder
 Temporary Items
 .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~
 Session.vim
 .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
 
+## 项目概览
+这是一组自动化脚本,用来:
+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 sys
+import json
 import argparse
 import smtplib
 import ssl
@@ -38,9 +39,52 @@ import imaplib
 from email.message import EmailMessage
 from typing import List, Dict, Any, Optional, Tuple
 import pandas as pd
+from openpyxl import load_workbook
 from datetime import datetime
 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)
 FALLBACK_TEMPLATE = """\
 <html>
@@ -74,6 +118,7 @@ def load_keys(path: str) -> List[str]:
     return keys
 
 def save_remaining_keys(path: str, keys: List[str]) -> None:
+    ensure_parent_dir(path)
     tmp = path + ".tmp"
     with open(tmp, "w", encoding="utf-8") as f:
         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)
 
 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:
     if dry_run:
         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["From"] = f"{from_name} <{from_email}>" if from_name else from_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.add_alternative(html, subtype="html")
 
@@ -137,6 +184,48 @@ def parse_channel_and_link(links_str: str) -> Tuple[str, str]:
             return (label, first)
     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:
     if isinstance(u, (bytes, bytearray)):
         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():
     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("--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-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("--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).")
 
     # 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
     parser.add_argument("--mark-answered", action="store_true",
     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()
 
+    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
     if not args.dry_run:
         for vname in ["smtp_host", "smtp_user", "smtp_pass", "from_email"]:
@@ -236,7 +389,12 @@ def main():
     template_html = load_template(args.template)
 
     # 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"]
     for c in required_cols:
         if c not in df.columns:
@@ -247,9 +405,10 @@ def main():
     sent_uids = set()
     if args.skip_sent and os.path.exists(args.out):
         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:
             pass
 
@@ -278,125 +437,156 @@ def main():
     emails_ok = 0           # 发送成功(SENT 或 SENT_TEST)
     emails_fail = 0         # 失败/跳过(ERROR 或 SKIPPED_NO_KEYS 等)
     keys_assigned_total = 0 # 实际分配(写进邮件里的)key 数(dry-run/test 也会统计)
+    duplicate_skipped = 0   # 因邮箱重复而跳过的行数
 
     uids_to_mark_answered: list[str] = []
+    interrupted = False
 
     # 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:
-                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()
                 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:
-                    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({
                         "No.": row_no,
                         "Send Date": send_date,
                         "Channel": channel or "Steam",
                         "Curator/Name": curator_name,
                         "Purpose": "评测",
-                        "Social Link": safe_link,
-                        "Key": k,
+                        "Social Link": chosen_link,
+                        "Key": "",
                         "Mailbox Key": uid,
                         "To": email_to,
                         "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,
                     })
                     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({
                     "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": "评测",
-                    "Social Link": chosen_link,
+                    "Social Link": str(row.get("Curator/Social Links", "")),
                     "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),
-                    "Actual Recipient": to_addr if (args.test or args.dry_run) else email_to,
+                    "Actual Recipient": "",
                 })
                 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:
         done = imap_mark_answered_batch(
@@ -469,8 +642,37 @@ def main():
     ]
     log_df = pd.DataFrame(log_rows, columns=columns)
     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"
@@ -484,8 +686,17 @@ def main():
     print(f"Attempted sends:      {attempt_rows}  (rows that had keys assigned)")
     print(f"Successful sends:     {emails_ok}")
     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"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)

+ 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=BIN
curator_requests.xlsx


BIN=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
 from bs4 import BeautifulSoup  # for HTML -> text
+from openpyxl import load_workbook
 import pandas as pd
 
 import re
@@ -98,6 +99,7 @@ KEY_REQUEST_PATTERNS = [
     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})-(\d{1,3})\s*keys?\b",
+    r"\b(\d{1,3})\s*(?:steam\s*)?(?:activation\s+(?:codes?|keys?))\b",
 ]
 
 CURATOR_KEYWORDS = [
@@ -112,6 +114,33 @@ REPLY_FWD_PREFIX = re.compile(
     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:
     """
     从正文中根据常见短语提取名字:
@@ -182,35 +211,27 @@ def mark_seen_batch(M, mailbox: str, uids: list[str], batch_size: int = 500) ->
     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)
-    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.login(user, pwd)
     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)
     if typ != "OK":
         raise RuntimeError(f"Cannot select mailbox {mailbox}: {typ} {data}")
     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:
         datetime.strptime(start_date, "%d-%b-%Y")
     except Exception:
@@ -219,8 +240,7 @@ def imap_date_string() -> str:
     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):
         return {"_path": path, "mailboxes": {}}
     try:
@@ -235,7 +255,10 @@ def load_state() -> Dict[str, Any]:
 
 
 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"
     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)
@@ -436,6 +459,7 @@ def fetch_and_parse(M: imaplib.IMAP4_SSL, uid: bytes) -> Dict[str, Any]:
     subject = get_subject(msg)
     date_local = parse_msg_date_bj(msg)
     reply_flag = is_reply_or_forward(msg, subject)
+    message_id = (msg.get("Message-ID") or "").strip()
 
     plain, html = get_payload_text(msg)
     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_email": addr or "",
         "subject": subject,
+        "message_id": message_id,
         "date_local": date_local,
         "body_preview": (merged_text[:3000] + ("..." if len(merged_text) > 3000 else "")),
         "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:
-        M = imap_login()
+        imap_conf = settings.get("imap", {})
+        mailbox_name = imap_conf.get("mailbox", "INBOX")
+        M = imap_login(imap_conf)
         try:
-            mailbox = select_mailbox(M)
+            mailbox = select_mailbox(M, mailbox_name)
             uids = uid_search(M, "ALL")
             count = len(uids)
             if count == 0:
@@ -495,16 +522,21 @@ def connectivity_test() -> int:
         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:
-        mailbox = select_mailbox(M)
+        mailbox = select_mailbox(M, mailbox_name)
         mailbox_key = get_mailbox_key(host, user, mailbox)
 
         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)
             print(f"[SCAN] Using bookmark last_uid={last_uid}; new UIDs found: {len(uids)}")
         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]] = []
         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] = {}
         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:
             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"],
                 "Curator/Social Links": rec["Curator/Social Links"],
                 "Body (preview)": rec["Body (preview)"],
+                "Original Message-ID": rec["Original Message-ID"],
                 "_has_links": rec["_has_links"],
             })
             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 = [
             "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:
@@ -641,19 +679,78 @@ def run_export(reset_state: bool = False, mark_read: bool = False) -> int:
             # drop helper
             if "_has_links" in df.columns:
                 df = df.drop(columns=["_has_links"])
+            for col in columns:
+                if col not in df.columns:
+                    df[col] = ""
             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
         print("\n=== SUMMARY ===")
         print(f"Total requested keys: {total_keys}")
         print(f"With social links:    {with_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("\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
     finally:
         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:
     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("--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.")
@@ -672,9 +770,11 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
 
 def main(argv: Optional[List[str]] = None) -> int:
     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:
-        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__":

BIN=BIN
send_log.xlsx


+ 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