feat(admin): 后台批量公告邮件功能#2899
Open
hyrhyrh wants to merge 10 commits into
Open
Conversation
新增 `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.
Contributor
|
All contributors have signed the CLA. ✅ |
Author
|
I have read the CLA Document and I hereby sign the CLA |
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
功能概述
为管理后台新增批量公告邮件能力。管理员可以撰写一封公告邮件,通过两种方式投递:
适用场景:站内公告弹窗依赖用户登录后才能看到,但有些重要通告(维护、扣费策略变更、安全告警等)需要触达所有用户邮箱,包括很久没登录的。本 PR 不替代站内公告系统,而是补足"邮件触达"这条独立通道。
入口集中在 管理后台 → 系统设置 → 邮件 区块,一个 Dialog 内完成所有操作 —— 左右分栏(撰写 + 实时预览),底部折叠的发送历史区,历史记录支持原地预览 / 删除,不会出现三级弹窗。
主要功能
POST /preview接口返回与最终投递完全一致的 HTML,通过 sandbox iframe(sandbox="allow-same-origin")实时渲染,所见即所得。<p>分段,单换行 →<br>)。smtp_from_name,失败回退到site_name,再失败回退"Sub2API"。让正文显示的品牌与收件箱里看到的发件人完全一致(例如部署方是 TurboAPI 的话,正文不会突兀地显示 Sub2API)。DELETE /admin/email-broadcasts/:id,只允许 completed / failed 状态。安全与健壮性
<script>/ 事件处理器 / 危险属性后才入库与发送。sandbox="allow-same-origin"(脚本/表单/导航全禁),作为 server-side sanitize 之上的纵深防御。context.Background()+ 30 分钟 timeout,HTTP 请求中断不会影响在飞 broadcast 的投递。409 EMAIL_BROADCAST_DELETE_IN_FLIGHT),避免与状态回写竞争。后端改动
EmailBroadcast与 migration145_email_broadcasts.sql(表结构含 status / counts /recipient_user_ids JSONB,以及对应 CHECK 约束)。EmailBroadcastRepository(ent 实现)+EmailBroadcastService(Send/List/Get/Delete/PreviewHTML)。ProviderSet与AdminHandlers中注册。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— 生成预览 HTMLGET /api/v1/admin/email-broadcasts/recipients/search?q=...— 收件人 picker 搜索后端EmailService新增SendEmailWithConfigAndContentType,允许调用方自定义 MIMEContent-Type,而不破坏原有 helper 的 html 默认行为。github.com/microcosm-cc/bluemonday v1.0.27(HTML sanitize)。前端改动
frontend/src/api/admin/emailBroadcasts.tsAPI 客户端(类型完整:list / getById / create / delete / preview / searchRecipients)。EmailBroadcastDialog.vue(约 900 行):AbortController取消,避免快速键入时的 raceConfirmDialog二次确认SettingsView.vue,放在邮件区块下方(由email_verify_enabled开关控制可见性)。admin.emailBroadcast.*(form / status / recipientsMode / preview / history / toolbar / notifications)。测试计划
go build ./.../go vet ./...全部通过go test -short ./internal/service/...新增 19 个测试覆盖核心路径:<script>同时保留安全标签smtp_from_name>site_name> 兜底pnpm vue-tsc --noEmit通过给 reviewer 的几点说明
text/html,保证收件人看到的排版统一。composeHTMLBody是单一来源,实时预览和实际投递共用,所见即所发。AdminService.ListUsers+Search过滤,没有引入新的用户查询路径。