feat: automações com envio de e-mail

This commit is contained in:
esdrasrenan 2025-12-13 12:38:08 -03:00
parent 469608a10b
commit 58a1ed6b36
6 changed files with 958 additions and 49 deletions

View file

@ -14,6 +14,15 @@ import {
} from "./automationsEngine"
import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates"
import { TICKET_FORM_CONFIG } from "./ticketForms.config"
import { buildBaseUrl, renderAutomationEmail, type EmailTicketSummary } from "./emailTemplates"
type AutomationEmailTarget = "AUTO" | "PORTAL" | "STAFF"
type AutomationEmailRecipient =
| { type: "REQUESTER" }
| { type: "ASSIGNEE" }
| { type: "USER"; userId: Id<"users"> }
| { type: "EMAIL"; email: string }
type AutomationAction =
| { type: "SET_PRIORITY"; priority: string }
@ -22,6 +31,14 @@ type AutomationAction =
| { type: "SET_FORM_TEMPLATE"; formTemplate: string | null }
| { type: "SET_CHAT_ENABLED"; enabled: boolean }
| { type: "ADD_INTERNAL_COMMENT"; body: string }
| {
type: "SEND_EMAIL"
recipients: AutomationEmailRecipient[]
subject: string
message: string
ctaTarget?: AutomationEmailTarget
ctaLabel?: string
}
type AutomationRunStatus = "SUCCESS" | "SKIPPED" | "ERROR"
@ -123,6 +140,59 @@ function parseAction(value: unknown): AutomationAction | null {
return { type: "ADD_INTERNAL_COMMENT", body }
}
if (type === "SEND_EMAIL") {
const subject = typeof record.subject === "string" ? record.subject.trim() : ""
const message = typeof record.message === "string" ? record.message : ""
const recipientsRaw = record.recipients
if (!subject) return null
if (!message.trim()) return null
if (!Array.isArray(recipientsRaw) || recipientsRaw.length === 0) return null
const recipients: AutomationEmailRecipient[] = []
for (const entry of recipientsRaw) {
const rec = parseRecord(entry)
if (!rec) continue
const recType = typeof rec.type === "string" ? rec.type.trim().toUpperCase() : ""
if (recType === "REQUESTER") {
recipients.push({ type: "REQUESTER" })
continue
}
if (recType === "ASSIGNEE") {
recipients.push({ type: "ASSIGNEE" })
continue
}
if (recType === "USER") {
const userId = typeof rec.userId === "string" ? rec.userId : ""
if (!userId) continue
recipients.push({ type: "USER", userId: userId as Id<"users"> })
continue
}
if (recType === "EMAIL") {
const email = typeof rec.email === "string" ? rec.email.trim() : ""
if (!email) continue
recipients.push({ type: "EMAIL", email })
}
}
if (recipients.length === 0) return null
const ctaTargetRaw = typeof record.ctaTarget === "string" ? record.ctaTarget.trim().toUpperCase() : ""
const ctaTarget: AutomationEmailTarget =
ctaTargetRaw === "PORTAL" || ctaTargetRaw === "STAFF" ? (ctaTargetRaw as AutomationEmailTarget) : "AUTO"
const ctaLabel = typeof record.ctaLabel === "string" ? record.ctaLabel.trim() : ""
return {
type: "SEND_EMAIL",
recipients,
subject,
message,
ctaTarget,
ctaLabel: ctaLabel.length > 0 ? ctaLabel : undefined,
}
}
return null
}
@ -593,6 +663,13 @@ async function applyActions(
const patch: Partial<Doc<"tickets">> = {}
const applied: Array<{ type: string; details?: Record<string, unknown> }> = []
const pendingEmails: Array<{
recipients: AutomationEmailRecipient[]
subject: string
message: string
ctaTarget: AutomationEmailTarget
ctaLabel: string
}> = []
for (const action of actions) {
if (action.type === "SET_PRIORITY") {
@ -735,6 +812,18 @@ async function applyActions(
applied.push({ type: action.type })
continue
}
if (action.type === "SEND_EMAIL") {
const subject = action.subject.trim()
const message = action.message.replace(/\r\n/g, "\n").trim()
if (!subject || !message) continue
const ctaTarget = action.ctaTarget ?? "AUTO"
const ctaLabel = (action.ctaLabel ?? "Abrir chamado").trim() || "Abrir chamado"
pendingEmails.push({ recipients: action.recipients, subject, message, ctaTarget, ctaLabel })
continue
}
}
if (Object.keys(patch).length > 0) {
@ -742,5 +831,140 @@ async function applyActions(
await ctx.db.patch(ticket._id, patch)
}
if (pendingEmails.length > 0) {
const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter !== "function") {
throw new ConvexError("Scheduler indisponível para envio de e-mail")
}
const nextTicket = { ...ticket, ...patch } as Doc<"tickets">
const baseUrl = buildBaseUrl()
const portalUrl = `${baseUrl}/portal/tickets/${nextTicket._id}`
const staffUrl = `${baseUrl}/tickets/${nextTicket._id}`
const tokens: Record<string, string> = {
"automation.name": automation.name,
"ticket.id": String(nextTicket._id),
"ticket.reference": String(nextTicket.reference ?? ""),
"ticket.subject": nextTicket.subject ?? "",
"ticket.status": nextTicket.status ?? "",
"ticket.priority": nextTicket.priority ?? "",
"ticket.url.portal": portalUrl,
"ticket.url.staff": staffUrl,
"company.name": (nextTicket.companySnapshot as { name?: string } | undefined)?.name ?? "",
"requester.name": (nextTicket.requesterSnapshot as { name?: string } | undefined)?.name ?? "",
"assignee.name": ((nextTicket.assigneeSnapshot as { name?: string } | undefined)?.name as string | undefined) ?? "",
}
const interpolate = (input: string) =>
input.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_m, key) => tokens[key] ?? "")
const normalizeEmail = (email: string) => email.trim().toLowerCase()
const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
for (const emailAction of pendingEmails) {
const subject = interpolate(emailAction.subject).trim()
const message = interpolate(emailAction.message).trim()
const ctaLabel = interpolate(emailAction.ctaLabel).trim() || "Abrir chamado"
const effectiveTarget: AutomationEmailTarget =
emailAction.ctaTarget === "AUTO"
? emailAction.recipients.some((r) => r.type === "ASSIGNEE" || r.type === "USER")
? "STAFF"
: "PORTAL"
: emailAction.ctaTarget
const ctaUrl = effectiveTarget === "PORTAL" ? portalUrl : staffUrl
const recipientEmails = new Set<string>()
for (const recipient of emailAction.recipients) {
if (recipient.type === "REQUESTER") {
const snapshotEmail =
((nextTicket.requesterSnapshot as { email?: string } | undefined)?.email as string | undefined) ?? null
const email = normalizeEmail(snapshotEmail ?? "")
if (email && isValidEmail(email)) {
recipientEmails.add(email)
continue
}
const requester = (await ctx.db.get(nextTicket.requesterId)) as Doc<"users"> | null
if (requester && requester.tenantId === nextTicket.tenantId) {
const fallback = normalizeEmail(requester.email ?? "")
if (fallback && isValidEmail(fallback)) recipientEmails.add(fallback)
}
continue
}
if (recipient.type === "ASSIGNEE") {
if (!nextTicket.assigneeId) continue
const snapshotEmail =
((nextTicket.assigneeSnapshot as { email?: string } | undefined)?.email as string | undefined) ?? null
const email = normalizeEmail(snapshotEmail ?? "")
if (email && isValidEmail(email)) {
recipientEmails.add(email)
continue
}
const assignee = (await ctx.db.get(nextTicket.assigneeId)) as Doc<"users"> | null
if (assignee && assignee.tenantId === nextTicket.tenantId) {
const fallback = normalizeEmail(assignee.email ?? "")
if (fallback && isValidEmail(fallback)) recipientEmails.add(fallback)
}
continue
}
if (recipient.type === "USER") {
const user = (await ctx.db.get(recipient.userId)) as Doc<"users"> | null
if (user && user.tenantId === nextTicket.tenantId) {
const email = normalizeEmail(user.email ?? "")
if (email && isValidEmail(email)) recipientEmails.add(email)
}
continue
}
if (recipient.type === "EMAIL") {
const email = normalizeEmail(recipient.email)
if (email && isValidEmail(email)) recipientEmails.add(email)
continue
}
}
const to = Array.from(recipientEmails).slice(0, 50)
if (to.length === 0) continue
const ticketSummary: EmailTicketSummary = {
reference: nextTicket.reference ?? 0,
subject: nextTicket.subject ?? "",
status: nextTicket.status ?? null,
priority: nextTicket.priority ?? null,
companyName: (nextTicket.companySnapshot as { name?: string } | undefined)?.name ?? null,
requesterName: (nextTicket.requesterSnapshot as { name?: string } | undefined)?.name ?? null,
assigneeName: ((nextTicket.assigneeSnapshot as { name?: string } | undefined)?.name as string | undefined) ?? null,
}
const html = renderAutomationEmail({
title: subject,
message,
ticket: ticketSummary,
ctaLabel,
ctaUrl,
})
await schedulerRunAfter(1, api.ticketNotifications.sendAutomationEmail, {
to,
subject,
html,
})
applied.push({
type: "SEND_EMAIL",
details: {
toCount: to.length,
ctaTarget: effectiveTarget,
},
})
}
}
return applied
}

