import email import random import string from email import policy import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def _generate_email_local_part(): letters1 = "".join(random.choices(string.ascii_lowercase, k=5)) numbers = "".join(random.choices(string.digits, k=random.randint(1, 3))) letters2 = "".join(random.choices(string.ascii_lowercase, k=random.randint(1, 3))) return letters1 + numbers + letters2 def _extract_mail_bodies(raw_text): if not raw_text: return "", "" try: msg = email.message_from_string(raw_text, policy=policy.default) except Exception: return "", "" text_parts = [] html_parts = [] for part in msg.walk(): if part.get_content_maintype() == "multipart": continue if part.get_filename(): continue content_type = part.get_content_type() try: content = part.get_content() except Exception: payload = part.get_payload(decode=True) or b"" charset = part.get_content_charset() or "utf-8" content = payload.decode(charset, errors="replace") if not isinstance(content, str): content = str(content or "") if content_type == "text/plain": text_parts.append(content) elif content_type == "text/html": html_parts.append(content) return "\n\n".join(text_parts).strip(), "\n\n".join(html_parts).strip() def _normalize_cloudflare_mail(item): normalized = dict(item or {}) raw_text = str(normalized.get("raw") or "") text_body, html_body = _extract_mail_bodies(raw_text) if raw_text: try: msg = email.message_from_string(raw_text, policy=policy.default) normalized["subject"] = str(msg.get("subject") or normalized.get("subject") or "").strip() normalized["from"] = str(msg.get("from") or normalized.get("source") or "").strip() except Exception: normalized["subject"] = str(normalized.get("subject") or "").strip() normalized["from"] = str(normalized.get("source") or "").strip() else: normalized["subject"] = str(normalized.get("subject") or "").strip() normalized["from"] = str(normalized.get("source") or "").strip() normalized["createdAt"] = str( normalized.get("createdAt") or normalized.get("created_at") or "" ).strip() normalized["text"] = text_body normalized["html"] = html_body normalized["intro"] = text_body[:200] if text_body else "" return normalized class DuckMailClient: def __init__(self, api_base, domain, bearer, proxy=None, timeout=120, backend_type="cloudflare_temp_email"): self.api_base = (api_base or "").rstrip("/") self.domain = (domain or "").strip() self.bearer = (bearer or "").strip() if self.bearer.lower().startswith("bearer "): self.bearer = self.bearer[7:].strip() self.proxy = proxy or "" self.timeout = timeout self.backend_type = (backend_type or "cloudflare_temp_email").strip().lower() self.session = self._create_session() def _create_session(self): session = requests.Session() retry_strategy = Retry( total=5, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["HEAD", "GET", "POST", "OPTIONS"], ) adapter = HTTPAdapter(max_retries=retry_strategy) session.mount("https://", adapter) session.mount("http://", adapter) session.headers.update( { "User-Agent": "DuckMailGUI/1.0", "Accept": "application/json", "Content-Type": "application/json", } ) if self.proxy: session.proxies = {"http": self.proxy, "https": self.proxy} return session def _is_cloudflare_temp_mail(self): return self.backend_type == "cloudflare_temp_email" def _api_bases(self): base = self.api_base if not base: return [] candidates = [base] if base.endswith("/api"): candidates.append(base[:-4]) else: candidates.append(f"{base}/api") unique = [] for item in candidates: if item and item not in unique: unique.append(item) return unique def _auth_headers(self): if not self.bearer: return {} return { "Authorization": f"Bearer {self.bearer}", "X-Api-Key": self.bearer, } def _admin_headers(self): if not self.bearer: return {} return {"x-admin-auth": self.bearer} def _request(self, method, path, *, headers=None, **kwargs): last_response = None for idx, api_base in enumerate(self._api_bases()): response = self.session.request( method, f"{api_base}{path}", headers=headers, timeout=self.timeout, **kwargs, ) last_response = response if response.status_code == 404 and idx < len(self._api_bases()) - 1: continue return response return last_response def create_email(self): if not self.bearer: raise ValueError("管理员密码未设置") if not self.domain: raise ValueError("邮箱域名未设置") if not self._api_bases(): raise ValueError("API Base 未设置") if self._is_cloudflare_temp_mail(): for _ in range(5): email_local_part = _generate_email_local_part() res = self._request( "POST", "/admin/new_address", headers=self._admin_headers(), json={ "name": email_local_part, "domain": self.domain, "enablePrefix": True, }, ) if res.status_code == 200: result = res.json() jwt = result.get("jwt") address = result.get("address") if jwt and address: return address, "", jwt raise RuntimeError(f"创建邮箱成功但未返回 JWT: {res.text[:200]}") if res.status_code == 400 and "exists" in res.text.lower(): continue if res.status_code in (401, 403): raise RuntimeError(f"管理员密码无效或权限不足: {res.text[:200]}") raise RuntimeError(f"创建邮箱失败: {res.status_code} - {res.text[:200]}") raise RuntimeError("创建邮箱失败: 超过最大重试次数") raise RuntimeError("当前版本只适配 cloudflare_temp_email 后端") def fetch_emails(self, mail_token): if not mail_token: return [] if not self._api_bases(): return [] if self._is_cloudflare_temp_mail(): res = self._request( "GET", "/api/mails", headers={"Authorization": f"Bearer {mail_token}"}, params={"limit": 20, "offset": 0}, ) if res.status_code == 200: data = res.json() items = data.get("results", []) if isinstance(data, dict) else data return [_normalize_cloudflare_mail(item) for item in (items or [])] if res.status_code == 401: raise RuntimeError("邮箱 JWT 已失效,请重新补全 Token") raise RuntimeError(f"获取邮件失败: {res.status_code} - {res.text[:200]}") raise RuntimeError("当前版本只适配 cloudflare_temp_email 后端") def get_token(self, email_address, mail_password): if not email_address: raise ValueError("邮箱为空") if not self._api_bases(): raise ValueError("API Base 未设置") if not self.bearer: raise ValueError("管理员密码未设置") if self._is_cloudflare_temp_mail(): res = self._request( "GET", "/admin/address", headers=self._admin_headers(), params={"query": email_address, "limit": 20, "offset": 0}, ) if res.status_code in (401, 403): raise RuntimeError(f"管理员密码无效或权限不足: {res.text[:200]}") if res.status_code != 200: raise RuntimeError(f"查询邮箱失败: {res.status_code} - {res.text[:200]}") data = res.json() if res.content else {} results = data.get("results", []) if isinstance(data, dict) else [] matched = None for item in results: if str(item.get("name") or "").strip().lower() == email_address.strip().lower(): matched = item break if not matched: raise RuntimeError( f"未找到对应邮箱: {email_address}。请确认这个地址已经由当前这套 cloudflare_temp_email 创建," "并且该域名已经加入当前 Worker 的 DOMAINS 配置。旧系统里的同名地址不会自动出现在新系统里。" ) address_id = matched.get("id") if not address_id: raise RuntimeError("已找到邮箱,但缺少 address_id,无法补全 JWT") show_password_res = self._request( "GET", f"/admin/show_password/{address_id}", headers=self._admin_headers(), ) if show_password_res.status_code != 200: raise RuntimeError( f"获取邮箱 JWT 失败: {show_password_res.status_code} - {show_password_res.text[:200]}" ) token_data = show_password_res.json() jwt = token_data.get("jwt") if not jwt: raise RuntimeError("管理员接口未返回邮箱 JWT") return jwt raise RuntimeError("当前版本只适配 cloudflare_temp_email 后端") def fetch_message_detail(self, mail_token, msg_id): if not mail_token or not msg_id: return None if not self._api_bases(): return None if self._is_cloudflare_temp_mail(): res = self._request( "GET", f"/api/mail/{msg_id}", headers={"Authorization": f"Bearer {mail_token}"}, ) if res.status_code == 200: return _normalize_cloudflare_mail(res.json()) return None raise RuntimeError("当前版本只适配 cloudflare_temp_email 后端")