.
This commit is contained in:
+130
@@ -37,6 +37,136 @@ async function getTransporter(): Promise<Transporter | null> {
|
||||
return transporter;
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared branded layout for ticket emails. Every mail states the same rule:
|
||||
* email is notification-only — real replies happen on the /review page.
|
||||
*/
|
||||
function ticketLayout(title: string, bodyHtml: string, reviewUrl: string): { html: string; text: string } {
|
||||
const text = bodyHtml
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n\n")
|
||||
.replace(/<[^>]+>/g, "");
|
||||
const html =
|
||||
`<!doctype html><html><body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">` +
|
||||
`<div style="max-width:560px;margin:0 auto;padding:24px 16px;">` +
|
||||
`<div style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e7e5e4;">` +
|
||||
`<div style="background:#059669;padding:20px 24px;color:#ffffff;">` +
|
||||
`<div style="font-size:20px;font-weight:700;">KisAssistant</div>` +
|
||||
`<div style="font-size:13px;opacity:.85;">${escHtml(title)}</div>` +
|
||||
`</div>` +
|
||||
`<div style="padding:24px;color:#1c1917;font-size:14px;line-height:1.6;">${bodyHtml}</div>` +
|
||||
`<div style="padding:16px 24px;background:#fafaf9;border-top:1px solid #e7e5e4;font-size:12px;color:#78716c;">` +
|
||||
`This email is notification-only — please do not reply to it.<br>` +
|
||||
`Respond on the official site: <a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a>` +
|
||||
`</div></div></div></body></html>`;
|
||||
return { html, text: `${text}\n\n--\nThis email is notification-only, do not reply. Respond at: ${reviewUrl}\n` };
|
||||
}
|
||||
|
||||
async function sendTicketMail(to: string, subject: string, title: string, bodyHtml: string, reviewUrl: string): Promise<boolean> {
|
||||
const t = await getTransporter();
|
||||
if (!t) {
|
||||
console.error("[mail] SMTP_HOST not set — ticket email skipped");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const { html, text } = ticketLayout(title, bodyHtml, reviewUrl);
|
||||
await t.sendMail({ from: config.smtpFrom, to, subject, text, html });
|
||||
console.log(`[mail] ticket email "${subject}" sent`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[mail] send failed:", (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirm a wishlist request was received, with the /review ticket link. */
|
||||
export async function sendAccessReceivedEmail(to: string, username: string, reviewUrl: string): Promise<boolean> {
|
||||
return sendTicketMail(
|
||||
to,
|
||||
"KisAssistant — access request received",
|
||||
"Access request received",
|
||||
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
|
||||
`<p>We received your request for a KisAssistant account. An admin will review it shortly.</p>` +
|
||||
`<p>Follow the discussion and add arguments here:<br>` +
|
||||
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>` +
|
||||
`<p>Keep this link — it is your personal ticket page.</p>`,
|
||||
reviewUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/** Notify status changes. `key` is set only on acceptance (shown once). */
|
||||
export async function sendAccessStatusEmail(
|
||||
to: string,
|
||||
username: string,
|
||||
status: "reviewing" | "accepted" | "refused",
|
||||
reason: string,
|
||||
reviewUrl: string,
|
||||
key?: string,
|
||||
): Promise<boolean> {
|
||||
if (status === "accepted") {
|
||||
return sendTicketMail(
|
||||
to,
|
||||
"KisAssistant — access granted",
|
||||
"Access granted",
|
||||
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
|
||||
`<p>Good news — your KisAssistant account was created${reason ? ` (${escHtml(reason)})` : ""}.</p>` +
|
||||
`<p>Your access key (shown <strong>once</strong> — save it now):</p>` +
|
||||
`<p style="background:#f5f5f4;border-radius:8px;padding:12px 16px;font-family:monospace;font-size:15px;">${escHtml(key ?? "")}</p>` +
|
||||
`<p>Log in with your username and this key. You can still discuss on your ticket page:<br>` +
|
||||
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
|
||||
reviewUrl,
|
||||
);
|
||||
}
|
||||
if (status === "refused") {
|
||||
return sendTicketMail(
|
||||
to,
|
||||
"KisAssistant — access request refused",
|
||||
"Access refused",
|
||||
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
|
||||
`<p>Your request for a KisAssistant account was refused${reason ? ` for this reason: <em>${escHtml(reason)}</em>` : ""}.</p>` +
|
||||
`<p>You may still reply on your ticket page if you think this is a mistake:<br>` +
|
||||
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
|
||||
reviewUrl,
|
||||
);
|
||||
}
|
||||
return sendTicketMail(
|
||||
to,
|
||||
"KisAssistant — access request under review",
|
||||
"Under review",
|
||||
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
|
||||
`<p>An admin is now reviewing your request${reason ? `: <em>${escHtml(reason)}</em>` : ""}.</p>` +
|
||||
`<p>You can add arguments on your ticket page:<br>` +
|
||||
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
|
||||
reviewUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/** Forward each new ticket message to the requester (notification-only). */
|
||||
export async function sendAccessMessageEmail(
|
||||
to: string,
|
||||
username: string,
|
||||
fromAdmin: boolean,
|
||||
body: string,
|
||||
reviewUrl: string,
|
||||
): Promise<boolean> {
|
||||
const excerpt = body.length > 500 ? `${body.slice(0, 500)}…` : body;
|
||||
return sendTicketMail(
|
||||
to,
|
||||
`KisAssistant — new message on your access request`,
|
||||
"New ticket message",
|
||||
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
|
||||
`<p><strong>${fromAdmin ? "An admin" : "You"}</strong> wrote on your access-request ticket:</p>` +
|
||||
`<p style="background:#f5f5f4;border-left:4px solid #059669;border-radius:0 8px 8px 0;padding:12px 16px;white-space:pre-wrap;">${escHtml(excerpt)}</p>` +
|
||||
`<p>Reply on your ticket page:<br>` +
|
||||
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
|
||||
reviewUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/** Send a key-recovery email with a one-time reset link. Fire-and-forget safe. */
|
||||
export async function sendRecoveryEmail(to: string, username: string, resetUrl: string): Promise<boolean> {
|
||||
const t = await getTransporter();
|
||||
|
||||
Reference in New Issue
Block a user