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