diff --git a/tools/apply_cloudmail_upgrade.py b/tools/apply_cloudmail_upgrade.py new file mode 100644 index 0000000..3fdbb91 --- /dev/null +++ b/tools/apply_cloudmail_upgrade.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +from pathlib import Path +import ast +import json + + +def replace_once(text, old, new, label): + count = text.count(old) + if count != 1: + raise RuntimeError(f"{label}: expected exactly one match, found {count}") + return text.replace(old, new, 1) + + +app_path = Path("grok_register_ttk.py") +app = app_path.read_text(encoding="utf-8-sig") + +app = replace_once( + app, + ' "cloudflare_path_messages": "/api/mails",\n "proxy": "http://127.0.0.1:7890",', + ' "cloudflare_path_messages": "/api/mails",\n' + ' "cloudmail_api_base": "",\n' + ' "cloudmail_public_token": "",\n' + ' "cloudmail_domains": "",\n' + ' "cloudmail_path_messages": "/api/public/emailList",\n' + ' "proxy": "http://127.0.0.1:7890",', + "default Cloud Mail config", +) + +app = replace_once( + app, + 'config = DEFAULT_CONFIG.copy()\n_cf_domain_index = 0\n', + 'config = DEFAULT_CONFIG.copy()\n_cf_domain_index = 0\n_cloudmail_domain_index = 0\n', + "Cloud Mail domain index", +) + +cloudmail_helpers = '''def get_cloudmail_api_base(): + return str(config.get("cloudmail_api_base", "") or "").strip().rstrip("/") + + +def get_cloudmail_public_token(): + return str(config.get("cloudmail_public_token", "") or "").strip() + + +def get_cloudmail_path(): + raw = str( + config.get("cloudmail_path_messages", "/api/public/emailList") + or "/api/public/emailList" + ).strip() + return raw if raw.startswith("/") else "/" + raw + + +def cloudmail_next_domain(): + """按配置轮换选择 Cloud Mail 无人收件域名。""" + global _cloudmail_domain_index + domains = [ + item.strip().lstrip("@") + for item in str(config.get("cloudmail_domains", "") or "").split(",") + if item.strip().lstrip("@") + ] + if not domains: + return "" + domain = domains[_cloudmail_domain_index % len(domains)] + _cloudmail_domain_index += 1 + return domain + + +def cloudmail_get_email_and_token(): + """生成无需预创建账号的 Cloud Mail 收件地址。""" + if not get_cloudmail_api_base(): + raise Exception("Cloud Mail API Base 未配置") + if not get_cloudmail_public_token(): + raise Exception("Cloud Mail Public Token 未配置") + domain = cloudmail_next_domain() + if not domain: + raise Exception("Cloud Mail 收件域名未配置") + address = f"{generate_username(12)}@{domain}" + # 仅返回非敏感占位凭证;公共 Token 始终只从 config.json 读取。 + return address, f"cloudmail:{address}" + + +def cloudmail_get_messages(address): + api_base = get_cloudmail_api_base() + public_token = get_cloudmail_public_token() + if not api_base: + raise Exception("Cloud Mail API Base 未配置") + if not public_token: + raise Exception("Cloud Mail Public Token 未配置") + payload = { + "toEmail": address, + "type": 0, + "isDel": 0, + "timeSort": "desc", + "num": 1, + "size": 20, + } + resp = http_post( + f"{api_base}{get_cloudmail_path()}", + headers={ + "Authorization": public_token, + "Content-Type": "application/json", + }, + json=payload, + timeout=20, + ) + resp.raise_for_status() + try: + data = resp.json() + except Exception: + raise Exception(f"Cloud Mail 邮件接口返回非JSON: {resp.text[:300]}") + if not isinstance(data, dict): + raise Exception(f"Cloud Mail 邮件接口返回格式错误: {data}") + result_code = data.get("code") + if result_code not in (None, 200, "200"): + raise Exception( + f"Cloud Mail 邮件接口失败: code={result_code}, message={data.get('message', '')}" + ) + messages = data.get("data") + if isinstance(messages, list): + return messages + return _pick_list_payload(data) + + +''' + +app = replace_once( + app, + ' return address, jwt\n\n\ndef get_user_agent():', + ' return address, jwt\n\n\n' + cloudmail_helpers + 'def get_user_agent():', + "Cloud Mail helper functions", +) + +app = replace_once( + app, + ' if provider == "yyds":\n' + ' return yyds_get_email_and_token(api_key=api_key, jwt=get_yyds_jwt())\n' + ' if provider == "cloudflare":', + ' if provider == "yyds":\n' + ' return yyds_get_email_and_token(api_key=api_key, jwt=get_yyds_jwt())\n' + ' if provider == "cloudmail":\n' + ' return cloudmail_get_email_and_token()\n' + ' if provider == "cloudflare":', + "Cloud Mail address dispatch", +) + +app = replace_once( + app, + ' if provider == "cloudflare":\n' + ' return cloudflare_get_oai_code(\n', + ' if provider == "cloudmail":\n' + ' return cloudmail_get_oai_code(\n' + ' dev_token,\n' + ' email,\n' + ' timeout=timeout,\n' + ' poll_interval=poll_interval,\n' + ' log_callback=log_callback,\n' + ' cancel_callback=cancel_callback,\n' + ' resend_callback=resend_callback,\n' + ' )\n' + ' if provider == "cloudflare":\n' + ' return cloudflare_get_oai_code(\n', + "Cloud Mail verification dispatch", +) + +cloudmail_poll = '''def cloudmail_get_oai_code( + dev_token, + email, + timeout=180, + poll_interval=3, + log_callback=None, + cancel_callback=None, + resend_callback=None, +): + # dev_token 是为了保持现有邮箱 Provider 调用契约;Cloud Mail 使用配置中的公共 Token。 + _ = dev_token + deadline = time.time() + timeout + seen_attempts = {} + next_resend_at = time.time() + 35 + while time.time() < deadline: + raise_if_cancelled(cancel_callback) + if resend_callback and time.time() >= next_resend_at: + try: + resend_callback() + if log_callback: + log_callback("[*] 已触发重新发送验证码") + except Exception as exc: + if log_callback: + log_callback(f"[Debug] 触发重发验证码失败: {exc}") + next_resend_at = time.time() + 35 + try: + messages = cloudmail_get_messages(email) + except Exception as exc: + if log_callback: + log_callback(f"[Debug] Cloud Mail 拉取邮件列表失败: {exc}") + sleep_with_cancel(poll_interval, cancel_callback) + continue + if log_callback: + log_callback(f"[Debug] Cloud Mail 本轮邮件数量: {len(messages)}") + for msg in messages: + msg_id = msg.get("emailId") or msg.get("email_id") or msg.get("id") + if not msg_id: + continue + attempt = int(seen_attempts.get(msg_id, 0)) + if attempt >= 5: + continue + seen_attempts[msg_id] = attempt + 1 + target_address = str( + msg.get("toEmail") or msg.get("to_email") or "" + ).strip().lower() + if target_address and target_address != email.lower(): + continue + parts = [] + code_value = str(msg.get("code", "") or "").strip() + if code_value: + parts.append(f"verification code: {code_value}") + for field in ("text", "content", "html", "body", "snippet"): + value = msg.get(field) + values = value if isinstance(value, list) else [value] + for item in values: + if isinstance(item, str) and item.strip(): + parts.append(re.sub(r"<[^>]+>", " ", item)) + subject = str(msg.get("subject", "") or "") + combined = "\n".join(parts) + if log_callback: + log_callback(f"[Debug] Cloud Mail 收到邮件: {subject}") + code = extract_verification_code(combined, subject) + if code: + if log_callback: + log_callback(f"[*] Cloud Mail 从邮件中提取到验证码: {code}") + return code + if log_callback: + log_callback( + f"[Debug] Cloud Mail 邮件已解析但未提取到验证码 " + f"id={msg_id} attempt={seen_attempts[msg_id]}" + ) + sleep_with_cancel(poll_interval, cancel_callback) + raise Exception(f"Cloud Mail 在 {timeout}s 内未收到验证码邮件") + + +''' + +app = replace_once( + app, + ' return None\n\n\ndef duckmail_get_oai_code(', + ' return None\n\n\n' + cloudmail_poll + 'def duckmail_get_oai_code(', + "Cloud Mail verification polling", +) + +app = replace_once( + app, + 'self.email_provider_var, ["duckmail", "yyds", "cloudflare"], width=12', + 'self.email_provider_var, ["duckmail", "yyds", "cloudflare", "cloudmail"], width=12', + "Cloud Mail GUI provider option", +) + +old_gui_block = ''' add_label(5, 0, "grok2api 本地入池:") + self.grok2api_local_auto_var = tk.BooleanVar(value=bool(config.get("grok2api_auto_add_local", True))) + self.grok2api_local_auto_check = tk_checkbutton(config_frame, variable=self.grok2api_local_auto_var) + add_field(self.grok2api_local_auto_check, 5, 1, sticky=tk.W) + + add_label(5, 2, "grok2api 池名:") + self.grok2api_pool_name_var = tk.StringVar(value=str(config.get("grok2api_pool_name", "ssoBasic"))) + self.grok2api_pool_name_combo = tk_option_menu( + config_frame, self.grok2api_pool_name_var, ["ssoBasic", "ssoSuper"], width=12 + ) + add_field(self.grok2api_pool_name_combo, 5, 3, sticky=tk.W) + + add_label(6, 0, "本地 token.json:") + self.grok2api_local_file_var = tk.StringVar(value=str(config.get("grok2api_local_token_file", ""))) + self.grok2api_local_file_entry = tk_entry(config_frame, textvariable=self.grok2api_local_file_var, width=72) + add_field(self.grok2api_local_file_entry, 6, 1, columnspan=3) + + add_label(7, 0, "grok2api 远端入池:") + self.grok2api_remote_auto_var = tk.BooleanVar(value=bool(config.get("grok2api_auto_add_remote", False))) + self.grok2api_remote_auto_check = tk_checkbutton(config_frame, variable=self.grok2api_remote_auto_var) + add_field(self.grok2api_remote_auto_check, 7, 1, sticky=tk.W) + + add_label(8, 0, "grok2api 远端 Base:") + self.grok2api_remote_base_var = tk.StringVar(value=str(config.get("grok2api_remote_base", ""))) + self.grok2api_remote_base_entry = tk_entry(config_frame, textvariable=self.grok2api_remote_base_var, width=72) + add_field(self.grok2api_remote_base_entry, 8, 1, columnspan=3) + + add_label(9, 0, "grok2api 远端 app_key:") + self.grok2api_remote_key_var = tk.StringVar(value=str(config.get("grok2api_remote_app_key", ""))) + self.grok2api_remote_key_entry = tk_entry(config_frame, textvariable=self.grok2api_remote_key_var, width=72) + add_field(self.grok2api_remote_key_entry, 9, 1, columnspan=3) +''' + +new_gui_block = ''' add_label(5, 0, "Cloud Mail API Base:") + self.cloudmail_api_base_var = tk.StringVar(value=config.get("cloudmail_api_base", "")) + self.cloudmail_api_base_entry = tk_entry(config_frame, textvariable=self.cloudmail_api_base_var, width=34) + add_field(self.cloudmail_api_base_entry, 5, 1) + + add_label(5, 2, "Cloud Mail 域名:") + self.cloudmail_domains_var = tk.StringVar(value=config.get("cloudmail_domains", "")) + self.cloudmail_domains_entry = tk_entry(config_frame, textvariable=self.cloudmail_domains_var, width=34) + add_field(self.cloudmail_domains_entry, 5, 3) + + add_label(6, 0, "Cloud Mail Public Token:") + self.cloudmail_public_token_var = tk.StringVar(value=config.get("cloudmail_public_token", "")) + self.cloudmail_public_token_entry = tk_entry(config_frame, textvariable=self.cloudmail_public_token_var, width=72) + add_field(self.cloudmail_public_token_entry, 6, 1, columnspan=3) + + add_label(7, 0, "grok2api 本地入池:") + self.grok2api_local_auto_var = tk.BooleanVar(value=bool(config.get("grok2api_auto_add_local", True))) + self.grok2api_local_auto_check = tk_checkbutton(config_frame, variable=self.grok2api_local_auto_var) + add_field(self.grok2api_local_auto_check, 7, 1, sticky=tk.W) + + add_label(7, 2, "grok2api 池名:") + self.grok2api_pool_name_var = tk.StringVar(value=str(config.get("grok2api_pool_name", "ssoBasic"))) + self.grok2api_pool_name_combo = tk_option_menu( + config_frame, self.grok2api_pool_name_var, ["ssoBasic", "ssoSuper"], width=12 + ) + add_field(self.grok2api_pool_name_combo, 7, 3, sticky=tk.W) + + add_label(8, 0, "本地 token.json:") + self.grok2api_local_file_var = tk.StringVar(value=str(config.get("grok2api_local_token_file", ""))) + self.grok2api_local_file_entry = tk_entry(config_frame, textvariable=self.grok2api_local_file_var, width=72) + add_field(self.grok2api_local_file_entry, 8, 1, columnspan=3) + + add_label(9, 0, "grok2api 远端入池:") + self.grok2api_remote_auto_var = tk.BooleanVar(value=bool(config.get("grok2api_auto_add_remote", False))) + self.grok2api_remote_auto_check = tk_checkbutton(config_frame, variable=self.grok2api_remote_auto_var) + add_field(self.grok2api_remote_auto_check, 9, 1, sticky=tk.W) + + add_label(10, 0, "grok2api 远端 Base:") + self.grok2api_remote_base_var = tk.StringVar(value=str(config.get("grok2api_remote_base", ""))) + self.grok2api_remote_base_entry = tk_entry(config_frame, textvariable=self.grok2api_remote_base_var, width=72) + add_field(self.grok2api_remote_base_entry, 10, 1, columnspan=3) + + add_label(11, 0, "grok2api 远端 app_key:") + self.grok2api_remote_key_var = tk.StringVar(value=str(config.get("grok2api_remote_app_key", ""))) + self.grok2api_remote_key_entry = tk_entry(config_frame, textvariable=self.grok2api_remote_key_var, width=72) + add_field(self.grok2api_remote_key_entry, 11, 1, columnspan=3) +''' + +app = replace_once(app, old_gui_block, new_gui_block, "Cloud Mail GUI fields") + +app = replace_once( + app, + ' config["cloudflare_auth_mode"] = self.cloudflare_auth_mode_var.get().strip() or "none"\n' + ' config["grok2api_auto_add_local"] = bool(self.grok2api_local_auto_var.get())', + ' config["cloudflare_auth_mode"] = self.cloudflare_auth_mode_var.get().strip() or "none"\n' + ' config["cloudmail_api_base"] = self.cloudmail_api_base_var.get().strip()\n' + ' config["cloudmail_public_token"] = self.cloudmail_public_token_var.get().strip()\n' + ' config["cloudmail_domains"] = self.cloudmail_domains_var.get().strip()\n' + ' config["grok2api_auto_add_local"] = bool(self.grok2api_local_auto_var.get())', + "save Cloud Mail GUI config", +) + +app = replace_once( + app, + ' if config["email_provider"] == "cloudflare" and not config["cloudflare_api_base"]:\n' + ' self.log("[!] Cloudflare 模式需要先填写 Cloudflare API Base")\n' + ' return\n' + ' try:', + ' if config["email_provider"] == "cloudflare" and not config["cloudflare_api_base"]:\n' + ' self.log("[!] Cloudflare 模式需要先填写 Cloudflare API Base")\n' + ' return\n' + ' if config["email_provider"] == "cloudmail":\n' + ' missing = []\n' + ' if not config["cloudmail_api_base"]:\n' + ' missing.append("API Base")\n' + ' if not config["cloudmail_public_token"]:\n' + ' missing.append("Public Token")\n' + ' if not config["cloudmail_domains"]:\n' + ' missing.append("域名")\n' + ' if missing:\n' + ' self.log(f"[!] Cloud Mail 模式缺少配置: {\', \'.join(missing)}")\n' + ' return\n' + ' try:', + "validate Cloud Mail GUI config", +) + +ast.parse(app) +app_path.write_text(app, encoding="utf-8-sig") + +config_path = Path("config.example.json") +config_text = config_path.read_text(encoding="utf-8") +config_text = replace_once( + config_text, + ' "cloudflare_path_messages": "/api/mails",\n "proxy": "",', + ' "cloudflare_path_messages": "/api/mails",\n' + ' "cloudmail_api_base": "https://mail.example.com",\n' + ' "cloudmail_public_token": "",\n' + ' "cloudmail_domains": "example.com",\n' + ' "cloudmail_path_messages": "/api/public/emailList",\n' + ' "proxy": "",', + "config.example Cloud Mail fields", +) +json.loads(config_text) +config_path.write_text(config_text, encoding="utf-8") + +readme_path = Path("README.md") +readme = readme_path.read_text(encoding="utf-8") +readme = replace_once( + readme, + '- 支持 DuckMail、YYDS、Cloudflare 临时邮箱接口。', + '- 支持 DuckMail、YYDS、Cloudflare,以及 Cloud Mail 无人收件模式。', + "README feature list", +) +readme = replace_once( + readme, + '| `email_provider` | 邮箱服务商:`duckmail`、`yyds`、`cloudflare` |', + '| `email_provider` | 邮箱服务商:`duckmail`、`yyds`、`cloudflare`、`cloudmail` |', + "README provider list", +) +readme = replace_once( + readme, + '| `defaultDomains` | Cloudflare 临时邮箱默认域名 |\n' + '| `grok2api_auto_add_local` | 是否写入本地 grok2api token 池 |', + '| `defaultDomains` | Cloudflare 临时邮箱默认域名 |\n' + '| `cloudmail_api_base` | Cloud Mail 站点地址 |\n' + '| `cloudmail_public_token` | Cloud Mail 公共 API Token,不会写入邮箱凭证文件 |\n' + '| `cloudmail_domains` | Cloud Mail 无人收件域名,多个域名用英文逗号分隔 |\n' + '| `cloudmail_path_messages` | Cloud Mail 公共收件接口路径,默认 `/api/public/emailList` |\n' + '| `grok2api_auto_add_local` | 是否写入本地 grok2api token 池 |', + "README Cloud Mail config rows", +) + +cloudmail_readme = '''### Cloud Mail 无人收件模式(可选) + +该模式对接 `maillab/cloud-mail`,使用其“无人收件”功能:程序直接生成随机邮箱地址,不需要先在 Cloud Mail 中创建邮箱账号。 + +使用前需要: + +1. 在 Cloud Mail 系统设置中开启“无人收件”。 +2. 使用管理员账号生成公共 API Token: + +```bash +curl -X POST "https://你的-Cloud-Mail-域名/api/public/genToken" \\ + -H "Content-Type: application/json" \\ + -d '{"email":"管理员邮箱","password":"管理员密码"}' +``` + +3. 配置本项目: + +```json +{ + "email_provider": "cloudmail", + "cloudmail_api_base": "https://你的-Cloud-Mail-域名", + "cloudmail_public_token": "返回结果 data 中的 token", + "cloudmail_domains": "你的收信域名.com", + "cloudmail_path_messages": "/api/public/emailList" +} +``` + +程序会通过 `POST /api/public/emailList` 按目标地址查询邮件。公共 Token 只从 `config.json` 读取,不会作为邮箱 credential 输出到日志或 `mail_credentials.txt`。 + +''' + +readme = replace_once( + readme, + '### grok2api 远端入池配置\n', + cloudmail_readme + '### grok2api 远端入池配置\n', + "README Cloud Mail section", +) +readme_path.write_text(readme, encoding="utf-8")