Skip to content

Commit f458f21

Browse files
committed
Merge remote-tracking branch 'origin/master'
2 parents f3268ec + 5cd6aa8 commit f458f21

14 files changed

Lines changed: 801 additions & 9 deletions

src/config/constants.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class EmailServiceType(str, Enum):
3838
DUCK_MAIL = "duck_mail"
3939
FREEMAIL = "freemail"
4040
IMAP_MAIL = "imap_mail"
41+
CLOUD_MAIL = "cloud_mail"
4142

4243

4344
# ============================================================================
@@ -143,7 +144,15 @@ class EmailServiceType(str, Enum):
143144
"password": "",
144145
"timeout": 30,
145146
"max_retries": 3,
146-
}
147+
},
148+
"cloud_mail": {
149+
"base_url": "",
150+
"admin_email": "",
151+
"admin_password": "",
152+
"default_domain": "",
153+
"timeout": 30,
154+
"max_retries": 3,
155+
},
147156
}
148157

149158
# ============================================================================

src/services/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .duck_mail import DuckMailService
1818
from .freemail import FreemailService
1919
from .imap_mail import ImapMailService
20+
from .cloud_mail import CloudMailService
2021

2122
# 注册服务
2223
EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
@@ -26,6 +27,7 @@
2627
EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService)
2728
EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService)
2829
EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService)
30+
EmailServiceFactory.register(EmailServiceType.CLOUD_MAIL, CloudMailService)
2931

