feat(mail): 添加收件箱功能,自动获得验证码

This commit is contained in:
cnlimiter
2026-03-20 12:54:39 +08:00
parent 87ff48cdaf
commit b1a8d02353
16 changed files with 186 additions and 44 deletions

View File

@@ -33,7 +33,7 @@ class EmailServiceType(str, Enum):
"""邮箱服务类型"""
TEMPMAIL = "tempmail"
OUTLOOK = "outlook"
CUSTOM_DOMAIN = "custom_domain"
MOE_MAIL = "moe_mail"
TEMP_MAIL = "temp_mail"
DUCK_MAIL = "duck_mail"
FREEMAIL = "freemail"
@@ -109,7 +109,7 @@ EMAIL_SERVICE_DEFAULTS = {
"smtp_port": 587,
"timeout": 30,
},
"custom_domain": {
"moe_mail": {
"base_url": "", # 需要用户配置
"api_key_header": "X-API-Key",
"timeout": 30,

View File

@@ -22,7 +22,7 @@ class SettingCategory(str, Enum):
REGISTRATION = "registration"
EMAIL = "email"
TEMPMAIL = "tempmail"
CUSTOM_DOMAIN = "custom_domain"
CUSTOM_DOMAIN = "moe_mail"
SECURITY = "security"
CPA = "cpa"
@@ -252,7 +252,7 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
# 邮箱服务配置
"email_service_priority": SettingDefinition(
db_key="email.service_priority",
default_value={"tempmail": 0, "outlook": 1, "custom_domain": 2},
default_value={"tempmail": 0, "outlook": 1, "moe_mail": 2},
category=SettingCategory.EMAIL,
description="邮箱服务优先级"
),
@@ -665,7 +665,7 @@ class Settings(BaseModel):
registration_sleep_max: int = 30
# 邮箱服务配置
email_service_priority: Dict[str, int] = {"tempmail": 0, "outlook": 1, "custom_domain": 2}
email_service_priority: Dict[str, int] = {"tempmail": 0, "outlook": 1, "moe_mail": 2}
# Tempmail.lol 配置
tempmail_base_url: str = "https://api.tempmail.lol/v2"

View File

@@ -42,7 +42,7 @@ class Account(Base):
client_id = Column(String(255)) # OAuth Client ID
account_id = Column(String(255))
workspace_id = Column(String(255))
email_service = Column(String(50), nullable=False) # 'tempmail', 'outlook', 'custom_domain'
email_service = Column(String(50), nullable=False) # 'tempmail', 'outlook', 'moe_mail'
email_service_id = Column(String(255)) # 邮箱服务中的ID
proxy_used = Column(String(255))
registered_at = Column(DateTime, default=datetime.utcnow)
@@ -89,7 +89,7 @@ class EmailService(Base):
__tablename__ = 'email_services'
id = Column(Integer, primary_key=True, autoincrement=True)
service_type = Column(String(50), nullable=False) # 'outlook', 'custom_domain'
service_type = Column(String(50), nullable=False) # 'outlook', 'moe_mail'
name = Column(String(100), nullable=False)
config = Column(JSONEncodedDict, nullable=False) # 服务配置(加密存储)
enabled = Column(Boolean, default=True)

View File

@@ -117,6 +117,14 @@ class DatabaseSessionManager:
Base.metadata.create_all(bind=self.engine)
with self.engine.connect() as conn:
# 数据迁移:将旧的 custom_domain 记录统一为 moe_mail
try:
conn.execute(text("UPDATE email_services SET service_type='moe_mail' WHERE service_type='custom_domain'"))
conn.execute(text("UPDATE accounts SET email_service='moe_mail' WHERE email_service='custom_domain'"))
conn.commit()
except Exception as e:
logger.warning(f"迁移 custom_domain -> moe_mail 时出错: {e}")
for table_name, column_name, column_type in migrations:
try:
# 检查列是否存在

View File

@@ -20,7 +20,7 @@ from .freemail import FreemailService
# 注册服务
EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService)
EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, MeoMailEmailService)
EmailServiceFactory.register(EmailServiceType.MOE_MAIL, MeoMailEmailService)
EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService)
EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService)
EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService)

