Browse Source
Trigger reply notification email after comment creation for public and unlisted endpoints with gated checks for global switch, SMTP readiness, receiver preferences, and self-notify suppression. Made-with: Cursormain
4 changed files with 276 additions and 0 deletions
@ -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); |
||||
|
}); |
||||
|
}); |
||||
@ -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<number | null>; |
||||
|
getGlobalConfig: () => Promise<NotifyGlobalConfig>; |
||||
|
getReceiverNotifyEnabled: (userId: number) => Promise<boolean>; |
||||
|
getReceiverProfile: (userId: number) => Promise<ReceiverProfile | null>; |
||||
|
sendMail: (input: { toEmail: string; fromEmail: string; replyBody: string }) => Promise<void>; |
||||
|
}; |
||||
|
|
||||
|
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<number | null> { |
||||
|
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<NotifyGlobalConfig> { |
||||
|
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<boolean> { |
||||
|
const { getMergedConfigValue } = await import("../config"); |
||||
|
return getMergedConfigValue(userId, "commentNotifyEnabled"); |
||||
|
} |
||||
|
|
||||
|
async function defaultGetReceiverProfile(userId: number): Promise<ReceiverProfile | null> { |
||||
|
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<void> { |
||||
|
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<void> { |
||||
|
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); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue