Some checks failed
- 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>
544 lines
16 KiB
TypeScript
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 }
|
|
},
|
|
})
|