View File

@@ -40,7 +40,7 @@ class MeoMailEmailService(BaseEmailService):
- default_expiry: 默认过期时间(毫秒)
name: 服务名称
"""
super().__init__(EmailServiceType.CUSTOM_DOMAIN, name)
super().__init__(EmailServiceType.MOE_MAIL, name)
# 必需配置检查
required_keys = ["base_url", "api_key"]

View File

@@ -950,3 +950,105 @@ async def upload_account_to_tm(account_id: int, request: Optional[UploadTMReques
success, message = upload_to_team_manager(account, api_url, api_key)
return {"success": success, "message": message}
# ============== Inbox Code ==============
def _build_inbox_config(db, service_type, email: str) -> dict:
"""根据账号邮箱服务类型从数据库构建服务配置(不传 proxy_url"""
from ...database.models import EmailService as EmailServiceModel
from ...services import EmailServiceType as EST
if service_type == EST.TEMPMAIL:
settings = get_settings()
return {
"base_url": settings.tempmail_base_url,
"timeout": settings.tempmail_timeout,
"max_retries": settings.tempmail_max_retries,
}
if service_type == EST.MOE_MAIL:
# 按域名后缀匹配,找不到则取 priority 最小的
domain = email.split("@")[1] if "@" in email else ""
services = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == "moe_mail",
EmailServiceModel.enabled == True
).order_by(EmailServiceModel.priority.asc()).all()
svc = None
for s in services:
cfg = s.config or {}
if cfg.get("default_domain") == domain or cfg.get("domain") == domain:
svc = s
break
if not svc and services:
svc = services[0]
if not svc:
return None
cfg = svc.config.copy()
if "api_url" in cfg and "base_url" not in cfg:
cfg["base_url"] = cfg.pop("api_url")
return cfg
# 其余服务类型:直接按 service_type 查数据库
type_map = {
EST.TEMP_MAIL: "temp_mail",
EST.DUCK_MAIL: "duck_mail",
EST.FREEMAIL: "freemail",
EST.OUTLOOK: "outlook",
}
db_type = type_map.get(service_type)
if not db_type:
return None
query = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == db_type,
EmailServiceModel.enabled == True
)
if service_type == EST.OUTLOOK:
# 按 config.email 匹配账号 email
services = query.all()
svc = next((s for s in services if (s.config or {}).get("email") == email), None)
else:
svc = query.order_by(EmailServiceModel.priority.asc()).first()
if not svc:
return None
cfg = svc.config.copy() if svc.config else {}
if "api_url" in cfg and "base_url" not in cfg:
cfg["base_url"] = cfg.pop("api_url")
return cfg
@router.post("/{account_id}/inbox-code")
async def get_account_inbox_code(account_id: int):
"""查询账号邮箱收件箱最新验证码"""
from ...services import EmailServiceFactory, EmailServiceType
with get_db() as db:
account = crud.get_account_by_id(db, account_id)
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
try:
service_type = EmailServiceType(account.email_service)
except ValueError:
return {"success": False, "error": "不支持的邮箱服务类型"}
config = _build_inbox_config(db, service_type, account.email)
if config is None:
return {"success": False, "error": "未找到可用的邮箱服务配置"}
try:
svc = EmailServiceFactory.create(service_type, config)
code = svc.get_verification_code(
account.email,
email_id=account.email_service_id,
timeout=12
)
except Exception as e:
return {"success": False, "error": str(e)}
if not code:
return {"success": False, "error": "未收到验证码邮件"}
return {"success": True, "code": code, "email": account.email}

View File

@@ -153,7 +153,7 @@ async def get_email_services_stats():
for service_type, count in type_stats:
if service_type == 'outlook':
stats['outlook_count'] = count
elif service_type == 'custom_domain':
elif service_type == 'moe_mail':
stats['custom_count'] = count
elif service_type == 'temp_mail':
stats['temp_mail_count'] = count
@@ -191,8 +191,8 @@ async def get_service_types():
]
},
{
"value": "custom_domain",
"label": "自定义域名",
"value": "moe_mail",
"label": "MoeMail",
"description": "自定义域名邮箱服务",
"config_fields": [
{"name": "base_url", "label": "API 地址", "required": True},

View File

@@ -205,7 +205,7 @@ def _normalize_email_service_config(
if 'api_url' in normalized and 'base_url' not in normalized:
normalized['base_url'] = normalized.pop('api_url')
if service_type == EmailServiceType.CUSTOM_DOMAIN:
if service_type == EmailServiceType.MOE_MAIL:
if 'domain' in normalized and 'default_domain' not in normalized:
normalized['default_domain'] = normalized.pop('domain')
elif service_type in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL):
@@ -291,11 +291,11 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
"max_retries": settings.tempmail_max_retries,
"proxy_url": actual_proxy_url,
}
elif service_type == EmailServiceType.CUSTOM_DOMAIN:
elif service_type == EmailServiceType.MOE_MAIL:
# 检查数据库中是否有可用的自定义域名服务
from ...database.models import EmailService as EmailServiceModel
db_service = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == "custom_domain",
EmailServiceModel.service_type == "moe_mail",
EmailServiceModel.enabled == True
).order_by(EmailServiceModel.priority.asc()).first()
@@ -796,7 +796,7 @@ async def start_registration(
"""
启动注册任务
- email_service_type: 邮箱服务类型 (tempmail, outlook, custom_domain)
- email_service_type: 邮箱服务类型 (tempmail, outlook, moe_mail)
- proxy: 代理地址
- email_service_config: 邮箱服务配置outlook 需要提供账户信息)
"""
@@ -1069,7 +1069,7 @@ async def get_available_email_services():
返回所有已启用的邮箱服务,包括:
- tempmail: 临时邮箱(无需配置)
- outlook: 已导入的 Outlook 账户
- custom_domain: 已配置的自定义域名服务
- moe_mail: 已配置的自定义域名服务
"""
from ...database.models import EmailService as EmailServiceModel
from ...config.settings import get_settings
@@ -1091,7 +1091,7 @@ async def get_available_email_services():
"count": 0,
"services": []
},
"custom_domain": {
"moe_mail": {
"available": False,
"count": 0,
"services": []
@@ -1135,32 +1135,32 @@ async def get_available_email_services():
# 获取自定义域名服务
custom_services = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == "custom_domain",
EmailServiceModel.service_type == "moe_mail",
EmailServiceModel.enabled == True
).order_by(EmailServiceModel.priority.asc()).all()
for service in custom_services:
config = service.config or {}
result["custom_domain"]["services"].append({
result["moe_mail"]["services"].append({
"id": service.id,
"name": service.name,
"type": "custom_domain",
"type": "moe_mail",
"default_domain": config.get("default_domain"),
"priority": service.priority
})
result["custom_domain"]["count"] = len(custom_services)
result["custom_domain"]["available"] = len(custom_services) > 0
result["moe_mail"]["count"] = len(custom_services)
result["moe_mail"]["available"] = len(custom_services) > 0
# 如果数据库中没有自定义域名服务,检查 settings
if not result["custom_domain"]["available"]:
if not result["moe_mail"]["available"]:
if settings.custom_domain_base_url and settings.custom_domain_api_key:
result["custom_domain"]["available"] = True
result["custom_domain"]["count"] = 1
result["custom_domain"]["services"].append({
result["moe_mail"]["available"] = True
result["moe_mail"]["count"] = 1
result["moe_mail"]["services"].append({
"id": None,
"name": "默认自定义域名服务",
"type": "custom_domain",
"type": "moe_mail",
"from_settings": True
})