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 密码重置 + + + +
+
+
+

重置你的 Foxel 密码

+
+
+

你好,${username}。

+

我们收到了重置你 Foxel 帐号密码的请求。请点击下方按钮完成密码重置操作:

+ +

如果按钮无法点击,你也可以复制下面的链接到浏览器打开:

+
+
${reset_link}
+
+

该链接在 ${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 邮件配置测试 + + + +
+
+ +
+
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.')} + +
+ +
+ + + + + + + +
+ + +
+
+ ); +} 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() { /> + + + + , + ]} + /> + + ); + } + + return ( +
+
+ +
+ +
+
+ {success ? : } +
+ {t('Set a new password')} + {userInfo && {userInfo.email}} +
+ +
+ + + + + + + +
+
+
+ ); +} diff --git a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx index 205ba2e..db14d34 100644 --- a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx +++ b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx @@ -2,7 +2,7 @@ import { message, Tabs, Space } from 'antd'; import { useEffect, useState } from 'react'; import PageCard from '../../components/PageCard'; import { getAllConfig, setConfig } from '../../api/config'; -import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined } from '@ant-design/icons'; +import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOutlined } from '@ant-design/icons'; import { useTheme } from '../../contexts/ThemeContext'; import '../../styles/settings-tabs.css'; import { useI18n } from '../../i18n'; @@ -10,10 +10,11 @@ import AppearanceSettingsTab from './components/AppearanceSettingsTab'; import AppSettingsTab from './components/AppSettingsTab'; import AiSettingsTab from './components/AiSettingsTab'; import VectorDbSettingsTab from './components/VectorDbSettingsTab'; +import EmailSettingsTab from './components/EmailSettingsTab'; -type TabKey = 'appearance' | 'app' | 'ai' | 'vector-db'; +type TabKey = 'appearance' | 'app' | 'email' | 'ai' | 'vector-db'; -const TAB_KEYS: TabKey[] = ['appearance', 'app', 'ai', 'vector-db']; +const TAB_KEYS: TabKey[] = ['appearance', 'app', 'email', 'ai', 'vector-db']; const DEFAULT_TAB: TabKey = 'appearance'; const isValidTab = (key?: string): key is TabKey => !!key && (TAB_KEYS as string[]).includes(key); @@ -150,6 +151,22 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett /> ), }, + { + key: 'email', + label: ( + + + {t('Email Settings')} + + ), + children: ( + + ), + }, { key: 'ai', label: ( diff --git a/web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx b/web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx new file mode 100644 index 0000000..ff2b6b9 --- /dev/null +++ b/web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx @@ -0,0 +1,440 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Button, + Card, + Col, + Descriptions, + Divider, + Form, + Input, + InputNumber, + Row, + Select, + Space, + Typography, + message, + Tag, + Skeleton, +} from 'antd'; +import { HighlightOutlined, EyeOutlined, SaveOutlined, SendOutlined, InfoCircleOutlined } from '@ant-design/icons'; +import { useI18n } from '../../../i18n'; +import { + sendTestEmail, + getEmailTemplate, + updateEmailTemplate, + previewEmailTemplate, +} from '../../../api/email'; + +interface EmailSettingsTabProps { + config: Record; + loading: boolean; + onSave: (values: Record) => Promise; +} + +interface EmailFormValues { + host: string; + port: number; + username?: string; + password?: string; + sender_name?: string; + sender_email: string; + security: 'none' | 'ssl' | 'starttls'; + timeout?: number; +} + +interface TestFormValues { + to: string; + subject: string; + username?: string; +} + +interface PreviewContext extends Record { + username: string; + reset_link: string; + expire_minutes: number; +} + +const DEFAULT_FORM: EmailFormValues = { + host: '', + port: 465, + username: '', + password: '', + sender_name: '', + sender_email: '', + security: 'ssl', + timeout: 30, +}; + +const TEMPLATE_NAME = 'password_reset'; + +function parseEmailConfig(raw?: string | null): EmailFormValues { + if (!raw) return { ...DEFAULT_FORM }; + try { + const data = JSON.parse(raw) as Partial; + return { + ...DEFAULT_FORM, + ...data, + port: Number(data?.port ?? DEFAULT_FORM.port), + timeout: data?.timeout !== undefined ? Number(data.timeout) : DEFAULT_FORM.timeout, + security: (data?.security ?? DEFAULT_FORM.security) as EmailFormValues['security'], + }; + } catch (_err) { + return { ...DEFAULT_FORM }; + } +} + +export default function EmailSettingsTab({ config, loading, onSave }: EmailSettingsTabProps) { + const { t } = useI18n(); + const [testForm] = Form.useForm(); + const [previewForm] = Form.useForm(); + const [testing, setTesting] = useState(false); + const [template, setTemplate] = useState(''); + const [templateLoading, setTemplateLoading] = useState(true); + const [templateSaving, setTemplateSaving] = useState(false); + const [previewing, setPreviewing] = useState(false); + const [previewHtml, setPreviewHtml] = useState(''); + + const initialValues = useMemo(() => parseEmailConfig(config?.EMAIL_CONFIG), [config]); + + const summary = useMemo(() => { + const parsed = parseEmailConfig(config?.EMAIL_CONFIG); + return [ + { label: t('SMTP Host'), value: parsed.host || '-' }, + { label: t('SMTP Port'), value: parsed.port || '-' }, + { label: t('Security'), value: parsed.security.toUpperCase() }, + { label: t('Sender Email'), value: parsed.sender_email || '-' }, + { label: t('Sender Name'), value: parsed.sender_name || t('Not set') }, + { label: t('Timeout (seconds)'), value: parsed.timeout || '-' }, + ]; + }, [config, t]); + + useEffect(() => { + setTemplateLoading(true); + getEmailTemplate(TEMPLATE_NAME) + .then((res) => setTemplate(res.content)) + .catch((err) => { + message.error(err?.message || t('Failed to load template')); + }) + .finally(() => setTemplateLoading(false)); + }, [t]); + + useEffect(() => { + previewForm.setFieldsValue({ + username: 'Foxel 用户', + reset_link: 'https://foxel.cc/reset-password?token=demo', + expire_minutes: 10, + }); + }, [previewForm]); + + const handleSaveConfig = async (values: EmailFormValues) => { + if (!values.host || !values.port || !values.sender_email) { + message.error(t('Please complete all required fields')); + return; + } + const payload: Record = { + host: values.host.trim(), + port: Number(values.port), + sender_email: values.sender_email.trim(), + security: values.security, + }; + if (!Number.isFinite(payload.port as number) || (payload.port as number) <= 0) { + message.error(t('SMTP port must be a positive number')); + return; + } + if (values.username?.trim()) { + payload.username = values.username.trim(); + } + if (values.password?.length) { + payload.password = values.password; + } + if (values.sender_name?.trim()) { + payload.sender_name = values.sender_name.trim(); + } + if (values.timeout !== undefined && values.timeout !== null) { + const timeoutNumber = Number(values.timeout); + if (Number.isFinite(timeoutNumber) && timeoutNumber > 0) { + payload.timeout = timeoutNumber; + } + } + await onSave({ EMAIL_CONFIG: JSON.stringify(payload) }); + }; + + const handleTest = async () => { + try { + const values = await testForm.validateFields(); + setTesting(true); + const response = await sendTestEmail({ + to: values.to, + subject: values.subject, + template: 'test', + context: { username: values.username || values.to }, + }); + message.success(t('Test email queued (task {{taskId}})', { taskId: response.task_id })); + } catch (err: any) { + if (err?.errorFields) { + return; + } + message.error(err?.message || t('Test email failed')); + } finally { + setTesting(false); + } + }; + + const handlePreviewTemplate = async () => { + try { + const values = await previewForm.validateFields(); + setPreviewing(true); + const res = await previewEmailTemplate(TEMPLATE_NAME, values); + setPreviewHtml(res.html); + } catch (err: any) { + if (err?.errorFields) return; + message.error(err?.message || t('Preview failed')); + } finally { + setPreviewing(false); + } + }; + + const handleSaveTemplate = async () => { + setTemplateSaving(true); + try { + await updateEmailTemplate(TEMPLATE_NAME, template); + message.success(t('Template saved')); + } catch (err: any) { + message.error(err?.message || t('Failed to save template')); + } finally { + setTemplateSaving(false); + } + }; + + return ( + + + + } + bodyStyle={{ paddingBottom: 12 }} + > + + layout="vertical" + initialValues={initialValues} + onFinish={handleSaveConfig} + key={'email-settings-' + (config?.EMAIL_CONFIG ?? '')} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {summary.map(item => ( + }> + {item.value} + + ))} + + + }> + + form={testForm} + layout="vertical" + initialValues={{ + subject: t('Foxel Mail Test'), + username: '', + }} + > + + + + + + + + + + + + + + + + + + + {t('Password Reset Template')} + + } + extra={ + + + + + } + > + + + {templateLoading ? ( + + ) : ( + setTemplate(e.target.value)} + autoSize={{ minRows: 20 }} + style={{ fontFamily: 'monospace', background: '#0f172a', color: '#e2e8f0' }} + /> + )} +
+ {t('Available variables')}: + + ${'{username}'} + ${'{reset_link}'} + ${'{expire_minutes}'} + +
+ + + + layout="vertical" form={previewForm}> + + + + + + + + + + + + +
+