"use node" import net from "net" import tls from "tls" import { action } from "./_generated/server" import { v } from "convex/values" import { renderSimpleNotificationEmailHtml } from "./reactEmail" import { buildBaseUrl } from "./url" function b64(input: string) { return Buffer.from(input, "utf8").toString("base64") } function extractEnvelopeAddress(from: string): string { const angle = from.match(/<\s*([^>\s]+)\s*>/) if (angle?.[1]) return angle[1] const paren = from.match(/\(([^)\s]+@[^)\s]+)\)/) if (paren?.[1]) return paren[1] const email = from.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/) if (email?.[0]) return email[0] return from } type SmtpConfig = { host: string port: number username: string password: string from: string secure: boolean timeoutMs: number } function buildSmtpConfig(): SmtpConfig | null { const host = process.env.SMTP_ADDRESS || process.env.SMTP_HOST const port = Number(process.env.SMTP_PORT ?? 465) const username = process.env.SMTP_USERNAME || process.env.SMTP_USER const password = process.env.SMTP_PASSWORD || process.env.SMTP_PASS const legacyFrom = process.env.MAILER_SENDER_EMAIL const fromEmail = process.env.SMTP_FROM_EMAIL const fromName = process.env.SMTP_FROM_NAME || "Raven" const from = legacyFrom || (fromEmail ? `"${fromName}" <${fromEmail}>` : "Raven ") if (!host || !username || !password) return null const secureFlag = (process.env.SMTP_SECURE ?? process.env.SMTP_TLS ?? "").toLowerCase() const secure = secureFlag ? secureFlag === "true" : port === 465 return { host, port, username, password, from, secure, timeoutMs: 30000 } } type SmtpSocket = net.Socket | tls.TLSSocket type SmtpResponse = { code: number; lines: string[] } function createSmtpReader(socket: SmtpSocket, timeoutMs: number) { let buffer = "" let current: SmtpResponse | null = null const queue: SmtpResponse[] = [] let pending: | { resolve: (response: SmtpResponse) => void; reject: (error: unknown) => void; timer: ReturnType } | null = null const finalize = (response: SmtpResponse) => { if (pending) { clearTimeout(pending.timer) const resolve = pending.resolve pending = null resolve(response) return } queue.push(response) } const onData = (data: Buffer) => { buffer += data.toString("utf8") const lines = buffer.split(/\r?\n/) buffer = lines.pop() ?? "" for (const line of lines) { if (!line) continue const match = line.match(/^(\d{3})([ -])\s?(.*)$/) if (!match) continue const code = Number(match[1]) const isFinal = match[2] === " " if (!current) current = { code, lines: [] } current.lines.push(line) if (isFinal) { const response = current current = null finalize(response) } } } const onError = (error: unknown) => { if (pending) { clearTimeout(pending.timer) const reject = pending.reject pending = null reject(error) } } socket.on("data", onData) socket.on("error", onError) const read = () => new Promise((resolve, reject) => { const queued = queue.shift() if (queued) { resolve(queued) return } if (pending) { reject(new Error("smtp_concurrent_read")) return } const timer = setTimeout(() => { if (!pending) return const rejectPending = pending.reject pending = null rejectPending(new Error("smtp_timeout")) }, timeoutMs) pending = { resolve, reject, timer } }) const dispose = () => { socket.off("data", onData) socket.off("error", onError) if (pending) { clearTimeout(pending.timer) pending = null } } return { read, dispose } } function isCapability(lines: string[], capability: string) { const upper = capability.trim().toUpperCase() return lines.some((line) => line.replace(/^(\d{3})([ -])/, "").trim().toUpperCase().startsWith(upper)) } function assertCode(response: SmtpResponse, expected: number | ((code: number) => boolean), context: string) { const ok = typeof expected === "number" ? response.code === expected : expected(response.code) if (ok) return throw new Error(`smtp_unexpected_response:${context}:${response.code}:${response.lines.join(" | ")}`) } async function connectPlain(host: string, port: number, timeoutMs: number) { return new Promise((resolve, reject) => { const socket = net.connect(port, host) const timer = setTimeout(() => { socket.destroy() reject(new Error("smtp_connect_timeout")) }, timeoutMs) socket.once("connect", () => { clearTimeout(timer) resolve(socket) }) socket.once("error", (e) => { clearTimeout(timer) reject(e) }) }) } async function connectTls(host: string, port: number, timeoutMs: number) { return new Promise((resolve, reject) => { const socket = tls.connect({ host, port, rejectUnauthorized: false, servername: host }) const timer = setTimeout(() => { socket.destroy() reject(new Error("smtp_connect_timeout")) }, timeoutMs) socket.once("secureConnect", () => { clearTimeout(timer) resolve(socket) }) socket.once("error", (e) => { clearTimeout(timer) reject(e) }) }) } async function upgradeToStartTls(socket: net.Socket, host: string, timeoutMs: number) { return new Promise((resolve, reject) => { const tlsSocket = tls.connect({ socket, servername: host, rejectUnauthorized: false }) const timer = setTimeout(() => { tlsSocket.destroy() reject(new Error("smtp_connect_timeout")) }, timeoutMs) tlsSocket.once("secureConnect", () => { clearTimeout(timer) resolve(tlsSocket) }) tlsSocket.once("error", (e) => { clearTimeout(timer) reject(e) }) }) } async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: string) { const timeoutMs = Math.max(1000, cfg.timeoutMs) let socket: SmtpSocket | null = null let reader: ReturnType | null = null const sendLine = (line: string) => socket?.write(line + "\r\n") const readExpected = async (expected: number | ((code: number) => boolean), context: string) => { if (!reader) throw new Error("smtp_reader_not_ready") const response = await reader.read() assertCode(response, expected, context) return response } try { socket = cfg.secure ? await connectTls(cfg.host, cfg.port, timeoutMs) : await connectPlain(cfg.host, cfg.port, timeoutMs) reader = createSmtpReader(socket, timeoutMs) await readExpected(220, "greeting") sendLine(`EHLO ${cfg.host}`) let ehlo = await readExpected(250, "ehlo") if (!cfg.secure && isCapability(ehlo.lines, "STARTTLS")) { sendLine("STARTTLS") await readExpected(220, "starttls") reader.dispose() socket = await upgradeToStartTls(socket as net.Socket, cfg.host, timeoutMs) reader = createSmtpReader(socket, timeoutMs) sendLine(`EHLO ${cfg.host}`) ehlo = await readExpected(250, "ehlo_starttls") } sendLine("AUTH LOGIN") await readExpected(334, "auth_login") sendLine(b64(cfg.username)) await readExpected(334, "auth_username") sendLine(b64(cfg.password)) await readExpected(235, "auth_password") const envelopeFrom = extractEnvelopeAddress(cfg.from) sendLine(`MAIL FROM:<${envelopeFrom}>`) await readExpected((code) => Math.floor(code / 100) === 2, "mail_from") sendLine(`RCPT TO:<${to}>`) await readExpected((code) => Math.floor(code / 100) === 2, "rcpt_to") sendLine("DATA") await readExpected(354, "data") const headers = [ `From: ${cfg.from}`, `To: ${to}`, `Subject: ${subject}`, "MIME-Version: 1.0", "Content-Type: text/html; charset=UTF-8", ].join("\r\n") sendLine(headers + "\r\n\r\n" + html + "\r\n.") await readExpected((code) => Math.floor(code / 100) === 2, "message") sendLine("QUIT") await readExpected(221, "quit") } finally { reader?.dispose() socket?.end() } } export const sendTicketCreatedEmail = action({ args: { to: v.string(), ticketId: v.string(), reference: v.number(), subject: v.string(), priority: v.string(), }, handler: async (_ctx, { to, ticketId, reference, subject, priority }) => { const smtp = buildSmtpConfig() if (!smtp) { console.warn("SMTP not configured; skipping ticket created email") return { skipped: true } } const baseUrl = buildBaseUrl() const url = `${baseUrl}/portal/tickets/${ticketId}` const priorityLabels: Record = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente", } const priorityLabel = priorityLabels[priority] ?? priority const mailSubject = `Novo chamado #${reference} aberto` const html = await renderSimpleNotificationEmailHtml({ title: `Novo chamado #${reference} aberto`, message: `Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: ${subject}\nPrioridade: ${priorityLabel}\nStatus: Pendente`, ctaLabel: "Ver chamado", ctaUrl: url, }) await sendSmtpMail(smtp, to, mailSubject, html) return { ok: true } }, }) export const sendPublicCommentEmail = action({ args: { to: v.string(), ticketId: v.string(), reference: v.number(), subject: v.string(), }, handler: async (_ctx, { to, ticketId, reference, subject }) => { const smtp = buildSmtpConfig() if (!smtp) { console.warn("SMTP not configured; skipping ticket comment email") return { skipped: true } } const baseUrl = buildBaseUrl() const url = `${baseUrl}/portal/tickets/${ticketId}` const mailSubject = `Atualização no chamado #${reference}: ${subject}` const html = await renderSimpleNotificationEmailHtml({ title: `Nova atualização no seu chamado #${reference}`, message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`, ctaLabel: "Abrir e responder", ctaUrl: url, }) await sendSmtpMail(smtp, to, mailSubject, html) return { ok: true } }, }) export const sendResolvedEmail = action({ args: { to: v.string(), ticketId: v.string(), reference: v.number(), subject: v.string(), }, handler: async (_ctx, { to, ticketId, reference, subject }) => { const smtp = buildSmtpConfig() if (!smtp) { console.warn("SMTP not configured; skipping ticket resolution email") return { skipped: true } } const baseUrl = buildBaseUrl() const url = `${baseUrl}/portal/tickets/${ticketId}` const mailSubject = `Seu chamado #${reference} foi encerrado` const html = await renderSimpleNotificationEmailHtml({ title: `Chamado #${reference} encerrado`, message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`, ctaLabel: "Ver detalhes", ctaUrl: url, }) await sendSmtpMail(smtp, to, mailSubject, html) return { ok: true } }, }) export const sendAutomationEmail = action({ args: { to: v.array(v.string()), subject: v.string(), html: v.string(), }, handler: async (_ctx, { to, subject, html }) => { const smtp = buildSmtpConfig() if (!smtp) { console.warn("SMTP not configured; skipping automation email") return { skipped: true } } const recipients = to .map((email) => email.trim()) .filter(Boolean) .slice(0, 50) if (recipients.length === 0) { return { skipped: true, reason: "no_recipients" } } for (const recipient of recipients) { await sendSmtpMail(smtp, recipient, subject, html) } return { ok: true, sent: recipients.length } }, })