sistema-de-chamados/convex/ticketNotifications.ts
rever-tecnologia b8170d0225
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
Quality Checks / Lint, Test and Build (push) Failing after 2m45s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m41s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m44s
fix(automations): corrige erro de import dinamico no envio de email
- Move renderizacao do React Email para a action Node.js
- Passa props do email em vez do HTML ja renderizado
- Resolve erro "dynamic module import unsupported"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 15:23:39 -03:00

544 lines
16 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"
// API do Next.js para verificar preferências
async function sendViaNextApi(params: {
type: string
to: { email: string; name?: string; userId?: string }
subject: string
data: Record<string, unknown>
tenantId?: string
}): Promise<{ success: boolean; skipped?: boolean; reason?: string }> {
const baseUrl = buildBaseUrl()
const token = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET
if (!token) {
console.warn("[ticketNotifications] Token interno não configurado, enviando diretamente")
return { success: false, reason: "no_token" }
}
try {
const response = await fetch(`${baseUrl}/api/notifications/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(params),
})
if (!response.ok) {
const error = await response.text()
console.error("[ticketNotifications] Erro na API:", error)
return { success: false, reason: "api_error" }
}
return await response.json()
} catch (error) {
console.error("[ticketNotifications] Erro ao chamar API:", error)
return { success: false, reason: "fetch_error" }
}
}
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(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
priority: v.string(),
tenantId: v.optional(v.string()),
},
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => {
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`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "ticket_created",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
status: "Pendente",
priority: priorityLabel,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket created email")
return { skipped: true }
}
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(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
tenantId: v.optional(v.string()),
},
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "comment_public",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket comment email")
return { skipped: true }
}
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(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
tenantId: v.optional(v.string()),
},
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Seu chamado #${reference} foi encerrado`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "ticket_resolved",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket resolution email")
return { skipped: true }
}
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(),
emailProps: v.object({
title: v.string(),
message: v.string(),
ticket: v.object({
reference: v.number(),
subject: v.string(),
status: v.optional(v.union(v.string(), v.null())),
priority: v.optional(v.union(v.string(), v.null())),
companyName: v.optional(v.union(v.string(), v.null())),
requesterName: v.optional(v.union(v.string(), v.null())),
assigneeName: v.optional(v.union(v.string(), v.null())),
}),
ctaLabel: v.string(),
ctaUrl: v.string(),
}),
},
handler: async (_ctx, { to, subject, emailProps }) => {
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" }
}
// Renderiza o HTML aqui (ambiente Node.js suporta imports dinâmicos)
const { renderAutomationEmailHtml } = await import("./reactEmail")
const html = await renderAutomationEmailHtml({
title: emailProps.title,
message: emailProps.message,
ticket: {
reference: emailProps.ticket.reference,
subject: emailProps.ticket.subject,
status: emailProps.ticket.status ?? null,
priority: emailProps.ticket.priority ?? null,
companyName: emailProps.ticket.companyName ?? null,
requesterName: emailProps.ticket.requesterName ?? null,
assigneeName: emailProps.ticket.assigneeName ?? null,
},
ctaLabel: emailProps.ctaLabel,
ctaUrl: emailProps.ctaUrl,
})
for (const recipient of recipients) {
await sendSmtpMail(smtp, recipient, subject, html)
}
return { ok: true, sent: recipients.length }
},
})