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>