feat: add password reset functionality with email templates

This commit is contained in:
shiyu
2025-11-06 15:31:13 +08:00
parent ba62bd0d4a
commit 4e724b9c4a
21 changed files with 1643 additions and 7 deletions

View File

@@ -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)

View File

@@ -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="密码已重置")

92
api/routes/email.py Normal file
View File

@@ -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})

View File

@@ -22,4 +22,5 @@ dependencies = [
"uvicorn>=0.37.0",
"pymilvus[milvus-lite]>=2.6.2",
"paramiko>=4.0.0",
"pydantic[email]>=2.11.7",
]

18
schemas/email.py Normal file
View File

@@ -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)

View File

@@ -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,

201
services/email.py Normal file
View File

@@ -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},
)

View File

@@ -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}")

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Foxel 密码重置</title>
<style>
body {
background: #f4f7fb;
font-family: 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 32px 0;
color: #1f2937;
}
.wrapper {
max-width: 560px;
margin: 0 auto;
}
.card {
background: #ffffff;
border-radius: 16px;
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
overflow: hidden;
border: 1px solid rgba(99, 102, 241, 0.12);
}
.header {
background: linear-gradient(120deg, #4f46e5, #7c3aed);
padding: 32px;
color: #ffffff;
}
.header h1 {
margin: 0;
font-size: 24px;
letter-spacing: 0.2px;
}
.content {
padding: 32px;
}
.content p {
margin: 16px 0;
line-height: 1.6;
}
.cta {
display: block;
margin: 32px 0;
text-align: center;
}
.cta a {
display: inline-block;
background: linear-gradient(120deg, #6366f1, #8b5cf6);
color: #ffffff;
text-decoration: none;
padding: 14px 32px;
border-radius: 999px;
font-weight: 600;
box-shadow: 0 8px 24px rgba(79, 70, 229, 0.32);
}
.info-box {
background: #f5f3ff;
border: 1px solid rgba(107, 114, 128, 0.1);
border-radius: 12px;
padding: 18px;
margin-top: 16px;
}
.footer {
padding: 24px 32px;
font-size: 12px;
color: #6b7280;
line-height: 1.6;
background: #fafafa;
border-top: 1px solid rgba(15, 23, 42, 0.04);
}
.footer a {
color: #6366f1;
text-decoration: none;
}
</style>
</head>
<body>
<div class="wrapper">
<div class="card">
<div class="header">
<h1>重置你的 Foxel 密码</h1>
</div>
<div class="content">
<p>你好,${username}。</p>
<p>我们收到了重置你 Foxel 帐号密码的请求。请点击下方按钮完成密码重置操作:</p>
<div class="cta">
<a href="${reset_link}" target="_blank" rel="noopener">重置密码</a>
</div>
<p>如果按钮无法点击,你也可以复制下面的链接到浏览器打开:</p>
<div class="info-box">
<div style="word-break: break-all;">${reset_link}</div>
</div>
<p>该链接在 ${expire_minutes} 分钟内有效。若你未发起此请求,请忽略本邮件,你的密码不会发生变化。</p>
</div>
<div class="footer">
<div>此邮件由 Foxel 系统自动发送,请勿直接回复。</div>
</div>
</div>
</div>
</body>
</html>

97
templates/email/test.html Normal file
View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Foxel 邮件配置测试</title>
<style>
body {
margin: 0;
padding: 32px 0;
background: linear-gradient(135deg, #eef2ff, #e0f2fe);
font-family: 'Helvetica Neue', Arial, sans-serif;
color: #0f172a;
}
.wrapper {
max-width: 560px;
margin: 0 auto;
}
.card {
background: #ffffff;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
border: 1px solid rgba(99, 102, 241, 0.08);
}
.banner {
background: linear-gradient(120deg, #1d4ed8, #6366f1);
padding: 36px;
color: #ffffff;
letter-spacing: 0.2px;
}
.banner h1 {
margin: 0;
font-size: 24px;
}
.content {
padding: 32px;
line-height: 1.7;
}
.badge {
display: inline-block;
padding: 6px 14px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
font-weight: 600;
font-size: 13px;
margin-bottom: 16px;
}
.cta-box {
margin-top: 32px;
padding: 20px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(14, 165, 233, 0.08));
border: 1px solid rgba(99, 102, 241, 0.12);
}
.cta-box strong {
display: block;
margin-bottom: 8px;
font-size: 16px;
}
.footer {
padding: 24px 32px;
background: #f8fafc;
font-size: 12px;
color: #64748b;
border-top: 1px solid rgba(148, 163, 184, 0.18);
}
</style>
</head>
<body>
<div class="wrapper">
<div class="card">
<div class="banner">
<h1>Foxel 邮件服务已连通</h1>
</div>
<div class="content">
<div class="badge">Mail Delivery Test</div>
<p>你好,${username}</p>
<p>
这是一封来自 <strong>Foxel</strong> 的测试邮件。如果你能够正常阅读到这段内容,说明系统已经成功与配置的邮箱服务建立连接。
</p>
<div class="cta-box">
<strong>接下来可以做什么?</strong>
<ul style="margin: 0; padding-left: 18px; line-height: 1.7;">
<li>继续完善系统通知、密码重置等业务功能</li>
<li>在后台页面中自定义更精美的邮件模板</li>
<li>保持发送凭据安全,避免泄露</li>
</ul>
</div>
</div>
<div class="footer">
本邮件由 Foxel 系统自动发送,请勿直接回复。如非本人操作,请忽略此邮件。
</div>
</div>
</div>
</body>
</html>

29
uv.lock generated
View File

@@ -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"

View File

@@ -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<any> => {
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,
});
},
};

