diff --git a/convex/automations.ts b/convex/automations.ts index d971d85..6090220 100644 --- a/convex/automations.ts +++ b/convex/automations.ts @@ -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> = {} const applied: Array<{ type: string; details?: Record }> = [] + 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 = { + "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() + + 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 } diff --git a/convex/emailTemplates.ts b/convex/emailTemplates.ts new file mode 100644 index 0000000..7933432 --- /dev/null +++ b/convex/emailTemplates.ts @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +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) => `

${escapeHtml(p).replace(/\n/g, "
")}

`) + .join("") +} + +function badge(label: string, bg: string, color: string) { + return `${escapeHtml(label)}` +} + +function statusBadge(statusRaw: string) { + const status = statusRaw.trim().toUpperCase() + const map: Record = { + 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 = { + 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(` + + Chamado + #${escapeHtml(ticket.reference)} + + `) + + rows.push(` + + Assunto + ${escapeHtml(ticket.subject)} + + `) + + if (ticket.companyName) { + rows.push(` + + Empresa + ${escapeHtml(ticket.companyName)} + + `) + } + + if (ticket.status) { + rows.push(` + + Status + ${statusBadge(ticket.status)} + + `) + } + + if (ticket.priority) { + rows.push(` + + Prioridade + ${priorityBadge(ticket.priority)} + + `) + } + + if (ticket.requesterName) { + rows.push(` + + Solicitante + ${escapeHtml(ticket.requesterName)} + + `) + } + + if (ticket.assigneeName) { + rows.push(` + + Responsável + ${escapeHtml(ticket.assigneeName)} + + `) + } + + return ` + + + + +
+ + ${rows.join("")} +
+
+ ` +} + +function button(label: string, url: string) { + return `${escapeHtml(label)}` +} + +export function renderAutomationEmail(params: { + title: string + message: string + ticket: EmailTicketSummary + ctaLabel: string + ctaUrl: string +}) { + const baseUrl = buildBaseUrl() + + const content = ` + + + + + +
+ Raven + + Raven +
+ +

${escapeHtml(params.title)}

+ ${textToHtmlParagraphs(params.message)} + ${ticketInfoCard(params.ticket)} +
+ ${button(params.ctaLabel, params.ctaUrl)} +
+

+ Se o botão não funcionar, copie e cole esta URL no navegador:
+ ${escapeHtml(params.ctaUrl)} +

+ ` + + return ` + + + + +
+ + + + +
+ ${content} +
+

© ${new Date().getFullYear()} Raven — Rever Tecnologia

+
+ ` +} + +export function renderSimpleNotificationEmail(params: { + title: string + message: string + ctaLabel: string + ctaUrl: string +}) { + const baseUrl = buildBaseUrl() + + const content = ` + + + + + +
+ Raven + + Raven +
+ +

${escapeHtml(params.title)}

+ ${textToHtmlParagraphs(params.message)} +
+ ${button(params.ctaLabel, params.ctaUrl)} +
+

+ Se o botão não funcionar, copie e cole esta URL no navegador:
+ ${escapeHtml(params.ctaUrl)} +

+ ` + + return ` + + + + +
+ + + + +
+ ${content} +
+

© ${new Date().getFullYear()} Raven — Rever Tecnologia

+
+ ` +} diff --git a/convex/ticketNotifications.ts b/convex/ticketNotifications.ts index e19a6b4..4e0a9b5 100644 --- a/convex/ticketNotifications.ts +++ b/convex/ticketNotifications.ts @@ -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 ") + + 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((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 ` - - - - -
- - - - -
-
- Raven - Raven -
-

${title}

-

${message}

- ${ctaLabel} -

Se o botão não funcionar, copie e cole esta URL no navegador:
${ctaUrl}

-
-

© ${new Date().getFullYear()} Raven — Rever Tecnologia

-
` -} - 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 ", - } - 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 ", - } - 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 } + }, +}) diff --git a/docs/AUTOMATIONS_EMAIL.md b/docs/AUTOMATIONS_EMAIL.md new file mode 100644 index 0000000..46779ee --- /dev/null +++ b/docs/AUTOMATIONS_EMAIL.md @@ -0,0 +1,79 @@ +# Automações ▸ Envio de e-mails (guia de manutenção) + +## Visão geral +O envio de e-mails via automações funciona como uma **ação** dentro do motor de automações de tickets. + +Por motivos de compatibilidade e segurança: +- O HTML do e-mail é gerado com **tabelas + CSS inline** (compatível com Gmail/Outlook/Apple Mail). +- O envio (SMTP) acontece em **Convex Action** (`"use node"`), porque mutações Convex não devem fazer I/O de rede. + +## Onde as automações disparam +Os eventos de ticket chamam o motor de automações em `convex/tickets.ts`: +- Criação do ticket (`TICKET_CREATED`) +- Alteração de status (`STATUS_CHANGED`) +- Alteração de prioridade (`PRIORITY_CHANGED`) +- Alteração de fila (`QUEUE_CHANGED`) +- Inclusão de comentário (`COMMENT_ADDED`) +- Finalização/resolução (`TICKET_RESOLVED`) + +## Onde a ação é validada e aplicada +Arquivo: `convex/automations.ts` + +Pontos principais: +- **Validação/parse** da ação `SEND_EMAIL` em `parseAction(...)`. +- **Execução** em `applyActions(...)`: + - Resolve destinatários (solicitante, responsável, usuário interno e e-mails livres). + - Faz interpolação de variáveis `{{...}}` em assunto/mensagem. + - Gera o HTML com `renderAutomationEmail(...)` (`convex/emailTemplates.ts`). + - Agenda o envio via `ctx.scheduler.runAfter(1, api.ticketNotifications.sendAutomationEmail, ...)`. + +## Onde o e-mail é enviado de fato (SMTP) +Arquivo: `convex/ticketNotifications.ts` + +- A action `sendAutomationEmail` faz o envio via SMTP e aceita: + - `to`: lista de destinatários + - `subject`: assunto + - `html`: HTML já renderizado + +Observação: para não “vazar” destinatários entre si, o envio é feito **um-a-um** (um e-mail por destinatário). + +## Templates de e-mail +Arquivo: `convex/emailTemplates.ts` + +Templates adicionados: +- `renderAutomationEmail(...)`: usado pela ação `SEND_EMAIL` (inclui cartão com dados do ticket + CTA). +- `renderSimpleNotificationEmail(...)`: utilitário reaproveitado por notificações simples (comentário público / encerramento). + +## Variáveis suportadas (interpolação) +Você pode usar estas variáveis em **Assunto** e **Mensagem**: +- `{{ticket.reference}}` +- `{{ticket.subject}}` +- `{{ticket.status}}` +- `{{ticket.priority}}` +- `{{company.name}}` +- `{{requester.name}}` +- `{{assignee.name}}` +- `{{ticket.url.portal}}` +- `{{ticket.url.staff}}` +- `{{automation.name}}` + +## Link do botão (CTA) +A UI permite escolher: +- `Auto` (padrão): se houver destinatário interno (responsável/usuário) usa **Painel**; caso contrário usa **Portal**. +- `Portal (cliente)`: `/portal/tickets/:id` +- `Painel (agente)`: `/tickets/:id` + +Se você precisar enviar para cliente **e** agente no mesmo evento, prefira criar **duas ações SEND_EMAIL** (uma com link Portal e outra com link Painel). + +## Variáveis de ambiente (SMTP) +O envio no Convex tenta usar: +- `SMTP_ADDRESS` ou `SMTP_HOST` +- `SMTP_USERNAME` ou `SMTP_USER` +- `SMTP_PASSWORD` ou `SMTP_PASS` +- `SMTP_PORT` (default `465`) +- `MAILER_SENDER_EMAIL` (legacy) ou `SMTP_FROM_EMAIL` + `SMTP_FROM_NAME` + +## Testes de regressão +Arquivo: `tests/automations-engine.test.ts` +- Teste adiciona um cenário onde a ação `SEND_EMAIL` está presente e valida que o envio é agendado via `scheduler.runAfter`. + diff --git a/src/components/automations/automation-editor-dialog.tsx b/src/components/automations/automation-editor-dialog.tsx index b6f82bc..0f74600 100644 --- a/src/components/automations/automation-editor-dialog.tsx +++ b/src/components/automations/automation-editor-dialog.tsx @@ -11,6 +11,7 @@ import { useAuth } from "@/lib/auth-client" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" import { DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -57,6 +58,9 @@ type ActionType = | "SET_FORM_TEMPLATE" | "SET_CHAT_ENABLED" | "ADD_INTERNAL_COMMENT" + | "SEND_EMAIL" + +type EmailCtaTarget = "AUTO" | "PORTAL" | "STAFF" type ActionDraft = | { id: string; type: "SET_PRIORITY"; priority: string } @@ -65,6 +69,18 @@ type ActionDraft = | { id: string; type: "SET_FORM_TEMPLATE"; formTemplate: string | null } | { id: string; type: "SET_CHAT_ENABLED"; enabled: boolean } | { id: string; type: "ADD_INTERNAL_COMMENT"; body: string } + | { + id: string + type: "SEND_EMAIL" + subject: string + message: string + toRequester: boolean + toAssignee: boolean + toUserId: string + toEmails: string + ctaTarget: EmailCtaTarget + ctaLabel: string + } const PRIORITIES = [ { value: "LOW", label: "Baixa" }, @@ -109,6 +125,17 @@ function safeString(value: unknown) { return typeof value === "string" ? value : "" } +function parseEmailAddresses(raw: string) { + return raw + .split(/[\n,;]+/g) + .map((email) => email.trim()) + .filter(Boolean) +} + +function isValidEmail(email: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) +} + function toDraftConditions(raw: unknown | null): ConditionDraft[] { const group = raw as { conditions?: unknown } | null const list = Array.isArray(group?.conditions) ? group?.conditions : [] @@ -128,6 +155,43 @@ function toDraftActions(raw: unknown[]): ActionDraft[] { const base = a as Record const type = safeString(base.type) as ActionType const id = crypto.randomUUID() + if (type === "SEND_EMAIL") { + const recipientsRaw = Array.isArray(base.recipients) ? (base.recipients as unknown[]) : [] + const recipientTypes = recipientsRaw + .map((r) => (r && typeof r === "object" ? (r as { type?: unknown }).type : null)) + .filter((t): t is string => typeof t === "string") + .map((t) => t.trim().toUpperCase()) + const toRequester = recipientTypes.includes("REQUESTER") + const toAssignee = recipientTypes.includes("ASSIGNEE") + const userEntry = recipientsRaw.find((r) => { + const rec = r as { type?: unknown; userId?: unknown } + return typeof rec?.type === "string" && rec.type.trim().toUpperCase() === "USER" && typeof rec.userId === "string" + }) as { userId?: string } | undefined + const toUserId = userEntry?.userId ?? "" + const emails = recipientsRaw + .filter((r) => { + const rec = r as { type?: unknown; email?: unknown } + return typeof rec?.type === "string" && rec.type.trim().toUpperCase() === "EMAIL" && typeof rec.email === "string" + }) + .map((r) => (r as { email?: string }).email ?? "") + .filter(Boolean) + const ctaTargetRaw = safeString(base.ctaTarget).toUpperCase() + const ctaTarget: EmailCtaTarget = + ctaTargetRaw === "PORTAL" || ctaTargetRaw === "STAFF" ? (ctaTargetRaw as EmailCtaTarget) : "AUTO" + + return { + id, + type, + subject: safeString(base.subject), + message: safeString(base.message), + toRequester, + toAssignee, + toUserId, + toEmails: emails.join(", "), + ctaTarget, + ctaLabel: safeString(base.ctaLabel) || "Abrir chamado", + } + } if (type === "MOVE_QUEUE") return { id, type, queueId: safeString(base.queueId) } if (type === "ASSIGN_TO") return { id, type, assigneeId: safeString(base.assigneeId) } if (type === "SET_FORM_TEMPLATE") return { id, type, formTemplate: safeString(base.formTemplate) || null } @@ -273,6 +337,39 @@ export function AutomationEditorDialog({ if (a.type === "ASSIGN_TO") return { type: a.type, assigneeId: a.assigneeId } if (a.type === "SET_FORM_TEMPLATE") return { type: a.type, formTemplate: a.formTemplate } if (a.type === "SET_CHAT_ENABLED") return { type: a.type, enabled: a.enabled } + if (a.type === "SEND_EMAIL") { + const subject = a.subject.trim() + const message = a.message.trim() + if (!subject) throw new Error("Informe o assunto do e-mail.") + if (!message) throw new Error("Informe a mensagem do e-mail.") + + const recipients: Array> = [] + if (a.toRequester) recipients.push({ type: "REQUESTER" }) + if (a.toAssignee) recipients.push({ type: "ASSIGNEE" }) + if (a.toUserId.trim()) recipients.push({ type: "USER", userId: a.toUserId.trim() }) + + const emails = parseEmailAddresses(a.toEmails) + const invalid = emails.filter((email) => !isValidEmail(email)) + if (invalid.length > 0) { + throw new Error(`E-mail(s) inválido(s): ${invalid.join(", ")}`) + } + emails.forEach((email) => recipients.push({ type: "EMAIL", email })) + + if (recipients.length === 0) { + throw new Error("Selecione pelo menos um destinatário para o e-mail.") + } + + const ctaLabel = a.ctaLabel.trim() || "Abrir chamado" + + return { + type: "SEND_EMAIL", + subject, + message, + recipients, + ctaTarget: a.ctaTarget, + ctaLabel, + } + } return { type: a.type, body: a.body } }) @@ -703,6 +800,20 @@ export function AutomationEditorDialog({ if (next === "SET_FORM_TEMPLATE") return { id: item.id, type: next, formTemplate: null } if (next === "SET_CHAT_ENABLED") return { id: item.id, type: next, enabled: true } if (next === "ADD_INTERNAL_COMMENT") return { id: item.id, type: next, body: "" } + if (next === "SEND_EMAIL") { + return { + id: item.id, + type: next, + subject: "", + message: "", + toRequester: true, + toAssignee: false, + toUserId: "", + toEmails: "", + ctaTarget: "AUTO", + ctaLabel: "Abrir chamado", + } + } return { id: item.id, type: "SET_PRIORITY", priority: "MEDIUM" } }) ) @@ -718,6 +829,7 @@ export function AutomationEditorDialog({ Aplicar formulário Habilitar/desabilitar chat Adicionar comentário interno + Enviar e-mail @@ -808,6 +920,131 @@ export function AutomationEditorDialog({ className="data-[state=checked]:bg-black data-[state=unchecked]:bg-slate-300" /> + ) : a.type === "SEND_EMAIL" ? ( +
+
+ + + setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, subject: e.target.value } : item))) + } + placeholder="Ex.: Atualização do chamado #{{ticket.reference}}" + /> +
+
+ +