diff --git a/apps/backend/api/admin.py b/apps/backend/api/admin.py index c291d8f..bcbe261 100644 --- a/apps/backend/api/admin.py +++ b/apps/backend/api/admin.py @@ -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, diff --git a/apps/backend/migration_steps/email_notification_settings.py b/apps/backend/migration_steps/email_notification_settings.py new file mode 100644 index 0000000..6c677f7 --- /dev/null +++ b/apps/backend/migration_steps/email_notification_settings.py @@ -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() diff --git a/apps/backend/migrations.py b/apps/backend/migrations.py index 6f52d53..8bfc265 100644 --- a/apps/backend/migrations.py +++ b/apps/backend/migrations.py @@ -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, + ), ) diff --git a/apps/backend/models/__init__.py b/apps/backend/models/__init__.py index ae72d2f..abff3c2 100644 --- a/apps/backend/models/__init__.py +++ b/apps/backend/models/__init__.py @@ -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", +] diff --git a/apps/backend/models/email_settings.py b/apps/backend/models/email_settings.py new file mode 100644 index 0000000..8f0e3bc --- /dev/null +++ b/apps/backend/models/email_settings.py @@ -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="更新时间" + ) diff --git a/apps/backend/schemas/__init__.py b/apps/backend/schemas/__init__.py index 0f85b9f..837b8a3 100644 --- a/apps/backend/schemas/__init__.py +++ b/apps/backend/schemas/__init__.py @@ -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", ] diff --git a/apps/backend/schemas/email_settings.py b/apps/backend/schemas/email_settings.py new file mode 100644 index 0000000..56baaca --- /dev/null +++ b/apps/backend/schemas/email_settings.py @@ -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 diff --git a/apps/backend/services/email_service.py b/apps/backend/services/email_service.py index 8aeb7ad..5c51b13 100644 --- a/apps/backend/services/email_service.py +++ b/apps/backend/services/email_service.py @@ -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} 未设置邮箱,跳过打卡通知") diff --git a/apps/backend/services/email_settings_service.py b/apps/backend/services/email_settings_service.py new file mode 100644 index 0000000..fb5bf76 --- /dev/null +++ b/apps/backend/services/email_settings_service.py @@ -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() diff --git a/apps/backend/workers/email_notifier.py b/apps/backend/workers/email_notifier.py index 4d8ecf4..66bc892 100644 --- a/apps/backend/workers/email_notifier.py +++ b/apps/backend/workers/email_notifier.py @@ -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( diff --git a/apps/frontend/src/App.vue b/apps/frontend/src/App.vue index c6952eb..03d5e85 100644 --- a/apps/frontend/src/App.vue +++ b/apps/frontend/src/App.vue @@ -16,6 +16,7 @@ import AdminTemplatesView from '@/views/admin/AdminTemplatesView.vue' import AdminRecordsView from '@/views/admin/AdminRecordsView.vue' import AdminLogsView from '@/views/admin/AdminLogsView.vue' import AdminStatsView from '@/views/admin/AdminStatsView.vue' +import AdminEmailSettingsView from '@/views/admin/AdminEmailSettingsView.vue' const router = useRouter() const auth = useAuth() @@ -46,6 +47,8 @@ const view = computed(() => { return AdminLogsView case 'admin-stats': return AdminStatsView + case 'admin-email-settings': + return AdminEmailSettingsView default: return NotFoundView } diff --git a/apps/frontend/src/api/index.ts b/apps/frontend/src/api/index.ts index 51864b5..7e88216 100644 --- a/apps/frontend/src/api/index.ts +++ b/apps/frontend/src/api/index.ts @@ -6,6 +6,8 @@ import type { CheckInStartResponse, CreateTaskFromTemplatePayload, CronValidation, + EmailNotificationSettings, + EmailNotificationSettingsUpdate, LoginResponse, LogsResponse, PaginatedResponse, @@ -118,6 +120,9 @@ export const adminApi = { ), batchCheckIn: (task_ids: number[]) => apiClient.post('/api/admin/batch_check_in', { task_ids }), + emailSettings: () => apiClient.get('/api/admin/email_settings'), + updateEmailSettings: (payload: EmailNotificationSettingsUpdate) => + apiClient.put('/api/admin/email_settings', payload), } export type * from './types' diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index 5685fba..fd625dd 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -202,6 +202,30 @@ export interface LogsResponse { logs: string } +export interface EmailNotificationSettings { + id: number + smtp_server: string + smtp_port: number + smtp_sender_email: string + smtp_use_ssl: boolean + notify_token_expiring: boolean + notify_check_in_success: boolean + has_smtp_sender_password: boolean + created_at?: string | null + updated_at?: string | null +} + +export interface EmailNotificationSettingsUpdate { + smtp_server: string + smtp_port: number + smtp_sender_email: string + smtp_use_ssl: boolean + notify_token_expiring: boolean + notify_check_in_success: boolean + smtp_sender_password?: string + clear_smtp_sender_password?: boolean +} + export interface CronValidation { valid: boolean message: string diff --git a/apps/frontend/src/app/router.ts b/apps/frontend/src/app/router.ts index a55eae3..84e1f0d 100644 --- a/apps/frontend/src/app/router.ts +++ b/apps/frontend/src/app/router.ts @@ -14,6 +14,7 @@ export type RouteKey = | 'admin-records' | 'admin-logs' | 'admin-stats' + | 'admin-email-settings' | 'not-found' export interface AppRoute { @@ -67,6 +68,13 @@ export const routes: AppRoute[] = [ requiresAuth: true, requiresAdmin: true, }, + { + key: 'admin-email-settings', + path: '/admin/email-settings', + title: '邮件设置', + requiresAuth: true, + requiresAdmin: true, + }, { key: 'not-found', path: '/:pathMatch(.*)*', title: '页面未找到' }, ] diff --git a/apps/frontend/src/components/AppLayout.vue b/apps/frontend/src/components/AppLayout.vue index a229c0b..038faca 100644 --- a/apps/frontend/src/components/AppLayout.vue +++ b/apps/frontend/src/components/AppLayout.vue @@ -7,6 +7,7 @@ import { FileText, LayoutDashboard, LogOut, + Mail, Menu, Monitor, MoonStar, @@ -43,6 +44,7 @@ const adminLinks = [ { path: '/admin/records', label: '全量记录', icon: ScrollText }, { path: '/admin/logs', label: '日志', icon: Shield }, { path: '/admin/stats', label: '统计', icon: BarChart3 }, + { path: '/admin/email-settings', label: '邮件', icon: Mail }, ] const title = computed(() => router.current.value.title) diff --git a/apps/frontend/src/views/admin/AdminEmailSettingsView.vue b/apps/frontend/src/views/admin/AdminEmailSettingsView.vue new file mode 100644 index 0000000..780152f --- /dev/null +++ b/apps/frontend/src/views/admin/AdminEmailSettingsView.vue @@ -0,0 +1,231 @@ + + + diff --git a/pyproject.toml b/pyproject.toml index 6e8a43e..9ba80a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ redis = [ [dependency-groups] dev = [ + "httpx>=0.28.1", "pre-commit>=4.0.0", "pytest>=8.0.0", "pytest-asyncio>=0.24.0", diff --git a/tests/test_backend_auto_migrations.py b/tests/test_backend_auto_migrations.py index 9f22f23..06e18ab 100644 --- a/tests/test_backend_auto_migrations.py +++ b/tests/test_backend_auto_migrations.py @@ -13,6 +13,9 @@ from backend.migrations import ( run_pending_migrations, ) 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 @@ -75,13 +78,36 @@ def test_existing_migrations_are_registered_in_order() -> None: assert [migration.id for migration in MIGRATIONS] == [ "2026050401_add_account_lockout", "2026050402_add_task_thread_id", + "2026050501_add_email_notification_settings", ] assert [migration.apply.__module__ for migration in MIGRATIONS] == [ "backend.migration_steps.account_lockout", "backend.migration_steps.task_thread_id", + "backend.migration_steps.email_notification_settings", ] +def test_email_notification_settings_migration_creates_settings_table() -> None: + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + + with engine.connect() as conn: + apply_email_notification_settings(conn) + + columns = { + row[1] for row in conn.execute(text("PRAGMA table_info(email_notification_settings)")) + } + + assert { + "smtp_server", + "smtp_port", + "smtp_sender_email", + "smtp_sender_password", + "smtp_use_ssl", + "notify_token_expiring", + "notify_check_in_success", + } <= columns + + def test_account_lockout_migration_adds_missing_user_fields() -> None: engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) diff --git a/tests/test_email_notification_settings.py b/tests/test_email_notification_settings.py new file mode 100644 index 0000000..5fea8ca --- /dev/null +++ b/tests/test_email_notification_settings.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pydantic import ValidationError +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from backend.api import admin as admin_api +from backend.dependencies import get_current_user, get_db +from backend.models import Base, User +from backend.schemas.email_settings import EmailNotificationSettingsUpdate +from backend.services import email_settings_service +from backend.services.email_service import EmailService +from backend.services.email_settings_service import EmailSettingsService + + +def make_session(): + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine) + session = session_factory() + return engine, session_factory, session + + +def test_email_settings_default_row_uses_environment_defaults_and_masks_password( + monkeypatch, +) -> None: + engine, _, session = make_session() + monkeypatch.setattr(email_settings_service.settings, "SMTP_SERVER", "smtp.example.test") + monkeypatch.setattr(email_settings_service.settings, "SMTP_PORT", 2525) + monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_EMAIL", "mailer@example.com") + monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_PASSWORD", "secret") + monkeypatch.setattr(email_settings_service.settings, "SMTP_USE_SSL", False) + + snapshot = EmailSettingsService.get_snapshot(session) + + assert snapshot.smtp_server == "smtp.example.test" + assert snapshot.smtp_port == 2525 + assert snapshot.smtp_sender_email == "mailer@example.com" + assert snapshot.smtp_sender_password == "secret" + assert snapshot.has_smtp_sender_password is True + session.close() + engine.dispose() + + +def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> None: + engine, _, session = make_session() + monkeypatch.setattr(email_settings_service.settings, "SMTP_SERVER", "smtp.example.test") + monkeypatch.setattr(email_settings_service.settings, "SMTP_PORT", 2525) + monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_EMAIL", "mailer@example.com") + monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_PASSWORD", "") + monkeypatch.setattr(email_settings_service.settings, "SMTP_USE_SSL", False) + + EmailSettingsService.get_snapshot(session) + EmailSettingsService.update_settings( + session, + EmailNotificationSettingsUpdate( + smtp_server="smtp.changed.test", + smtp_port=587, + smtp_sender_email="ops@example.com", + smtp_use_ssl=True, + notify_token_expiring=False, + notify_check_in_success=False, + smtp_sender_password="new-secret", + ), + ) + + updated = EmailSettingsService.get_snapshot(session) + assert updated.smtp_server == "smtp.changed.test" + assert updated.smtp_port == 587 + assert updated.smtp_sender_email == "ops@example.com" + assert updated.smtp_sender_password == "new-secret" + assert updated.notify_token_expiring is False + assert updated.notify_check_in_success is False + + EmailSettingsService.update_settings( + session, + EmailNotificationSettingsUpdate( + smtp_server="smtp.changed.test", + smtp_port=587, + smtp_sender_email="ops@example.com", + smtp_use_ssl=True, + notify_token_expiring=False, + notify_check_in_success=False, + clear_smtp_sender_password=True, + ), + ) + + cleared = EmailSettingsService.get_snapshot(session) + assert cleared.smtp_sender_password in ("", None) + assert cleared.has_smtp_sender_password is False + session.close() + engine.dispose() + + +def test_admin_email_settings_route_rejects_non_admin() -> None: + engine, session_factory, session = make_session() + app = FastAPI() + app.include_router(admin_api.router, prefix="/api/admin") + + def override_get_db(): + yield session + + async def override_get_current_user() -> User: + return User(id=1, alias="user", role="user", is_approved=True) + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_current_user] = override_get_current_user + + client = TestClient(app) + response = client.get("/api/admin/email_settings") + + assert response.status_code == 403 + session.close() + engine.dispose() + + +def test_token_expiring_notification_can_be_disabled(monkeypatch) -> None: + engine, session_factory, session = make_session() + monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory) + EmailSettingsService.update_settings( + session, + EmailNotificationSettingsUpdate( + smtp_server="smtp.example.test", + smtp_port=465, + smtp_sender_email="mailer@example.com", + smtp_use_ssl=True, + notify_token_expiring=False, + notify_check_in_success=True, + ), + ) + + sent: dict[str, object] = {} + + def fake_send(*args, **kwargs) -> bool: + sent["called"] = True + return True + + monkeypatch.setattr(EmailService, "send_email", fake_send) + + user = User(alias="Alice", email="alice@example.com") + assert EmailService.notify_token_expiring(user, "1000") is False + assert sent == {} + session.close() + engine.dispose() + + +def test_success_check_in_notification_can_be_disabled(monkeypatch) -> None: + engine, session_factory, session = make_session() + monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory) + EmailSettingsService.update_settings( + session, + EmailNotificationSettingsUpdate( + smtp_server="smtp.example.test", + smtp_port=465, + smtp_sender_email="mailer@example.com", + smtp_use_ssl=True, + notify_token_expiring=True, + notify_check_in_success=False, + ), + ) + + sent: dict[str, object] = {} + + def fake_send(*args, **kwargs) -> bool: + sent["called"] = True + return True + + monkeypatch.setattr(EmailService, "send_email", fake_send) + + user = User(alias="Alice", email="alice@example.com") + assert ( + EmailService.notify_check_in_result( + user, + {"thread_id": "thread-1"}, + True, + "打卡成功", + ) + is False + ) + assert sent == {} + session.close() + engine.dispose() + + +def test_email_settings_update_validates_sender_email() -> None: + with pytest.raises(ValidationError): + EmailNotificationSettingsUpdate( + smtp_server="smtp.example.test", + smtp_port=465, + smtp_sender_email="not-an-email", + smtp_use_ssl=True, + notify_token_expiring=True, + notify_check_in_success=True, + ) diff --git a/tests/test_frontend_architecture.py b/tests/test_frontend_architecture.py index ad28fb8..68b71f8 100644 --- a/tests/test_frontend_architecture.py +++ b/tests/test_frontend_architecture.py @@ -29,6 +29,7 @@ def test_frontend_has_business_app_structure() -> None: "views/admin/AdminRecordsView.vue", "views/admin/AdminLogsView.vue", "views/admin/AdminStatsView.vue", + "views/admin/AdminEmailSettingsView.vue", "utils/format.ts", ] @@ -53,10 +54,17 @@ def test_frontend_routes_cover_user_and_admin_workflows() -> None: "/admin/records", "/admin/logs", "/admin/stats", + "/admin/email-settings", ]: assert path in router +def test_frontend_admin_api_covers_email_settings() -> None: + api = (SRC_ROOT / "api" / "index.ts").read_text(encoding="utf-8") + + assert "/api/admin/email_settings" in api + + def test_frontend_replaces_starter_component() -> None: app = (SRC_ROOT / "App.vue").read_text(encoding="utf-8") diff --git a/uv.lock b/uv.lock index a9db5c5..4bbed4a 100644 --- a/uv.lock +++ b/uv.lock @@ -233,6 +233,7 @@ redis = [ [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -262,6 +263,7 @@ provides-extras = ["production", "redis"] [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.28.1" }, { name = "pre-commit", specifier = ">=4.0.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, @@ -437,6 +439,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -466,6 +481,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.19"