41
web/src/api/email.ts Normal file
View File

@@ -0,0 +1,41 @@
import request from './client';
export interface EmailTestPayload {
to: string;
subject: string;
template?: string;
context?: Record<string, unknown>;
}
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<string, unknown>) {
return request<{ html: string }>(`/email/templates/${encodeURIComponent(name)}/preview`, {
method: 'POST',
json: { context },
});
}

View File

@@ -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',

View File

@@ -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': '系统初始化',

View File

@@ -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 (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 16px',
position: 'relative'
}}>
<div style={{ position: 'absolute', top: 16, right: 16 }}>
<LanguageSwitcher />
</div>
<Card
style={{
width: '100%',
maxWidth: 460,
borderRadius: 20,
boxShadow: '0 24px 60px rgba(15,23,42,0.12)',
border: '1px solid rgba(99,102,241,0.12)',
}}
styles={{ body: { padding: '40px 36px' } }}
>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{
width: 64,
height: 64,
borderRadius: '50%',
margin: '0 auto 16px',
background: 'linear-gradient(135deg,#6366f1,#8b5cf6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 28,
}}>
<MailOutlined />
</div>
<Title level={3} style={{ marginBottom: 8 }}>{t('Reset Your Password')}</Title>
<Text type="secondary">
{t('Enter the email linked to your account and we will send a reset link.')}
</Text>
</div>
<Form layout="vertical" size="large" onFinish={handleSubmit}>
<Form.Item
name="email"
label={t('Email')}
rules={[
{ required: true, message: t('Please input recipient email') },
{ type: 'email', message: t('Please input a valid email!') },
]}
>
<Input placeholder="me@example.com" autoComplete="email" />
</Form.Item>
<Form.Item style={{ marginTop: 32 }}>
<Button type="primary" htmlType="submit" loading={submitting} block>
{sent ? t('Resend Link') : t('Send Reset Link')}
</Button>
</Form.Item>
</Form>
<Button
type="link"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/login')}
style={{ padding: 0 }}
>
{t('Back to login')}
</Button>
</Card>
</div>
);
}

View File

