sistema-de-chamados/convex/ticketNotifications.ts
rever-tecnologia 1bc08d3a5f feat: adiciona fluxo de redefinição de senha e melhora página de configurações
- Adiciona página /recuperar para solicitar redefinição de senha
- Adiciona página /redefinir-senha para definir nova senha com token
- Cria APIs /api/auth/forgot-password e /api/auth/reset-password
- Adiciona notificação por e-mail quando ticket é criado
- Repagina página de configurações removendo informações técnicas
- Adiciona script de teste para todos os tipos de e-mail
- Corrige acentuações em templates de e-mail

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 10:42:08 -03:00

403 lines
12 KiB
TypeScript

"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 <no-reply@example.com>")
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<typeof setTimeout> }
| 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<SmtpResponse>((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<net.Socket>((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<tls.TLSSocket>((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<tls.TLSSocket>((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<typeof createSmtpReader> | 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<string, string> = {
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 }
},
})