Skip to content

feat(admin): 后台批量公告邮件功能#2899

Open
hyrhyrh wants to merge 10 commits into
Wei-Shaw:mainfrom
hyrhyrh:pr/email-broadcast
Open

feat(admin): 后台批量公告邮件功能#2899
hyrhyrh wants to merge 10 commits into
Wei-Shaw:mainfrom
hyrhyrh:pr/email-broadcast

Conversation

@hyrhyrh
Copy link
Copy Markdown

@hyrhyrh hyrhyrh commented May 30, 2026

功能概述

为管理后台新增批量公告邮件能力。管理员可以撰写一封公告邮件,通过两种方式投递:

  • 多选指定用户(支持邮箱 / 用户名实时搜索)
  • 一键发送给全部已注册用户

适用场景:站内公告弹窗依赖用户登录后才能看到,但有些重要通告(维护、扣费策略变更、安全告警等)需要触达所有用户邮箱,包括很久没登录的。本 PR 不替代站内公告系统,而是补足"邮件触达"这条独立通道。

入口集中在 管理后台 → 系统设置 → 邮件 区块,一个 Dialog 内完成所有操作 —— 左右分栏(撰写 + 实时预览),底部折叠的发送历史区,历史记录支持原地预览 / 删除,不会出现三级弹窗

主要功能

  • 撰写与发送 — 主题 + 正文(HTML / 纯文本可切换) + 收件人选择(用户多选 picker 或"发送给全部已注册用户"开关)。
  • 实时预览 — 新增 POST /preview 接口返回与最终投递完全一致的 HTML,通过 sandbox iframe(sandbox="allow-same-origin")实时渲染,所见即所得。
  • 统一邮件模板 — 600px 居中卡片排版(顶部品牌横幅 + 内容卡片 + 系统签名页脚),风格与现有 balance / quota 通知邮件保持一致;纯文本模式段落感知(空行 → <p> 分段,单换行 → <br>)。
  • 品牌名取自 SMTP 发件人名称 — 邮件正文头部和签名读取 smtp_from_name,失败回退到 site_name,再失败回退 "Sub2API"。让正文显示的品牌与收件箱里看到的发件人完全一致(例如部署方是 TurboAPI 的话,正文不会突兀地显示 Sub2API)。
  • HTML 编辑工具栏 — 8 个快捷按钮(P / B / I / Link / List / H2 / HR / Br),包裹当前选中文本;无选中时插入空标签便于编辑。tooltip 走 i18n。
  • 发送历史(master-detail) — 同一 Dialog 内折叠的历史区,每条记录右侧有「预览」「删除」按钮。预览会原地切到 detail 视图(顶部展示主题/状态/收件人模式/成功比/发送时间/错误信息,下方 iframe 渲染当时的真实邮件 HTML),不会再弹一个三级窗口。删除走 DELETE /admin/email-broadcasts/:id,只允许 completed / failed 状态。
  • 异步发送 — HTTP 请求立刻返回 202 + 持久化的 broadcast 快照,实际 SMTP 投递在后台 goroutine 中执行,每封间隔 200ms 节流,避免触发 SMTP 速率限制。

安全与健壮性

  • HTML 正文在 service 层用 bluemonday UGCPolicy 做 sanitize,移除 <script> / 事件处理器 / 危险属性后才入库与发送。
  • 预览 iframe 用 sandbox="allow-same-origin"(脚本/表单/导航全禁),作为 server-side sanitize 之上的纵深防御
  • service 层全量边界校验:主题 ≤ 200 字符、正文 ≤ 64 KiB、指定模式收件人 ≤ 5000。
  • 后台 worker 用独立的 context.Background() + 30 分钟 timeout,HTTP 请求中断不会影响在飞 broadcast 的投递。
  • pending / sending 状态的 broadcast 不允许删除(409 EMAIL_BROADCAST_DELETE_IN_FLIGHT),避免与状态回写竞争。

后端改动

  • 新增 ent schema EmailBroadcast 与 migration 145_email_broadcasts.sql(表结构含 status / counts / recipient_user_ids JSONB,以及对应 CHECK 约束)。
  • 新增 EmailBroadcastRepository(ent 实现)+ EmailBroadcastService(Send / List / Get / Delete / PreviewHTML)。
  • Wire 注入:在 service / repository / handler 的 ProviderSetAdminHandlers 中注册。
  • 新增 admin handler 与路由:
    • POST /api/v1/admin/email-broadcasts — 创建 + 触发异步发送
    • GET /api/v1/admin/email-broadcasts — 分页列表(summary,不返回正文)
    • GET /api/v1/admin/email-broadcasts/:id — 完整详情
    • DELETE /api/v1/admin/email-broadcasts/:id — 物理删除(仅 completed/failed)
    • POST /api/v1/admin/email-broadcasts/preview — 生成预览 HTML
    • GET /api/v1/admin/email-broadcasts/recipients/search?q=... — 收件人 picker 搜索后端
  • EmailService 新增 SendEmailWithConfigAndContentType,允许调用方自定义 MIME Content-Type,而不破坏原有 helper 的 html 默认行为。
  • 新增依赖:github.com/microcosm-cc/bluemonday v1.0.27(HTML sanitize)。

