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:
esdrasrenan 2025-12-13 20:51:47 -03:00
parent 4306b0504d
commit 88a9ef454e
27 changed files with 2685 additions and 226 deletions

View file

@ -1,11 +1,14 @@
import net from "net"
import tls from "tls"
type SmtpConfig = {
host: string
port: number
domain?: string
username: string
password: string
from: string
starttls?: boolean
tls?: boolean
rejectUnauthorized?: boolean
timeoutMs?: number
@ -29,74 +32,253 @@ function extractEnvelopeAddress(from: string): string {
return from
}
export async function sendSmtpMail(cfg: SmtpConfig, to: string | string[], subject: string, html: string) {
return new Promise<void>((resolve, reject) => {
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: cfg.rejectUnauthorized ?? false }, () => {
let buffer = ""
const send = (line: string) => socket.write(line + "\r\n")
const wait = (expected: string | RegExp) =>
new Promise<void>((res, rej) => {
const timeout = setTimeout(() => {
socket.removeListener("data", onData)
rej(new Error("smtp_timeout"))
}, Math.max(1000, cfg.timeoutMs ?? 10000))
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)
clearTimeout(timeout)
res()
}
}
socket.on("data", onData)
socket.on("error", (e) => {
clearTimeout(timeout)
rej(e)
})
})
type SmtpSocket = net.Socket | tls.TLSSocket
;(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 /)
const envelopeFrom = extractEnvelopeAddress(cfg.from)
send(`MAIL FROM:<${envelopeFrom}>`)
await wait(/^250 /)
const rcpts: string[] = Array.isArray(to)
? to
: String(to)
.split(/[;,]/)
.map((s) => s.trim())
.filter(Boolean)
for (const rcpt of rcpts) {
send(`RCPT TO:<${rcpt}>`)
await wait(/^250 /)
}
send("DATA")
await wait(/^354 /)
const headers = [
`From: ${cfg.from}`,
`To: ${Array.isArray(to) ? to.join(", ") : 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)
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
const details = response.lines.join(" | ")
throw new Error(`smtp_unexpected_response:${context}:${response.code}:${details}`)
}
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)
})
socket.on("error", reject)
})
}
async function connectTls(host: string, port: number, rejectUnauthorized: boolean, timeoutMs: number) {
return new Promise<tls.TLSSocket>((resolve, reject) => {
const socket = tls.connect({ host, port, rejectUnauthorized, 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, rejectUnauthorized: boolean, timeoutMs: number) {
return new Promise<tls.TLSSocket>((resolve, reject) => {
const tlsSocket = tls.connect({ socket, servername: host, rejectUnauthorized })
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)
})
})
}
export async function sendSmtpMail(cfg: SmtpConfig, to: string | string[], subject: string, html: string) {
const timeoutMs = Math.max(1000, cfg.timeoutMs ?? 10000)
const rejectUnauthorized = cfg.rejectUnauthorized ?? false
const implicitTls = cfg.tls ?? cfg.port === 465
const wantsStarttls = cfg.starttls ?? !implicitTls
const rcpts: string[] = Array.isArray(to)
? to
: String(to)
.split(/[;,]/)
.map((s) => s.trim())
.filter(Boolean)
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 = implicitTls
? await connectTls(cfg.host, cfg.port, rejectUnauthorized, timeoutMs)
: await connectPlain(cfg.host, cfg.port, timeoutMs)
reader = createSmtpReader(socket, timeoutMs)
await readExpected(220, "greeting")
const domain = cfg.domain ?? cfg.host
sendLine(`EHLO ${domain}`)
let ehlo = await readExpected(250, "ehlo")
if (!implicitTls && wantsStarttls) {
const supportsStarttls = isCapability(ehlo.lines, "STARTTLS")
if (!supportsStarttls) {
throw new Error("smtp_starttls_not_supported")
}
sendLine("STARTTLS")
await readExpected(220, "starttls")
reader.dispose()
const upgraded = await upgradeToStartTls(socket as net.Socket, cfg.host, rejectUnauthorized, timeoutMs)
socket = upgraded
reader = createSmtpReader(socket, timeoutMs)
sendLine(`EHLO ${domain}`)
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")
for (const rcpt of rcpts) {
sendLine(`RCPT TO:<${rcpt}>`)
await readExpected((code) => Math.floor(code / 100) === 2, "rcpt_to")
}
sendLine("DATA")
await readExpected(354, "data")
const headers = [
`From: ${cfg.from}`,
`To: ${Array.isArray(to) ? to.join(", ") : 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()
}
}