Browse Source

feat(comment): send best-effort reply notification emails

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: Cursor
main
npmrun 3 weeks ago
parent
commit
3c99e9a0c9
  1. 7
      server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts
  2. 7
      server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts
  3. 78
      server/service/comment-notify/index.test.ts
  4. 184
      server/service/comment-notify/index.ts

7
server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts

@ -1,6 +1,7 @@
import { getRequestIP } from "h3"; import { getRequestIP } from "h3";
import { getPublicPostCommentContext } from "#server/service/posts"; import { getPublicPostCommentContext } from "#server/service/posts";
import { createComment } from "#server/service/post-comments"; import { createComment } from "#server/service/post-comments";
import { notifyReplyCommentCreated } from "#server/service/comment-notify";
import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; import { assertUnderRateLimit } from "#server/utils/simple-rate-limit";
import { parsePublicPostCreateCommentBody } from "#server/api/public/comments-create-body"; import { parsePublicPostCreateCommentBody } from "#server/api/public/comments-create-body";
@ -45,5 +46,11 @@ export default defineEventHandler(async (event) => {
body: parsed.body, body: parsed.body,
}); });
void notifyReplyCommentCreated({
parentId: parsed.parentId,
actorUserId: viewer?.id ?? null,
replyBody: parsed.body,
});
return R.success({ id: newCommentId }); return R.success({ id: newCommentId });
}); });

7
server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts

@ -1,6 +1,7 @@
import { getRequestIP } from "h3"; import { getRequestIP } from "h3";
import { getUnlistedPostCommentContext } from "#server/service/posts"; import { getUnlistedPostCommentContext } from "#server/service/posts";
import { createComment } from "#server/service/post-comments"; import { createComment } from "#server/service/post-comments";
import { notifyReplyCommentCreated } from "#server/service/comment-notify";
import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; import { assertUnderRateLimit } from "#server/utils/simple-rate-limit";
import { parseUnlistedCreateCommentBody } from "#server/api/public/comments-create-body"; import { parseUnlistedCreateCommentBody } from "#server/api/public/comments-create-body";
@ -45,5 +46,11 @@ export default defineEventHandler(async (event) => {
body: parsed.body, body: parsed.body,
}); });
void notifyReplyCommentCreated({
parentId: parsed.parentId,
actorUserId: viewer?.id ?? null,
replyBody: parsed.body,
});
return R.success({ id: newCommentId }); return R.success({ id: newCommentId });
}); });

78
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);
});
});

184
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<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…
Cancel
Save