前端改动

  • 新增 frontend/src/api/admin/emailBroadcasts.ts API 客户端(类型完整:list / getById / create / delete / preview / searchRecipients)。
  • 新增 EmailBroadcastDialog.vue(约 900 行):
    • 两栏布局,左侧表单 + 右侧 sandbox iframe 实时预览
    • 预览请求防抖 350ms + AbortController 取消,避免快速键入时的 race
    • HTML 模式下显示快捷工具栏(选区感知包裹)
    • 收件人多选 picker(防抖搜索 + 可移除 chip)
    • master-detail 历史区(顶部面包屑显示当前预览主题)
    • 删除走 ConfirmDialog 二次确认
  • 集成到 SettingsView.vue,放在邮件区块下方(由 email_verify_enabled 开关控制可见性)。
  • 完整 zh + en i18n,文案集中在 admin.emailBroadcast.*(form / status / recipientsMode / preview / history / toolbar / notifications)。

测试计划

  • go build ./... / go vet ./... 全部通过
  • go test -short ./internal/service/... 新增 19 个测试覆盖核心路径:
    • 全部入参校验分支(主题/正文长度、format、mode、收件人上下限、SMTP 未配置)
    • HTML sanitize 正确移除 <script> 同时保留安全标签
    • 纯文本渲染:HTML-escape + 段落分隔
    • sender-name 解析优先级:smtp_from_name > site_name > 兜底
    • hard-delete 拒绝 pending/sending、允许 completed/failed、拒绝 0-id
    • PreviewHTML 模板含主题与品牌
  • 前端 pnpm vue-tsc --noEmit 通过
  • 实机测试:
    • 发送给"全部用户",收件箱实际收到了 brand 与 SMTP From-Name 一致的卡片邮件
    • 实时预览跟随主题 / 正文 / 格式切换刷新
    • 历史列表 / 预览 / 删除全部工作正常
    • HTML 工具栏选区包裹正确

给 reviewer 的几点说明

  • 纯文本正文也走与 HTML 一致的模板包装,最终 MIME 都是 text/html,保证收件人看到的排版统一。composeHTMLBody 是单一来源,实时预览和实际投递共用,所见即所发
  • 选择物理删除而非软删除:广播邮件不属于计费/订单等需要审计追溯的数据,没有合规要求需要保留。pending/sending 的 409 已经能保护后台 worker 不被打断。
  • 收件人 multi-select 复用 AdminService.ListUsers + Search 过滤,没有引入新的用户查询路径。

hyrhyrh added 10 commits May 30, 2026 00:11
新增 `email_broadcasts` 表用于持久化管理员批量公告邮件的发送记录,
包含主题、正文、收件人模式、聚合计数与状态字段,索引按 status / created_at /
created_by 建立。配套生成 ent client 代码与 domain 常量/错误。

Adds a new `email_broadcasts` table to persist admin-issued bulk
announcement emails (subject, body, recipients mode, aggregate counters
and lifecycle status), with indexes on status, created_at and created_by.
Includes the regenerated ent client and matching domain constants/errors.
新增 EmailBroadcastRepository(ent 后端) 与 EmailBroadcastService。
service 负责输入校验、SMTP 配置预检、bluemonday HTML sanitize、
异步执行收件人解析与逐封 SMTP 投递,并把成功 / 失败计数增量写回。

同时为 EmailService 增加 SendEmailWithConfigAndContentType,
允许调用方自定义 MIME Content-Type(纯文本广播会以 text/html 包装,
保持邮件 MIME 一致并启用基础排版)。

EmailBroadcastService validates inputs, pre-checks the SMTP config,
sanitizes HTML through bluemonday, expands recipients (either all
registered users or an explicit ID list) and dispatches each mail with
a small per-message throttle. Counters are written back to the database
incrementally so list views show live progress.

EmailService gains SendEmailWithConfigAndContentType so callers can
choose the MIME Content-Type (existing helpers keep their html default).
新增管理员 HTTP 路由:
- POST   /api/v1/admin/email-broadcasts                  发起一次批量公告邮件
- GET    /api/v1/admin/email-broadcasts                  列出最近的广播记录
- GET    /api/v1/admin/email-broadcasts/:id              查看单条广播详情
- GET    /api/v1/admin/email-broadcasts/recipients/search 收件人 picker 搜索接口

