# Trigger the temporary upgrade after the workflow exists on main. name: Temporary Cloud Mail upgrade on: push: branches: - main paths: - .github/workflows/temp-cloudmail-upgrade.yml permissions: contents: write jobs: apply-upgrade: if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - name: Checkout repository 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 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 shell: bash run: | rm -f .github/workflows/temp-cloudmail-upgrade.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 commit -m "feat(mail): add optional Cloud Mail no-recipient provider" git push origin HEAD:main