You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
244 lines
7.2 KiB
244 lines
7.2 KiB
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<ReceiverTarget | 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 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<ReceiverTarget | 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,
|
|
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<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 {
|
|
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<void> {
|
|
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),
|
|
});
|
|
}
|
|
}
|
|
|