diff --git a/.github/workflows/temp-cloudmail-upgrade.yml b/.github/workflows/temp-cloudmail-upgrade.yml index c3cf70f..9258e6f 100644 --- a/.github/workflows/temp-cloudmail-upgrade.yml +++ b/.github/workflows/temp-cloudmail-upgrade.yml @@ -1,4 +1,4 @@ -# Trigger the temporary upgrade after the workflow exists on main. +# One-shot workflow to finish the Cloud Mail upgrade and remove temporary files. name: Temporary Cloud Mail upgrade on: @@ -16,480 +16,23 @@ jobs: if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - - name: Checkout repository + - name: Checkout main uses: actions/checkout@v4 with: ref: main fetch-depth: 0 - - name: Apply minimal Cloud Mail provider patch - shell: bash - run: | - python <<'PY' - from pathlib import Path - import ast - import json + - name: Apply Cloud Mail provider patch + run: python tools/apply_cloudmail_upgrade.py - 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) - - - ''' - cloudmail_helpers = cloudmail_helpers.replace("\n ", "\n") - - 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 内未收到验证码邮件") - - - ''' - cloudmail_poll = cloudmail_poll.replace("\n ", "\n") - - 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) - '''.replace("\n ", "\n") - - 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) - '''.replace("\n ", "\n") - - 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`。 - - '''.replace("\n ", "\n") - - readme = replace_once( - readme, - '### grok2api 远端入池配置\n', - cloudmail_readme + '### grok2api 远端入池配置\n', - "README Cloud Mail section", - ) - readme_path.write_text(readme, encoding="utf-8") - PY - - - name: Commit upgrade and remove temporary workflow + - name: Commit upgrade and clean temporary files shell: bash run: | + rm -f tools/apply_cloudmail_upgrade.py rm -f .github/workflows/temp-cloudmail-upgrade.yml + rm -f .github/workflows/temp-cloudmail-issue-trigger.yml git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add grok_register_ttk.py config.example.json README.md .github/workflows/temp-cloudmail-upgrade.yml + git add grok_register_ttk.py config.example.json README.md tools/apply_cloudmail_upgrade.py .github/workflows/temp-cloudmail-upgrade.yml .github/workflows/temp-cloudmail-issue-trigger.yml git commit -m "feat(mail): add optional Cloud Mail no-recipient provider" git push origin HEAD:main