Adapt GUI for cloudflare temp mail
This commit is contained in:
@@ -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 后端")
|
||||
|
||||
Reference in New Issue
Block a user