268
convex/emailTemplates.ts Normal file
View file

@ -0,0 +1,268 @@
const COLORS = {
primary: "#00e8ff",
primaryDark: "#00c4d6",
primaryForeground: "#020617",
background: "#f8fafc",
card: "#ffffff",
border: "#e2e8f0",
textPrimary: "#0f172a",
textSecondary: "#334155",
textMuted: "#64748b",
statusPending: "#64748b",
statusPendingBg: "#f1f5f9",
statusProgress: "#0ea5e9",
statusProgressBg: "#e0f2fe",
statusPaused: "#f59e0b",
statusPausedBg: "#fef3c7",
statusResolved: "#10b981",
statusResolvedBg: "#d1fae5",
priorityLow: "#64748b",
priorityLowBg: "#f1f5f9",
priorityMedium: "#0a4760",
priorityMediumBg: "#dff1fb",
priorityHigh: "#7d3b05",
priorityHighBg: "#fde8d1",
priorityUrgent: "#8b0f1c",
priorityUrgentBg: "#fbd9dd",
}
export type EmailTicketSummary = {
reference: number
subject: string
status?: string | null
priority?: string | null
companyName?: string | null
requesterName?: string | null
assigneeName?: string | null
}
function escapeHtml(value: unknown): string {
if (value === null || value === undefined) return ""
const s = String(value)
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
function textToHtmlParagraphs(text: string): string {
const trimmed = text.replace(/\r\n/g, "\n").trim()
if (!trimmed) return ""
const paragraphs = trimmed.split(/\n{2,}/g)
return paragraphs
.map((p) => `<p style="font-size:14px;line-height:1.65;margin:0 0 12px 0;color:${COLORS.textSecondary};">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`)
.join("")
}
function badge(label: string, bg: string, color: string) {
return `<span style="display:inline-block;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:600;background:${bg};color:${color};white-space:nowrap;">${escapeHtml(label)}</span>`
}
function statusBadge(statusRaw: string) {
const status = statusRaw.trim().toUpperCase()
const map: Record<string, { label: string; bg: string; color: string }> = {
PENDING: { label: "Pendente", bg: COLORS.statusPendingBg, color: COLORS.statusPending },
AWAITING_ATTENDANCE: { label: "Em andamento", bg: COLORS.statusProgressBg, color: COLORS.statusProgress },
PAUSED: { label: "Pausado", bg: COLORS.statusPausedBg, color: COLORS.statusPaused },
RESOLVED: { label: "Resolvido", bg: COLORS.statusResolvedBg, color: COLORS.statusResolved },
}
const entry = map[status] ?? { label: statusRaw, bg: COLORS.statusPendingBg, color: COLORS.statusPending }
return badge(entry.label, entry.bg, entry.color)
}
function priorityBadge(priorityRaw: string) {
const priority = priorityRaw.trim().toUpperCase()
const map: Record<string, { label: string; bg: string; color: string }> = {
LOW: { label: "Baixa", bg: COLORS.priorityLowBg, color: COLORS.priorityLow },
MEDIUM: { label: "Média", bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium },
HIGH: { label: "Alta", bg: COLORS.priorityHighBg, color: COLORS.priorityHigh },
URGENT: { label: "Urgente", bg: COLORS.priorityUrgentBg, color: COLORS.priorityUrgent },
}
const entry = map[priority] ?? { label: priorityRaw, bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium }
return badge(entry.label, entry.bg, entry.color)
}
export function buildBaseUrl() {
return process.env.NEXT_PUBLIC_APP_URL || process.env.APP_BASE_URL || "http://localhost:3000"
}
function ticketInfoCard(ticket: EmailTicketSummary) {
const rows: string[] = []
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Chamado</td>
<td style="color:${COLORS.textPrimary};font-size:14px;font-weight:700;padding:4px 0;">#${escapeHtml(ticket.reference)}</td>
</tr>
`)
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Assunto</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.subject)}</td>
</tr>
`)
if (ticket.companyName) {
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Empresa</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.companyName)}</td>
</tr>
`)
}
if (ticket.status) {
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Status</td>
<td style="padding:4px 0;">${statusBadge(ticket.status)}</td>
</tr>
`)
}
if (ticket.priority) {
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Prioridade</td>
<td style="padding:4px 0;">${priorityBadge(ticket.priority)}</td>
</tr>
`)
}
if (ticket.requesterName) {
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Solicitante</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.requesterName)}</td>
</tr>
`)
}
if (ticket.assigneeName) {
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Responsável</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.assigneeName)}</td>
</tr>
`)
}
return `
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.background};border-radius:10px;margin:18px 0 0 0;">
<tr>
<td style="padding:16px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
${rows.join("")}
</table>
</td>
</tr>
</table>
`
}
function button(label: string, url: string) {
return `<a href="${escapeHtml(url)}" style="display:inline-block;background:${COLORS.primary};color:${COLORS.primaryForeground};text-decoration:none;border-radius:12px;padding:12px 18px;font-weight:800;font-size:14px;border:1px solid ${COLORS.primaryDark};">${escapeHtml(label)}</a>`
}
export function renderAutomationEmail(params: {
title: string
message: string
ticket: EmailTicketSummary
ctaLabel: string
ctaUrl: string
}) {
const baseUrl = buildBaseUrl()
const content = `
<table cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="vertical-align:middle;">
<img src="${baseUrl}/logo-raven.png" alt="Raven" style="width:36px;height:36px;border-radius:10px;display:block;" />
</td>
<td style="vertical-align:middle;padding-left:12px;">
<span style="font-weight:800;font-size:18px;color:${COLORS.textPrimary};">Raven</span>
</td>
</tr>
</table>
<h1 style="font-size:20px;line-height:1.3;margin:16px 0 8px 0;color:${COLORS.textPrimary};">${escapeHtml(params.title)}</h1>
${textToHtmlParagraphs(params.message)}
${ticketInfoCard(params.ticket)}
<div style="margin-top:18px;">
${button(params.ctaLabel, params.ctaUrl)}
</div>
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:18px;">
Se o botão não funcionar, copie e cole esta URL no navegador:<br/>
<a href="${escapeHtml(params.ctaUrl)}" style="color:${COLORS.primaryDark};text-decoration:none;">${escapeHtml(params.ctaUrl)}</a>
</p>
`
return `
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.background};padding:28px 0;font-family:Arial,Helvetica,sans-serif;color:${COLORS.textPrimary};">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.card};border:1px solid ${COLORS.border};border-radius:16px;padding:24px;max-width:600px;width:100%;">
<tr>
<td style="text-align:left;">
${content}
</td>
</tr>
</table>
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:14px;">&copy; ${new Date().getFullYear()} Raven Rever Tecnologia</p>
</td>
</tr>
</table>
`
}
export function renderSimpleNotificationEmail(params: {
title: string
message: string
ctaLabel: string
ctaUrl: string
}) {
const baseUrl = buildBaseUrl()
const content = `
<table cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="vertical-align:middle;">
<img src="${baseUrl}/logo-raven.png" alt="Raven" style="width:36px;height:36px;border-radius:10px;display:block;" />
</td>
<td style="vertical-align:middle;padding-left:12px;">
<span style="font-weight:800;font-size:18px;color:${COLORS.textPrimary};">Raven</span>
</td>
</tr>
</table>
<h1 style="font-size:20px;line-height:1.3;margin:16px 0 8px 0;color:${COLORS.textPrimary};">${escapeHtml(params.title)}</h1>
${textToHtmlParagraphs(params.message)}
<div style="margin-top:18px;">
${button(params.ctaLabel, params.ctaUrl)}
</div>
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:18px;">
Se o botão não funcionar, copie e cole esta URL no navegador:<br/>
<a href="${escapeHtml(params.ctaUrl)}" style="color:${COLORS.primaryDark};text-decoration:none;">${escapeHtml(params.ctaUrl)}</a>
</p>
`
return `
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.background};padding:28px 0;font-family:Arial,Helvetica,sans-serif;color:${COLORS.textPrimary};">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.card};border:1px solid ${COLORS.border};border-radius:16px;padding:24px;max-width:600px;width:100%;">
<tr>
<td style="text-align:left;">
${content}
</td>
</tr>
</table>
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:14px;">&copy; ${new Date().getFullYear()} Raven Rever Tecnologia</p>
</td>
</tr>
</table>
`
}

View file

@ -4,11 +4,31 @@ import tls from "tls"
import { action } from "./_generated/server"
import { v } from "convex/values"
import { buildBaseUrl, renderSimpleNotificationEmail } from "./emailTemplates"
function b64(input: string) {
return Buffer.from(input, "utf8").toString("base64")
}
async function sendSmtpMail(cfg: { host: string; port: number; username: string; password: string; from: string }, to: string, subject: string, html: string) {
type SmtpConfig = { host: string; port: number; username: string; password: string; from: string }
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
return { host, port, username, password, from }
}
async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: string) {
return new Promise<void>((resolve, reject) => {
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => {
let buffer = ""
@ -63,35 +83,6 @@ async function sendSmtpMail(cfg: { host: string; port: number; username: string;
})
}
function buildBaseUrl() {
return process.env.NEXT_PUBLIC_APP_URL || process.env.APP_BASE_URL || "http://localhost:3000"
}
function emailTemplate({ title, message, ctaLabel, ctaUrl }: { title: string; message: string; ctaLabel: string; ctaUrl: string }) {
return `
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8fafc;padding:24px 0;font-family:Arial,Helvetica,sans-serif;color:#0f172a;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;">
<tr>
<td style="text-align:left;">
<div style="display:flex;align-items:center;gap:12px;">
<img src="${buildBaseUrl()}/logo-raven.png" alt="Raven" style="width:36px;height:36px;border-radius:8px;"/>
<span style="font-weight:700;font-size:18px;">Raven</span>
</div>
<h1 style="font-size:20px;line-height:1.3;margin:16px 0 8px 0;">${title}</h1>
<p style="font-size:14px;line-height:1.6;margin:0 0 16px 0;color:#334155;">${message}</p>
<a href="${ctaUrl}" style="display:inline-block;background:#111827;color:#fff;text-decoration:none;border-radius:10px;padding:10px 16px;font-weight:600;">${ctaLabel}</a>
<p style="font-size:12px;color:#64748b;margin-top:20px;">Se o botão não funcionar, copie e cole esta URL no navegador:<br/><a href="${ctaUrl}" style="color:#0ea5e9;text-decoration:none;">${ctaUrl}</a></p>
</td>
</tr>
</table>
<p style="font-size:12px;color:#94a3b8;margin-top:12px;">&copy; ${new Date().getFullYear()} Raven Rever Tecnologia</p>
</td>
</tr>
</table>`
}
export const sendPublicCommentEmail = action({
args: {
to: v.string(),
@ -100,21 +91,15 @@ export const sendPublicCommentEmail = action({
subject: v.string(),
},
handler: async (_ctx, { to, ticketId, reference, subject }) => {
const smtp = {
host: process.env.SMTP_ADDRESS!,
port: Number(process.env.SMTP_PORT ?? 465),
username: process.env.SMTP_USERNAME!,
password: process.env.SMTP_PASSWORD!,
from: process.env.MAILER_SENDER_EMAIL || "Raven <no-reply@example.com>",
}
if (!smtp.host || !smtp.username || !smtp.password) {
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 = emailTemplate({
const html = renderSimpleNotificationEmail({
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",
@ -133,21 +118,15 @@ export const sendResolvedEmail = action({
subject: v.string(),
},
handler: async (_ctx, { to, ticketId, reference, subject }) => {
const smtp = {
host: process.env.SMTP_ADDRESS!,
port: Number(process.env.SMTP_PORT ?? 465),
username: process.env.SMTP_USERNAME!,
password: process.env.SMTP_PASSWORD!,
from: process.env.MAILER_SENDER_EMAIL || "Raven <no-reply@example.com>",
}
if (!smtp.host || !smtp.username || !smtp.password) {
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 = emailTemplate({
const html = renderSimpleNotificationEmail({
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",
@ -157,3 +136,33 @@ export const sendResolvedEmail = action({
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 }
},
})