Adapt GUI for cloudflare temp mail

This commit is contained in:
GitHub Actions
2026-03-21 09:25:24 +08:00
parent 77b71dcf2a
commit 8678b966d9
7 changed files with 828 additions and 270 deletions

View File

@@ -1,6 +1,7 @@
import email
import random
import string
import time
from email import policy
import requests
from requests.adapters import HTTPAdapter
@@ -14,32 +15,74 @@ def _generate_email_local_part():
return letters1 + numbers + letters2
def _generate_password(length=14):
lower = string.ascii_lowercase
upper = string.ascii_uppercase
digits = string.digits
special = "!@#$%&*"
pwd = [
random.choice(lower),
random.choice(upper),
random.choice(digits),
random.choice(special),
]
all_chars = lower + upper + digits + special
pwd += [random.choice(all_chars) for _ in range(length - 4)]
random.shuffle(pwd)
return "".join(pwd)
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):
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 ""
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):
@@ -64,15 +107,10 @@ class DuckMailClient:
session.proxies = {"http": self.proxy, "https": self.proxy}
return session
def _auth_headers(self):
if not self.bearer:
return {}
return {
"Authorization": f"Bearer {self.bearer}",
"X-Api-Key": self.bearer,
}
def _is_cloudflare_temp_mail(self):
return self.backend_type == "cloudflare_temp_email"
def _duckmail_api_bases(self):
def _api_bases(self):
base = self.api_base
if not base:
return []
@@ -87,200 +125,163 @@ class DuckMailClient:
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("DUCKMAIL_BEARER 未设置")
raise ValueError("管理员密码未设置")
if not self.domain:
raise ValueError("DUCKMAIL_DOMAIN 未设置")
api_bases = self._duckmail_api_bases()
if not api_bases:
raise ValueError("DUCKMAIL_API_BASE 未设置")
raise ValueError("邮箱域名未设置")
if not self._api_bases():
raise ValueError("API Base 未设置")
is_worker = "workers.dev" in self.api_base or "temp-email" in self.api_base
max_retries = 5
for attempt in range(max_retries):
email_local_part = _generate_email_local_part()
email = f"{email_local_part}@{self.domain}"
password = _generate_password()
if is_worker:
payload = {
"admin_password": self.bearer,
"name": email_local_part,
"domain": self.domain,
}
res = None
for idx, api_base in enumerate(api_bases):
res = self.session.post(
f"{api_base}/new_address", json=payload, timeout=self.timeout
)
if res.status_code == 404 and idx < len(api_bases) - 1:
continue
break
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()
if result.get("jwt"):
return result.get("address"), password, result.get("jwt")
raise RuntimeError(f"Worker获取邮件 Token 失败: {res.text}")
if res.status_code == 403:
raise RuntimeError(
f"Worker创建邮箱返回 403可能是 Bearer 无效或权限不足: {res.text[:200]}"
)
if res.status_code == 400 and "already exists" in res.text:
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
raise RuntimeError(
f"Worker创建邮箱失败: {res.status_code} - {res.text[:200]}"
)
if res.status_code in (401, 403):
raise RuntimeError(f"管理员密码无效或权限不足: {res.text[:200]}")
raise RuntimeError(f"创建邮箱失败: {res.status_code} - {res.text[:200]}")
raise RuntimeError("创建邮箱失败: 超过最大重试次数")
headers = {"Authorization": f"Bearer {self.bearer}"}
headers = self._auth_headers()
res = None
used_api_base = None
for idx, api_base in enumerate(api_bases):
used_api_base = api_base
res = self.session.post(
f"{api_base}/accounts",
json={"address": email, "password": password},
headers=headers,
timeout=self.timeout,
)
if res.status_code == 404 and idx < len(api_bases) - 1:
continue
break
if res.status_code in (200, 201):
result = res.json()
if result.get("address"):
time.sleep(0.5)
token_res = self.session.post(
f"{used_api_base}/token",
json={"address": email, "password": password},
headers=headers,
timeout=self.timeout,
)
if token_res.status_code == 200:
mail_token = token_res.json().get("token")
if mail_token:
return email, password, mail_token
if token_res.status_code == 403:
raise RuntimeError(
"获取邮件 Token 返回 403可能是 Bearer 无效或权限不足"
)
raise RuntimeError(f"获取邮件 Token 失败: {token_res.status_code}")
if res.status_code == 403:
raise RuntimeError(
f"创建邮箱返回 403可能是 Bearer 无效或权限不足: {res.text[:200]}"
)
if res.status_code == 422 and "already exists" in res.text:
continue
raise RuntimeError(
f"创建邮箱失败: {res.status_code} - {res.text[:200]}"
)
raise RuntimeError("DuckMail 创建邮箱失败: 超过最大重试次数")
raise RuntimeError("当前版本只适配 cloudflare_temp_email 后端")
def fetch_emails(self, mail_token):
if not mail_token:
return []
api_bases = self._duckmail_api_bases()
if not api_bases:
if not self._api_bases():
return []
is_worker = "workers.dev" in self.api_base or "temp-email" in self.api_base
if is_worker:
res = None
for idx, api_base in enumerate(api_bases):
res = self.session.get(
f"{api_base}/mails",
params={"limit": 20, "offset": 0},
headers={"Authorization": f"Bearer {mail_token}"},
timeout=self.timeout,
)
if res.status_code == 404 and idx < len(api_bases) - 1:
continue
break
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()
if isinstance(data, dict) and "results" in data:
return data.get("results", [])
if isinstance(data, list):
return data
return []
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]}")
res = None
for idx, api_base in enumerate(api_bases):
res = self.session.get(
f"{api_base}/messages",
params={"page": 1},
headers={"Authorization": f"Bearer {mail_token}"},
timeout=self.timeout,
)
if res.status_code == 404 and idx < len(api_bases) - 1:
continue
break
if res.status_code == 200:
data = res.json()
if isinstance(data, dict) and "hydra:member" in data:
return data.get("hydra:member", [])
if isinstance(data, list):
return data
return []
raise RuntimeError("当前版本只适配 cloudflare_temp_email 后端")
def get_token(self, email, mail_password):
if not email or not mail_password:
raise ValueError("邮箱或邮箱密码为空")
api_bases = self._duckmail_api_bases()
if not api_bases:
raise ValueError("DUCKMAIL_API_BASE 未设置")
headers = self._auth_headers()
if not headers:
raise ValueError("DUCKMAIL_BEARER 未设置")
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("管理员密码未设置")
res = None
used_api_base = None
for idx, api_base in enumerate(api_bases):
used_api_base = api_base
res = self.session.post(
f"{api_base}/token",
json={"address": email, "password": mail_password},
headers=headers,
timeout=self.timeout,
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 == 404 and idx < len(api_bases) - 1:
continue
break
if res.status_code == 200:
data = res.json()
token = data.get("token")
if token:
return token
if res.status_code == 403:
raise RuntimeError(
f"获取邮件 Token 返回 403可能是 API Key 无效或无权限: {res.text[:200]}"
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(),
)
raise RuntimeError(
f"获取邮件 Token 失败: {res.status_code} - {res.text[:200]}"
)
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 isinstance(msg_id, str) and msg_id.startswith("/messages/"):
msg_id = msg_id.split("/")[-1]
msg_id = str(msg_id).strip()
api_bases = self._duckmail_api_bases()
if not api_bases:
if not self._api_bases():
return None
res = None
for idx, api_base in enumerate(api_bases):
res = self.session.get(
f"{api_base}/messages/{msg_id}",
if self._is_cloudflare_temp_mail():
res = self._request(
"GET",
f"/api/mail/{msg_id}",
headers={"Authorization": f"Bearer {mail_token}"},
timeout=self.timeout,
)
if res.status_code == 404 and idx < len(api_bases) - 1:
continue
break
if res.status_code == 200:
return res.json()
return None
if res.status_code == 200:
return _normalize_cloudflare_mail(res.json())
return None
raise RuntimeError("当前版本只适配 cloudflare_temp_email 后端")