handler 立即返回 202 + 新建 broadcast 的快照,实际发送在 service
层的后台 goroutine 中进行,避免占用 HTTP 请求 goroutine。Wire 注入
更新到 AdminHandlers / ProviderSet。

Exposes the admin HTTP surface for the bulk announcement email feature.
Create returns 202 + the persisted broadcast snapshot immediately and
delegates the actual SMTP delivery to a background goroutine in the
service layer. Wire / AdminHandlers wiring updated accordingly.
在管理后台"系统设置 → 邮件"区块新增"批量公告邮件"卡片与按钮,
点击后弹出 EmailBroadcastDialog:
- 主题 + 正文(HTML / 纯文本切换,字符上限提示)
- 收件人:"发送给全部已注册用户"开关 + 邮箱/用户名搜索 + 多选 chip
- 历史折叠区(默认收起,展开拉取最近 10 条记录,显示状态徽章和 success/total)
- 表单校验:主题 / 正文必填、二选一(全部用户开关 vs 至少一个手选收件人)

新增 `frontend/src/api/admin/emailBroadcasts.ts` 提供 list / getById /
create / searchRecipients API 客户端,并通过 adminAPI barrel export 暴露。
zh / en locale 文案同步完整覆盖。

Adds the admin UI: a new card in Settings → Email opens an
EmailBroadcastDialog covering compose, recipient picking (all-users
toggle + searchable user multi-select chips), an inline history pane
showing recent broadcasts, and basic form validation. Backed by a new
admin API client; zh/en i18n covers every string.
后端:
- 邮件最终 HTML 改为统一卡片排版(品牌头 + 内容卡片 + 系统签名),
  600px 居中,与 sub2api 现有 balance / quota 邮件模板一致。
- 纯文本正文从单一 <br> 转换升级为段落感知:空行 → <p> 分段、
  单换行 → <br>,可读性显著提升。
- composeHTMLBody 增加 subject 与 siteName 参数,SiteName 从
  setting_repo 读取,失败回退 "Sub2API"。
- 新增 POST /api/v1/admin/email-broadcasts/preview:接收
  {subject, body, body_format} 返回与最终投递完全一致的 HTML,
  供管理后台编辑器实时预览。

前端:
- EmailBroadcastDialog 改为两栏布局,左侧表单 + 右侧 iframe 实时预览。
- 输入防抖 350ms,预览请求支持 AbortController 取消,避免快速键入时的 race。
- iframe 用 `sandbox=""` 限制脚本/弹窗等,即便服务端 sanitize 已通过 bluemonday
  做了 XSS 防护,渲染端仍二次加固。
- HTML 模式新增 P / B / I / Link / List / H2 / HR / Br 工具栏,
  选中文本可一键包裹标签;无选区时直接插入空标签便于编辑。
- 历史区移到独立分隔条下,避免占用主预览空间。

i18n 增加 emailBroadcast.preview.* 文案,zh/en 同步。

Backend bundles the final email in a unified 600px card template
(branded header + content card + system footer) consistent with
existing balance / quota email styles. Plain-text mode now produces
paragraph-aware HTML: blank lines become <p>, single newlines <br>.
composeHTMLBody also accepts subject + siteName so the header always
reflects the configured site name (falls back to "Sub2API").

New POST /admin/email-broadcasts/preview returns the exact HTML that
would be delivered for arbitrary subject/body/format input. The admin
composer uses this to drive a debounced live-preview iframe rendered
under a strict sandbox, and a small HTML toolbar (P / B / I / Link /
List / H2 / HR / Br) wraps the current selection.
…y renders

Bug: previous implementation set `sandbox=""` (strictest mode) on the
preview iframe and tried to write into it via
`iframe.contentDocument.open()/write()`. An iframe with the `sandbox`
attribute is treated as a unique opaque origin, so the parent document
cannot access its `contentDocument` — `doc` was either null or the
`open()` call threw under SecurityError, leaving the preview blank.

Fix: switch to the declarative `srcdoc` attribute bound to a reactive
`previewHtml` ref. Vue writes the HTML into the attribute (no
cross-origin read needed), and the iframe still runs under
`sandbox="allow-same-origin"` so scripts / forms / navigation remain
disabled. The server-side bluemonday sanitize stays in place, so this
is purely a rendering plumbing fix — no security regression.

Bug:之前给 preview iframe 设了最严格的 sandbox="",然后通过
contentDocument.open()/write() 写 HTML 进去。带 sandbox 的 iframe 会被
视为唯一 opaque 源,父页面无法读 contentDocument —— doc 要么为 null
要么 open() 抛 SecurityError,最终预览始终是白板。