3032
# 导出 Outlook 模块的额外内容
3133
from .outlook.base import (
@@ -59,6 +61,7 @@
5961
'DuckMailService',
6062
'FreemailService',
6163
'ImapMailService',
64+
'CloudMailService',
6265
# Outlook 模块
6366
'ProviderType',
6467
'EmailMessage',

src/services/cloud_mail.py

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
"""
2+
Cloud Mail 邮箱服务实现
3+
基于 maillab/cloud-mail 的 public API
4+
"""
5+
6+
import logging
7+
import random
8+
import string
9+
import time
10+
from datetime import datetime, timezone
11+
from typing import Any, Dict, List, Optional
12+
13+
from .base import BaseEmailService, EmailServiceError, EmailServiceType, RateLimitedEmailServiceError
14+
from ..config.constants import OTP_CODE_PATTERN
15+
from ..core.http_client import HTTPClient, RequestConfig
16+
17+
18+
logger = logging.getLogger(__name__)
19+
20+
OTP_SENT_AT_TOLERANCE_SECONDS = 2
21+
22+
23+
class CloudMailService(BaseEmailService):
24+
"""Cloud Mail 邮箱服务"""
25+
26+
def __init__(self, config: Dict[str, Any] = None, name: str = None):
27+
super().__init__(EmailServiceType.CLOUD_MAIL, name)
28+
29+
required_keys = ["base_url", "admin_email", "admin_password", "default_domain"]
30+
missing_keys = [key for key in required_keys if not (config or {}).get(key)]
31+
if missing_keys:
32+
raise ValueError(f"缺少必需配置: {missing_keys}")
33+
34+
default_config = {
35+
"timeout": 30,
36+
"max_retries": 3,
37+
"password_length": 16,
38+
}
39+
self.config = {**default_config, **(config or {})}
40+
self.config["base_url"] = str(self.config["base_url"]).rstrip("/")
41+
self.config["default_domain"] = str(self.config["default_domain"]).strip().lstrip("@")
42+
43+
http_config = RequestConfig(
44+
timeout=self.config["timeout"],
45+
max_retries=self.config["max_retries"],
46+
)
47+
self.http_client = HTTPClient(proxy_url=None, config=http_config)
48+
49+
self._email_cache: Dict[str, Dict[str, Any]] = {}
50+
51+
def _build_headers(
52+
self,
53+
token: Optional[str] = None,
54+
extra_headers: Optional[Dict[str, str]] = None,
55+
) -> Dict[str, str]:
56+
headers = {
57+
"Accept": "application/json",
58+
"Content-Type": "application/json",
59+
}
60+
if token:
61+
headers["Authorization"] = token
62+
if extra_headers:
63+
headers.update(extra_headers)
64+
return headers
65+
66+
def _unwrap_result(self, payload: Any) -> Any:
67+
if not isinstance(payload, dict) or "code" not in payload:
68+
return payload
69+
70+
if payload.get("code") != 200:
71+
raise EmailServiceError(str(payload.get("message") or "Cloud Mail API 返回失败"))
72+
73+
return payload.get("data")
74+
75+
def _make_request(
76+
self,
77+
method: str,
78+
path: str,
79+
token: Optional[str] = None,
80+
**kwargs,
81+
) -> Any:
82+
url = f"{self.config['base_url']}/api{path}"
83+
kwargs["headers"] = self._build_headers(token=token, extra_headers=kwargs.get("headers"))
84+
85+
try:
86+
response = self.http_client.request(method, url, **kwargs)
87+
88+
if response.status_code >= 400:
89+
error_msg = f"请求失败: {response.status_code}"
90+
try:
91+
error_data = response.json()
92+
error_msg = f"{error_msg} - {error_data}"
93+
except Exception:
94+
error_msg = f"{error_msg} - {response.text[:200]}"
95+
retry_after = None
96+
if response.status_code == 429:
97+
retry_after_header = response.headers.get("Retry-After")
98+
if retry_after_header:
99+
try:
100+
retry_after = max(1, int(retry_after_header))
101+
except ValueError:
102+
retry_after = None
103+
error = RateLimitedEmailServiceError(error_msg, retry_after=retry_after)
104+
else:
105+
error = EmailServiceError(error_msg)
106+
self.update_status(False, error)
107+
raise error
108+
109+
try:
110+
payload = response.json()
111+
except Exception:
112+
payload = {"raw_response": response.text}
113+
114+
data = self._unwrap_result(payload)
115+
return data
116+
except Exception as e:
117+
self.update_status(False, e)
118+
if isinstance(e, EmailServiceError):
119+
raise
120+
raise EmailServiceError(f"请求失败: {method} {path} - {e}")
121+
122+
def _get_public_token(self) -> str:
123+
data = self._make_request(
124+
"POST",
125+
"/public/genToken",
126+
json={
127+
"email": self.config["admin_email"],
128+
"password": self.config["admin_password"],
129+
},
130+
)
131+
132+
if isinstance(data, dict):
133+
token = str(data.get("token") or "").strip()
134+
else:
135+
token = str(data or "").strip()
136+
137+
if not token:
138+
raise EmailServiceError("Cloud Mail 未返回 public token")
139+
140+
return token
141+
142+
def _generate_local_part(self) -> str:
143+
first = random.choice(string.ascii_lowercase)
144+
rest = "".join(random.choices(string.ascii_lowercase + string.digits, k=7))
145+
return f"{first}{rest}"
146+
147+
def _generate_password(self) -> str:
148+
length = max(8, int(self.config.get("password_length") or 16))
149+
alphabet = string.ascii_letters + string.digits
150+
return "".join(random.choices(alphabet, k=length))
151+
152+
def _parse_message_time(self, value: Any) -> Optional[float]:
153+
if value is None or value == "":
154+
return None
155+
156+
if isinstance(value, (int, float)):
157+
timestamp = float(value)
158+
else:
159+
text = str(value).strip()
160+
if not text:
161+
return None
162+
163+
try:
164+
timestamp = float(text)
165+
except ValueError:
166+
normalized = text.replace("Z", "+00:00")
167+
if "T" not in normalized and "+" not in normalized[10:] and normalized.count(":") >= 2:
168+
normalized = normalized.replace(" ", "T", 1) + "+00:00"
169+
try:
170+
parsed = datetime.fromisoformat(normalized)
171+
except ValueError:
172+
return None
173+
if parsed.tzinfo is None:
174+
parsed = parsed.replace(tzinfo=timezone.utc)
175+
timestamp = parsed.astimezone(timezone.utc).timestamp()
176+
177+
while timestamp > 1e11:
178+
timestamp /= 1000.0
179+
return timestamp if timestamp > 0 else None
180+
181+
def _get_received_timestamp(self, mail: Dict[str, Any]) -> Optional[float]:
182+
for field_name in ("createTime", "createdAt", "receivedAt", "timestamp", "time"):
183+
timestamp = self._parse_message_time(mail.get(field_name))
184+
if timestamp is not None:
185+
return timestamp
186+
return None
187+
188+
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
189+
request_config = config or {}
190+
local_part = str(request_config.get("name") or self._generate_local_part()).strip()
191+
domain = str(
192+
request_config.get("default_domain")
193+
or request_config.get("domain")
194+
or self.config["default_domain"]
195+
).strip().lstrip("@")
196+
address = f"{local_part}@{domain}"
197+
password = str(request_config.get("password") or self._generate_password())
198+
199+
token = self._get_public_token()
200+
self._make_request(
201+
"POST",
202+
"/public/addUser",
203+
token=token,
204+
json={
205+
"list": [{
206+
"email": address,
207+
"password": password,
208+
}]
209+
},
210+
)
211+
212+
email_info = {
213+
"email": address,
214+
"password": password,
215+
"service_id": address,
216+
"id": address,
217+
"created_at": time.time(),
218+
}
219+
self._email_cache[address.lower()] = email_info
220+
self.update_status(True)
221+
logger.info(f"成功创建 Cloud Mail 邮箱: {address}")
222+
return email_info
223+
224+
def get_verification_code(
225+
self,
226+
email: str,
227+
email_id: str = None,
228+
timeout: int = 120,
229+
pattern: str = OTP_CODE_PATTERN,
230+
otp_sent_at: Optional[float] = None,
231+
) -> Optional[str]:
232+
logger.info(f"正在从 Cloud Mail 邮箱 {email} 获取验证码...")
233+
234+
start_time = time.time()
235+
seen_mail_ids: set = set()
236+
237+
while time.time() - start_time < timeout:
238+
try:
239+
token = self._get_public_token()
240+
mails = self._make_request(
241+
"POST",
242+
"/public/emailList",
243+
token=token,
244+
json={
245+
"toEmail": email,
246+
"num": 1,
247+
"size": 20,
248+
},
249+
)
250+
251+
if isinstance(mails, dict) and isinstance(mails.get("list"), list):
252+
mails = mails["list"]
253+
254+
if not isinstance(mails, list):
255+
time.sleep(3)
256+
continue
257+
258+
for mail in mails:
259+
msg_timestamp = self._get_received_timestamp(mail)
260+
if otp_sent_at is not None:
261+
min_allowed_timestamp = otp_sent_at - OTP_SENT_AT_TOLERANCE_SECONDS
262+
if msg_timestamp is None or msg_timestamp <= min_allowed_timestamp:
263+
continue
264+
265+
mail_id = mail.get("emailId") or mail.get("id")
266+
if mail_id in seen_mail_ids:
267+
continue
268+
if mail_id is not None:
269+
seen_mail_ids.add(mail_id)
270+
271+
sender = str(mail.get("sendEmail") or mail.get("sender") or "")
272+
sender_name = str(mail.get("sendName") or mail.get("name") or "")
273+
subject = str(mail.get("subject") or "")
274+
text_body = str(mail.get("text") or "")
275+
content = str(mail.get("content") or "")
276+
search_text = "\n".join(
277+
part for part in [sender, sender_name, subject, text_body, content] if part
278+
).strip()
279+
280+
if "openai" not in search_text.lower():
281+
continue
282+
283+
code = self._extract_otp_from_text(search_text, pattern)
284+
if code:
285+
self.update_status(True)
286+
logger.info(f"从 Cloud Mail 邮箱 {email} 找到验证码: {code}")
287+
return code
288+
except Exception as e:
289+
logger.debug(f"检查 Cloud Mail 邮件时出错: {e}")
290+
291+
time.sleep(3)
292+
293+
logger.warning(f"等待 Cloud Mail 验证码超时: {email}")
294+
return None
295+
296+
def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
297+
return list(self._email_cache.values())
298+
299+
def delete_email(self, email_id: str) -> bool:
300+
self._email_cache.pop(str(email_id).strip().lower(), None)
301+
self.update_status(True)
302+
return True
303+
304+
def check_health(self) -> bool:
305+
try:
306+
self._get_public_token()
307+
self.update_status(True)
308+
return True
309+
except Exception as e:
310+
logger.warning(f"Cloud Mail 健康检查失败: {e}")
311+
self.update_status(False, e)
312+
return False

src/web/routes/accounts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1540,6 +1540,7 @@ def _build_inbox_config(db, service_type, email: str) -> dict:
15401540
EST.DUCK_MAIL: "duck_mail",
15411541
EST.FREEMAIL: "freemail",
15421542
EST.IMAP_MAIL: "imap_mail",
1543+
EST.CLOUD_MAIL: "cloud_mail",
15431544
EST.OUTLOOK: "outlook",
15441545
}
15451546
db_type = type_map.get(service_type)

0 commit comments

Comments
 (0)