@@ -107,6 +107,12 @@ export default function LoginPage() {
/>
</Form.Item>
<Form.Item style={{ marginBottom: 8, textAlign: 'right' }}>
<Button type="link" onClick={() => navigate('/forgot-password')} style={{ padding: 0 }}>
{t('Forgot Password?')}
</Button>
</Form.Item>
<Form.Item>
<Button
type="primary"

View File

@@ -0,0 +1,146 @@
import { useEffect, useMemo, useState } from 'react';
import { Card, Form, Input, Button, Typography, message, Result } from 'antd';
import { LockOutlined, CheckCircleTwoTone } from '@ant-design/icons';
import { useLocation, 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 ResetPasswordPage() {
const { t } = useI18n();
const navigate = useNavigate();
const location = useLocation();
const token = useMemo(() => new URLSearchParams(location.search).get('token') || '', [location.search]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userInfo, setUserInfo] = useState<{ username: string; email: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!token) {
setError(t('Reset link is invalid'));
setLoading(false);
return;
}
authApi.verifyPasswordResetToken(token)
.then(setUserInfo)
.catch((err) => {
setError(err?.message || t('Reset link is invalid or expired'));
})
.finally(() => setLoading(false));
}, [token, t]);
const handleSubmit = async (values: { password: string; confirm: string }) => {
if (values.password !== values.confirm) {
message.error(t('Passwords do not match'));
return;
}
setSubmitting(true);
try {
await authApi.confirmPasswordReset({ token, password: values.password });
setSuccess(true);
message.success(t('Password updated, please login again.'));
setTimeout(() => navigate('/login'), 1500);
} catch (err: any) {
message.error(err?.message || t('Failed to reset password'));
} finally {
setSubmitting(false);
}
};
if (loading) {
return null;
}
if (error) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Result
status="error"
title={t('Reset failed')}
subTitle={error}
extra={[
<Button type="primary" key="back" onClick={() => navigate('/forgot-password')}>
{t('Try again')}
</Button>,
]}
/>
</div>
);
}
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 16px',
position: 'relative'
}}>
<div style={{ position: 'absolute', top: 16, right: 16 }}>
<LanguageSwitcher />
</div>
<Card
style={{
width: '100%',
maxWidth: 480,
borderRadius: 20,
border: '1px solid rgba(99,102,241,0.14)',
boxShadow: '0 24px 60px rgba(79,70,229,0.18)',
}}
bodyStyle={{ padding: '40px 36px' }}
>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{
width: 64,
height: 64,
borderRadius: '50%',
margin: '0 auto 16px',
background: success ? '#ecfdf5' : 'linear-gradient(135deg,#6366f1,#8b5cf6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: success ? '#047857' : '#fff',
fontSize: success ? 32 : 28,
}}>
{success ? <CheckCircleTwoTone twoToneColor="#22c55e" /> : <LockOutlined />}
</div>
<Title level={3} style={{ marginBottom: 8 }}>{t('Set a new password')}</Title>
{userInfo && <Text type="secondary">{userInfo.email}</Text>}
</div>
<Form layout="vertical" size="large" onFinish={handleSubmit}>
<Form.Item
name="password"
label={t('New Password')}
rules={[{ required: true, message: t('Please enter new password') }]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
name="confirm"
label={t('Confirm Password')}
rules={[{ required: true, message: t('Please confirm new password') }]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Button
type="primary"
htmlType="submit"
loading={submitting}
block
size="large"
>
{t('Update Password')}
</Button>
</Form>
</Card>
</div>
);
}

View File

