import type { Transporter } from "nodemailer"; import { config } from "./config.ts"; let transporter: Transporter | null = null; let warned = false; export function mailEnabled(): boolean { return config.smtpHost.length > 0; } // Lazy import: the backend boots (and serves) fine without node_modules // installed; SMTP is only needed when actually sending. async function loadMailer(): Promise { try { return await import("nodemailer"); } catch { if (!warned) { warned = true; console.error("[mail] nodemailer not installed — run `bun install` for email support"); } return null; } } async function getTransporter(): Promise { if (!mailEnabled()) return null; if (!transporter) { const mailer = await loadMailer(); if (!mailer) return null; transporter = mailer.createTransport({ host: config.smtpHost, port: config.smtpPort, secure: config.smtpSecure, auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined, }); } return transporter; } function escHtml(s: string): string { return s.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(//gi, "\n") .replace(/<\/p>/gi, "\n\n") .replace(/<[^>]+>/g, ""); const html = `` + `
` + `
` + `
` + `
KisAssistant
` + `
${escHtml(title)}
` + `
` + `
${bodyHtml}
` + `
` + `This email is notification-only — please do not reply to it.
` + `Respond on the official site: ${escHtml(reviewUrl)}` + `
`; 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 { 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 { return sendTicketMail( to, "KisAssistant — access request received", "Access request received", `

Hello ${escHtml(username)},

` + `

We received your request for a KisAssistant account. An admin will review it shortly.

` + `

Follow the discussion and add arguments here:
` + `${escHtml(reviewUrl)}

` + `

Keep this link — it is your personal ticket page.

`, 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 { if (status === "accepted") { return sendTicketMail( to, "KisAssistant — access granted", "Access granted", `

Hello ${escHtml(username)},

` + `

Good news — your KisAssistant account was created${reason ? ` (${escHtml(reason)})` : ""}.

` + `

Your access key (shown once — save it now):

` + `

${escHtml(key ?? "")}

` + `

Log in with your username and this key. You can still discuss on your ticket page:
` + `${escHtml(reviewUrl)}

`, reviewUrl, ); } if (status === "refused") { return sendTicketMail( to, "KisAssistant — access request refused", "Access refused", `

Hello ${escHtml(username)},

` + `

Your request for a KisAssistant account was refused${reason ? ` for this reason: ${escHtml(reason)}` : ""}.

` + `

You may still reply on your ticket page if you think this is a mistake:
` + `${escHtml(reviewUrl)}

`, reviewUrl, ); } return sendTicketMail( to, "KisAssistant — access request under review", "Under review", `

Hello ${escHtml(username)},

` + `

An admin is now reviewing your request${reason ? `: ${escHtml(reason)}` : ""}.

` + `

You can add arguments on your ticket page:
` + `${escHtml(reviewUrl)}

`, 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 { const excerpt = body.length > 500 ? `${body.slice(0, 500)}…` : body; return sendTicketMail( to, `KisAssistant — new message on your access request`, "New ticket message", `

Hello ${escHtml(username)},

` + `

${fromAdmin ? "An admin" : "You"} wrote on your access-request ticket:

` + `

${escHtml(excerpt)}

` + `

Reply on your ticket page:
` + `${escHtml(reviewUrl)}

`, 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 { const t = await getTransporter(); if (!t) { if (!warned) { warned = true; console.error("[mail] SMTP_HOST not set — recovery emails disabled"); } return false; } try { await t.sendMail({ from: config.smtpFrom, to, subject: "KisAssistant — recover your access key", text: `Hello ${username},\n\n` + `Someone requested a new access key for your KisAssistant account.\n` + `Open this link within 60 minutes to get a fresh key (your old one stops working):\n\n` + `${resetUrl}\n\n` + `If you did not request this, just ignore this email.\n`, }); console.log(`[mail] recovery email sent to ${username}`); return true; } catch (e) { console.error("[mail] send failed:", (e as Error).message); return false; } }