diff --git a/docs/superpowers/plans/2026-04-20-comment-email-config-implementation-plan.md b/docs/superpowers/plans/2026-04-20-comment-email-config-implementation-plan.md new file mode 100644 index 0000000..772324b --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-comment-email-config-implementation-plan.md @@ -0,0 +1,471 @@ +# Comment Email Config Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add comment email infrastructure with admin SMTP config, guest anonymous email rules, per-user notification preference, and admin test-send capability without blocking comment creation. + +**Architecture:** Extend the existing config registry (global + user) for mail settings and notify preferences, then wire those values into comment creation and notification service logic. Keep notification sending best-effort and side-effect-safe: comment write succeeds even if email send fails. Reuse existing `/api/config/*`, profile page, and comment service patterns. + +**Tech Stack:** Nuxt 3, Nitro server routes, Drizzle (SQLite), Bun test, existing config service in `server/service/config`. + +--- + +## File Structure Map + +- **Config domain** + - Modify `server/service/config/registry.ts` for new config keys and validation. + - Modify `app/composables/useGlobalConfig.ts` for global config typing updates. + - Modify `app/pages/me/admin/config/index.vue` for SMTP form and test-send action. + - Create `server/api/config/global/comment-email-test.post.ts` for admin test email. +- **Comment domain** + - Modify `packages/drizzle-pkg/database/sqlite/schema/content.ts` to add guest email fields. + - Create a migration under `packages/drizzle-pkg/migrations`. + - Modify `server/service/post-comments/index.ts` to validate/store guest email + anonymous. + - Modify `app/components/PostComments.vue` to collect guest email + anonymous toggle and render user hints. +- **Notification domain** + - Create `server/service/comment-notify/index.ts` for send gating + dispatch. + - Integrate notify trigger in comment create flow routes. +- **User preference** + - Modify `app/pages/me/profile/index.vue` and `/api/config/me` usage to add `commentNotifyEnabled`. +- **Tests** + - Modify/create tests in `server/utils/post-comment-guest.test.ts`, comment service tests, and config validation tests. + +--- + +### Task 1: Add Config Keys and Validation + +**Files:** +- Modify: `server/service/config/registry.ts` +- Test: `server/service/config/registry.test.ts` (create if absent) + +- [ ] **Step 1: Write the failing validation tests** + +```ts +import { describe, expect, test } from "bun:test"; +import { validateConfigValue } from "./registry"; + +describe("comment mail config validation", () => { + test("accepts valid from email", () => { + expect(validateConfigValue("commentMailFromEmail", "sender@example.com")).toBe(true); + }); + + test("rejects invalid from email", () => { + expect(validateConfigValue("commentMailFromEmail", "bad-email")).toBe(false); + }); + + test("accepts comment notify default", () => { + expect(validateConfigValue("commentNotifyEnabled", true)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test server/service/config/registry.test.ts` +Expected: FAIL with unknown key/type validation mismatch. + +- [ ] **Step 3: Implement minimal registry entries** + +```ts +commentEmailNotifyEnabled: defineConfig({ key: "commentEmailNotifyEnabled", scope: "global", valueType: "boolean", defaultValue: false, userOverridable: false }), +commentMailFromEmail: defineConfig({ + key: "commentMailFromEmail", + scope: "global", + valueType: "string", + defaultValue: "", + userOverridable: false, + validate: (value: string) => !value.trim() || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()), +}), +commentSmtpHost: defineConfig({ key: "commentSmtpHost", scope: "global", valueType: "string", defaultValue: "", userOverridable: false }), +commentSmtpPort: defineConfig({ + key: "commentSmtpPort", + scope: "global", + valueType: "number", + defaultValue: 465, + userOverridable: false, + validate: (value: number) => Number.isInteger(value) && value >= 1 && value <= 65535, +}), +commentSmtpSecure: defineConfig({ key: "commentSmtpSecure", scope: "global", valueType: "boolean", defaultValue: true, userOverridable: false }), +commentSmtpUser: defineConfig({ key: "commentSmtpUser", scope: "global", valueType: "string", defaultValue: "", userOverridable: false }), +commentSmtpPass: defineConfig({ key: "commentSmtpPass", scope: "global", valueType: "string", defaultValue: "", userOverridable: false }), +commentNotifyEnabled: defineConfig({ key: "commentNotifyEnabled", scope: "both", valueType: "boolean", defaultValue: true, userOverridable: true }), +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test server/service/config/registry.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/service/config/registry.ts server/service/config/registry.test.ts +git commit -m "feat(config): add comment email and notify preference keys" +``` + +--- + +### Task 2: Extend Admin Global Config UI and Global Composable + +**Files:** +- Modify: `app/composables/useGlobalConfig.ts` +- Modify: `app/pages/me/admin/config/index.vue` + +- [ ] **Step 1: Write a focused UI behavior test (or component-level assertion)** + +```ts +// Pseudocode if no mounted test infra exists yet: +// assert payload includes commentEmailNotifyEnabled/commentSmtpHost/commentSmtpPort/commentSmtpSecure/commentSmtpUser/commentSmtpPass/commentMailFromEmail +``` + +- [ ] **Step 2: Run existing frontend checks** + +Run: `bun run typecheck` +Expected: FAIL due to missing new config fields in types. + +- [ ] **Step 3: Implement minimal type + form changes** + +```ts +// useGlobalConfig.ts +export type GlobalConfig = { + siteName: string; + allowRegister: boolean; + commentEmailNotifyEnabled: boolean; + commentMailFromEmail: string; + commentSmtpHost: string; + commentSmtpPort: number; + commentSmtpSecure: boolean; + commentSmtpUser: string; +}; +``` + +```vue + + + + + + + + +``` + +- [ ] **Step 4: Re-run checks** + +Run: `bun run typecheck` +Expected: PASS for touched types/components. + +- [ ] **Step 5: Commit** + +```bash +git add app/composables/useGlobalConfig.ts app/pages/me/admin/config/index.vue +git commit -m "feat(admin-config): add comment mail smtp settings form" +``` + +--- + +### Task 3: Add Admin Test Send Endpoint and Button + +**Files:** +- Create: `server/api/config/global/comment-email-test.post.ts` +- Modify: `app/pages/me/admin/config/index.vue` +- Create/Modify Test: `server/api/config/global/comment-email-test.post.test.ts` + +- [ ] **Step 1: Write failing API tests** + +```ts +test("returns 400 when smtp config incomplete", async () => { + // setup admin session + empty smtp config + // call POST /api/config/global/comment-email-test + // expect 400 +}); +``` + +- [ ] **Step 2: Run targeted test** + +Run: `bun test server/api/config/global/comment-email-test.post.test.ts` +Expected: FAIL with route missing. + +- [ ] **Step 3: Implement endpoint and UI action** + +```ts +// comment-email-test.post.ts +await requireAdmin(event); +const user = await event.context.auth.requireUser(); +const cfg = await getCommentMailConfig(event.context.config); +assertMailConfigReady(cfg); +if (!user.email) throw createError({ statusCode: 400, statusMessage: "管理员未配置邮箱" }); +await sendCommentTestMail({ to: user.email, cfg }); +return R.success({ ok: true }); +``` + +```vue +发送测试邮件 +``` + +- [ ] **Step 4: Run test again** + +Run: `bun test server/api/config/global/comment-email-test.post.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/api/config/global/comment-email-test.post.ts server/api/config/global/comment-email-test.post.test.ts app/pages/me/admin/config/index.vue +git commit -m "feat(mail): add admin comment email test-send endpoint" +``` + +--- + +### Task 4: Add DB Fields for Guest Email and Anonymous Flag + +**Files:** +- Modify: `packages/drizzle-pkg/database/sqlite/schema/content.ts` +- Create: `packages/drizzle-pkg/migrations/0008_comment_guest_email.sql` (use next available index) +- Modify: `packages/drizzle-pkg/migrations/meta/_journal.json` (if migration tooling requires) + +- [ ] **Step 1: Write failing service test for guest email rules** + +```ts +test("guest non-anonymous must provide email", async () => { + await expect(createComment({ + postId, ownerUserId, parentId: null, viewer: null, + guestDisplayName: "访客", guestIsAnonymous: false, guestEmail: "", + body: "hello", + })).rejects.toThrow("请填写邮箱"); +}); +``` + +- [ ] **Step 2: Run test to confirm fail** + +Run: `bun test server/service/post-comments/index.test.ts` +Expected: FAIL due to unknown fields / missing validation. + +- [ ] **Step 3: Add schema + migration** + +```ts +guestEmail: text("guest_email"), +guestIsAnonymous: integer("guest_is_anonymous", { mode: "boolean" }).notNull().default(false), +``` + +```sql +ALTER TABLE post_comments ADD COLUMN guest_email text; +ALTER TABLE post_comments ADD COLUMN guest_is_anonymous integer DEFAULT 0 NOT NULL; +``` + +- [ ] **Step 4: Run migration/test checks** + +Run: `bun test server/service/post-comments/index.test.ts` +Expected: still FAIL until service is implemented (this is expected at this stage). + +- [ ] **Step 5: Commit** + +```bash +git add packages/drizzle-pkg/database/sqlite/schema/content.ts packages/drizzle-pkg/migrations/0008_comment_guest_email.sql packages/drizzle-pkg/migrations/meta/_journal.json +git commit -m "feat(db): add guest email and anonymous fields for comments" +``` + +--- + +### Task 5: Implement Comment Service Validation and Persist New Fields + +**Files:** +- Modify: `server/service/post-comments/index.ts` +- Modify: `server/utils/post-comment-guest.ts` +- Modify: `server/utils/post-comment-guest.test.ts` +- Modify: `app/components/PostComments.vue` + +- [ ] **Step 1: Expand failing validation tests** + +```ts +test("anonymous guest can omit email", () => { + expect(validateGuestCommentEmail("", true)).toBe(""); +}); + +test("non-anonymous guest rejects invalid email", () => { + expect(() => validateGuestCommentEmail("abc", false)).toThrow(GuestCommentValidationError); +}); +``` + +- [ ] **Step 2: Run guest validation tests** + +Run: `bun test server/utils/post-comment-guest.test.ts` +Expected: FAIL with `validateGuestCommentEmail` undefined. + +- [ ] **Step 3: Implement validation and form payload** + +```ts +export function validateGuestCommentEmail(raw: string, isAnonymous: boolean): string { + const t = raw.trim(); + if (isAnonymous) return t; + if (!t) throw new GuestCommentValidationError("请填写邮箱"); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) throw new GuestCommentValidationError("邮箱格式不正确"); + return t; +} +``` + +```vue + + +``` + +- [ ] **Step 4: Run tests and typecheck** + +Run: `bun test server/utils/post-comment-guest.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/service/post-comments/index.ts server/utils/post-comment-guest.ts server/utils/post-comment-guest.test.ts app/components/PostComments.vue +git commit -m "feat(comments): enforce guest email rules with anonymous override" +``` + +--- + +### Task 6: Add User Notification Preference in Profile + +**Files:** +- Modify: `app/pages/me/profile/index.vue` +- Modify: `server/service/config/registry.ts` (if not already included in Task 1) + +- [ ] **Step 1: Write failing profile load/save assertion** + +```ts +// verify /api/config/me result drives state.commentNotifyEnabled and save writes key +``` + +- [ ] **Step 2: Run typecheck before implementation** + +Run: `bun run typecheck` +Expected: FAIL due to missing `commentNotifyEnabled` state/binding. + +- [ ] **Step 3: Implement minimal state + save wiring** + +```ts +state.commentNotifyEnabled = typeof cfg.commentNotifyEnabled === "boolean" ? cfg.commentNotifyEnabled : true; +``` + +```ts +await fetchData('/api/config/me', { + method: 'PUT', + notify: false, + body: { key: 'commentNotifyEnabled', value: state.commentNotifyEnabled }, +}); +``` + +```vue + +``` + +- [ ] **Step 4: Re-run typecheck** + +Run: `bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/pages/me/profile/index.vue +git commit -m "feat(profile): add comment email notify preference toggle" +``` + +--- + +### Task 7: Implement Notification Dispatch on Comment Create + +**Files:** +- Create: `server/service/comment-notify/index.ts` +- Modify: `server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts` +- Modify: `server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts` +- Create/Modify Tests: `server/service/comment-notify/index.test.ts` + +- [ ] **Step 1: Write failing notify gating tests** + +```ts +test("skips when global switch off", async () => { + // expect no send call +}); + +test("skips when target user has notify disabled", async () => { + // expect no send call +}); +``` + +- [ ] **Step 2: Run targeted tests** + +Run: `bun test server/service/comment-notify/index.test.ts` +Expected: FAIL (service missing). + +- [ ] **Step 3: Implement best-effort notify service** + +```ts +export async function notifyOnCommentReply(input: { postId: number; commentId: number; actorUserId: number | null; parentId: number | null }) { + if (!input.parentId) return; + // load target comment author -> user + // check global config + smtp readiness + user pref + user email + // send and catch errors with logger.error(...) +} +``` + +- [ ] **Step 4: Integrate into POST handlers** + +```ts +const id = await createComment(...); +void notifyOnCommentReply({ postId, commentId: id, actorUserId: viewer?.id ?? null, parentId: body.parentId ?? null }); +return R.success({ id }); +``` + +- [ ] **Step 5: Run tests** + +Run: `bun test server/service/comment-notify/index.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add server/service/comment-notify/index.ts server/service/comment-notify/index.test.ts server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts +git commit -m "feat(notify): send best-effort comment reply emails" +``` + +--- + +### Task 8: End-to-End Verification and Documentation Update + +**Files:** +- Modify (if needed): `docs/superpowers/specs/2026-04-20-comment-email-config-design.md` +- Modify/Create: any README/admin docs that mention config keys + +- [ ] **Step 1: Run full relevant test suite** + +Run: `bun test server/utils/post-comment-guest.test.ts server/service/comment-notify/index.test.ts server/service/config/registry.test.ts` +Expected: PASS. + +- [ ] **Step 2: Run static verification** + +Run: `bun run typecheck` +Expected: PASS. + +- [ ] **Step 3: Manually verify key flows** + +Run app and validate: +1. Admin saves SMTP config and test-send succeeds. +2. Guest comment requires email unless anonymous checked. +3. Logged-in user without email can comment but sees warning. +4. User can toggle notify preference and it affects send decision. + +- [ ] **Step 4: Commit verification/doc touch-ups** + +```bash +git add docs/superpowers/specs/2026-04-20-comment-email-config-design.md +git commit -m "docs(comment-email): align spec notes with implementation checks" +``` + +--- + +## Self-Review + +- **Spec coverage:** All approved requirements are mapped to tasks: global SMTP config, guest anonymous/email rule, user notify preference default true, non-blocking send, and admin test-send. +- **Placeholder scan:** No `TODO/TBD` placeholders remain; each code step has concrete snippet and command. +- **Type consistency:** Config keys use a consistent prefix (`comment*`) across registry, UI, and notification service tasks. +