Jimmy commited on
Commit ·
b1d5fc8
1
Parent(s): f34f49d
feat: add Inbucket mail provider integration and UI support
Browse files
services/register/mail_provider.py
CHANGED
|
@@ -446,6 +446,109 @@ class MoEmailProvider(BaseMailProvider):
|
|
| 446 |
self.session.close()
|
| 447 |
|
| 448 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
class YydsMailProvider(BaseMailProvider):
|
| 450 |
name = "yyds_mail"
|
| 451 |
|
|
@@ -544,6 +647,8 @@ def _create_provider(mail_config: dict, provider: str = "", provider_ref: str =
|
|
| 544 |
return GptMailProvider(entry, conf)
|
| 545 |
if entry["type"] == "moemail":
|
| 546 |
return MoEmailProvider(entry, conf)
|
|
|
|
|
|
|
| 547 |
if entry["type"] == "yyds_mail":
|
| 548 |
return YydsMailProvider(entry, conf)
|
| 549 |
raise RuntimeError(f"不支持的 mail.provider: {entry['type']}")
|
|
|
|
| 446 |
self.session.close()
|
| 447 |
|
| 448 |
|
| 449 |
+
class InbucketMailProvider(BaseMailProvider):
|
| 450 |
+
name = "inbucket"
|
| 451 |
+
|
| 452 |
+
def __init__(self, entry: dict, conf: dict):
|
| 453 |
+
super().__init__(conf, str(entry.get("provider_ref") or ""))
|
| 454 |
+
self.api_base = str(entry["api_base"]).rstrip("/")
|
| 455 |
+
raw_domains = entry.get("domain") or []
|
| 456 |
+
if isinstance(raw_domains, list):
|
| 457 |
+
self.domain = [str(item).strip() for item in raw_domains if str(item).strip()]
|
| 458 |
+
else:
|
| 459 |
+
self.domain = [str(raw_domains).strip()] if str(raw_domains).strip() else []
|
| 460 |
+
self.random_subdomain = bool(entry.get("random_subdomain", True))
|
| 461 |
+
self.session = requests.Session()
|
| 462 |
+
self.session.trust_env = False
|
| 463 |
+
self.session.headers.update({
|
| 464 |
+
"User-Agent": conf["user_agent"],
|
| 465 |
+
"Accept": "application/json",
|
| 466 |
+
})
|
| 467 |
+
|
| 468 |
+
def _request(self, method: str, path: str, expected: tuple[int, ...] = (200,)):
|
| 469 |
+
resp = self.session.request(
|
| 470 |
+
method.upper(),
|
| 471 |
+
f"{self.api_base}{path}",
|
| 472 |
+
timeout=self.conf["request_timeout"],
|
| 473 |
+
verify=False,
|
| 474 |
+
)
|
| 475 |
+
if resp.status_code not in expected:
|
| 476 |
+
raise RuntimeError(f"Inbucket 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
|
| 477 |
+
if resp.status_code == 204:
|
| 478 |
+
return {}
|
| 479 |
+
content_type = str(resp.headers.get("content-type") or "").lower()
|
| 480 |
+
if "application/json" in content_type:
|
| 481 |
+
return resp.json()
|
| 482 |
+
return resp.text
|
| 483 |
+
|
| 484 |
+
def _resolve_domain(self) -> str:
|
| 485 |
+
if self.domain:
|
| 486 |
+
return _next_domain(self.domain)
|
| 487 |
+
raise RuntimeError("Inbucket 需要至少配置一个 domain")
|
| 488 |
+
|
| 489 |
+
def _mailbox_name(self, address: str) -> str:
|
| 490 |
+
local_part, _, _ = str(address or "").partition("@")
|
| 491 |
+
return local_part.strip()
|
| 492 |
+
|
| 493 |
+
def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
|
| 494 |
+
local_part = username or _random_mailbox_name()
|
| 495 |
+
base_domain = self._resolve_domain()
|
| 496 |
+
domain = f"{_random_subdomain_label()}.{base_domain}" if self.random_subdomain else base_domain
|
| 497 |
+
address = f"{local_part}@{domain}"
|
| 498 |
+
mailbox_name = self._mailbox_name(address)
|
| 499 |
+
return {
|
| 500 |
+
"provider": self.name,
|
| 501 |
+
"provider_ref": self.provider_ref,
|
| 502 |
+
"address": address,
|
| 503 |
+
"base_domain": base_domain,
|
| 504 |
+
"mailbox_name": mailbox_name,
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
|
| 508 |
+
mailbox_name = str(mailbox.get("mailbox_name") or self._mailbox_name(str(mailbox.get("address") or ""))).strip()
|
| 509 |
+
if not mailbox_name:
|
| 510 |
+
raise RuntimeError("Inbucket 缺少 mailbox_name")
|
| 511 |
+
data = self._request("GET", f"/api/v1/mailbox/{mailbox_name}")
|
| 512 |
+
items = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
|
| 513 |
+
if not items:
|
| 514 |
+
return None
|
| 515 |
+
items.sort(
|
| 516 |
+
key=lambda value: (
|
| 517 |
+
(_parse_received_at(value.get("date")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(),
|
| 518 |
+
str(value.get("id") or ""),
|
| 519 |
+
),
|
| 520 |
+
reverse=True,
|
| 521 |
+
)
|
| 522 |
+
address = str(mailbox.get("address") or "").strip()
|
| 523 |
+
for item in items:
|
| 524 |
+
message_id = str(item.get("id") or "").strip()
|
| 525 |
+
if not message_id:
|
| 526 |
+
continue
|
| 527 |
+
detail = self._request("GET", f"/api/v1/mailbox/{mailbox_name}/{message_id}")
|
| 528 |
+
if not isinstance(detail, dict):
|
| 529 |
+
continue
|
| 530 |
+
header = detail.get("header") if isinstance(detail.get("header"), dict) else {}
|
| 531 |
+
body = detail.get("body") if isinstance(detail.get("body"), dict) else {}
|
| 532 |
+
normalized = {
|
| 533 |
+
"provider": self.name,
|
| 534 |
+
"mailbox": mailbox_name,
|
| 535 |
+
"message_id": message_id,
|
| 536 |
+
"subject": str(detail.get("subject") or item.get("subject") or ""),
|
| 537 |
+
"sender": str(detail.get("from") or item.get("from") or ""),
|
| 538 |
+
"text_content": str(body.get("text") or ""),
|
| 539 |
+
"html_content": str(body.get("html") or ""),
|
| 540 |
+
"received_at": _parse_received_at(detail.get("date") or item.get("date")),
|
| 541 |
+
"to": header.get("To") if isinstance(header, dict) else None,
|
| 542 |
+
"raw": detail,
|
| 543 |
+
}
|
| 544 |
+
if _message_matches_email(normalized, address):
|
| 545 |
+
return normalized
|
| 546 |
+
return None
|
| 547 |
+
|
| 548 |
+
def close(self) -> None:
|
| 549 |
+
self.session.close()
|
| 550 |
+
|
| 551 |
+
|
| 552 |
class YydsMailProvider(BaseMailProvider):
|
| 553 |
name = "yyds_mail"
|
| 554 |
|
|
|
|
| 647 |
return GptMailProvider(entry, conf)
|
| 648 |
if entry["type"] == "moemail":
|
| 649 |
return MoEmailProvider(entry, conf)
|
| 650 |
+
if entry["type"] == "inbucket":
|
| 651 |
+
return InbucketMailProvider(entry, conf)
|
| 652 |
if entry["type"] == "yyds_mail":
|
| 653 |
return YydsMailProvider(entry, conf)
|
| 654 |
raise RuntimeError(f"不支持的 mail.provider: {entry['type']}")
|
web/src/app/register/components/register-card.tsx
CHANGED
|
@@ -50,6 +50,7 @@ export function RegisterCard() {
|
|
| 50 |
...(type === "cloudflare_temp_email" ? { api_base: "", admin_password: "", domain: [] } : {}),
|
| 51 |
...(type === "tempmail_lol" ? { api_key: "", domain: [] } : {}),
|
| 52 |
...(type === "moemail" ? { api_base: "", api_key: "", domain: [] } : {}),
|
|
|
|
| 53 |
...(type === "duckmail" ? { api_key: "", default_domain: "duckmail.sbs" } : {}),
|
| 54 |
...(type === "gptmail" ? { api_key: "", default_domain: "" } : {}),
|
| 55 |
...(type === "yyds_mail" ? { api_base: "https://maliapi.215.im/v1", api_key: "", domain: [], subdomain: "", wildcard: false } : {}),
|
|
@@ -168,13 +169,14 @@ export function RegisterCard() {
|
|
| 168 |
<SelectItem value="cloudflare_temp_email">cloudflare_temp_email</SelectItem>
|
| 169 |
<SelectItem value="tempmail_lol">tempmail_lol</SelectItem>
|
| 170 |
<SelectItem value="moemail">moemail</SelectItem>
|
|
|
|
| 171 |
<SelectItem value="duckmail">duckmail</SelectItem>
|
| 172 |
<SelectItem value="gptmail">gptmail(未测试)</SelectItem>
|
| 173 |
<SelectItem value="yyds_mail">yyds_mail</SelectItem>
|
| 174 |
</SelectContent>
|
| 175 |
</Select>
|
| 176 |
</div>
|
| 177 |
-
{type === "cloudflare_temp_email" || type === "moemail" || type === "yyds_mail" ? (
|
| 178 |
<>
|
| 179 |
<div className="space-y-2">
|
| 180 |
<label className="text-sm text-stone-700">API Base</label>
|
|
@@ -188,6 +190,12 @@ export function RegisterCard() {
|
|
| 188 |
) : null}
|
| 189 |
</>
|
| 190 |
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
{type === "tempmail_lol" || type === "moemail" || type === "duckmail" || type === "gptmail" || type === "yyds_mail" ? (
|
| 192 |
<div className="space-y-2">
|
| 193 |
<label className="text-sm text-stone-700">API Key</label>
|
|
@@ -214,10 +222,10 @@ export function RegisterCard() {
|
|
| 214 |
) : null}
|
| 215 |
</div>
|
| 216 |
|
| 217 |
-
{type === "tempmail_lol" || type === "cloudflare_temp_email" || type === "moemail" || type === "yyds_mail" ? (
|
| 218 |
<div className="space-y-2">
|
| 219 |
-
<label className="text-sm text-stone-700">Domain</label>
|
| 220 |
-
<Textarea value={domains} onChange={(event) => updateProvider(index, { domain: event.target.value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean) })} placeholder={type === "moemail" ? "每行一个域名" : "每行一个域名,留空则使用服务默认域名"} className="min-h-20 rounded-xl border-stone-200 bg-white font-mono text-xs" disabled={config.enabled} />
|
| 221 |
</div>
|
| 222 |
) : null}
|
| 223 |
</div>
|
|
|
|
| 50 |
...(type === "cloudflare_temp_email" ? { api_base: "", admin_password: "", domain: [] } : {}),
|
| 51 |
...(type === "tempmail_lol" ? { api_key: "", domain: [] } : {}),
|
| 52 |
...(type === "moemail" ? { api_base: "", api_key: "", domain: [] } : {}),
|
| 53 |
+
...(type === "inbucket" ? { api_base: "", domain: [], random_subdomain: true } : {}),
|
| 54 |
...(type === "duckmail" ? { api_key: "", default_domain: "duckmail.sbs" } : {}),
|
| 55 |
...(type === "gptmail" ? { api_key: "", default_domain: "" } : {}),
|
| 56 |
...(type === "yyds_mail" ? { api_base: "https://maliapi.215.im/v1", api_key: "", domain: [], subdomain: "", wildcard: false } : {}),
|
|
|
|
| 169 |
<SelectItem value="cloudflare_temp_email">cloudflare_temp_email</SelectItem>
|
| 170 |
<SelectItem value="tempmail_lol">tempmail_lol</SelectItem>
|
| 171 |
<SelectItem value="moemail">moemail</SelectItem>
|
| 172 |
+
<SelectItem value="inbucket">inbucket</SelectItem>
|
| 173 |
<SelectItem value="duckmail">duckmail</SelectItem>
|
| 174 |
<SelectItem value="gptmail">gptmail(未测试)</SelectItem>
|
| 175 |
<SelectItem value="yyds_mail">yyds_mail</SelectItem>
|
| 176 |
</SelectContent>
|
| 177 |
</Select>
|
| 178 |
</div>
|
| 179 |
+
{type === "cloudflare_temp_email" || type === "moemail" || type === "inbucket" || type === "yyds_mail" ? (
|
| 180 |
<>
|
| 181 |
<div className="space-y-2">
|
| 182 |
<label className="text-sm text-stone-700">API Base</label>
|
|
|
|
| 190 |
) : null}
|
| 191 |
</>
|
| 192 |
) : null}
|
| 193 |
+
{type === "inbucket" ? (
|
| 194 |
+
<label className="flex items-center gap-3 pt-8 text-sm text-stone-700">
|
| 195 |
+
<Checkbox checked={Boolean(provider.random_subdomain ?? true)} onCheckedChange={(checked) => updateProvider(index, { random_subdomain: Boolean(checked) })} disabled={config.enabled} />
|
| 196 |
+
启用随机子域名
|
| 197 |
+
</label>
|
| 198 |
+
) : null}
|
| 199 |
{type === "tempmail_lol" || type === "moemail" || type === "duckmail" || type === "gptmail" || type === "yyds_mail" ? (
|
| 200 |
<div className="space-y-2">
|
| 201 |
<label className="text-sm text-stone-700">API Key</label>
|
|
|
|
| 222 |
) : null}
|
| 223 |
</div>
|
| 224 |
|
| 225 |
+
{type === "tempmail_lol" || type === "cloudflare_temp_email" || type === "moemail" || type === "inbucket" || type === "yyds_mail" ? (
|
| 226 |
<div className="space-y-2">
|
| 227 |
+
<label className="text-sm text-stone-700">{type === "inbucket" ? "基础域名列表" : "Domain"}</label>
|
| 228 |
+
<Textarea value={domains} onChange={(event) => updateProvider(index, { domain: event.target.value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean) })} placeholder={type === "inbucket" ? "每行一个基础域名,系统会自动生成随机子域名" : type === "moemail" ? "每行一个域名" : "每行一个域名,留空则使用服务默认域名"} className="min-h-20 rounded-xl border-stone-200 bg-white font-mono text-xs" disabled={config.enabled} />
|
| 229 |
</div>
|
| 230 |
) : null}
|
| 231 |
</div>
|