Files
duckmail_gui/duckmail_client.py
2026-03-21 09:25:24 +08:00

288 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 后端")