diff --git a/api/routers.py b/api/routers.py
index c3c5326..19dc39e 100644
--- a/api/routers.py
+++ b/api/routers.py
@@ -1,6 +1,6 @@
from fastapi import FastAPI
-from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers
+from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers, email
from .routes import webdav
from .routes import plugins
@@ -22,3 +22,4 @@ def include_routers(app: FastAPI):
app.include_router(plugins.router)
app.include_router(webdav.router)
app.include_router(offline_downloads.router)
+ app.include_router(email.router)
diff --git a/api/routes/auth.py b/api/routes/auth.py
index d18fcef..6d0683a 100644
--- a/api/routes/auth.py
+++ b/api/routes/auth.py
@@ -10,6 +10,9 @@ from services.auth import (
Token,
get_current_active_user,
User,
+ request_password_reset,
+ verify_password_reset_token,
+ reset_password_with_token,
)
from pydantic import BaseModel
from datetime import timedelta
@@ -83,6 +86,15 @@ class UpdateMeRequest(BaseModel):
new_password: str | None = None
+class PasswordResetRequest(BaseModel):
+ email: str
+
+
+class PasswordResetConfirm(BaseModel):
+ token: str
+ password: str
+
+
@router.put("/me", summary="更新当前登录用户信息")
async def update_me(
payload: UpdateMeRequest,
@@ -120,3 +132,24 @@ async def update_me(
"full_name": db_user.full_name,
"gravatar_url": gravatar_url,
})
+
+
+@router.post("/password-reset/request", summary="请求密码重置邮件")
+async def password_reset_request_endpoint(payload: PasswordResetRequest):
+ await request_password_reset(payload.email)
+ return success(msg="如果邮箱存在,将发送重置邮件")
+
+
+@router.get("/password-reset/verify", summary="校验密码重置令牌")
+async def password_reset_verify(token: str):
+ user = await verify_password_reset_token(token)
+ return success({
+ "username": user.username,
+ "email": user.email,
+ })
+
+
+@router.post("/password-reset/confirm", summary="使用令牌重置密码")
+async def password_reset_confirm(payload: PasswordResetConfirm):
+ await reset_password_with_token(payload.token, payload.password)
+ return success(msg="密码已重置")
diff --git a/api/routes/email.py b/api/routes/email.py
new file mode 100644
index 0000000..8f09e66
--- /dev/null
+++ b/api/routes/email.py
@@ -0,0 +1,92 @@
+from fastapi import APIRouter, Depends, HTTPException
+
+from services.auth import User, get_current_active_user
+from services.email import EmailService, EmailTemplateRenderer
+from schemas.email import EmailTestRequest, EmailTemplateUpdate, EmailTemplatePreviewPayload
+from api.response import success
+from services.logging import LogService
+
+
+router = APIRouter(
+ prefix="/api/email",
+ tags=["email"],
+)
+
+
+@router.post("/test")
+async def trigger_test_email(
+ payload: EmailTestRequest,
+ current_user: User = Depends(get_current_active_user),
+):
+ try:
+ task = await EmailService.enqueue_email(
+ recipients=[str(payload.to)],
+ subject=payload.subject,
+ template=payload.template,
+ context=payload.context,
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ await LogService.action(
+ "route:email",
+ "Triggered email test",
+ details={"task_id": task.id, "template": payload.template, "to": str(payload.to)},
+ user_id=getattr(current_user, "id", None),
+ )
+ return success({"task_id": task.id})
+
+
+@router.get("/templates")
+async def list_email_templates(
+ current_user: User = Depends(get_current_active_user),
+):
+ templates = await EmailTemplateRenderer.list_templates()
+ return success({"templates": templates})
+
+
+@router.get("/templates/{name}")
+async def get_email_template(
+ name: str,
+ current_user: User = Depends(get_current_active_user),
+):
+ try:
+ content = await EmailTemplateRenderer.load(name)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ except FileNotFoundError:
+ raise HTTPException(status_code=404, detail="模板不存在")
+ return success({"name": name, "content": content})
+
+
+@router.post("/templates/{name}")
+async def update_email_template(
+ name: str,
+ payload: EmailTemplateUpdate,
+ current_user: User = Depends(get_current_active_user),
+):
+ try:
+ await EmailTemplateRenderer.save(name, payload.content)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ await LogService.action(
+ "route:email",
+ "Updated email template",
+ details={"template": name},
+ user_id=getattr(current_user, "id", None),
+ )
+ return success({"name": name})
+
+
+@router.post("/templates/{name}/preview")
+async def preview_email_template(
+ name: str,
+ payload: EmailTemplatePreviewPayload,
+ current_user: User = Depends(get_current_active_user),
+):
+ try:
+ html = await EmailTemplateRenderer.render(name, payload.context)
+ except FileNotFoundError:
+ raise HTTPException(status_code=404, detail="模板不存在")
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ return success({"html": html})
diff --git a/pyproject.toml b/pyproject.toml
index b0c793a..b7d9a99 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,4 +22,5 @@ dependencies = [
"uvicorn>=0.37.0",
"pymilvus[milvus-lite]>=2.6.2",
"paramiko>=4.0.0",
+ "pydantic[email]>=2.11.7",
]
diff --git a/schemas/email.py b/schemas/email.py
new file mode 100644
index 0000000..55a603b
--- /dev/null
+++ b/schemas/email.py
@@ -0,0 +1,18 @@
+from typing import Any, Dict
+
+from pydantic import BaseModel, EmailStr, Field
+
+
+class EmailTestRequest(BaseModel):
+ to: EmailStr
+ subject: str = Field(..., min_length=1)
+ template: str = Field(default="test", min_length=1)
+ context: Dict[str, Any] = Field(default_factory=dict)
+
+
+class EmailTemplateUpdate(BaseModel):
+ content: str
+
+
+class EmailTemplatePreviewPayload(BaseModel):
+ context: Dict[str, Any] = Field(default_factory=dict)
diff --git a/services/auth.py b/services/auth.py
index 6ba3d94..e08c92d 100644
--- a/services/auth.py
+++ b/services/auth.py
@@ -1,5 +1,8 @@
+import asyncio
+from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Annotated
+import secrets
import jwt
from fastapi import Depends, HTTPException, status
@@ -10,9 +13,78 @@ from pydantic import BaseModel
from models.database import UserAccount
from services.config import ConfigCenter
+from services.logging import LogService
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 365
+PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 10
+
+
+def _now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+@dataclass
+class PasswordResetEntry:
+ user_id: int
+ email: str
+ username: str
+ expires_at: datetime
+ used: bool = False
+
+
+class PasswordResetStore:
+ _tokens: dict[str, PasswordResetEntry] = {}
+ _lock = asyncio.Lock()
+
+ @classmethod
+ def _cleanup(cls):
+ now = _now()
+ for token, record in list(cls._tokens.items()):
+ if record.used or record.expires_at < now:
+ cls._tokens.pop(token, None)
+
+ @classmethod
+ async def create(cls, user: UserAccount) -> str:
+ async with cls._lock:
+ cls._cleanup()
+ for key, record in list(cls._tokens.items()):
+ if record.user_id == user.id:
+ cls._tokens.pop(key, None)
+ token = secrets.token_urlsafe(32)
+ expires_at = _now() + timedelta(minutes=PASSWORD_RESET_TOKEN_EXPIRE_MINUTES)
+ cls._tokens[token] = PasswordResetEntry(
+ user_id=user.id,
+ email=user.email or "",
+ username=user.username,
+ expires_at=expires_at,
+ )
+ return token
+
+ @classmethod
+ async def get(cls, token: str) -> PasswordResetEntry | None:
+ async with cls._lock:
+ cls._cleanup()
+ record = cls._tokens.get(token)
+ if not record or record.used:
+ return None
+ return record
+
+ @classmethod
+ async def mark_used(cls, token: str) -> None:
+ async with cls._lock:
+ record = cls._tokens.get(token)
+ if record:
+ record.used = True
+ cls._cleanup()
+
+ @classmethod
+ async def invalidate_user(cls, user_id: int, except_token: str | None = None) -> None:
+ async with cls._lock:
+ for key, record in list(cls._tokens.items()):
+ if record.user_id == user_id and key != except_token:
+ cls._tokens.pop(key, None)
+ cls._cleanup()
async def get_secret_key():
@@ -132,6 +204,94 @@ async def create_access_token(data: dict, expires_delta: timedelta | None = None
return encoded_jwt
+def _normalize_email(email: str | None) -> str:
+ return (email or "").strip().lower()
+
+
+async def _send_password_reset_email(user: UserAccount, token: str) -> None:
+ from services.email import EmailService
+
+ app_domain = await ConfigCenter.get("APP_DOMAIN", None)
+ base_url = (app_domain or "http://localhost:5173").rstrip("/")
+ reset_link = f"{base_url}/reset-password?token={token}"
+ await EmailService.enqueue_email(
+ recipients=[user.email],
+ subject="Foxel 密码重置",
+ template="password_reset",
+ context={
+ "username": user.username,
+ "reset_link": reset_link,
+ "expire_minutes": PASSWORD_RESET_TOKEN_EXPIRE_MINUTES,
+ },
+ )
+
+
+async def request_password_reset(email: str) -> bool:
+ normalized = _normalize_email(email)
+ if not normalized:
+ return False
+ user = await UserAccount.get_or_none(email=normalized)
+ if not user or not user.email:
+ return False
+
+ token = await PasswordResetStore.create(user)
+ try:
+ await _send_password_reset_email(user, token)
+ except Exception as exc: # noqa: BLE001
+ await PasswordResetStore.mark_used(token)
+ await PasswordResetStore.invalidate_user(user.id)
+ await LogService.error(
+ "auth",
+ f"Failed to enqueue password reset email: {exc}",
+ details={"user_id": user.id},
+ user_id=user.id,
+ )
+ raise HTTPException(status_code=500, detail="邮件发送失败") from exc
+ await LogService.action(
+ "auth",
+ "Password reset requested",
+ details={"user_id": user.id},
+ user_id=user.id,
+ )
+ return True
+
+
+async def verify_password_reset_token(token: str) -> UserAccount:
+ record = await PasswordResetStore.get(token)
+ if not record:
+ raise HTTPException(status_code=400, detail="重置链接无效")
+ user = await UserAccount.get_or_none(id=record.user_id)
+ if not user:
+ raise HTTPException(status_code=400, detail="重置链接无效")
+ if record.expires_at < _now():
+ await PasswordResetStore.mark_used(token)
+ raise HTTPException(status_code=400, detail="重置链接已过期")
+ return user
+
+
+async def reset_password_with_token(token: str, new_password: str) -> None:
+ record = await PasswordResetStore.get(token)
+ if not record:
+ raise HTTPException(status_code=400, detail="重置链接无效")
+ if record.expires_at < _now():
+ await PasswordResetStore.mark_used(token)
+ raise HTTPException(status_code=400, detail="重置链接已过期")
+
+ user = await UserAccount.get_or_none(id=record.user_id)
+ if not user:
+ raise HTTPException(status_code=400, detail="重置链接无效")
+ user.hashed_password = get_password_hash(new_password)
+ await user.save(update_fields=["hashed_password"])
+ await PasswordResetStore.mark_used(token)
+ await PasswordResetStore.invalidate_user(user.id)
+ await LogService.action(
+ "auth",
+ "Password reset via email",
+ details={"user_id": user.id},
+ user_id=user.id,
+ )
+
+
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
diff --git a/services/email.py b/services/email.py
new file mode 100644
index 0000000..fdc7b5f
--- /dev/null
+++ b/services/email.py
@@ -0,0 +1,201 @@
+import asyncio
+import json
+import re
+import smtplib
+from email.message import EmailMessage
+from email.utils import formataddr
+from enum import Enum
+from pathlib import Path
+from string import Template
+from typing import Any, Dict, List, Optional
+
+from pydantic import BaseModel, EmailStr, Field, ValidationError
+
+from services.config import ConfigCenter
+from services.logging import LogService
+
+
+class EmailSecurity(str, Enum):
+ NONE = "none"
+ SSL = "ssl"
+ STARTTLS = "starttls"
+
+
+class EmailConfig(BaseModel):
+ host: str
+ port: int = Field(..., gt=0)
+ username: Optional[str] = None
+ password: Optional[str] = None
+ sender_email: EmailStr
+ sender_name: Optional[str] = None
+ security: EmailSecurity = EmailSecurity.NONE
+ timeout: float = Field(default=30.0, gt=0.0)
+
+
+class EmailSendPayload(BaseModel):
+ recipients: List[EmailStr] = Field(..., min_length=1)
+ subject: str = Field(..., min_length=1)
+ template: str = Field(..., min_length=1)
+ context: Dict[str, Any] = Field(default_factory=dict)
+
+
+class EmailTemplateRenderer:
+ ROOT = Path("templates/email")
+
+ @classmethod
+ def _resolve_path(cls, template_name: str) -> Path:
+ if not re.fullmatch(r"[A-Za-z0-9_\-]+", template_name):
+ raise ValueError("Invalid template name")
+ return cls.ROOT / f"{template_name}.html"
+
+ @classmethod
+ async def list_templates(cls) -> list[str]:
+ cls.ROOT.mkdir(parents=True, exist_ok=True)
+ return sorted(
+ path.stem
+ for path in cls.ROOT.glob("*.html")
+ if path.is_file()
+ )
+
+ @classmethod
+ async def load(cls, template_name: str) -> str:
+ path = cls._resolve_path(template_name)
+ if not path.is_file():
+ raise FileNotFoundError(f"Email template '{template_name}' not found")
+ return await asyncio.to_thread(path.read_text, encoding="utf-8")
+
+ @classmethod
+ async def save(cls, template_name: str, content: str) -> None:
+ path = cls._resolve_path(template_name)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ await asyncio.to_thread(path.write_text, content, encoding="utf-8")
+
+ @classmethod
+ async def render(cls, template_name: str, context: Dict[str, Any]) -> str:
+ raw = await cls.load(template_name)
+ context = {k: str(v) for k, v in (context or {}).items()}
+ return Template(raw).safe_substitute(context)
+
+
+class EmailService:
+ CONFIG_KEY = "EMAIL_CONFIG"
+
+ @classmethod
+ async def _load_config(cls) -> EmailConfig:
+ raw_config = await ConfigCenter.get(cls.CONFIG_KEY)
+ if raw_config is None:
+ raise ValueError("Email configuration not found")
+
+ if isinstance(raw_config, str):
+ raw_config = raw_config.strip()
+ data: Any = json.loads(raw_config) if raw_config else {}
+ elif isinstance(raw_config, dict):
+ data = raw_config
+ else:
+ raise ValueError("Invalid email configuration format")
+
+ try:
+ return EmailConfig(**data)
+ except ValidationError as exc:
+ raise ValueError(f"Invalid email configuration: {exc}") from exc
+
+ @staticmethod
+ def _html_to_text(html: str) -> str:
+ stripped = re.sub(r"<[^>]+>", " ", html)
+ return " ".join(stripped.split())
+
+ @classmethod
+ async def _deliver(cls, config: EmailConfig, payload: EmailSendPayload, html_body: str):
+ message = EmailMessage()
+ message["Subject"] = payload.subject
+ message["From"] = formataddr((config.sender_name or str(config.sender_email), str(config.sender_email)))
+ message["To"] = ", ".join([str(addr) for addr in payload.recipients])
+
+ plain_body = cls._html_to_text(html_body)
+ message.set_content(plain_body or html_body)
+ message.add_alternative(html_body, subtype="html")
+
+ await asyncio.to_thread(cls._deliver_sync, config, message)
+
+ @staticmethod
+ def _deliver_sync(config: EmailConfig, message: EmailMessage):
+ if config.security == EmailSecurity.SSL:
+ smtp: smtplib.SMTP = smtplib.SMTP_SSL(config.host, config.port, timeout=config.timeout)
+ else:
+ smtp = smtplib.SMTP(config.host, config.port, timeout=config.timeout)
+
+ try:
+ if config.security == EmailSecurity.STARTTLS:
+ smtp.starttls()
+ if config.username and config.password:
+ smtp.login(config.username, config.password)
+ smtp.send_message(message)
+ finally:
+ try:
+ smtp.quit()
+ except Exception:
+ pass
+
+ @classmethod
+ async def enqueue_email(
+ cls,
+ recipients: List[str],
+ subject: str,
+ template: str,
+ context: Optional[Dict[str, Any]] = None,
+ ):
+ from services.task_queue import TaskProgress, task_queue_service
+
+ payload = EmailSendPayload(
+ recipients=recipients,
+ subject=subject,
+ template=template,
+ context=context or {},
+ )
+
+ task = await task_queue_service.add_task(
+ "send_email",
+ payload.model_dump(mode="json"),
+ )
+
+ await task_queue_service.update_progress(
+ task.id,
+ TaskProgress(stage="queued", percent=0.0, detail="Waiting to send"),
+ )
+ await LogService.action(
+ "email_service",
+ "Email task enqueued",
+ details={"task_id": task.id, "subject": subject, "template": template},
+ )
+ return task
+
+ @classmethod
+ async def send_from_task(cls, task_id: str, data: Dict[str, Any]):
+ from services.task_queue import TaskProgress, task_queue_service
+
+ payload = EmailSendPayload(**data)
+
+ await task_queue_service.update_progress(
+ task_id,
+ TaskProgress(stage="preparing", percent=10.0, detail="Rendering template"),
+ )
+
+ config = await cls._load_config()
+ html_body = await EmailTemplateRenderer.render(payload.template, payload.context)
+
+ await task_queue_service.update_progress(
+ task_id,
+ TaskProgress(stage="sending", percent=60.0, detail="Sending message"),
+ )
+
+ await cls._deliver(config, payload, html_body)
+
+ await task_queue_service.update_progress(
+ task_id,
+ TaskProgress(stage="completed", percent=100.0, detail="Email sent"),
+ )
+ await LogService.info(
+ "email_service",
+ "Email sent",
+ details={"task_id": task_id, "subject": payload.subject},
+ )
diff --git a/services/task_queue.py b/services/task_queue.py
index b86d51d..2f7db40 100644
--- a/services/task_queue.py
+++ b/services/task_queue.py
@@ -130,6 +130,10 @@ class TaskQueueService:
result = await run_cross_mount_transfer_task(task)
task.result = result
+ elif task.name == "send_email":
+ from services.email import EmailService
+ await EmailService.send_from_task(task.id, task.task_info)
+ task.result = "Email sent"
else:
raise ValueError(f"Unknown task name: {task.name}")
diff --git a/templates/email/password_reset.html b/templates/email/password_reset.html
new file mode 100644
index 0000000..5b33d90
--- /dev/null
+++ b/templates/email/password_reset.html
@@ -0,0 +1,102 @@
+
+
+
+
+ Foxel 密码重置
+
+
+
+
+
+
+
+
你好,${username}。
+
我们收到了重置你 Foxel 帐号密码的请求。请点击下方按钮完成密码重置操作:
+
+
如果按钮无法点击,你也可以复制下面的链接到浏览器打开:
+
+
该链接在 ${expire_minutes} 分钟内有效。若你未发起此请求,请忽略本邮件,你的密码不会发生变化。
+
+
+
+
+
+
diff --git a/templates/email/test.html b/templates/email/test.html
new file mode 100644
index 0000000..4d6c5d8
--- /dev/null
+++ b/templates/email/test.html
@@ -0,0 +1,97 @@
+
+
+
+
+ Foxel 邮件配置测试
+
+
+
+
+
+
+
Foxel 邮件服务已连通
+
+
+
Mail Delivery Test
+
你好,${username}!
+
+ 这是一封来自 Foxel 的测试邮件。如果你能够正常阅读到这段内容,说明系统已经成功与配置的邮箱服务建立连接。
+
+
+
接下来可以做什么?
+
+ - 继续完善系统通知、密码重置等业务功能
+ - 在后台页面中自定义更精美的邮件模板
+ - 保持发送凭据安全,避免泄露
+
+
+
+
+
+
+
+
diff --git a/uv.lock b/uv.lock
index 72c3312..04cfbd0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -373,6 +373,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/7c/15ad426257615f9be8caf7f97990cf3dcbb5b8dd7ed7e0db581a1c4759dd/cryptography-46.0.2-cp38-abi3-win_arm64.whl", hash = "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", size = 2918153, upload-time = "2025-10-01T00:28:51.003Z" },
]
+[[package]]
+name = "dnspython"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
+]
+
+[[package]]
+name = "email-validator"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dnspython" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
+]
+
[[package]]
name = "fastapi"
version = "0.116.1"
@@ -399,6 +421,7 @@ dependencies = [
{ name = "paramiko" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pillow" },
+ { name = "pydantic", extra = ["email"] },
{ name = "pyjwt" },
{ name = "pymilvus", extra = ["milvus-lite"] },
{ name = "pysocks" },
@@ -420,6 +443,7 @@ requires-dist = [
{ name = "paramiko", specifier = ">=4.0.0" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "pillow", specifier = ">=11.3.0" },
+ { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "pymilvus", extras = ["milvus-lite"], specifier = ">=2.6.2" },
{ name = "pysocks", specifier = ">=1.7.1" },
@@ -1050,6 +1074,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
]
+[package.optional-dependencies]
+email = [
+ { name = "email-validator" },
+]
+
[[package]]
name = "pydantic-core"
version = "2.33.2"
diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts
index b07a49c..aaf1e2c 100644
--- a/web/src/api/auth.ts
+++ b/web/src/api/auth.ts
@@ -32,6 +32,15 @@ export interface UpdateMePayload {
new_password?: string;
}
+export interface PasswordResetRequestPayload {
+ email: string;
+}
+
+export interface PasswordResetConfirmPayload {
+ token: string;
+ password: string;
+}
+
export const authApi = {
register: async (username: string, password: string, email?: string, full_name?: string): Promise => {
return request('/auth/register', {
@@ -68,4 +77,19 @@ export const authApi = {
json: payload,
});
},
+ requestPasswordReset: async (payload: PasswordResetRequestPayload) => {
+ return await request('/auth/password-reset/request', {
+ method: 'POST',
+ json: payload,
+ });
+ },
+ verifyPasswordResetToken: async (token: string) => {
+ return await request<{ username: string; email: string }>('/auth/password-reset/verify?token=' + encodeURIComponent(token));
+ },
+ confirmPasswordReset: async (payload: PasswordResetConfirmPayload) => {
+ return await request('/auth/password-reset/confirm', {
+ method: 'POST',
+ json: payload,
+ });
+ },
};
diff --git a/web/src/api/email.ts b/web/src/api/email.ts
new file mode 100644
index 0000000..56b3709
--- /dev/null
+++ b/web/src/api/email.ts
@@ -0,0 +1,41 @@
+import request from './client';
+
+export interface EmailTestPayload {
+ to: string;
+ subject: string;
+ template?: string;
+ context?: Record;
+}
+
+export async function sendTestEmail(payload: EmailTestPayload) {
+ return request<{ task_id: string }>('/email/test', {
+ method: 'POST',
+ json: {
+ template: 'test',
+ context: {},
+ ...payload,
+ },
+ });
+}
+
+export async function listEmailTemplates() {
+ return request<{ templates: string[] }>('/email/templates');
+}
+
+export async function getEmailTemplate(name: string) {
+ return request<{ name: string; content: string }>(`/email/templates/${encodeURIComponent(name)}`);
+}
+
+export async function updateEmailTemplate(name: string, content: string) {
+ return request(`/email/templates/${encodeURIComponent(name)}`, {
+ method: 'POST',
+ json: { content },
+ });
+}
+
+export async function previewEmailTemplate(name: string, context: Record) {
+ return request<{ html: string }>(`/email/templates/${encodeURIComponent(name)}/preview`, {
+ method: 'POST',
+ json: { context },
+ });
+}
diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts
index bc12d38..a9ce993 100644
--- a/web/src/i18n/locales/en.ts
+++ b/web/src/i18n/locales/en.ts
@@ -284,6 +284,7 @@ export const en = {
'Custom CSS': 'Custom CSS',
'Save': 'Save',
'App Settings': 'App Settings',
+ 'Email Settings': 'Email Settings',
'AI Settings': 'AI Settings',
'Vision Model': 'Vision Model',
'Embedding Model': 'Embedding Model',
@@ -331,6 +332,62 @@ export const en = {
'Favicon URL': 'Favicon URL',
'App Domain': 'App Domain',
'File Domain': 'File Domain',
+ 'SMTP Settings': 'SMTP Settings',
+ 'SMTP Host': 'SMTP Host',
+ 'Please input SMTP host': 'Please input SMTP host',
+ 'SMTP Port': 'SMTP Port',
+ 'Please input SMTP port': 'Please input SMTP port',
+ 'Security': 'Security',
+ 'None': 'None',
+ 'SSL': 'SSL',
+ 'STARTTLS': 'STARTTLS',
+ 'Timeout (seconds)': 'Timeout (seconds)',
+ 'Sender': 'Sender',
+ 'Sender Name': 'Sender Name',
+ 'Sender Email': 'Sender Email',
+ 'Please input sender email': 'Please input sender email',
+ 'Authentication': 'Authentication',
+ 'SMTP Username': 'SMTP Username',
+ 'SMTP Password': 'SMTP Password',
+ 'Test Email': 'Test Email',
+ 'Current Configuration': 'Current Configuration',
+ 'Available variables': 'Available variables',
+ 'Not set': 'Not set',
+ 'Password Reset Template': 'Password Reset Template',
+ 'Live Preview': 'Live Preview',
+ 'Foxel Mail Test': 'Foxel Mail Test',
+ 'Recipient Address': 'Recipient Address',
+ 'Please input recipient email': 'Please input recipient email',
+ 'Test Subject': 'Test Subject',
+ 'Test User Name': 'Test User Name',
+ 'Optional': 'Optional',
+ 'Send Test Email': 'Send Test Email',
+ 'Please complete all required fields': 'Please complete all required fields',
+ 'SMTP port must be a positive number': 'SMTP port must be a positive number',
+ 'Test email queued (task {{taskId}})': 'Test email queued (task {{taskId}})',
+ 'Test email failed': 'Test email failed',
+
+ // Auth reset
+ 'Forgot Password?': 'Forgot password?',
+ 'Reset Your Password': 'Reset Your Password',
+ 'Enter the email linked to your account and we will send a reset link.': 'Enter the email linked to your account and we will send a reset link.',
+ 'If the email exists, a reset link has been sent.': 'If the email exists, a reset link has been sent.',
+ 'Send Reset Link': 'Send Reset Link',
+ 'Resend Link': 'Resend Link',
+ 'Back to login': 'Back to login',
+ 'Request failed': 'Request failed',
+ 'Reset link is invalid': 'Reset link is invalid',
+ 'Reset link is invalid or expired': 'Reset link is invalid or expired',
+ 'Reset failed': 'Reset failed',
+ 'Try again': 'Try again',
+ 'Set a new password': 'Set a new password',
+ 'Please enter new password': 'Please enter new password',
+ 'Confirm Password': 'Confirm Password',
+ 'Please confirm new password': 'Please confirm new password',
+ 'Update Password': 'Update Password',
+ 'Passwords do not match': 'Passwords do not match',
+ 'Password updated, please login again.': 'Password updated, please login again.',
+ 'Failed to reset password': 'Failed to reset password',
'Vision API URL': 'Vision API URL',
'Vision API Key': 'Vision API Key',
'Embedding API URL': 'Embedding API URL',
@@ -599,7 +656,6 @@ export const en = {
'This is the first account with full permissions': 'This is the first account with full permissions',
'Username': 'Username',
'Please input a valid email!': 'Please input a valid email!',
- 'Confirm Password': 'Confirm Password',
'Please confirm your password!': 'Please confirm your password!',
'Passwords do not match!': 'Passwords do not match!',
'System Initialization': 'System Initialization',
diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts
index 82410a3..e3995b9 100644
--- a/web/src/i18n/locales/zh.ts
+++ b/web/src/i18n/locales/zh.ts
@@ -63,12 +63,32 @@ export const zh = {
'Sign In': '登录',
'Please enter username and password': '请输入用户名与密码',
'Login failed': '登录失败',
+ 'Forgot Password?': '忘记密码?',
'Your next-generation file manager': '您的下一代文件管理系统',
'Cross-platform sync, access anywhere': '跨平台同步,随时随地访问',
'AI-powered search for quick find': 'AI 驱动的智能搜索,快速定位文件',
'Flexible sharing and collaboration': '灵活的分享与协作,提升团队效率',
'Powerful automation to simplify tasks': '强大的自动化工作流,简化繁琐任务',
'Join our community:': '加入我们的社区:',
+ 'Reset Your Password': '重置你的密码',
+ 'Enter the email linked to your account and we will send a reset link.': '请输入你账户绑定的邮箱,我们会发送重置链接。',
+ 'If the email exists, a reset link has been sent.': '如果邮箱存在,我们已发送重置链接。',
+ 'Send Reset Link': '发送重置链接',
+ 'Resend Link': '重新发送链接',
+ 'Back to login': '返回登录',
+ 'Request failed': '请求失败',
+ 'Reset link is invalid': '重置链接无效',
+ 'Reset link is invalid or expired': '重置链接无效或已过期',
+ 'Reset failed': '重置失败',
+ 'Try again': '重试',
+ 'Set a new password': '设置新密码',
+ 'Please enter new password': '请输入新密码',
+ 'Confirm Password': '确认新密码',
+ 'Please confirm new password': '请确认新密码',
+ 'Update Password': '更新密码',
+ 'Passwords do not match': '两次输入的密码不一致',
+ 'Password updated, please login again.': '密码已更新,请重新登录。',
+ 'Failed to reset password': '密码重置失败',
// Share page
'Refresh': '刷新',
@@ -285,6 +305,7 @@ export const zh = {
'Custom CSS': '自定义 CSS',
'Save': '保存',
'App Settings': '应用设置',
+ 'Email Settings': '邮箱设置',
'AI Settings': 'AI设置',
'Choose Template': '选择模板',
'Configure Provider': '配置提供商',
@@ -336,6 +357,44 @@ export const zh = {
'Favicon URL': 'Favicon 地址',
'App Domain': '应用域名',
'File Domain': '文件域名',
+ 'SMTP Settings': 'SMTP 配置',
+ 'SMTP Host': 'SMTP 服务器',
+ 'Please input SMTP host': '请输入 SMTP 服务器',
+ 'SMTP Port': 'SMTP 端口',
+ 'Please input SMTP port': '请输入 SMTP 端口',
+ 'Security': '安全协议',
+ 'None': '无',
+ 'SSL': 'SSL',
+ 'STARTTLS': 'STARTTLS',
+ 'Timeout (seconds)': '超时时间(秒)',
+ 'Sender': '发件人',
+ 'Sender Name': '发件人名称',
+ 'Sender Email': '发件人邮箱',
+ 'Please input sender email': '请输入发件人邮箱',
+ 'Authentication': '身份认证',
+ 'SMTP Username': 'SMTP 用户名',
+ 'SMTP Password': 'SMTP 密码',
+ 'Test Email': '测试发信',
+ 'Current Configuration': '当前配置摘要',
+ 'Available variables': '可用变量',
+ 'Not set': '未设置',
+ 'Password Reset Template': '密码重置模板',
+ 'Live Preview': '实时预览',
+ 'Template saved': '模板已保存',
+ 'Failed to save template': '模板保存失败',
+ 'Failed to load template': '模板加载失败',
+ 'Preview failed': '预览失败',
+ 'Foxel Mail Test': 'Foxel 邮件测试',
+ 'Recipient Address': '收件人地址',
+ 'Please input recipient email': '请输入收件人邮箱',
+ 'Test Subject': '测试邮件标题',
+ 'Test User Name': '测试用户名',
+ 'Optional': '可选',
+ 'Send Test Email': '发送测试邮件',
+ 'Please complete all required fields': '请填写所有必填项',
+ 'SMTP port must be a positive number': 'SMTP 端口必须为正数',
+ 'Test email queued (task {{taskId}})': '测试邮件已入队(任务 {{taskId}})',
+ 'Test email failed': '测试邮件发送失败',
'Vision API URL': '视觉模型 API 地址',
'Vision API Key': '视觉模型 API Key',
'Embedding API URL': '嵌入模型 API 地址',
@@ -612,7 +671,6 @@ export const zh = {
'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。',
'Username': '用户名',
'Please input a valid email!': '请输入有效的邮箱地址!',
- 'Confirm Password': '确认密码',
'Please confirm your password!': '请确认您的密码!',
'Passwords do not match!': '两次输入的密码不一致!',
'System Initialization': '系统初始化',
diff --git a/web/src/pages/ForgotPasswordPage.tsx b/web/src/pages/ForgotPasswordPage.tsx
new file mode 100644
index 0000000..6da6822
--- /dev/null
+++ b/web/src/pages/ForgotPasswordPage.tsx
@@ -0,0 +1,104 @@
+import { useState } from 'react';
+import { Card, Form, Input, Button, Typography, message } from 'antd';
+import { MailOutlined, ArrowLeftOutlined } from '@ant-design/icons';
+import { useNavigate } from 'react-router';
+import { authApi } from '../api/auth';
+import { useI18n } from '../i18n';
+import LanguageSwitcher from '../components/LanguageSwitcher';
+
+const { Title, Text } = Typography;
+
+export default function ForgotPasswordPage() {
+ const { t } = useI18n();
+ const navigate = useNavigate();
+ const [submitting, setSubmitting] = useState(false);
+ const [sent, setSent] = useState(false);
+
+ const handleSubmit = async (values: { email: string }) => {
+ setSubmitting(true);
+ try {
+ await authApi.requestPasswordReset({ email: values.email });
+ message.success(t('If the email exists, a reset link has been sent.'));
+ setSent(true);
+ } catch (err: any) {
+ message.error(err?.message || t('Request failed'));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
{t('Reset Your Password')}
+
+ {t('Enter the email linked to your account and we will send a reset link.')}
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onClick={() => navigate('/login')}
+ style={{ padding: 0 }}
+ >
+ {t('Back to login')}
+
+
+
+ );
+}
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx
index ba7f19b..cce37d0 100644
--- a/web/src/pages/LoginPage.tsx
+++ b/web/src/pages/LoginPage.tsx
@@ -107,6 +107,12 @@ export default function LoginPage() {
/>
+
+
+
+
+
+
+
+
+
+
+
+
+ {summary.map(item => (
+ }>
+ {item.value}
+
+ ))}
+
+
+ }>
+
+
+
+
+
+
+
+
+
+ }>
+ {t('Send Test Email')}
+
+
+
+
+
+
+
+
+
+ {t('Password Reset Template')}
+
+ }
+ extra={
+
+ } onClick={handlePreviewTemplate} loading={previewing}>
+ {t('Preview')}
+
+ } onClick={handleSaveTemplate} loading={templateSaving}>
+ {t('Save')}
+
+
+ }
+ >
+
+
+ {templateLoading ? (
+
+ ) : (
+ setTemplate(e.target.value)}
+ autoSize={{ minRows: 20 }}
+ style={{ fontFamily: 'monospace', background: '#0f172a', color: '#e2e8f0' }}
+ />
+ )}
+
+ {t('Available variables')}:
+
+ ${'{username}'}
+ ${'{reset_link}'}
+ ${'{expire_minutes}'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function TextLabel({ text }: { text: string }) {
+ return {text};
+}
diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx
index 72f8010..39e7d98 100644
--- a/web/src/router/index.tsx
+++ b/web/src/router/index.tsx
@@ -4,6 +4,8 @@ import LayoutShell from './LayoutShell.tsx';
import LoginPage from '../pages/LoginPage.tsx';
import SetupPage from '../pages/SetupPage.tsx';
import PublicSharePage from '../pages/PublicSharePage';
+import ForgotPasswordPage from '../pages/ForgotPasswordPage';
+import ResetPasswordPage from '../pages/ResetPasswordPage';
import { useAuth } from '../contexts/AuthContext';
import type { JSX } from 'react';
@@ -13,12 +15,16 @@ export const routes: RouteObject[] = [
{ path: '/login', element: },
{ path: '/share/:token', element: },
{ path: '/setup', element: },
+ { path: '/forgot-password', element: },
+ { path: '/reset-password', element: },
];
function RequireAuth({ children }: { children: JSX.Element }) {
const { isAuthenticated } = useAuth();
const location = useLocation();
- if (!isAuthenticated && !location.pathname.startsWith('/share/') && location.pathname !== '/login' && location.pathname !== '/register') {
+ const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password'];
+ const isPublic = publicPaths.some((p) => location.pathname.startsWith(p));
+ if (!isAuthenticated && !location.pathname.startsWith('/share/') && !isPublic) {
return ;
}
return children;