feat(email): add admin notification settings

This commit is contained in:
2026-05-05 13:38:34 +08:00
parent a780c1bf52
commit 73d476bcea
21 changed files with 929 additions and 17 deletions
+48
View File
@@ -6,9 +6,14 @@ from pydantic import BaseModel
from backend.models import get_db, User, CheckInTask
from backend.schemas.check_in import BatchCheckInRequest
from backend.schemas.email_settings import (
EmailNotificationSettingsResponse,
EmailNotificationSettingsUpdate,
)
from backend.schemas.user import UserResponse
from backend.services.check_in_service import CheckInService
from backend.services.admin_service import AdminService
from backend.services.email_settings_service import EmailSettingsService
from backend.dependencies import get_current_admin_user
from backend.config import settings
from backend.exceptions import BaseAPIException
@@ -25,6 +30,49 @@ class BatchToggleTasksRequest(BaseModel):
is_active: bool
@router.get(
"/email_settings",
response_model=EmailNotificationSettingsResponse,
summary="获取邮件与通知设置",
)
async def get_email_settings(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
"""获取管理员可配置的 SMTP 与通知策略设置。"""
try:
return EmailSettingsService.get_response(db)
except EXPECTED_API_EXCEPTIONS:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取邮件设置失败: {str(e)}",
)
@router.put(
"/email_settings",
response_model=EmailNotificationSettingsResponse,
summary="更新邮件与通知设置",
)
async def update_email_settings(
request: EmailNotificationSettingsUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
"""更新管理员可配置的 SMTP 与通知策略设置。"""
try:
return EmailSettingsService.update_response(db, request)
except EXPECTED_API_EXCEPTIONS:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"更新邮件设置失败: {str(e)}",
)
@router.post("/batch_toggle_tasks", summary="批量启用/禁用任务")
async def batch_toggle_tasks(
request: BatchToggleTasksRequest,
@@ -0,0 +1,26 @@
from __future__ import annotations
from sqlalchemy import text
from sqlalchemy.engine import Connection
def apply(conn: Connection) -> None:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS email_notification_settings (
id INTEGER PRIMARY KEY,
smtp_server VARCHAR(255) NOT NULL DEFAULT '',
smtp_port INTEGER NOT NULL DEFAULT 465,
smtp_sender_email VARCHAR(255) NOT NULL DEFAULT '',
smtp_sender_password VARCHAR(500) NOT NULL DEFAULT '',
smtp_use_ssl BOOLEAN NOT NULL DEFAULT 1,
notify_token_expiring BOOLEAN NOT NULL DEFAULT 1,
notify_check_in_success BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME
)
"""
)
)
conn.commit()
+8
View File
@@ -9,6 +9,9 @@ from sqlalchemy import Engine, text
from sqlalchemy.engine import Connection
from backend.migration_steps.account_lockout import apply as apply_account_lockout
from backend.migration_steps.email_notification_settings import (
apply as apply_email_notification_settings,
)
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
from backend.models.database import engine as default_engine
@@ -86,6 +89,11 @@ MIGRATIONS: tuple[Migration, ...] = (
description="Add and backfill check-in task thread identity.",
apply=apply_task_thread_id,
),
Migration(
id="2026050501_add_email_notification_settings",
description="Add admin-managed email notification settings.",
apply=apply_email_notification_settings,
),
)
+11 -1
View File
@@ -3,5 +3,15 @@ from backend.models.user import User
from backend.models.check_in_task import CheckInTask
from backend.models.check_in_record import CheckInRecord
from backend.models.task_template import TaskTemplate
from backend.models.email_settings import EmailNotificationSettings
__all__ = ["Base", "get_db", "init_db", "User", "CheckInTask", "CheckInRecord", "TaskTemplate"]
__all__ = [
"Base",
"get_db",
"init_db",
"User",
"CheckInTask",
"CheckInRecord",
"TaskTemplate",
"EmailNotificationSettings",
]
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from backend.models.database import Base
class EmailNotificationSettings(Base):
"""系统邮件配置与通知策略设置。"""
__tablename__ = "email_notification_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
smtp_server: Mapped[str] = mapped_column(String(255), default="", nullable=False)
smtp_port: Mapped[int] = mapped_column(Integer, default=465, nullable=False)
smtp_sender_email: Mapped[str] = mapped_column(String(255), default="", nullable=False)
smtp_sender_password: Mapped[str] = mapped_column(String(500), default="", nullable=False)
smtp_use_ssl: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notify_token_expiring: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notify_check_in_success: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), comment="创建时间"
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=func.now(), comment="更新时间"
)
+6
View File
@@ -37,6 +37,10 @@ from backend.schemas.template import (
TaskFromTemplateRequest,
TemplatePreviewResponse,
)
from backend.schemas.email_settings import (
EmailNotificationSettingsResponse,
EmailNotificationSettingsUpdate,
)
__all__ = [
"UserBase",
@@ -68,4 +72,6 @@ __all__ = [
"TemplateResponse",
"TaskFromTemplateRequest",
"TemplatePreviewResponse",
"EmailNotificationSettingsResponse",
"EmailNotificationSettingsUpdate",
]
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from datetime import datetime
from email_validator import EmailNotValidError, validate_email
from pydantic import BaseModel, ConfigDict, Field, field_validator
class EmailNotificationSettingsBase(BaseModel):
smtp_server: str = Field("", max_length=255, description="SMTP 服务器地址")
smtp_port: int = Field(465, ge=1, le=65535, description="SMTP 端口")
smtp_sender_email: str = Field("", max_length=255, description="发件邮箱")
smtp_use_ssl: bool = Field(True, description="是否使用 SMTP SSL")
notify_token_expiring: bool = Field(True, description="是否通知 Token 即将过期")
notify_check_in_success: bool = Field(True, description="是否通知打卡成功")
@field_validator("smtp_server", "smtp_sender_email", mode="before")
@classmethod
def strip_text(cls, value: object) -> object:
if isinstance(value, str):
return value.strip()
return value
@field_validator("smtp_sender_email")
@classmethod
def validate_sender_email(cls, value: str) -> str:
if value == "":
return value
try:
return str(validate_email(value, check_deliverability=False).normalized)
except EmailNotValidError as exc:
raise ValueError("发件邮箱格式无效") from exc
class EmailNotificationSettingsUpdate(EmailNotificationSettingsBase):
smtp_sender_password: str | None = Field(
None,
max_length=500,
description="新 SMTP 密码;省略表示保留现有值",
)
clear_smtp_sender_password: bool = Field(False, description="是否清空 SMTP 密码")
@field_validator("smtp_sender_password")
@classmethod
def normalize_password(cls, value: str | None) -> str | None:
if value == "":
return None
return value
class EmailNotificationSettingsResponse(EmailNotificationSettingsBase):
model_config = ConfigDict(from_attributes=True)
id: int
has_smtp_sender_password: bool
created_at: datetime | None = None
updated_at: datetime | None = None
+9
View File
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
from backend.config import settings
from backend.models import User
from backend.services.email_settings_service import EmailSettingsService
from backend.services.email_templates import EmailTemplate, SafeHtml, render_email_template
from backend.utils.time_helpers import minutes_until_expiry, parse_jwt_exp
from backend.workers.email_notifier import EmailNotifier
@@ -229,6 +230,10 @@ class EmailService:
Returns:
是否发送成功
"""
if not EmailSettingsService.is_token_expiring_notification_enabled():
logger.info("Token 即将过期通知已关闭,跳过发送")
return False
user_email = user.email
if user_email is None:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 过期通知")
@@ -291,6 +296,10 @@ class EmailService:
Returns:
是否发送成功
"""
if success and not EmailSettingsService.is_check_in_success_notification_enabled():
logger.info("打卡成功通知已关闭,跳过发送")
return False
user_email = user.email
if user_email is None:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过打卡通知")
@@ -0,0 +1,194 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from backend.config import settings
from backend.models import EmailNotificationSettings
from backend.models.database import SessionLocal
from backend.schemas.email_settings import (
EmailNotificationSettingsResponse,
EmailNotificationSettingsUpdate,
)
SETTINGS_ROW_ID = 1
@dataclass(frozen=True)
class EmailSettingsSnapshot:
id: int
smtp_server: str
smtp_port: int
smtp_sender_email: str
smtp_sender_password: str
smtp_use_ssl: bool
notify_token_expiring: bool
notify_check_in_success: bool
has_smtp_sender_password: bool
created_at: datetime | None = None
updated_at: datetime | None = None
class EmailSettingsService:
"""读取和更新系统邮件配置。"""
@staticmethod
def _defaults() -> EmailNotificationSettings:
return EmailNotificationSettings(
id=SETTINGS_ROW_ID,
smtp_server=settings.SMTP_SERVER,
smtp_port=settings.SMTP_PORT,
smtp_sender_email=settings.SMTP_SENDER_EMAIL,
smtp_sender_password=settings.SMTP_SENDER_PASSWORD,
smtp_use_ssl=settings.SMTP_USE_SSL,
notify_token_expiring=True,
notify_check_in_success=True,
)
@staticmethod
def _default_snapshot() -> EmailSettingsSnapshot:
password = str(settings.SMTP_SENDER_PASSWORD or "")
return EmailSettingsSnapshot(
id=SETTINGS_ROW_ID,
smtp_server=settings.SMTP_SERVER,
smtp_port=settings.SMTP_PORT,
smtp_sender_email=settings.SMTP_SENDER_EMAIL,
smtp_sender_password=password,
smtp_use_ssl=settings.SMTP_USE_SSL,
notify_token_expiring=True,
notify_check_in_success=True,
has_smtp_sender_password=bool(password),
created_at=None,
updated_at=None,
)
@staticmethod
def _get_or_create(db: Session) -> EmailNotificationSettings:
row = (
db.query(EmailNotificationSettings)
.filter(EmailNotificationSettings.id == SETTINGS_ROW_ID)
.first()
)
if row:
return row
row = EmailSettingsService._defaults()
db.add(row)
db.commit()
db.refresh(row)
return row
@staticmethod
def _to_snapshot(row: EmailNotificationSettings) -> EmailSettingsSnapshot:
password = str(row.smtp_sender_password or "")
return EmailSettingsSnapshot(
id=row.id,
smtp_server=str(row.smtp_server or ""),
smtp_port=int(row.smtp_port or 0),
smtp_sender_email=str(row.smtp_sender_email or ""),
smtp_sender_password=password,
smtp_use_ssl=bool(row.smtp_use_ssl),
notify_token_expiring=bool(row.notify_token_expiring),
notify_check_in_success=bool(row.notify_check_in_success),
has_smtp_sender_password=bool(password),
created_at=row.created_at,
updated_at=row.updated_at,
)
@staticmethod
def _to_response(snapshot: EmailSettingsSnapshot) -> EmailNotificationSettingsResponse:
return EmailNotificationSettingsResponse(
id=snapshot.id,
smtp_server=snapshot.smtp_server,
smtp_port=snapshot.smtp_port,
smtp_sender_email=snapshot.smtp_sender_email,
smtp_use_ssl=snapshot.smtp_use_ssl,
notify_token_expiring=snapshot.notify_token_expiring,
notify_check_in_success=snapshot.notify_check_in_success,
has_smtp_sender_password=snapshot.has_smtp_sender_password,
created_at=snapshot.created_at,
updated_at=snapshot.updated_at,
)
@staticmethod
def get_snapshot(db: Session) -> EmailSettingsSnapshot:
return EmailSettingsService._to_snapshot(EmailSettingsService._get_or_create(db))
@staticmethod
def get_response(db: Session) -> EmailNotificationSettingsResponse:
return EmailSettingsService._to_response(EmailSettingsService.get_snapshot(db))
@staticmethod
def update_settings(
db: Session, payload: EmailNotificationSettingsUpdate
) -> EmailSettingsSnapshot:
row = EmailSettingsService._get_or_create(db)
row.smtp_server = payload.smtp_server
row.smtp_port = payload.smtp_port
row.smtp_sender_email = str(payload.smtp_sender_email)
row.smtp_use_ssl = payload.smtp_use_ssl
row.notify_token_expiring = payload.notify_token_expiring
row.notify_check_in_success = payload.notify_check_in_success
if payload.clear_smtp_sender_password:
row.smtp_sender_password = ""
elif payload.smtp_sender_password is not None:
row.smtp_sender_password = payload.smtp_sender_password
row.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return EmailSettingsService._to_snapshot(row)
@staticmethod
def update_response(
db: Session, payload: EmailNotificationSettingsUpdate
) -> EmailNotificationSettingsResponse:
return EmailSettingsService._to_response(EmailSettingsService.update_settings(db, payload))
@staticmethod
def get_smtp_config() -> dict[str, object] | None:
db = SessionLocal()
try:
try:
snapshot = EmailSettingsService.get_snapshot(db)
except SQLAlchemyError:
snapshot = EmailSettingsService._default_snapshot()
finally:
db.close()
if not snapshot.smtp_server or not snapshot.smtp_sender_email or not snapshot.smtp_port:
return None
return {
"smtp_server": snapshot.smtp_server,
"smtp_port": snapshot.smtp_port,
"sender_email": snapshot.smtp_sender_email,
"sender_password": snapshot.smtp_sender_password,
"use_ssl": snapshot.smtp_use_ssl,
}
@staticmethod
def is_token_expiring_notification_enabled() -> bool:
db = SessionLocal()
try:
try:
return EmailSettingsService.get_snapshot(db).notify_token_expiring
except SQLAlchemyError:
return EmailSettingsService._default_snapshot().notify_token_expiring
finally:
db.close()
@staticmethod
def is_check_in_success_notification_enabled() -> bool:
db = SessionLocal()
try:
try:
return EmailSettingsService.get_snapshot(db).notify_check_in_success
except SQLAlchemyError:
return EmailSettingsService._default_snapshot().notify_check_in_success
finally:
db.close()
+5 -16
View File
@@ -14,7 +14,7 @@ from email.mime.multipart import MIMEMultipart
import logging
from typing import List, Optional
from backend.config import settings
from backend.services.email_settings_service import EmailSettingsService
logger = logging.getLogger(__name__)
@@ -25,30 +25,19 @@ class EmailNotifier:
@staticmethod
def get_email_config() -> Optional[dict]:
"""
从环境变量读取邮件配置
读取有效邮件配置
Returns:
dict: 邮件配置,如果配置不完整则返回 None
"""
# 检查必要的邮件配置是否存在
if not settings.SMTP_SERVER or not settings.SMTP_SENDER_EMAIL:
email_config = EmailSettingsService.get_smtp_config()
if not email_config:
logger.debug(
"邮件配置未完成(SMTP_SERVER 或 SMTP_SENDER_EMAIL 为空),邮件发送功能已禁用"
)
return None
if not settings.SMTP_PORT:
logger.debug("邮件配置未完成(SMTP_PORT 为空),邮件发送功能已禁用")
return None
# 返回配置字典
return {
"smtp_server": settings.SMTP_SERVER,
"smtp_port": settings.SMTP_PORT,
"sender_email": settings.SMTP_SENDER_EMAIL,
"sender_password": settings.SMTP_SENDER_PASSWORD,
"use_ssl": settings.SMTP_USE_SSL,
}
return email_config
@staticmethod
def send_email(