feat: checklists em tickets + automações
- Adiciona checklist no ticket (itens obrigatórios/opcionais) e bloqueia encerramento com pendências\n- Cria templates de checklist (globais/por empresa) + tela em /settings/checklists\n- Nova ação de automação: aplicar template de checklist\n- Corrige crash do Select (value vazio), warnings de Dialog e dimensionamento de charts\n- Ajusta SMTP (STARTTLS) e melhora teste de integração
This commit is contained in:
parent
4306b0504d
commit
88a9ef454e
27 changed files with 2685 additions and 226 deletions
|
|
@ -1,5 +1,6 @@
|
|||
"use node"
|
||||
|
||||
import net from "net"
|
||||
import tls from "tls"
|
||||
import { action } from "./_generated/server"
|
||||
import { v } from "convex/values"
|
||||
|
|
@ -11,7 +12,28 @@ function b64(input: string) {
|
|||
return Buffer.from(input, "utf8").toString("base64")
|
||||
}
|
||||
|
||||
type SmtpConfig = { host: string; port: number; username: string; password: string; from: string }
|
||||
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
|
||||
|
|
@ -26,62 +48,237 @@ function buildSmtpConfig(): SmtpConfig | null {
|
|||
|
||||
if (!host || !username || !password) return null
|
||||
|
||||
return { host, port, username, password, from }
|
||||
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) {
|
||||
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)
|
||||
})
|
||||
const timeoutMs = Math.max(1000, cfg.timeoutMs)
|
||||
|
||||
;(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)
|
||||
})
|
||||
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 sendPublicCommentEmail = action({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue