import log4js from "logger"; import nodemailer from "nodemailer"; const logger = log4js.getLogger("COMMENT_NOTIFY"); const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 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; }; export type ReceiverTarget = | { kind: "user"; userId: number } | { kind: "guest"; email: string }; export type NotifyDeps = { getParentReceiver: (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 getReason(error: unknown): string { if (error instanceof Error && hasValue(error.message)) { return error.message; } if (typeof error === "string" && hasValue(error)) { return error; } return "unknown"; } function getStack(error: unknown): string { if (error instanceof Error && hasValue(error.stack ?? "")) { return error.stack ?? ""; } return ""; } function isValidEmail(value: string): boolean { return EMAIL_REGEX.test(value.trim()); } 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 defaultGetParentReceiver(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, guestEmail: postComments.guestEmail, guestIsAnonymous: postComments.guestIsAnonymous, }) .from(postComments) .where(eq(postComments.id, parentId)) .limit(1); if (!parent) { return null; } if (parent.authorUserId != null) { return { kind: "user", userId: parent.authorUserId }; } if (!parent.guestIsAnonymous && parent.guestEmail && hasValue(parent.guestEmail) && isValidEmail(parent.guestEmail)) { return { kind: "guest", email: parent.guestEmail.trim() }; } return 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 { getParentReceiver: defaultGetParentReceiver, getGlobalConfig: defaultGetGlobalConfig, getReceiverNotifyEnabled: defaultGetReceiverNotifyEnabled, getReceiverProfile: defaultGetReceiverProfile, sendMail: defaultSendMail, }; } export async function notifyReplyCommentCreated( input: { postId: number; postOwnerUserId: number; commentId: number; parentId: number | null; actorUserId: number | null; actorGuestEmail?: string | null; replyBody: string; }, deps: NotifyDeps = getDefaultDeps(), ): Promise { let receiverUserId: number | null = null; let receiverEmail: string | null = null; try { const receiverTarget = input.parentId == null ? ({ kind: "user", userId: input.postOwnerUserId } as const) : await deps.getParentReceiver(input.parentId); if (receiverTarget == null) { return; } const globalConfig = await deps.getGlobalConfig(); if (!isSmtpConfigReady(globalConfig)) { return; } if (receiverTarget.kind === "user") { receiverUserId = receiverTarget.userId; if (input.actorUserId != null && input.actorUserId === receiverUserId) { return; } const receiverNotifyEnabled = await deps.getReceiverNotifyEnabled(receiverUserId); if (!receiverNotifyEnabled) { return; } const receiver = await deps.getReceiverProfile(receiverUserId); if (!receiver?.email || !hasValue(receiver.email)) { return; } if (!isValidEmail(receiver.email)) { return; } receiverEmail = receiver.email.trim(); } else { receiverEmail = receiverTarget.email.trim(); const actorGuestEmail = input.actorGuestEmail?.trim(); if (actorGuestEmail && receiverEmail.toLowerCase() === actorGuestEmail.toLowerCase()) { return; } } await deps.sendMail({ toEmail: receiverEmail, fromEmail: globalConfig.fromEmail, replyBody: input.replyBody, }); } catch (error) { logger.warn("failed to send reply comment notify email", { postId: input.postId, commentId: input.commentId, receiverUserId, receiverEmail, reason: getReason(error), stack: getStack(error), }); } }