修复:改为响应式 previewHtml ref 通过 srcdoc 属性下发。Vue 只写属性、
不读 contentDocument,跨源限制不再阻断;iframe 仍带
sandbox="allow-same-origin",禁止脚本/表单/跳转。后端 bluemonday
sanitize 仍生效,纯渲染管线修复,无安全降级。
Replaces the hard-coded English `title` text on the P / B / I / Link /
List / H2 / HR / Br quick-insert buttons with i18n-driven tooltips
under `admin.emailBroadcast.toolbar.*`. The visible labels stay as
short tag names; only the hover tooltip is localized, so the toolbar
still reads compactly on narrow screens while now showing meaningful
Chinese hints ("段落(包裹选中文本为 <p>)" etc.) on hover.

P / B / I / Link / List / H2 / HR / Br 工具栏按钮的 hover title 改为
i18n 文案,可视标签保持原样(短),但鼠标悬停显示中文说明
("段落(包裹选中文本为 <p>)" 等),Chinese-first 体验更友好。
…ite_name)

The broadcast email header banner and the system footer previously showed
the site_name (or its "Sub2API" fallback). For deployments that ship
under a different brand (e.g. TurboAPI) the inbox shows the From-Name
configured in SMTP settings, but the email body still said "Sub2API" —
inconsistent and confusing.

Resolver order is now: smtp_from_name → site_name → "Sub2API". This
keeps the visible brand in the email body identical to the inbox sender
shown by the mail client.

PreviewHTML and runBroadcast both use the same resolver so the live
preview and the actually-delivered mail render the same brand.

邮件头横幅与底部签名原本读 site_name(回退 "Sub2API")。但运营方
若使用 TurboAPI 这种自有品牌,SMTP "发件人名称"(收件箱中看到的发件人
显示名)是 TurboAPI,正文却写着 Sub2API,看起来不一致。

解析顺序改为:smtp_from_name → site_name → "Sub2API",让正文显示的
品牌与收件箱发件人名一致。预览与实际投递走同一份 resolver,所见即所得。
新增 DELETE /api/v1/admin/email-broadcasts/:id 用于物理删除一条历史
广播记录。Service 层增加保护:status 为 pending / sending 或正在
后台 worker 中执行的记录禁止删除,返回 EMAIL_BROADCAST_DELETE_IN_FLIGHT
(409),让前端引导用户等记录收尾后重试,避免与状态回写的竞争。

为啥是物理删除:广播邮件不属于计费/订单等需要审计追溯的数据,
管理员清理误发或过期公告时,简洁的 hard delete 体验最直接。
upstream PR 也好接受 — 不需要新增 deleted_at 列与查询过滤分支。

Adds DELETE /api/v1/admin/email-broadcasts/:id. The service refuses
to delete records that are still pending / sending or actively being
dispatched by the in-process worker, returning a 409 with the
EMAIL_BROADCAST_DELETE_IN_FLIGHT code so the UI can guide the admin to
retry once the broadcast settles. Hard delete keeps the data model
clean — broadcasts have no compliance / audit requirement that soft
delete would satisfy.
将"发送历史"折叠区从纯列表升级为 master-detail 视图,**复用同一份 dialog**
不再叠加二级弹窗。

新增交互:
- 每条历史记录右侧增加「预览」「删除」按钮。
- 预览:就地切到 detail 视图,顶部展示主题/状态/收件人模式/成功比/发送
  时间/错误信息,下方 iframe 渲染该记录的真实邮件 HTML (调用既有的
  GET /admin/email-broadcasts/:id 拉详情,再走 /preview 接口生成与
  当时投递一致的 HTML)。
- 删除:ConfirmDialog 二次确认后调用 DELETE,仅 completed / failed 可删,
  pending / sending 状态按钮禁用并提示用户等待发送完成。

UI 决策:
- 历史详情**就地展开**而不是新弹窗,避免编辑器 dialog 上面再叠一层
  造成三级弹框 (用户痛点)。返回按钮回到列表,左侧编辑表单全程保留。
- 折叠 header 上动态显示"/ 当前预览主题"作为面包屑,方便用户感知层级。

The history panel now flips between a list view and an in-place detail
view inside the SAME dialog. Each row gets Preview and Delete buttons:
Preview pulls the full record and re-renders the exact HTML via the
existing /preview endpoint inside a sandboxed iframe; Delete asks for
confirmation, only allowed for completed/failed records (disabled for
pending/sending so the dispatcher can't be raced).

UX call: detail rendering reuses the existing dialog instead of opening
a third nested dialog. A breadcrumb-style label on the collapse header
shows the currently previewed subject for orientation, and the compose
form on the left is preserved while browsing history.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

All contributors have signed the CLA. ✅
Posted by the CLA Assistant Lite bot.

@hyrhyrh hyrhyrh changed the title feat(admin): bulk announcement email broadcast feat(admin): 后台批量公告邮件功能 May 30, 2026
@hyrhyrh
Copy link
Copy Markdown
Author

hyrhyrh commented May 30, 2026

I have read the CLA Document and I hereby sign the CLA

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant