159 lines
6.2 KiB
TypeScript
159 lines
6.2 KiB
TypeScript
"use node"
|
|
|
|
import tls from "tls"
|
|
import { action } from "./_generated/server"
|
|
import { v } from "convex/values"
|
|
|
|
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) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => {
|
|
let buffer = ""
|
|
const send = (line: string) => socket.write(line + "\r\n")
|
|
const wait = (expected: string | RegExp) =>
|
|
new Promise<void>((res) => {
|
|
const onData = (data: Buffer) => {
|
|
buffer += data.toString()
|
|
const lines = buffer.split(/\r?\n/)
|
|
const last = lines.filter(Boolean).slice(-1)[0] ?? ""
|
|
if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) {
|
|
socket.removeListener("data", onData)
|
|
res()
|
|
}
|
|
}
|
|
socket.on("data", onData)
|
|
socket.on("error", reject)
|
|
})
|
|
|
|
;(async () => {
|
|
await wait(/^220 /)
|
|
send(`EHLO ${cfg.host}`)
|
|
await wait(/^250-/)
|
|
await wait(/^250 /)
|
|
send("AUTH LOGIN")
|
|
await wait(/^334 /)
|
|
send(b64(cfg.username))
|
|
await wait(/^334 /)
|
|
send(b64(cfg.password))
|
|
await wait(/^235 /)
|
|
send(`MAIL FROM:<${cfg.from.match(/<(.+)>/)?.[1] ?? cfg.from}>`)
|
|
await wait(/^250 /)
|
|
send(`RCPT TO:<${to}>`)
|
|
await wait(/^250 /)
|
|
send("DATA")
|
|
await wait(/^354 /)
|
|
const headers = [
|
|
`From: ${cfg.from}`,
|
|
`To: ${to}`,
|
|
`Subject: ${subject}`,
|
|
"MIME-Version: 1.0",
|
|
"Content-Type: text/html; charset=UTF-8",
|
|
].join("\r\n")
|
|
send(headers + "\r\n\r\n" + html + "\r\n.")
|
|
await wait(/^250 /)
|
|
send("QUIT")
|
|
socket.end()
|
|
resolve()
|
|
})().catch(reject)
|
|
})
|
|
socket.on("error", reject)
|
|
})
|
|
}
|
|
|
|
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;">© ${new Date().getFullYear()} Raven — Rever Tecnologia</p>
|
|
</td>
|
|
</tr>
|
|
</table>`
|
|
}
|
|
|
|
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 = {
|
|
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) {
|
|
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({
|
|
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 = {
|
|
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) {
|
|
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({
|
|
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 }
|
|
},
|
|
})
|