Browse Source
- Added a comprehensive implementation plan for comment email infrastructure, including SMTP configuration, guest email handling, and user notification preferences. - Extended the config registry and admin UI to support new settings, ensuring a robust email notification system without blocking comment creation. - Created necessary API endpoints and tests to validate the functionality of the new features. This update enhances the comment system's email capabilities and user experience.main
1 changed files with 471 additions and 0 deletions
@ -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<boolean>({ key: "commentEmailNotifyEnabled", scope: "global", valueType: "boolean", defaultValue: false, userOverridable: false }), |
||||
|
commentMailFromEmail: defineConfig<string>({ |
||||
|
key: "commentMailFromEmail", |
||||
|
scope: "global", |
||||
|
valueType: "string", |
||||
|
defaultValue: "", |
||||
|
userOverridable: false, |
||||
|
validate: (value: string) => !value.trim() || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()), |
||||
|
}), |
||||
|
commentSmtpHost: defineConfig<string>({ key: "commentSmtpHost", scope: "global", valueType: "string", defaultValue: "", userOverridable: false }), |
||||
|
commentSmtpPort: defineConfig<number>({ |
||||
|
key: "commentSmtpPort", |
||||
|
scope: "global", |
||||
|
valueType: "number", |
||||
|
defaultValue: 465, |
||||
|
userOverridable: false, |
||||
|
validate: (value: number) => Number.isInteger(value) && value >= 1 && value <= 65535, |
||||
|
}), |
||||
|
commentSmtpSecure: defineConfig<boolean>({ key: "commentSmtpSecure", scope: "global", valueType: "boolean", defaultValue: true, userOverridable: false }), |
||||
|
commentSmtpUser: defineConfig<string>({ key: "commentSmtpUser", scope: "global", valueType: "string", defaultValue: "", userOverridable: false }), |
||||
|
commentSmtpPass: defineConfig<string>({ key: "commentSmtpPass", scope: "global", valueType: "string", defaultValue: "", userOverridable: false }), |
||||
|
commentNotifyEnabled: defineConfig<boolean>({ 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 |
||||
|
<!-- me/admin/config/index.vue --> |
||||
|
<UFormField label="评论邮件通知总开关"><UCheckbox v-model="commentEmailNotifyEnabled" label="开启" /></UFormField> |
||||
|
<UFormField label="发件人邮箱"><UInput v-model="commentMailFromEmail" placeholder="noreply@example.com" /></UFormField> |
||||
|
<UFormField label="SMTP Host"><UInput v-model="commentSmtpHost" /></UFormField> |
||||
|
<UFormField label="SMTP Port"><UInput v-model.number="commentSmtpPort" type="number" /></UFormField> |
||||
|
<UFormField label="SMTP Secure"><UCheckbox v-model="commentSmtpSecure" label="TLS/SSL" /></UFormField> |
||||
|
<UFormField label="SMTP User"><UInput v-model="commentSmtpUser" /></UFormField> |
||||
|
<UFormField label="SMTP Password"><UInput v-model="commentSmtpPass" type="password" autocomplete="new-password" /></UFormField> |
||||
|
``` |
||||
|
|
||||
|
- [ ] **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 |
||||
|
<UButton :loading="testingMail" variant="soft" @click="testSendMail">发送测试邮件</UButton> |
||||
|
``` |
||||
|
|
||||
|
- [ ] **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 |
||||
|
<UCheckbox v-model="guestIsAnonymous" label="匿名评论(可不填邮箱)" /> |
||||
|
<UInput v-model="guestEmail" :required="!guestIsAnonymous" placeholder="你的邮箱" /> |
||||
|
``` |
||||
|
|
||||
|
- [ ] **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 |
||||
|
<UCheckbox v-model="state.commentNotifyEnabled" label="接收评论邮件通知" /> |
||||
|
``` |
||||
|
|
||||
|
- [ ] **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. |
||||
|
|
||||
Loading…
Reference in new issue