@@ -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: (
<span>
<MailOutlined style={{ marginRight: 8 }} />
{t('Email Settings')}
</span>
),
children: (
<EmailSettingsTab
config={config}
loading={loading}
onSave={handleSave}
/>
),
},
{
key: 'ai',
label: (

View File

@@ -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<string, string>;
loading: boolean;
onSave: (values: Record<string, unknown>) => Promise<void>;
}
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<string, unknown> {
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<EmailFormValues>;
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<TestFormValues>();
const [previewForm] = Form.useForm<PreviewContext>();
const [testing, setTesting] = useState(false);
const [template, setTemplate] = useState<string>('');
const [templateLoading, setTemplateLoading] = useState(true);
const [templateSaving, setTemplateSaving] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string>('');
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<string, unknown> = {
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 (
<Space direction="vertical" size={32} style={{ width: '100%', marginTop: 24 }}>
<Row gutter={24}>
<Col xs={24} lg={15}>
<Card
title={t('SMTP Settings')}
extra={<InfoCircleOutlined style={{ color: 'var(--ant-color-primary)' }} />}
bodyStyle={{ paddingBottom: 12 }}
>
<Form<EmailFormValues>
layout="vertical"
initialValues={initialValues}
onFinish={handleSaveConfig}
key={'email-settings-' + (config?.EMAIL_CONFIG ?? '')}
>
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="host"
label={t('SMTP Host')}
rules={[{ required: true, message: t('Please input SMTP host') }]}
>
<Input size="large" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
name="port"
label={t('SMTP Port')}
rules={[{ required: true, message: t('Please input SMTP port') }]}
>
<InputNumber min={1} style={{ width: '100%' }} size="large" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="security" label={t('Security')}>
<Select
size="large"
options={[
{ value: 'none', label: t('None') },
{ value: 'ssl', label: 'SSL' },
{ value: 'starttls', label: 'STARTTLS' },
]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="timeout" label={t('Timeout (seconds)')}>
<InputNumber min={1} style={{ width: '100%' }} size="large" />
</Form.Item>
</Col>
</Row>
<Divider />
<Form.Item name="sender_name" label={t('Sender Name')}>
<Input size="large" />
</Form.Item>
<Form.Item
name="sender_email"
label={t('Sender Email')}
rules={[
{ required: true, message: t('Please input sender email') },
{ type: 'email', message: t('Please input a valid email!') },
]}
>
<Input size="large" />
</Form.Item>
<Divider />
<Row gutter={16}>
<Col span={12}>
<Form.Item name="username" label={t('SMTP Username')}>
<Input size="large" autoComplete="username" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="password" label={t('SMTP Password')}>
<Input.Password size="large" autoComplete="current-password" />
</Form.Item>
</Col>
</Row>
<Form.Item style={{ marginTop: 24 }}>
<Button type="primary" htmlType="submit" loading={loading} icon={<SaveOutlined />} block>
{t('Save')}
</Button>
</Form.Item>
</Form>
</Card>
</Col>
<Col xs={24} lg={9}>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Card title={t('Current Configuration')} bodyStyle={{ paddingBottom: 12 }}>
<Descriptions column={1} size="small" colon={false}>
{summary.map(item => (
<Descriptions.Item key={item.label} label={<TextLabel text={item.label} />}>
<Typography.Text strong>{item.value}</Typography.Text>
</Descriptions.Item>
))}
</Descriptions>
</Card>
<Card title={t('Test Email')} extra={<SendOutlined style={{ color: 'var(--ant-color-primary)' }} />}>
<Form<TestFormValues>
form={testForm}
layout="vertical"
initialValues={{
subject: t('Foxel Mail Test'),
username: '',
}}
>
<Form.Item
name="to"
label={t('Recipient Address')}
rules={[
{ required: true, message: t('Please input recipient email') },
{ type: 'email', message: t('Please input a valid email!') },
]}
>
<Input size="large" />
</Form.Item>
<Form.Item name="subject" label={t('Test Subject')}>
<Input size="large" />
</Form.Item>
<Form.Item name="username" label={t('Test User Name')}>
<Input size="large" placeholder={t('Optional')} />
</Form.Item>
<Button type="primary" onClick={handleTest} loading={testing} block icon={<SendOutlined />}>
{t('Send Test Email')}
</Button>
</Form>
</Card>
</Space>
</Col>
</Row>
<Card
title={
<Space>
<HighlightOutlined />
{t('Password Reset Template')}
</Space>
}
extra={
<Space>
<Button icon={<EyeOutlined />} onClick={handlePreviewTemplate} loading={previewing}>
{t('Preview')}
</Button>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveTemplate} loading={templateSaving}>
{t('Save')}
</Button>
</Space>
}
>
<Row gutter={24}>
<Col xs={24} lg={14}>
{templateLoading ? (
<Skeleton active paragraph={{ rows: 8 }} />
) : (
<Input.TextArea
value={template}
onChange={(e) => setTemplate(e.target.value)}
autoSize={{ minRows: 20 }}
style={{ fontFamily: 'monospace', background: '#0f172a', color: '#e2e8f0' }}
/>
)}
<div style={{ marginTop: 16 }}>
<Typography.Text type="secondary">{t('Available variables')}</Typography.Text>
<Space wrap style={{ marginTop: 8 }}>
<Tag color="blue">${'{username}'}</Tag>
<Tag color="blue">${'{reset_link}'}</Tag>
<Tag color="blue">${'{expire_minutes}'}</Tag>
</Space>
</div>
</Col>
<Col xs={24} lg={10}>
<Card title={t('Preview Context')} size="small" style={{ marginBottom: 16 }}>
<Form<PreviewContext> layout="vertical" form={previewForm}>
<Form.Item
name="username"
label="username"
rules={[{ required: true, message: t('Please complete all required fields') }]}
>
<Input />
</Form.Item>
<Form.Item
name="reset_link"
label="reset_link"
rules={[{ required: true, message: t('Please complete all required fields') }]}
>
<Input />
</Form.Item>
<Form.Item
name="expire_minutes"
label="expire_minutes"
rules={[{ required: true, message: t('Please complete all required fields') }]}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Card>
<Card title={t('Live Preview')} size="small" className="email-template-preview">
<div
style={{
border: '1px solid rgba(148,163,184,0.2)',
borderRadius: 12,
overflow: 'hidden',
height: 360,
background: '#f8fafc',
padding: 0,
}}
>
<iframe
title="email-preview"
style={{
width: '100%',
height: '100%',
border: 'none',
backgroundColor: '#f8fafc',
}}
srcDoc={previewHtml || template}
/>
</div>
</Card>
</Col>
</Row>
</Card>
</Space>
);
}
function TextLabel({ text }: { text: string }) {
return <Typography.Text type="secondary">{text}</Typography.Text>;
}

View File

@@ -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: <LoginPage /> },
{ path: '/share/:token', element: <PublicSharePage /> },
{ path: '/setup', element: <SetupPage /> },
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
{ path: '/reset-password', element: <ResetPasswordPage /> },
];
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 <Navigate to="/login" replace />;
}
return children;