reports(SLA): aplica filtro de período (7d/30d/90d) no Convex e inclui período no filename do CSV; admin(alerts): filtros no servidor; alerts: batch de últimos alertas por slugs; filtros persistentes de empresa (localStorage) em relatórios; prisma: Company.contractedHoursPerMonth; smtp: suporte a múltiplos destinatários e timeout opcional
This commit is contained in:
parent
a23b429e4d
commit
384d4411b6
13 changed files with 133 additions and 38 deletions
|
|
@ -150,6 +150,36 @@ export const lastForCompanyBySlug = query({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const lastForCompaniesBySlugs = query({
|
||||||
|
args: { tenantId: v.string(), slugs: v.array(v.string()) },
|
||||||
|
handler: async (ctx, { tenantId, slugs }) => {
|
||||||
|
const result: Record<string, { createdAt: number; usagePct: number; threshold: number } | null> = {}
|
||||||
|
// Fetch all alerts once for the tenant
|
||||||
|
const alerts = await ctx.db
|
||||||
|
.query("alerts")
|
||||||
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||||
|
.collect()
|
||||||
|
for (const slug of slugs) {
|
||||||
|
const company = await ctx.db
|
||||||
|
.query("companies")
|
||||||
|
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
|
||||||
|
.first()
|
||||||
|
if (!company) {
|
||||||
|
result[slug] = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const matches = alerts.filter((a) => a.companyId === company._id)
|
||||||
|
if (matches.length === 0) {
|
||||||
|
result[slug] = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const last = matches.sort((a, b) => b.createdAt - a.createdAt)[0]
|
||||||
|
result[slug] = { createdAt: last.createdAt, usagePct: last.usagePct, threshold: last.threshold }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const tenantIds = query({
|
export const tenantIds = query({
|
||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
|
|
|
||||||
|
|
@ -129,21 +129,28 @@ function formatDateKey(timestamp: number) {
|
||||||
|
|
||||||
export const slaOverview = query({
|
export const slaOverview = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||||
handler: async (ctx, { tenantId, viewerId, companyId }) => {
|
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||||
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
||||||
|
// Optional range filter (createdAt) for reporting purposes, similar ao backlog/csat
|
||||||
|
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||||
|
const end = new Date();
|
||||||
|
end.setUTCHours(0, 0, 0, 0);
|
||||||
|
const endMs = end.getTime() + ONE_DAY_MS;
|
||||||
|
const startMs = endMs - days * ONE_DAY_MS;
|
||||||
|
const inRange = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs);
|
||||||
const queues = await fetchQueues(ctx, tenantId);
|
const queues = await fetchQueues(ctx, tenantId);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
|
const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
|
||||||
const resolvedTickets = tickets.filter((ticket) => {
|
const resolvedTickets = inRange.filter((ticket) => {
|
||||||
const status = normalizeStatus(ticket.status);
|
const status = normalizeStatus(ticket.status);
|
||||||
return status === "RESOLVED";
|
return status === "RESOLVED";
|
||||||
});
|
});
|
||||||
const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
||||||
|
|
||||||
const firstResponseTimes = tickets
|
const firstResponseTimes = inRange
|
||||||
.filter((ticket) => ticket.firstResponseAt)
|
.filter((ticket) => ticket.firstResponseAt)
|
||||||
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
|
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
|
||||||
const resolutionTimes = resolvedTickets
|
const resolutionTimes = resolvedTickets
|
||||||
|
|
@ -161,7 +168,7 @@ export const slaOverview = query({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totals: {
|
totals: {
|
||||||
total: tickets.length,
|
total: inRange.length,
|
||||||
open: openTickets.length,
|
open: openTickets.length,
|
||||||
resolved: resolvedTickets.length,
|
resolved: resolvedTickets.length,
|
||||||
overdue: overdueTickets.length,
|
overdue: overdueTickets.length,
|
||||||
|
|
@ -175,6 +182,7 @@ export const slaOverview = query({
|
||||||
resolvedCount: resolutionTimes.length,
|
resolvedCount: resolutionTimes.length,
|
||||||
},
|
},
|
||||||
queueBreakdown,
|
queueBreakdown,
|
||||||
|
rangeDays: days,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ model Company {
|
||||||
name String
|
name String
|
||||||
slug String
|
slug String
|
||||||
isAvulso Boolean @default(false)
|
isAvulso Boolean @default(false)
|
||||||
|
contractedHoursPerMonth Float?
|
||||||
cnpj String?
|
cnpj String?
|
||||||
domain String?
|
domain String?
|
||||||
phone String?
|
phone String?
|
||||||
|
|
|
||||||
|
|
@ -21,17 +21,11 @@ export async function GET(request: Request) {
|
||||||
const slugs = slugsParam.split(",").map((s) => s.trim()).filter(Boolean)
|
const slugs = slugsParam.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
|
||||||
const tenantId = session.user.tenantId ?? "tenant-atlas"
|
const tenantId = session.user.tenantId ?? "tenant-atlas"
|
||||||
const result: Record<string, { createdAt: number; usagePct: number; threshold: number } | null> = {}
|
try {
|
||||||
for (const slug of slugs) {
|
const result = (await client.query(api.alerts.lastForCompaniesBySlugs, { tenantId, slugs })) as Record<string, { createdAt: number; usagePct: number; threshold: number } | null>
|
||||||
try {
|
return NextResponse.json({ items: result })
|
||||||
const last = (await client.query(api.alerts.lastForCompanyBySlug, { tenantId, slug })) as
|
} catch (error) {
|
||||||
| { createdAt: number; usagePct: number; threshold: number }
|
console.error("Failed to fetch last alerts by slugs", error)
|
||||||
| null
|
return NextResponse.json({ items: {} })
|
||||||
result[slug] = last
|
|
||||||
} catch {
|
|
||||||
result[slug] = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ items: result })
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
const rows: Array<Array<unknown>> = []
|
const rows: Array<Array<unknown>> = []
|
||||||
rows.push(["Relatório", "SLA e produtividade"])
|
rows.push(["Relatório", "SLA e produtividade"])
|
||||||
rows.push(["Período", range ?? "—"])
|
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? "90d")])
|
||||||
if (companyId) rows.push(["EmpresaId", companyId])
|
if (companyId) rows.push(["EmpresaId", companyId])
|
||||||
rows.push([])
|
rows.push([])
|
||||||
|
|
||||||
|
|
@ -89,10 +89,14 @@ export async function GET(request: Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const csv = rowsToCsv(rows)
|
const csv = rowsToCsv(rows)
|
||||||
|
const daysLabel = (() => {
|
||||||
|
const raw = (range ?? "90d").replace("d", "")
|
||||||
|
return /^(7|30|90)$/.test(raw) ? `${raw}d` : "all"
|
||||||
|
})()
|
||||||
return new NextResponse(csv, {
|
return new NextResponse(csv, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/csv; charset=UTF-8",
|
"Content-Type": "text/csv; charset=UTF-8",
|
||||||
"Content-Disposition": `attachment; filename="sla-${tenantId}.csv"`,
|
"Content-Disposition": `attachment; filename="sla-${tenantId}-${daysLabel}.csv"`,
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,21 @@ export function AdminAlertsManager() {
|
||||||
|
|
||||||
const alertsRaw = useQuery(
|
const alertsRaw = useQuery(
|
||||||
api.alerts.list,
|
api.alerts.list,
|
||||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
convexUserId
|
||||||
|
? ({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
||||||
|
})
|
||||||
|
: "skip"
|
||||||
) as Doc<"alerts">[] | undefined
|
) as Doc<"alerts">[] | undefined
|
||||||
|
|
||||||
const alerts = useMemo(() => {
|
const alerts = useMemo(() => {
|
||||||
let list = alertsRaw ?? []
|
const list = alertsRaw ?? []
|
||||||
if (companyId !== "all") list = list.filter((a) => String(a.companyId) === companyId)
|
|
||||||
if (typeof start === "number") list = list.filter((a) => a.createdAt >= start)
|
|
||||||
if (typeof end === "number") list = list.filter((a) => a.createdAt < end)
|
|
||||||
return list.sort((a, b) => b.createdAt - a.createdAt)
|
return list.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
}, [alertsRaw, companyId, start, end])
|
}, [alertsRaw])
|
||||||
|
|
||||||
const companies = useQuery(
|
const companies = useQuery(
|
||||||
api.companies.list,
|
api.companies.list,
|
||||||
|
|
@ -124,4 +129,3 @@ export function AdminAlertsManager() {
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
import {
|
import {
|
||||||
ToggleGroup,
|
ToggleGroup,
|
||||||
ToggleGroupItem,
|
ToggleGroupItem,
|
||||||
|
|
@ -44,8 +45,8 @@ export function ChartAreaInteractive() {
|
||||||
const [mounted, setMounted] = React.useState(false)
|
const [mounted, setMounted] = React.useState(false)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [timeRange, setTimeRange] = React.useState("7d")
|
const [timeRange, setTimeRange] = React.useState("7d")
|
||||||
// Use a non-empty sentinel value for "all" to satisfy Select.Item requirements
|
// Persistir seleção de empresa globalmente
|
||||||
const [companyId, setCompanyId] = React.useState<string>("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
const [companyQuery, setCompanyQuery] = React.useState("")
|
const [companyQuery, setCompanyQuery] = React.useState("")
|
||||||
const { session, convexUserId } = useAuth()
|
const { session, convexUserId } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<string, string> = {
|
const PRIORITY_LABELS: Record<string, string> = {
|
||||||
LOW: "Baixa",
|
LOW: "Baixa",
|
||||||
|
|
@ -30,7 +31,7 @@ const STATUS_LABELS: Record<string, string> = {
|
||||||
|
|
||||||
export function BacklogReport() {
|
export function BacklogReport() {
|
||||||
const [timeRange, setTimeRange] = useState("90d")
|
const [timeRange, setTimeRange] = useState("90d")
|
||||||
const [companyId, setCompanyId] = useState<string>("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
const { session, convexUserId } = useAuth()
|
const { session, convexUserId } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
const data = useQuery(
|
const data = useQuery(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
|
|
||||||
function formatScore(value: number | null) {
|
function formatScore(value: number | null) {
|
||||||
if (value === null) return "—"
|
if (value === null) return "—"
|
||||||
|
|
@ -20,7 +21,7 @@ function formatScore(value: number | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CsatReport() {
|
export function CsatReport() {
|
||||||
const [companyId, setCompanyId] = useState<string>("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
const [timeRange, setTimeRange] = useState<string>("90d")
|
const [timeRange, setTimeRange] = useState<string>("90d")
|
||||||
const { session, convexUserId } = useAuth()
|
const { session, convexUserId } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
|
|
||||||
function formatHours(ms: number) {
|
function formatHours(ms: number) {
|
||||||
const hours = ms / 3600000
|
const hours = ms / 3600000
|
||||||
|
|
@ -32,7 +33,7 @@ type HoursItem = {
|
||||||
export function HoursReport() {
|
export function HoursReport() {
|
||||||
const [timeRange, setTimeRange] = useState("90d")
|
const [timeRange, setTimeRange] = useState("90d")
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [companyId, setCompanyId] = useState<string>("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
const { session, convexUserId } = useAuth()
|
const { session, convexUserId } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
|
|
||||||
function formatMinutes(value: number | null) {
|
function formatMinutes(value: number | null) {
|
||||||
if (value === null) return "—"
|
if (value === null) return "—"
|
||||||
|
|
@ -25,7 +26,7 @@ function formatMinutes(value: number | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SlaReport() {
|
export function SlaReport() {
|
||||||
const [companyId, setCompanyId] = useState<string>("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
const [timeRange, setTimeRange] = useState<string>("90d")
|
const [timeRange, setTimeRange] = useState<string>("90d")
|
||||||
const { session, convexUserId } = useAuth()
|
const { session, convexUserId } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
|
||||||
31
src/lib/use-company-filter.ts
Normal file
31
src/lib/use-company-filter.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "ui:selectedCompanyId"
|
||||||
|
|
||||||
|
export function usePersistentCompanyFilter(initial: string = "all") {
|
||||||
|
const [companyId, setCompanyId] = useState<string>(initial)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (saved) setCompanyId(saved)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const update = (value: string) => {
|
||||||
|
setCompanyId(value)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, value)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [companyId, update] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -7,6 +7,8 @@ type SmtpConfig = {
|
||||||
password: string
|
password: string
|
||||||
from: string
|
from: string
|
||||||
tls?: boolean
|
tls?: boolean
|
||||||
|
rejectUnauthorized?: boolean
|
||||||
|
timeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function b64(input: string) {
|
function b64(input: string) {
|
||||||
|
|
@ -27,24 +29,32 @@ function extractEnvelopeAddress(from: string): string {
|
||||||
return from
|
return from
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: string) {
|
export async function sendSmtpMail(cfg: SmtpConfig, to: string | string[], subject: string, html: string) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => {
|
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: cfg.rejectUnauthorized ?? false }, () => {
|
||||||
let buffer = ""
|
let buffer = ""
|
||||||
const send = (line: string) => socket.write(line + "\r\n")
|
const send = (line: string) => socket.write(line + "\r\n")
|
||||||
const wait = (expected: string | RegExp) =>
|
const wait = (expected: string | RegExp) =>
|
||||||
new Promise<void>((res, rej) => {
|
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) => {
|
const onData = (data: Buffer) => {
|
||||||
buffer += data.toString()
|
buffer += data.toString()
|
||||||
const lines = buffer.split(/\r?\n/)
|
const lines = buffer.split(/\r?\n/)
|
||||||
const last = lines.filter(Boolean).slice(-1)[0] ?? ""
|
const last = lines.filter(Boolean).slice(-1)[0] ?? ""
|
||||||
if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) {
|
if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) {
|
||||||
socket.removeListener("data", onData)
|
socket.removeListener("data", onData)
|
||||||
|
clearTimeout(timeout)
|
||||||
res()
|
res()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
socket.on("data", onData)
|
socket.on("data", onData)
|
||||||
socket.on("error", rej)
|
socket.on("error", (e) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
rej(e)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
|
@ -61,13 +71,21 @@ export async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string,
|
||||||
const envelopeFrom = extractEnvelopeAddress(cfg.from)
|
const envelopeFrom = extractEnvelopeAddress(cfg.from)
|
||||||
send(`MAIL FROM:<${envelopeFrom}>`)
|
send(`MAIL FROM:<${envelopeFrom}>`)
|
||||||
await wait(/^250 /)
|
await wait(/^250 /)
|
||||||
send(`RCPT TO:<${to}>`)
|
const rcpts: string[] = Array.isArray(to)
|
||||||
await wait(/^250 /)
|
? to
|
||||||
|
: String(to)
|
||||||
|
.split(/[;,]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
for (const rcpt of rcpts) {
|
||||||
|
send(`RCPT TO:<${rcpt}>`)
|
||||||
|
await wait(/^250 /)
|
||||||
|
}
|
||||||
send("DATA")
|
send("DATA")
|
||||||
await wait(/^354 /)
|
await wait(/^354 /)
|
||||||
const headers = [
|
const headers = [
|
||||||
`From: ${cfg.from}`,
|
`From: ${cfg.from}`,
|
||||||
`To: ${to}`,
|
`To: ${Array.isArray(to) ? to.join(", ") : to}`,
|
||||||
`Subject: ${subject}`,
|
`Subject: ${subject}`,
|
||||||
"MIME-Version: 1.0",
|
"MIME-Version: 1.0",
|
||||||
"Content-Type: text/html; charset=UTF-8",
|
"Content-Type: text/html; charset=UTF-8",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue