diff --git a/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts b/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts index a95fa4e..4f69a2a 100644 --- a/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts +++ b/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts @@ -1,6 +1,7 @@ import { getRequestIP } from "h3"; import { getPublicPostCommentContext } from "#server/service/posts"; import { createComment } from "#server/service/post-comments"; +import { notifyReplyCommentCreated } from "#server/service/comment-notify"; import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; import { parsePublicPostCreateCommentBody } from "#server/api/public/comments-create-body"; @@ -45,5 +46,11 @@ export default defineEventHandler(async (event) => { body: parsed.body, }); + void notifyReplyCommentCreated({ + parentId: parsed.parentId, + actorUserId: viewer?.id ?? null, + replyBody: parsed.body, + }); + return R.success({ id: newCommentId }); }); diff --git a/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts b/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts index c931c71..193e566 100644 --- a/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts +++ b/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts @@ -1,6 +1,7 @@ import { getRequestIP } from "h3"; import { getUnlistedPostCommentContext } from "#server/service/posts"; import { createComment } from "#server/service/post-comments"; +import { notifyReplyCommentCreated } from "#server/service/comment-notify"; import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; import { parseUnlistedCreateCommentBody } from "#server/api/public/comments-create-body"; @@ -45,5 +46,11 @@ export default defineEventHandler(async (event) => { body: parsed.body, }); + void notifyReplyCommentCreated({ + parentId: parsed.parentId, + actorUserId: viewer?.id ?? null, + replyBody: parsed.body, + }); + return R.success({ id: newCommentId }); }); diff --git a/server/service/comment-notify/index.test.ts b/server/service/comment-notify/index.test.ts new file mode 100644 index 0000000..4c46c9b --- /dev/null +++ b/server/service/comment-notify/index.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import { notifyReplyCommentCreated } from "./index"; + +function createDeps() { + const state = { + sendMailCalled: false, + }; + + return { + state, + deps: { + getParentAuthorUserId: async () => 2, + getGlobalConfig: async () => ({ + enabled: true, + fromEmail: "noreply@example.com", + smtpHost: "smtp.example.com", + smtpPort: 465, + smtpSecure: true, + smtpUser: "smtp-user", + smtpPass: "smtp-pass", + }), + getReceiverNotifyEnabled: async () => true, + getReceiverProfile: async () => ({ + email: "receiver@example.com", + username: "receiver", + nickname: "Receiver", + }), + sendMail: async () => { + state.sendMailCalled = true; + }, + }, + }; +} + +describe("notifyReplyCommentCreated", () => { + test("全局开关关闭 -> 不发送", async () => { + const { deps, state } = createDeps(); + deps.getGlobalConfig = async () => ({ + enabled: false, + fromEmail: "noreply@example.com", + smtpHost: "smtp.example.com", + smtpPort: 465, + smtpSecure: true, + smtpUser: "smtp-user", + smtpPass: "smtp-pass", + }); + + await notifyReplyCommentCreated({ parentId: 100, actorUserId: 1, replyBody: "hello" }, deps); + + expect(state.sendMailCalled).toBe(false); + }); + + test("用户偏好关闭 -> 不发送", async () => { + const { deps, state } = createDeps(); + deps.getReceiverNotifyEnabled = async () => false; + + await notifyReplyCommentCreated({ parentId: 100, actorUserId: 1, replyBody: "hello" }, deps); + + expect(state.sendMailCalled).toBe(false); + }); + + test("无 parentId -> 不发送", async () => { + const { deps, state } = createDeps(); + + await notifyReplyCommentCreated({ parentId: null, actorUserId: 1, replyBody: "hello" }, deps); + + expect(state.sendMailCalled).toBe(false); + }); + + test("自通知 -> 不发送", async () => { + const { deps, state } = createDeps(); + deps.getParentAuthorUserId = async () => 1; + + await notifyReplyCommentCreated({ parentId: 100, actorUserId: 1, replyBody: "hello" }, deps); + + expect(state.sendMailCalled).toBe(false); + }); +}); diff --git a/server/service/comment-notify/index.ts b/server/service/comment-notify/index.ts new file mode 100644 index 0000000..44d8e44 --- /dev/null +++ b/server/service/comment-notify/index.ts @@ -0,0 +1,184 @@ +import log4js from "logger"; +import nodemailer from "nodemailer"; + +const logger = log4js.getLogger("COMMENT_NOTIFY"); + +type NotifyGlobalConfig = { + enabled: boolean; + fromEmail: string; + smtpHost: string; + smtpPort: number; + smtpSecure: boolean; + smtpUser: string; + smtpPass: string; +}; + +type ReceiverProfile = { + email: string | null; + username: string | null; + nickname: string | null; +}; + +type NotifyDeps = { + getParentAuthorUserId: (parentId: number) => Promise; + getGlobalConfig: () => Promise; + getReceiverNotifyEnabled: (userId: number) => Promise; + getReceiverProfile: (userId: number) => Promise; + sendMail: (input: { toEmail: string; fromEmail: string; replyBody: string }) => Promise; +}; + +function hasValue(value: string): boolean { + return value.trim().length > 0; +} + +function isSmtpConfigReady(config: NotifyGlobalConfig): boolean { + return ( + config.enabled && + hasValue(config.fromEmail) && + hasValue(config.smtpHost) && + hasValue(config.smtpUser) && + hasValue(config.smtpPass) && + Number.isInteger(config.smtpPort) && + config.smtpPort >= 1 && + config.smtpPort <= 65535 + ); +} + +async function defaultGetParentAuthorUserId(parentId: number): Promise { + const { dbGlobal } = await import("drizzle-pkg/lib/db"); + const { postComments } = await import("drizzle-pkg/lib/schema/content"); + const { eq } = await import("drizzle-orm"); + + const [parent] = await dbGlobal + .select({ + authorUserId: postComments.authorUserId, + }) + .from(postComments) + .where(eq(postComments.id, parentId)) + .limit(1); + + return parent?.authorUserId ?? null; +} + +async function defaultGetGlobalConfig(): Promise { + const { getGlobalConfigValue } = await import("../config"); + + return { + enabled: await getGlobalConfigValue("commentEmailNotifyEnabled"), + fromEmail: await getGlobalConfigValue("commentMailFromEmail"), + smtpHost: await getGlobalConfigValue("commentSmtpHost"), + smtpPort: await getGlobalConfigValue("commentSmtpPort"), + smtpSecure: await getGlobalConfigValue("commentSmtpSecure"), + smtpUser: await getGlobalConfigValue("commentSmtpUser"), + smtpPass: await getGlobalConfigValue("commentSmtpPass"), + }; +} + +async function defaultGetReceiverNotifyEnabled(userId: number): Promise { + const { getMergedConfigValue } = await import("../config"); + return getMergedConfigValue(userId, "commentNotifyEnabled"); +} + +async function defaultGetReceiverProfile(userId: number): Promise { + const { dbGlobal } = await import("drizzle-pkg/lib/db"); + const { users } = await import("drizzle-pkg/lib/schema/auth"); + const { eq } = await import("drizzle-orm"); + + const [user] = await dbGlobal + .select({ + email: users.email, + username: users.username, + nickname: users.nickname, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + return user ?? null; +} + +async function defaultSendMail(input: { toEmail: string; fromEmail: string; replyBody: string }): Promise { + const { getGlobalConfigValue } = await import("../config"); + + const smtpHost = await getGlobalConfigValue("commentSmtpHost"); + const smtpPort = await getGlobalConfigValue("commentSmtpPort"); + const smtpSecure = await getGlobalConfigValue("commentSmtpSecure"); + const smtpUser = await getGlobalConfigValue("commentSmtpUser"); + const smtpPass = await getGlobalConfigValue("commentSmtpPass"); + + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 15_000, + auth: { + user: smtpUser, + pass: smtpPass, + }, + }); + + await transporter.sendMail({ + from: input.fromEmail, + to: input.toEmail.trim(), + subject: "[Person Panel] 你收到一条评论回复", + text: `你收到了一条新的评论回复。\n\n回复内容:\n${input.replyBody}\n`, + }); +} + +function getDefaultDeps(): NotifyDeps { + return { + getParentAuthorUserId: defaultGetParentAuthorUserId, + getGlobalConfig: defaultGetGlobalConfig, + getReceiverNotifyEnabled: defaultGetReceiverNotifyEnabled, + getReceiverProfile: defaultGetReceiverProfile, + sendMail: defaultSendMail, + }; +} + +export async function notifyReplyCommentCreated( + input: { + parentId: number | null; + actorUserId: number | null; + replyBody: string; + }, + deps: NotifyDeps = getDefaultDeps(), +): Promise { + try { + if (input.parentId == null) { + return; + } + + const receiverUserId = await deps.getParentAuthorUserId(input.parentId); + if (receiverUserId == null) { + return; + } + + if (input.actorUserId != null && input.actorUserId === receiverUserId) { + return; + } + + const globalConfig = await deps.getGlobalConfig(); + if (!isSmtpConfigReady(globalConfig)) { + return; + } + + const receiverNotifyEnabled = await deps.getReceiverNotifyEnabled(receiverUserId); + if (!receiverNotifyEnabled) { + return; + } + + const receiver = await deps.getReceiverProfile(receiverUserId); + if (!receiver?.email || !hasValue(receiver.email)) { + return; + } + + await deps.sendMail({ + toEmail: receiver.email, + fromEmail: globalConfig.fromEmail, + replyBody: input.replyBody, + }); + } catch (error) { + logger.warn("failed to send reply comment notify email", error); + } +}