diff --git a/README.md b/README.md index d0d28657..7a99c823 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ TopicEye continuously crawls 25+ sources (RSS, Reddit, YouTube, podcasts, newsle | **Webnovel radar** | Fanqie / Qimao / Zhihu Yanxuan trending charts, gated behind a runtime feature flag. | | **Mother topics** | Multi-tenant topic templates — admins maintain the system library, users fork their own and customize keywords, weights, and target readers. Fork edits take effect immediately in the scoring queue. | | **AI model evaluation** | Admin UI to compare LLM models side-by-side on the same prompts, track quality and cost, and pick the right routing group per task. | -| **Email verification** | Transactional email via Brevo API or any SMTP provider (QQ Enterprise / Gmail / etc.), configured from the admin settings page. | +| **Email verification** | Transactional email via any standard SMTP provider (QQ Enterprise / Gmail / self-hosted etc.), configured from the admin settings page. | | **Article reader** | In-app reader for source URLs — fetches public HTML only, no auth/cookies/captcha bypass, with SSRF guard and snapshot caching. | | **Favorites** | Save items across sessions (per-user). | | **My topics** | Personalized topic configuration with mother-topic fork, keyword filters, and scoring overrides. | @@ -114,7 +114,7 @@ api/v1/ ──► services/ ──► repositories/ ──► models/ ──► | `models/` | Pure ORM declarations, field definitions, `__table_args__` | Business methods, side effects, IO | | `schemas/` | Pydantic request/response models, serialization | ORM imports, DB access | -Cross-cutting: `core/` (config, DB, logging, retry), `middleware/` (rate limit, request metrics), `services/email/` (Brevo + SMTP), `services/llm/` (failover, circuit breaker, response cache), `services/scrapers/` + `services/trending_scrapers/` (per-source fetchers). +Cross-cutting: `core/` (config, DB, logging, retry), `middleware/` (rate limit, request metrics), `services/email/` (SMTP), `services/llm/` (failover, circuit breaker, response cache), `services/scrapers/` + `services/trending_scrapers/` (per-source fetchers). ### Scoring engine @@ -164,7 +164,7 @@ Full config in [backend/app/services/scoring_engine.py](backend/app/services/sco - **Frontend:** Next.js 16 · React 19 · TypeScript · Tailwind CSS v4 - **Database:** PostgreSQL 16 · DuckDB as a read-only analytics layer over OLTP - **Auth:** Opaque bearer tokens (DB-hashed) + OAuth via Authlib -- **Email:** Brevo API or SMTP (user-configurable per deployment) +- **Email:** any standard SMTP server (user-configurable per deployment) - **Infra:** Docker / docker-compose (dev + prod) · APScheduler ## Quick start @@ -248,9 +248,9 @@ All configuration is environment-driven. See [`backend/.env.example`](backend/.e > > **Third-party login** (Google / GitHub) is configured from the admin settings page (**Admin → Settings → Third-party Login**): client id / secret and the enable toggle are stored in the database (secret encrypted), effective immediately without a restart. > -> **Email verification** is configured from the admin settings page. Two providers are supported: -> - **Brevo API** — free tier 300 emails/day, no credit card required but account approval needed. -> - **SMTP** — bring your own provider (QQ Enterprises / Gmail / etc.), no approval needed. +> **Email verification** is configured from the admin settings page: bring your own SMTP server +> (QQ Enterprises / Gmail / self-hosted Postfix etc.). Some providers (QQ / 163) require an +> app-specific authorization code instead of the login password. ## Development diff --git a/README.zh-CN.md b/README.zh-CN.md index a4627f67..034bdf21 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,7 +86,7 @@ AI 驱动的内容发现与选题分析平台。TopicEye 持续抓取 25+ 信源 | 网文雷达 | 番茄 / 七猫 / 知乎盐选热榜,受运行时功能开关控制,默认关闭 | | 母题模板 | 多租户模型——管理员维护系统模板库(只读),用户首次访问时自动 fork 一份到自己名下,可自定义关键词、权重、目标读者。修改立即影响打分队列。 | | AI 模型评测 | 后台 UI 对比多个 LLM 在同一 prompt 下的表现,追踪质量与成本,为不同任务选合适的路由组。 | -| 邮件验证 | 事务邮件支持 Brevo API 或任意 SMTP(QQ 企业邮 / Gmail 等),在管理后台设置页配置。 | +| 邮件验证 | 事务邮件走任意标准 SMTP(QQ 企业邮 / Gmail / 自建等),在管理后台设置页配置。 | | 站内阅读 | 对原文链接的站内阅读器——仅抓取公开 HTML,不使用登录态 / Cookie / 验证码 / 反爬绕过,带 SSRF 防护与快照缓存。 | | 收藏夹 | 跨会话保存感兴趣的内容(按用户隔离) | | 我的选题 | 个性化选题配置,支持母题 fork、关键词过滤、评分覆盖 | @@ -112,7 +112,7 @@ api/v1/ ──► services/ ──► repositories/ ──► models/ ──► | `models/` | 纯 ORM 声明、字段定义、`__table_args__` | 业务方法、副作用、IO | | `schemas/` | Pydantic 请求/响应模型、序列化 | ORM import、DB 访问 | -跨层支撑:`core/`(配置、DB、日志、retry)、`middleware/`(限流、请求指标)、`services/email/`(Brevo + SMTP)、`services/llm/`(failover / 熔断 / 响应缓存)、`services/scrapers/` + `services/trending_scrapers/`(按信源的抓取器)。 +跨层支撑:`core/`(配置、DB、日志、retry)、`middleware/`(限流、请求指标)、`services/email/`(SMTP)、`services/llm/`(failover / 熔断 / 响应缓存)、`services/scrapers/` + `services/trending_scrapers/`(按信源的抓取器)。 ### 评分引擎 @@ -162,7 +162,7 @@ api/v1/ ──► services/ ──► repositories/ ──► models/ ──► - **前端:** Next.js 16 · React 19 · TypeScript · Tailwind CSS v4 - **数据库:** PostgreSQL 16 · DuckDB 作为 OLTP 的只读分析层 - **认证:** 不透明 bearer token(DB hash)+ Authlib OAuth -- **邮件:** Brevo API 或 SMTP(按部署自行配置) +- **邮件:** 任意标准 SMTP 服务器(按部署自行配置) - **基础设施:** Docker / docker-compose(dev + prod)· APScheduler ## 快速开始 @@ -246,9 +246,8 @@ npm run dev > > **第三方登录**(Google / GitHub)在后台配置(**管理后台 → 系统设置 → 第三方登录**):client id / secret 与启用开关存数据库(secret 加密存储),保存后即时生效,无需重启。 > -> **邮件验证** 在管理后台设置页配置,两种 provider: -> - **Brevo API** — 免费版 300 邮件/天,无需信用卡,但需账号审核。 -> - **SMTP** — 自带任意 provider(QQ 企业邮 / Gmail 等),无需审核。 +> **邮件验证** 在管理后台设置页配置:自带任意标准 SMTP 服务器(QQ 企业邮 / Gmail / +> 自建 Postfix 等)。部分邮箱(QQ / 163)需使用授权码而非登录密码。 ## 开发 diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 45779c59..a1197122 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -167,41 +167,36 @@ async def update_feature_flags( # ── Email provider (邮件服务配置) ───────────────────────────────────── -# 支持的邮件 Provider 列表,前端据此渲染下拉选项 -SUPPORTED_EMAIL_PROVIDERS = ["brevo", "smtp"] +# 支持的邮件 Provider 列表(#108 起仅剩自定义 SMTP,Brevo 已移除) +SUPPORTED_EMAIL_PROVIDERS = ["smtp"] -# API Key / SMTP 密码脱敏前缀长度 +# SMTP 密码脱敏前缀长度 _SECRET_MASK_PREFIX = 6 class EmailProviderConfigResponse(BaseModel): """邮件 Provider 配置响应。敏感字段脱敏返回。""" - provider: str = "brevo" + provider: str = "smtp" from_email: str = "" from_name: str = "TopicEye" - api_key_configured: bool = False - api_key_preview: str = "" smtp_host: str = "" smtp_port: int = 587 smtp_username: str = "" smtp_password_configured: bool = False smtp_password_preview: str = "" smtp_use_ssl: bool = False - supported_providers: list[str] = [] class EmailProviderConfigUpdateRequest(BaseModel): """邮件 Provider 配置更新请求。 - api_key / smtp_password 为空字符串时保留原值(不修改),非空时覆盖。 - SMTP 字段仅在 provider == 'smtp' 时生效。 + smtp_password 为空字符串时保留原值(不修改),非空时覆盖。 """ - provider: str = "brevo" + provider: str = "smtp" from_email: str = "" from_name: str = "TopicEye" - api_key: str = "" smtp_host: str = "" smtp_port: int = 587 smtp_username: str = "" @@ -211,17 +206,17 @@ class EmailProviderConfigUpdateRequest(BaseModel): @router.get("/email-provider", response_model=EmailProviderConfigResponse) async def get_email_provider_config(db: AsyncSession = Depends(get_db)): - """获取当前邮件 Provider 配置。敏感字段(api_key / smtp_password)不返回明文,仅返回脱敏预览。""" + """获取当前邮件 Provider 配置。敏感字段(smtp_password)不返回明文,仅返回脱敏预览。""" row = await AppSettingRepository(db).get_by_key("email_provider_config") if not row or not row.value: - return EmailProviderConfigResponse(supported_providers=SUPPORTED_EMAIL_PROVIDERS) + return EmailProviderConfigResponse() try: config = json.loads(row.value) except json.JSONDecodeError: logger.warning("email_provider_config JSON 损坏,返回默认配置") - return EmailProviderConfigResponse(supported_providers=SUPPORTED_EMAIL_PROVIDERS) + return EmailProviderConfigResponse() from app.services.secret_store import decrypt_secret @@ -231,22 +226,25 @@ def _mask(plain: str) -> str: mask_len = min(_SECRET_MASK_PREFIX, len(plain)) return plain[:mask_len] + "****" - api_key_plain = decrypt_secret(config.get("api_key", "")) or "" + stored_provider = config.get("provider", "smtp") + if stored_provider not in SUPPORTED_EMAIL_PROVIDERS: + # #108 移除 Brevo 后的存量配置:归一展示为 smtp(字段自然为空), + # 邮件发送侧同样显式降级——管理员重配 SMTP 后恢复 + logger.warning("email_provider_config 存量 provider=%r 已不受支持,按未配置处理", stored_provider) + return EmailProviderConfigResponse() + smtp_password_plain = decrypt_secret(config.get("smtp_password", "")) or "" return EmailProviderConfigResponse( - provider=config.get("provider", "brevo"), + provider=stored_provider, from_email=config.get("from_email", ""), from_name=config.get("from_name", "TopicEye"), - api_key_configured=bool(api_key_plain), - api_key_preview=_mask(api_key_plain), smtp_host=config.get("smtp_host", ""), smtp_port=int(config.get("smtp_port", 587)), smtp_username=config.get("smtp_username", ""), smtp_password_configured=bool(smtp_password_plain), smtp_password_preview=_mask(smtp_password_plain), smtp_use_ssl=bool(config.get("smtp_use_ssl", False)), - supported_providers=SUPPORTED_EMAIL_PROVIDERS, ) @@ -257,21 +255,20 @@ async def update_email_provider_config( ): """更新邮件 Provider 配置。 - 敏感字段(api_key / smtp_password)为空时保留原值,非空时加密覆盖。 - SMTP 专属字段仅在 provider == 'smtp' 时校验必填。 + 敏感字段(smtp_password)为空时保留原值,非空时加密覆盖。 """ if payload.provider not in SUPPORTED_EMAIL_PROVIDERS: - raise HTTPException(status_code=400, detail=f"不支持的邮件 Provider: {payload.provider}") + raise HTTPException( + status_code=400, + detail=f"不支持的邮件 Provider: {payload.provider}(Brevo 已于 #108 移除,请使用自定义 SMTP)", + ) if not payload.from_email.strip(): raise HTTPException(status_code=400, detail="发件人邮箱不能为空") - - # SMTP 必填校验 - if payload.provider == "smtp": - if not payload.smtp_host.strip(): - raise HTTPException(status_code=400, detail="SMTP 服务器不能为空") - if not payload.smtp_username.strip(): - raise HTTPException(status_code=400, detail="SMTP 用户名不能为空") + if not payload.smtp_host.strip(): + raise HTTPException(status_code=400, detail="SMTP 服务器不能为空") + if not payload.smtp_username.strip(): + raise HTTPException(status_code=400, detail="SMTP 用户名不能为空") from app.services.secret_store import encrypt_secret @@ -286,9 +283,6 @@ async def update_email_provider_config( existing_config = {} # 敏感字段处理:空值保留原值,非空值加密覆盖 - api_key_stored = ( - encrypt_secret(payload.api_key.strip()) if payload.api_key.strip() else existing_config.get("api_key", "") - ) smtp_password_stored = ( encrypt_secret(payload.smtp_password.strip()) if payload.smtp_password.strip() @@ -299,7 +293,6 @@ async def update_email_provider_config( "provider": payload.provider, "from_email": payload.from_email.strip(), "from_name": payload.from_name.strip() or "TopicEye", - "api_key": api_key_stored, "smtp_host": payload.smtp_host.strip(), "smtp_port": int(payload.smtp_port) if payload.smtp_port else 587, "smtp_username": payload.smtp_username.strip(), diff --git a/backend/app/services/email/__init__.py b/backend/app/services/email/__init__.py index b42702de..3bb6f5f9 100644 --- a/backend/app/services/email/__init__.py +++ b/backend/app/services/email/__init__.py @@ -1,6 +1,7 @@ """邮件发送服务模块。 -提供可配置的邮件 Provider 抽象,当前默认实现为 Brevo。 +提供可配置的邮件 Provider 抽象,当前唯一实现为自定义 SMTP +(#108 起仅剩 SMTP,Brevo 已移除)。 新增 Provider 只需继承 EmailProvider 并在 factory 中注册。 """ # author: fxbin diff --git a/backend/app/services/email/brevo_provider.py b/backend/app/services/email/brevo_provider.py deleted file mode 100644 index 321356f0..00000000 --- a/backend/app/services/email/brevo_provider.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Brevo 邮件 Provider 实现。 - -通过 Brevo(原 Sendinblue)REST API 发送事务邮件。 -免费额度:300 封/天 ≈ 9000 封/月,无需信用卡。 -官方文档:https://developers.brevo.com/docs/getting-started -""" -# author: fxbin - -from __future__ import annotations - -import logging - -import httpx - -from app.services.email.base import EmailProvider, EmailSendError - -logger = logging.getLogger(__name__) - -# Brevo API 基础地址与发送端点 -_BREVO_API_BASE = "https://api.brevo.com/v3" -_BREVO_SEND_ENDPOINT = f"{_BREVO_API_BASE}/smtp/email" - -# HTTP 请求超时时间(秒) -_BREVO_TIMEOUT_SECONDS = 10.0 - -# 邮件主题 -_EMAIL_SUBJECT = "【TopicEye】邮箱验证码" - - -class BrevoProvider(EmailProvider): - """Brevo 邮件发送 Provider。 - - 通过 Brevo SMTP REST API 发送验证码邮件,需要在管理员后台配置 API Key。 - """ - - def __init__(self, *, api_key: str, from_email: str, from_name: str) -> None: - """初始化 Brevo Provider。 - - 参数: - api_key: Brevo API Key(以 xkeysib- 开头) - from_email: 发件人邮箱(需在 Brevo 后台完成域名认证) - from_name: 发件人显示名称 - """ - self._api_key = api_key - self._from_email = from_email - self._from_name = from_name - - @property - def name(self) -> str: - return "brevo" - - async def send_verification_code(self, to_email: str, code: str) -> None: - """通过 Brevo API 发送验证码邮件。 - - 参数: - to_email: 收件人邮箱 - code: 6 位数字验证码 - - 异常: - EmailSendError: 网络错误或 API 返回非 2xx 时抛出 - """ - headers = { - "api-key": self._api_key, - "Content-Type": "application/json", - "accept": "application/json", - } - payload = { - "sender": {"email": self._from_email, "name": self._from_name}, - "to": [{"email": to_email}], - "subject": _EMAIL_SUBJECT, - "htmlContent": _build_verification_email_html(code), - } - - try: - async with httpx.AsyncClient(timeout=_BREVO_TIMEOUT_SECONDS) as client: - response = await client.post(_BREVO_SEND_ENDPOINT, headers=headers, json=payload) - except httpx.HTTPError as exc: - logger.exception("Brevo API 请求失败") - raise EmailSendError("邮件服务暂时不可用,请稍后重试") from exc - - if response.status_code >= 400: - logger.error( - "Brevo API 返回错误: status=%s body=%s", - response.status_code, - response.text, - ) - raise EmailSendError("邮件发送失败,请检查发件配置或稍后重试") - - -def _build_verification_email_html(code: str) -> str: - """构造验证码邮件 HTML 内容。 - - 参数: - code: 验证码明文 - - 返回: - 符合邮件客户端渲染的 HTML 字符串 - """ - return f"""\ - - - - -

TopicEye 邮箱验证码

-

你正在注册 TopicEye 账号,验证码为:

-
- {code} -
-

验证码 10 分钟内有效。如非本人操作,请忽略此邮件。

-
-

此邮件由系统自动发送,请勿回复。

- - -""" diff --git a/backend/app/services/email/factory.py b/backend/app/services/email/factory.py index 16d11e8a..61fc6d7e 100644 --- a/backend/app/services/email/factory.py +++ b/backend/app/services/email/factory.py @@ -2,7 +2,10 @@ 根据管理员在后台配置的 email_provider_config 创建对应的 EmailProvider 实例。 配置存储在 AppSetting 表的 email_provider_config key 中(JSON 格式), -其中 api_key / smtp_password 字段使用 secret_store 加密存储。 +其中 smtp_password 字段使用 secret_store 加密存储。 + +#108 起仅支持自定义 SMTP(Brevo 已移除);存量 brevo 配置显式降级为 +不可用并记 warning,管理员在后台重配 SMTP 后恢复。 """ # author: fxbin @@ -15,18 +18,16 @@ from app.repositories.app_setting_repo import AppSettingRepository from app.services.email.base import EmailProvider -from app.services.email.brevo_provider import BrevoProvider from app.services.email.smtp_provider import SmtpProvider from app.services.secret_store import decrypt_secret logger = logging.getLogger(__name__) # Provider 名称常量 -PROVIDER_BREVO = "brevo" PROVIDER_SMTP = "smtp" -# 默认 Provider 名称 -DEFAULT_PROVIDER_NAME = PROVIDER_BREVO +# 唯一支持的 Provider 名称 +DEFAULT_PROVIDER_NAME = PROVIDER_SMTP # 默认发件人显示名称 DEFAULT_FROM_NAME = "TopicEye" @@ -63,29 +64,28 @@ async def get_email_provider(db: AsyncSession) -> EmailProvider | None: if not from_email: return None - if provider_name == PROVIDER_BREVO: - api_key = decrypt_secret(config.get("api_key")) - if not api_key: - return None - return BrevoProvider(api_key=api_key, from_email=from_email, from_name=from_name) - - if provider_name == PROVIDER_SMTP: - host = config.get("smtp_host", "") - port = config.get("smtp_port", 587) - username = config.get("smtp_username", "") - password = decrypt_secret(config.get("smtp_password")) - use_ssl = bool(config.get("smtp_use_ssl", False)) - if not host or not username or not password: - return None - return SmtpProvider( - host=host, - port=int(port), - username=username, - password=password, - use_ssl=use_ssl, - from_email=from_email, - from_name=from_name, + if provider_name != PROVIDER_SMTP: + # 含 #108 前的存量 brevo 配置:显式降级而非静默忽略 + logger.warning( + "email_provider_config 的 provider=%r 已不受支持(Brevo 已于 #108 移除)," + "邮件服务按未配置处理,请在后台改配自定义 SMTP", + provider_name, ) + return None - logger.warning("未知的邮件 Provider: %s", provider_name) - return None + host = config.get("smtp_host", "") + port = config.get("smtp_port", 587) + username = config.get("smtp_username", "") + password = decrypt_secret(config.get("smtp_password")) + use_ssl = bool(config.get("smtp_use_ssl", False)) + if not host or not username or not password: + return None + return SmtpProvider( + host=host, + port=int(port), + username=username, + password=password, + use_ssl=use_ssl, + from_email=from_email, + from_name=from_name, + ) diff --git a/backend/app/services/email/smtp_provider.py b/backend/app/services/email/smtp_provider.py index c70a92a5..2c23809a 100644 --- a/backend/app/services/email/smtp_provider.py +++ b/backend/app/services/email/smtp_provider.py @@ -72,8 +72,8 @@ async def send_verification_code(self, to_email: str, code: str) -> None: 异常: EmailSendError: 依赖缺失、连接或认证失败时抛出 """ - # 延迟导入:aiosmtplib 仅在 SMTP 模式实际发送时需要, - # 避免未安装该依赖时影响 Brevo 模式与应用启动 + # 延迟导入:aiosmtplib 仅在实际发送时需要,缺失时给出可操作的报错 + # 而不是拖垮应用启动 try: import aiosmtplib except ImportError as exc: @@ -117,7 +117,7 @@ def _build_verification_email_text(code: str) -> str: def _build_verification_email_html(code: str) -> str: """构造验证码邮件 HTML 内容。 - 与 Brevo Provider 保持一致的视觉风格。 + 纯文本降级内容与此 HTML 保持一致,供不支持 HTML 的客户端显示。 参数: code: 验证码明文 diff --git a/backend/tests/test_email_smtp_only.py b/backend/tests/test_email_smtp_only.py new file mode 100644 index 00000000..9c0134ba --- /dev/null +++ b/backend/tests/test_email_smtp_only.py @@ -0,0 +1,184 @@ +"""邮件服务 SMTP-only(#108):Brevo 移除后的 API 与 factory 行为。 + +覆盖: +- PUT provider=brevo → 400(显式拒绝,不留兼容路径) +- GET 对存量 brevo 配置归一为未配置(provider=smtp + 字段全空) +- SMTP 正常配置/回读:密码加密落库、脱敏预览、明文不外泄 +- PUT 密码留空 = 保留原值 +- factory:存量 brevo 配置返回 None(显式降级);SMTP 配置产出可用实例 +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncGenerator + +import httpx +import pytest +import pytest_asyncio +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +import app.main # noqa: F401 - import all models for Base.metadata +from app.api.v1 import auth as auth_api, settings as settings_api +from app.core.database import Base +from app.repositories.app_setting_repo import AppSettingRepository +from app.services.email.factory import get_email_provider +from app.services.email.smtp_provider import SmtpProvider +from app.services.secret_store import encrypt_secret + + +@pytest_asyncio.fixture +async def email_admin_client() -> AsyncGenerator[tuple[httpx.AsyncClient, str, dict], None]: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + from app.services.auth_service import create_session, create_user + + async with session_factory() as db: + admin = await create_user(db, email="admin@example.com", password="Password123", role="admin") + token, _ = await create_session(db, admin) + await db.commit() + + app = FastAPI() + app.include_router(auth_api.router) + app.include_router(settings_api.router) + + async def override_get_db() -> AsyncGenerator[AsyncSession, None]: + async with session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + for dependency in {auth_api.get_db, settings_api.get_db}: + app.dependency_overrides[dependency] = override_get_db + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client, token, session_factory + + await engine.dispose() + + +SMTP_PAYLOAD = { + "provider": "smtp", + "from_email": "noreply@example.com", + "from_name": "TopicEye", + "smtp_host": "smtp.example.com", + "smtp_port": 465, + "smtp_username": "noreply@example.com", + "smtp_password": "smtp-secret-plain", + "smtp_use_ssl": True, +} + + +@pytest.mark.asyncio +async def test_put_brevo_rejected_with_explicit_error(email_admin_client): + client, token, _ = email_admin_client + resp = await client.put( + "/settings/email-provider", + json={**SMTP_PAYLOAD, "provider": "brevo", "api_key": "xkeysdk-legacy"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 400 + assert "Brevo 已于 #108 移除" in resp.json()["detail"] + + +@pytest.mark.asyncio +async def test_put_smtp_requires_host_and_username(email_admin_client): + client, token, _ = email_admin_client + headers = {"Authorization": f"Bearer {token}"} + + missing_host = {k: v for k, v in SMTP_PAYLOAD.items() if k != "smtp_host"} + resp = await client.put("/settings/email-provider", json=missing_host, headers=headers) + assert resp.status_code == 400 and "SMTP 服务器" in resp.json()["detail"] + + missing_user = {k: v for k, v in SMTP_PAYLOAD.items() if k != "smtp_username"} + resp = await client.put("/settings/email-provider", json=missing_user, headers=headers) + assert resp.status_code == 400 and "SMTP 用户名" in resp.json()["detail"] + + +@pytest.mark.asyncio +async def test_smtp_roundtrip_masks_password_and_keeps_on_empty(email_admin_client): + client, token, _ = email_admin_client + headers = {"Authorization": f"Bearer {token}"} + + resp = await client.put("/settings/email-provider", json=SMTP_PAYLOAD, headers=headers) + assert resp.status_code == 200 and resp.json() == {"updated": True} + + got = (await client.get("/settings/email-provider", headers=headers)).json() + assert got["provider"] == "smtp" + assert got["smtp_host"] == "smtp.example.com" + assert got["smtp_username"] == "noreply@example.com" + assert got["smtp_password_configured"] is True + assert "smtp-secret-plain" not in json.dumps(got) + assert got["smtp_password_preview"].endswith("****") + # api_key 相关字段已随 #108 删除 + assert "api_key_configured" not in got and "supported_providers" not in got + + # 密码留空 = 保留原值(只改发件人名称) + resp = await client.put( + "/settings/email-provider", + json={**SMTP_PAYLOAD, "from_name": "改名", "smtp_password": ""}, + headers=headers, + ) + assert resp.status_code == 200 + got = (await client.get("/settings/email-provider", headers=headers)).json() + assert got["from_name"] == "改名" + assert got["smtp_password_configured"] is True + + +@pytest.mark.asyncio +async def test_legacy_brevo_config_normalized_on_get_and_factory(email_admin_client): + client, token, session_factory = email_admin_client + headers = {"Authorization": f"Bearer {token}"} + + # 直接落一份存量 brevo 配置(模拟 #108 之前的部署) + async with session_factory() as db: + legacy = { + "provider": "brevo", + "from_email": "norevo@example.com", + "from_name": "TopicEye", + "api_key": encrypt_secret("legacy-brevo-key"), + } + await AppSettingRepository(db).upsert_setting( + "email_provider_config", json.dumps(legacy), description="legacy brevo" + ) + await db.commit() + # factory:显式降级为不可用 + assert await get_email_provider(db) is None + + # GET:归一展示为未配置 + got = (await client.get("/settings/email-provider", headers=headers)).json() + assert got["provider"] == "smtp" + assert got["from_email"] == "" + assert got["smtp_password_configured"] is False + + +@pytest.mark.asyncio +async def test_factory_builds_smtp_provider_from_stored_config(email_admin_client): + _, token, session_factory = email_admin_client + async with session_factory() as db: + stored = { + "provider": "smtp", + "from_email": "noreply@example.com", + "from_name": "TopicEye", + "smtp_host": "smtp.example.com", + "smtp_port": 465, + "smtp_username": "noreply@example.com", + "smtp_password": encrypt_secret("smtp-secret-plain"), + "smtp_use_ssl": True, + } + await AppSettingRepository(db).upsert_setting("email_provider_config", json.dumps(stored), description="smtp") + await db.commit() + + provider = await get_email_provider(db) + assert isinstance(provider, SmtpProvider) + assert provider._from_email == "noreply@example.com" + + # 不触网、只验证「存量配置 → 解密 → 构造」的工厂闭环 diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index ad57ac0f..26069915 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect, useState } from 'react'; -import { CheckCircle2, ExternalLink, KeyRound, Loader2, LogIn, Mail, Plus, Server, Settings, Trash2, Webhook } from 'lucide-react'; +import { CheckCircle2, KeyRound, Loader2, LogIn, Mail, Plus, Server, Settings, Trash2, Webhook } from 'lucide-react'; import { useAppContext } from '@/components/ClientLayout'; import { settingsApi } from '@/lib/api'; import type { EmailProviderConfig, NotificationWebhookConfig, OAuthProviderItem, WebhookItem, WebhookItemUpdate } from '@/lib/api/_analytics'; @@ -18,42 +18,21 @@ const OAUTH_PROVIDER_LABELS: Record = { github: 'GitHub 登录', }; -/** Brevo 官方资源链接 */ -const BREVO_LINKS = { - /** 注册账号 */ - signup: 'https://www.brevo.com/', - /** 获取 API Key */ - apiKey: 'https://app.brevo.com/settings/keys/api', - /** 域名认证(SPF/DKIM) */ - domains: 'https://app.brevo.com/senders/domains', -}; - -/** 配置说明项 */ -const CONFIG_NOTES: Record = { - brevo: [ - 'Brevo 免费额度:300 封/天(约 9000 封/月),无需信用卡', - '发件人邮箱需在 Brevo 后台完成域名认证(SPF/DKIM)', - 'API Key 以加密方式存储,不会明文保存', - '配置完成后,用户注册时需先获取邮箱验证码', - ], - smtp: [ - '支持任意标准 SMTP 服务器(腾讯企业邮、Gmail、自建 Postfix 等)', - '端口 465 使用 SSL 直连;端口 587 使用 STARTTLS', - 'SMTP 密码以加密方式存储,不会明文保存', - '部分邮箱(如 QQ、163)需使用授权码而非登录密码', - ], -}; +/** 邮件配置说明项(#108 起仅剩自定义 SMTP) */ +const SMTP_NOTES: string[] = [ + '支持任意标准 SMTP 服务器(腾讯企业邮、Gmail、自建 Postfix 等)', + '端口 465 使用 SSL 直连;端口 587 使用 STARTTLS', + 'SMTP 密码以加密方式存储,不会明文保存', + '部分邮箱(如 QQ、163)需使用授权码而非登录密码', +]; export default function AdminSettingsPage() { const { currentUser } = useAppContext(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [config, setConfig] = useState(null); - const [provider, setProvider] = useState('brevo'); const [fromEmail, setFromEmail] = useState(''); const [fromName, setFromName] = useState(DEFAULT_FROM_NAME); - const [apiKey, setApiKey] = useState(''); - // SMTP 专属字段 const [smtpHost, setSmtpHost] = useState(''); const [smtpPort, setSmtpPort] = useState(587); const [smtpUsername, setSmtpUsername] = useState(''); @@ -95,7 +74,6 @@ export default function AdminSettingsPage() { ]) .then(([emailData, webhookData, oauthData]) => { setConfig(emailData); - setProvider(emailData.provider); setFromEmail(emailData.from_email); setFromName(emailData.from_name); setSmtpHost(emailData.smtp_host); @@ -120,15 +98,6 @@ export default function AdminSettingsPage() { .finally(() => setLoading(false)); }, []); - // 切换 provider 时联动 SSL 默认端口 - const handleProviderChange = (value: string) => { - setProvider(value); - if (value === 'smtp') { - // 切到 SMTP 时,若端口为默认 587 保持不变;用户可自行改 465 - if (smtpPort === 587 && !smtpUseSsl) setSmtpUseSsl(false); - } - }; - // 切换 SSL 时联动常用端口 const handleSslToggle = (checked: boolean) => { setSmtpUseSsl(checked); @@ -142,17 +111,15 @@ export default function AdminSettingsPage() { setNotice(null); try { await settingsApi.updateEmailProvider({ - provider, + provider: 'smtp', from_email: fromEmail, from_name: fromName, - api_key: apiKey, smtp_host: smtpHost, smtp_port: smtpPort, smtp_username: smtpUsername, smtp_password: smtpPassword, smtp_use_ssl: smtpUseSsl, }); - setApiKey(''); setSmtpPassword(''); setNotice('保存成功'); const fresh = await settingsApi.getEmailProvider(); @@ -275,10 +242,6 @@ export default function AdminSettingsPage() { ); } - const isBrevo = provider === 'brevo'; - const isSmtp = provider === 'smtp'; - const notes = CONFIG_NOTES[provider] || []; - return (

邮件服务

- {config?.api_key_configured || config?.smtp_password_configured ? ( + {config?.smtp_password_configured ? ( 已配置 @@ -300,40 +263,6 @@ export default function AdminSettingsPage() { )}
- {/* Provider 选择 */} - - - {/* Brevo 官方指引 */} - {isBrevo && ( - - )} - {/* 通用字段:发件人 */} - {/* Brevo 专属:API Key */} - {isBrevo && ( - - )} - - {/* SMTP 专属字段 */} - {isSmtp && ( - <> + {/* SMTP 配置 */} - - )} - {error && ( setError(null)}>{error} )} @@ -469,7 +372,7 @@ export default function AdminSettingsPage() {

配置说明

    - {notes.map((note) => ( + {SMTP_NOTES.map((note) => (
  • {note}
  • ))}
diff --git a/frontend/src/lib/api/_analytics.ts b/frontend/src/lib/api/_analytics.ts index b20586b6..71defb43 100644 --- a/frontend/src/lib/api/_analytics.ts +++ b/frontend/src/lib/api/_analytics.ts @@ -14,28 +14,24 @@ import type { TrendPoint, } from '@/types/trends'; -/** 邮件 Provider 配置响应(api_key / smtp_password 脱敏) */ +/** 邮件 Provider 配置响应(smtp_password 脱敏)。#108 起仅剩自定义 SMTP */ export interface EmailProviderConfig { provider: string; from_email: string; from_name: string; - api_key_configured: boolean; - api_key_preview: string; smtp_host: string; smtp_port: number; smtp_username: string; smtp_password_configured: boolean; smtp_password_preview: string; smtp_use_ssl: boolean; - supported_providers: string[]; } -/** 邮件 Provider 配置更新请求。api_key / smtp_password 为空时保留原值 */ +/** 邮件 Provider 配置更新请求。smtp_password 为空时保留原值 */ export interface EmailProviderConfigUpdate { provider: string; from_email: string; from_name: string; - api_key: string; smtp_host: string; smtp_port: number; smtp_username: string; @@ -126,12 +122,12 @@ export const settingsApi = { }); }, - /** 获取邮件 Provider 配置(api_key 脱敏) */ + /** 获取邮件 Provider 配置(密码脱敏) */ getEmailProvider(): Promise { return request('/settings/email-provider'); }, - /** 更新邮件 Provider 配置。api_key 为空时保留原值 */ + /** 更新邮件 Provider 配置。smtp_password 为空时保留原值 */ updateEmailProvider(data: EmailProviderConfigUpdate): Promise<{ updated: boolean }> { return request('/settings/email-provider', { method: 'PUT',