Add email channel: IMAP IDLE inbound, SMTP outbound, thread matching
Inbound mail on support@top-sysops.ru creates/updates tickets in realtime (IMAP IDLE, not polling). Replies thread via In-Reply-To/References against stored Message-IDs, falling back to the customer's open ticket. Mailbox credentials configured and encrypted via Settings -> Почта, same pattern as the Telegram channel. TLS verification is opt-in-skippable per mailbox (mail.top-sysops.ru's cert is currently expired; LAN-only server). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
cf0b6cf58e
commit
7e62ed739b
18 files changed
+1739
-7
No files matched your search
@@ -3,7 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Send, Globe, UserRound, Plus } from "lucide-react";
|
||||
import { Send, Globe, UserRound, Mail, Plus } from "lucide-react";
|
||||
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { TicketDTO, TicketStatus } from "@/lib/tickets/types";
|
||||
@@ -16,7 +16,7 @@ const COLUMNS: { status: TicketStatus; label: string }[] = [
|
||||
{ status: "closed", label: "Закрытые" },
|
||||
];
|
||||
|
||||
const CHANNEL_ICON = { telegram: Send, portal: Globe, manual: UserRound } as const;
|
||||
const CHANNEL_ICON = { telegram: Send, portal: Globe, manual: UserRound, email: Mail } as const;
|
||||
|
||||
const PRIORITY_COLOR: Record<TicketDTO["priority"], string> = {
|
||||
low: "var(--text-faint)",
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LayoutDashboard, Settings } from "lucide-react";
|
||||
import { LayoutDashboard, Send, Mail } from "lucide-react";
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: "Дашборд", icon: LayoutDashboard },
|
||||
{ href: "/settings/telegram", label: "Telegram", icon: Settings },
|
||||
{ href: "/settings/telegram", label: "Telegram", icon: Send },
|
||||
{ href: "/settings/email", label: "Почта", icon: Mail },
|
||||
];
|
||||
|
||||
export function NavLinks() {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
interface Status {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
user: string | null;
|
||||
imapHost: string | null;
|
||||
allowInsecureTls: boolean;
|
||||
verifiedAt: number | null;
|
||||
}
|
||||
|
||||
export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }) {
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [host, setHost] = useState(initialStatus.imapHost ?? "");
|
||||
const [imapPort, setImapPort] = useState("993");
|
||||
const [smtpPort, setSmtpPort] = useState("587");
|
||||
const [user, setUser] = useState(initialStatus.user ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [allowInsecureTls, setAllowInsecureTls] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleConnect(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch("/api/mailbox/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
host,
|
||||
imapPort: Number(imapPort),
|
||||
smtpPort: Number(smtpPort),
|
||||
user,
|
||||
password,
|
||||
allowInsecureTls,
|
||||
}),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось подключить почту");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus({ configured: true, enabled: true, user, imapHost: host, allowInsecureTls, verifiedAt: Date.now() });
|
||||
setPassword("");
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
setLoading(true);
|
||||
await fetch("/api/mailbox/config", { method: "DELETE" });
|
||||
setLoading(false);
|
||||
setStatus((s) => ({ ...s, enabled: false }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="mb-4 flex items-center gap-2 text-sm">
|
||||
{status.enabled ? (
|
||||
<>
|
||||
<CheckCircle2 size={16} className="text-success" />
|
||||
<span>Подключено: {status.user}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle size={16} className="text-text-faint" />
|
||||
<span className="text-text-muted">Почта не подключена</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleConnect}>
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Хост (IMAP и SMTP)</span>
|
||||
<input
|
||||
required
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="mail.top-sysops.ru или 10.33.33.42"
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">IMAP порт</span>
|
||||
<input
|
||||
required
|
||||
value={imapPort}
|
||||
onChange={(e) => setImapPort(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">SMTP порт</span>
|
||||
<input
|
||||
required
|
||||
value={smtpPort}
|
||||
onChange={(e) => setSmtpPort(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Логин</span>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
placeholder="support@top-sysops.ru"
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowInsecureTls}
|
||||
onChange={(e) => setAllowInsecureTls(e.target.checked)}
|
||||
/>
|
||||
Не проверять TLS-сертификат сервера (нужно, пока сертификат просрочен)
|
||||
</label>
|
||||
|
||||
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||
{status.configured ? "Обновить" : "Подключить"}
|
||||
</button>
|
||||
{status.enabled && (
|
||||
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
|
||||
Отключить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getMailboxStatus } from "@/lib/mail/config";
|
||||
import { MailboxSettingsForm } from "./mailbox-settings-form";
|
||||
|
||||
export default async function EmailSettingsPage() {
|
||||
const status = await getMailboxStatus();
|
||||
return (
|
||||
<div className="max-w-lg">
|
||||
<h1 className="mb-1 text-xl font-bold tracking-tight">Почта</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">
|
||||
Письма на этот ящик будут превращаться в заявки, ответы агентов уходят обратно по SMTP.
|
||||
</p>
|
||||
<MailboxSettingsForm initialStatus={status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { getMailboxStatus } from "@/lib/mail/config";
|
||||
import { configureMailbox, disableMailListener } from "@/lib/mail/imap";
|
||||
import { verifySmtp } from "@/lib/mail/smtp";
|
||||
|
||||
export async function GET() {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
return NextResponse.json(await getMailboxStatus());
|
||||
}
|
||||
|
||||
const configureSchema = z.object({
|
||||
host: z.string().min(1),
|
||||
imapPort: z.coerce.number().int().positive(),
|
||||
smtpPort: z.coerce.number().int().positive(),
|
||||
user: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
allowInsecureTls: z.boolean(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = configureSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const settings = {
|
||||
imapHost: parsed.data.host,
|
||||
imapPort: parsed.data.imapPort,
|
||||
smtpHost: parsed.data.host,
|
||||
smtpPort: parsed.data.smtpPort,
|
||||
user: parsed.data.user,
|
||||
password: parsed.data.password,
|
||||
allowInsecureTls: parsed.data.allowInsecureTls,
|
||||
};
|
||||
|
||||
try {
|
||||
await verifySmtp(settings);
|
||||
await configureMailbox(settings);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Не удалось подключиться: ${message}` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
await disableMailListener();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -3,8 +3,9 @@ export const runtime = "nodejs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { recordAgentReply } from "@/lib/tickets/service";
|
||||
import { recordAgentReply, getEmailThreadContext } from "@/lib/tickets/service";
|
||||
import { sendTelegramMessage } from "@/lib/telegram/bot";
|
||||
import { sendTicketReplyEmail } from "@/lib/mail/smtp";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { customers } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -24,11 +25,30 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({ where: (t, { eq: eqOp }) => eqOp(t.id, id) });
|
||||
if (!ticket) {
|
||||
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let emailMessageId: string | undefined;
|
||||
if (ticket.channel === "email") {
|
||||
const context = await getEmailThreadContext(id);
|
||||
if (context) {
|
||||
emailMessageId = await sendTicketReplyEmail({
|
||||
to: context.customerEmail,
|
||||
subject: context.subject,
|
||||
body: parsed.data.body,
|
||||
inReplyTo: context.inReplyTo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await recordAgentReply({
|
||||
ticketId: id,
|
||||
agentId: session.user.id,
|
||||
agentName: session.user.name,
|
||||
body: parsed.data.body,
|
||||
emailMessageId,
|
||||
});
|
||||
|
||||
if (result.ticket.channel === "telegram") {
|
||||
|
||||
@@ -2,5 +2,8 @@ export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { ensureTelegramBotStarted } = await import("@/lib/telegram/bot");
|
||||
await ensureTelegramBotStarted();
|
||||
|
||||
const { ensureMailListenerStarted } = await import("@/lib/mail/imap");
|
||||
await ensureMailListenerStarted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `mailbox_config` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`imap_host` text NOT NULL,
|
||||
`imap_port` integer DEFAULT 993 NOT NULL,
|
||||
`smtp_host` text NOT NULL,
|
||||
`smtp_port` integer DEFAULT 587 NOT NULL,
|
||||
`user` text NOT NULL,
|
||||
`password_enc` text NOT NULL,
|
||||
`allow_insecure_tls` integer DEFAULT false NOT NULL,
|
||||
`enabled` integer DEFAULT false NOT NULL,
|
||||
`verified_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `messages` ADD `email_message_id` text;
|
||||
@@ -0,0 +1,547 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "5e7b8e52-00cf-4515-91e1-c6afdc990797",
|
||||
"prevId": "32259727-b523-472c-96c8-bf1260f7ac56",
|
||||
"tables": {
|
||||
"customers": {
|
||||
"name": "customers",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"telegram_chat_id": {
|
||||
"name": "telegram_chat_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"portal_token": {
|
||||
"name": "portal_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch('subsec') * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"customers_telegram_chat_id_unique": {
|
||||
"name": "customers_telegram_chat_id_unique",
|
||||
"columns": [
|
||||
"telegram_chat_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"customers_portal_token_unique": {
|
||||
"name": "customers_portal_token_unique",
|
||||
"columns": [
|
||||
"portal_token"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"mailbox_config": {
|
||||
"name": "mailbox_config",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"imap_host": {
|
||||
"name": "imap_host",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"imap_port": {
|
||||
"name": "imap_port",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 993
|
||||
},
|
||||
"smtp_host": {
|
||||
"name": "smtp_host",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"smtp_port": {
|
||||
"name": "smtp_port",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 587
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_enc": {
|
||||
"name": "password_enc",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allow_insecure_tls": {
|
||||
"name": "allow_insecure_tls",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"verified_at": {
|
||||
"name": "verified_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch('subsec') * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"messages": {
|
||||
"name": "messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ticket_id": {
|
||||
"name": "ticket_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author_type": {
|
||||
"name": "author_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author_id": {
|
||||
"name": "author_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author_name": {
|
||||
"name": "author_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email_message_id": {
|
||||
"name": "email_message_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch('subsec') * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"messages_ticket_id_tickets_id_fk": {
|
||||
"name": "messages_ticket_id_tickets_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "tickets",
|
||||
"columnsFrom": [
|
||||
"ticket_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch('subsec') * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"telegram_config": {
|
||||
"name": "telegram_config",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"bot_token_enc": {
|
||||
"name": "bot_token_enc",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"bot_username": {
|
||||
"name": "bot_username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"verified_at": {
|
||||
"name": "verified_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch('subsec') * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tickets": {
|
||||
"name": "tickets",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"subject": {
|
||||
"name": "subject",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'new'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'normal'"
|
||||
},
|
||||
"channel": {
|
||||
"name": "channel",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"customer_id": {
|
||||
"name": "customer_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"assignee_id": {
|
||||
"name": "assignee_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_message_at": {
|
||||
"name": "last_message_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch('subsec') * 1000)"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch('subsec') * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch('subsec') * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"tickets_customer_id_customers_id_fk": {
|
||||
"name": "tickets_customer_id_customers_id_fk",
|
||||
"tableFrom": "tickets",
|
||||
"tableTo": "customers",
|
||||
"columnsFrom": [
|
||||
"customer_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"tickets_assignee_id_users_id_fk": {
|
||||
"name": "tickets_assignee_id_users_id_fk",
|
||||
"tableFrom": "tickets",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"assignee_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'agent'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch('subsec') * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1785068349476,
|
||||
"tag": "0000_superb_the_fallen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1785070407727,
|
||||
"tag": "0001_mushy_purple_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+24
-1
@@ -63,7 +63,7 @@ export const tickets = sqliteTable("tickets", {
|
||||
priority: text("priority", { enum: ["low", "normal", "high", "urgent"] })
|
||||
.notNull()
|
||||
.default("normal"),
|
||||
channel: text("channel", { enum: ["telegram", "portal", "manual"] }).notNull(),
|
||||
channel: text("channel", { enum: ["telegram", "portal", "manual", "email"] }).notNull(),
|
||||
customerId: text("customer_id")
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: "cascade" }),
|
||||
@@ -85,6 +85,10 @@ export const messages = sqliteTable("messages", {
|
||||
authorName: text("author_name").notNull(),
|
||||
body: text("body").notNull(),
|
||||
direction: text("direction", { enum: ["in", "out"] }).notNull(),
|
||||
// RFC 5322 Message-ID of this email, when the message came in/out over the
|
||||
// email channel — lets a customer's future reply be matched back to the
|
||||
// exact ticket via In-Reply-To/References, even if they have other tickets.
|
||||
emailMessageId: text("email_message_id"),
|
||||
createdAt: timestamps.createdAt,
|
||||
});
|
||||
|
||||
@@ -97,3 +101,22 @@ export const telegramConfig = sqliteTable("telegram_config", {
|
||||
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
||||
createdAt: timestamps.createdAt,
|
||||
});
|
||||
|
||||
/** Single-row-ish config for the email channel (one support mailbox per deployment). */
|
||||
export const mailboxConfig = sqliteTable("mailbox_config", {
|
||||
id: id(),
|
||||
imapHost: text("imap_host").notNull(),
|
||||
imapPort: integer("imap_port").notNull().default(993),
|
||||
smtpHost: text("smtp_host").notNull(),
|
||||
smtpPort: integer("smtp_port").notNull().default(587),
|
||||
user: text("user").notNull(),
|
||||
passwordEnc: text("password_enc").notNull(),
|
||||
// Both IMAP and SMTP presented an expired cert on support@top-sysops.ru at
|
||||
// setup time. This is a LAN-only mailbox (never exposed to the internet),
|
||||
// so skipping verification is an accepted risk — not a default for
|
||||
// arbitrary/public mail servers. Revisit once the cert is renewed.
|
||||
allowInsecureTls: integer("allow_insecure_tls", { mode: "boolean" }).notNull().default(false),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
||||
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
||||
createdAt: timestamps.createdAt,
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { mailboxConfig } from "@/lib/db/schema";
|
||||
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
|
||||
|
||||
export interface MailboxSettings {
|
||||
imapHost: string;
|
||||
imapPort: number;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
user: string;
|
||||
password: string;
|
||||
allowInsecureTls: boolean;
|
||||
}
|
||||
|
||||
/** Settings for the running listener/sender — null if never configured or disabled. */
|
||||
export async function getMailboxSettings(): Promise<MailboxSettings | null> {
|
||||
const config = await db.query.mailboxConfig.findFirst();
|
||||
if (!config || !config.enabled) return null;
|
||||
|
||||
return {
|
||||
imapHost: config.imapHost,
|
||||
imapPort: config.imapPort,
|
||||
smtpHost: config.smtpHost,
|
||||
smtpPort: config.smtpPort,
|
||||
user: config.user,
|
||||
password: decryptCredential(config.passwordEnc),
|
||||
allowInsecureTls: config.allowInsecureTls,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveMailboxSettings(settings: MailboxSettings): Promise<void> {
|
||||
const row = {
|
||||
imapHost: settings.imapHost,
|
||||
imapPort: settings.imapPort,
|
||||
smtpHost: settings.smtpHost,
|
||||
smtpPort: settings.smtpPort,
|
||||
user: settings.user,
|
||||
passwordEnc: encryptCredential(settings.password),
|
||||
allowInsecureTls: settings.allowInsecureTls,
|
||||
enabled: true,
|
||||
verifiedAt: new Date(),
|
||||
};
|
||||
|
||||
const existing = await db.query.mailboxConfig.findFirst();
|
||||
if (existing) {
|
||||
await db.update(mailboxConfig).set(row).where(eq(mailboxConfig.id, existing.id));
|
||||
} else {
|
||||
await db.insert(mailboxConfig).values(row);
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableMailboxConfig(): Promise<void> {
|
||||
const existing = await db.query.mailboxConfig.findFirst();
|
||||
if (existing) {
|
||||
await db.update(mailboxConfig).set({ enabled: false }).where(eq(mailboxConfig.id, existing.id));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMailboxStatus() {
|
||||
const config = await db.query.mailboxConfig.findFirst();
|
||||
return {
|
||||
configured: Boolean(config),
|
||||
enabled: Boolean(config?.enabled),
|
||||
user: config?.user ?? null,
|
||||
imapHost: config?.imapHost ?? null,
|
||||
allowInsecureTls: config?.allowInsecureTls ?? false,
|
||||
verifiedAt: config?.verifiedAt?.getTime() ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { ImapFlow } from "imapflow";
|
||||
import { simpleParser, type ParsedMail } from "mailparser";
|
||||
import { getMailboxSettings, saveMailboxSettings, disableMailboxConfig, type MailboxSettings } from "@/lib/mail/config";
|
||||
import { recordEmailInboundMessage } from "@/lib/tickets/service";
|
||||
|
||||
interface ListenerState {
|
||||
client: ImapFlow | null;
|
||||
stopping: boolean;
|
||||
}
|
||||
|
||||
// Survives Next.js dev-mode module reloads — one live IDLE connection per
|
||||
// mailbox, not one per HMR reload.
|
||||
const globalForImap = globalThis as unknown as { mailListenerState?: ListenerState };
|
||||
const state: ListenerState = globalForImap.mailListenerState ?? { client: null, stopping: false };
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForImap.mailListenerState = state;
|
||||
}
|
||||
|
||||
function createClient(settings: MailboxSettings): ImapFlow {
|
||||
return new ImapFlow({
|
||||
host: settings.imapHost,
|
||||
port: settings.imapPort,
|
||||
secure: settings.imapPort !== 143, // 993 = implicit TLS, 143 = STARTTLS
|
||||
auth: { user: settings.user, pass: settings.password },
|
||||
tls: settings.allowInsecureTls ? { rejectUnauthorized: false } : undefined,
|
||||
logger: false,
|
||||
});
|
||||
}
|
||||
|
||||
function extractBody(parsed: ParsedMail): string {
|
||||
if (parsed.text) return parsed.text;
|
||||
if (typeof parsed.html === "string") {
|
||||
return parsed.html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
return "(пустое письмо)";
|
||||
}
|
||||
|
||||
async function handleRawMessage(source: Buffer): Promise<void> {
|
||||
const parsed = await simpleParser(source);
|
||||
const from = parsed.from?.value[0];
|
||||
if (!from?.address) return; // no return address — nothing to reply to, skip
|
||||
|
||||
const referencedIds = [
|
||||
...(parsed.inReplyTo ? [parsed.inReplyTo] : []),
|
||||
...(Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : []),
|
||||
];
|
||||
|
||||
await recordEmailInboundMessage({
|
||||
fromEmail: from.address,
|
||||
fromName: from.name || from.address,
|
||||
subject: parsed.subject ?? "",
|
||||
body: extractBody(parsed),
|
||||
messageId: parsed.messageId ?? null,
|
||||
referencedMessageIds: referencedIds,
|
||||
});
|
||||
}
|
||||
|
||||
async function processUnseenMessages(client: ImapFlow): Promise<void> {
|
||||
const lock = await client.getMailboxLock("INBOX");
|
||||
try {
|
||||
const uids = await client.search({ seen: false }, { uid: true });
|
||||
if (!uids || uids.length === 0) return;
|
||||
|
||||
for await (const message of client.fetch(uids, { source: true, uid: true })) {
|
||||
if (message.source) {
|
||||
await handleRawMessage(message.source);
|
||||
}
|
||||
await client.messageFlagsAdd(message.uid, ["\\Seen"], { uid: true });
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function runIdleLoop(client: ImapFlow): Promise<void> {
|
||||
try {
|
||||
await client.mailboxOpen("INBOX");
|
||||
await processUnseenMessages(client);
|
||||
|
||||
while (state.client === client) {
|
||||
await client.idle();
|
||||
if (state.client !== client) break;
|
||||
await processUnseenMessages(client);
|
||||
}
|
||||
} catch (err) {
|
||||
if (state.client === client) {
|
||||
console.error("IMAP idle loop error, will retry shortly:", err);
|
||||
state.client = null;
|
||||
if (!state.stopping) {
|
||||
setTimeout(() => {
|
||||
ensureMailListenerStarted().catch((e) => console.error("Mail listener retry failed:", e));
|
||||
}, 15_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCurrentListener(): Promise<void> {
|
||||
state.stopping = true;
|
||||
const current = state.client;
|
||||
state.client = null;
|
||||
if (current) {
|
||||
try {
|
||||
await current.logout();
|
||||
} catch {
|
||||
// connection may already be dead — nothing more to do
|
||||
}
|
||||
}
|
||||
state.stopping = false;
|
||||
}
|
||||
|
||||
/** Validates settings by actually connecting before anything is persisted or replaced. */
|
||||
async function connectAndOpenInbox(settings: MailboxSettings): Promise<ImapFlow> {
|
||||
const client = createClient(settings);
|
||||
await client.connect();
|
||||
await client.mailboxOpen("INBOX");
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Called once at process startup — resumes the IDLE listener if a mailbox was previously configured and enabled. */
|
||||
export async function ensureMailListenerStarted(): Promise<void> {
|
||||
const settings = await getMailboxSettings();
|
||||
if (!settings) return;
|
||||
|
||||
try {
|
||||
const client = await connectAndOpenInbox(settings);
|
||||
state.client = client;
|
||||
void runIdleLoop(client);
|
||||
} catch (err) {
|
||||
console.error("Failed to start mail listener from stored config:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates + saves new mailbox settings and (re)starts the listener. Throws if the connection fails. */
|
||||
export async function configureMailbox(settings: MailboxSettings): Promise<void> {
|
||||
const client = await connectAndOpenInbox(settings); // validates before touching the running listener
|
||||
|
||||
await stopCurrentListener();
|
||||
state.client = client;
|
||||
void runIdleLoop(client);
|
||||
|
||||
await saveMailboxSettings(settings);
|
||||
}
|
||||
|
||||
export async function disableMailListener(): Promise<void> {
|
||||
await stopCurrentListener();
|
||||
await disableMailboxConfig();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import crypto from "node:crypto";
|
||||
import nodemailer from "nodemailer";
|
||||
import { getMailboxSettings, type MailboxSettings } from "@/lib/mail/config";
|
||||
|
||||
function createTransport(settings: MailboxSettings) {
|
||||
return nodemailer.createTransport({
|
||||
host: settings.smtpHost,
|
||||
port: settings.smtpPort,
|
||||
secure: settings.smtpPort === 465, // 465 = implicit TLS; 587/25 use STARTTLS
|
||||
auth: { user: settings.user, pass: settings.password },
|
||||
tls: settings.allowInsecureTls ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Verifies SMTP credentials without sending anything — used to validate settings before saving. */
|
||||
export async function verifySmtp(settings: MailboxSettings): Promise<void> {
|
||||
await createTransport(settings).verify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an agent's reply as an email, threaded onto the customer's previous
|
||||
* message via In-Reply-To/References when we have one. Returns the
|
||||
* Message-ID we generated, so the caller can store it for future threading.
|
||||
*/
|
||||
export async function sendTicketReplyEmail(params: {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
inReplyTo: string | null;
|
||||
}): Promise<string> {
|
||||
const settings = await getMailboxSettings();
|
||||
if (!settings) throw new Error("Email channel is not configured");
|
||||
|
||||
const messageId = `<${crypto.randomUUID()}@${settings.user.split("@")[1] ?? "top-sysops.ru"}>`;
|
||||
const subject = params.subject.toLowerCase().startsWith("re:") ? params.subject : `Re: ${params.subject}`;
|
||||
|
||||
await createTransport(settings).sendMail({
|
||||
from: settings.user,
|
||||
to: params.to,
|
||||
subject,
|
||||
text: params.body,
|
||||
messageId,
|
||||
...(params.inReplyTo
|
||||
? { inReplyTo: params.inReplyTo, references: params.inReplyTo }
|
||||
: {}),
|
||||
});
|
||||
|
||||
return messageId;
|
||||
}
|
||||
@@ -55,6 +55,7 @@ async function appendMessage(params: {
|
||||
authorName: string;
|
||||
body: string;
|
||||
direction: "in" | "out";
|
||||
emailMessageId?: string;
|
||||
}) {
|
||||
const [message] = await db
|
||||
.insert(messages)
|
||||
@@ -65,6 +66,7 @@ async function appendMessage(params: {
|
||||
authorName: params.authorName,
|
||||
body: params.body,
|
||||
direction: params.direction,
|
||||
emailMessageId: params.emailMessageId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -99,6 +101,35 @@ export async function findOrCreateCustomerByTelegramChatId(chatId: string, displ
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function findOrCreateCustomerByEmail(email: string, displayName: string) {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
const existing = await db.query.customers.findFirst({
|
||||
where: eq(customers.email, normalized),
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const [created] = await db
|
||||
.insert(customers)
|
||||
.values({ displayName, email: normalized })
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the ticket a reply belongs to by matching the email's In-Reply-To /
|
||||
* References headers against Message-IDs we've previously sent or received.
|
||||
* Falls back to null if none match (caller then uses the open-ticket
|
||||
* find-or-create heuristic instead).
|
||||
*/
|
||||
async function findTicketIdByEmailReferences(candidateMessageIds: string[]): Promise<string | null> {
|
||||
if (candidateMessageIds.length === 0) return null;
|
||||
|
||||
const match = await db.query.messages.findFirst({
|
||||
where: (m, { inArray }) => inArray(m.emailMessageId, candidateMessageIds),
|
||||
});
|
||||
return match?.ticketId ?? null;
|
||||
}
|
||||
|
||||
/** The customer's most recent ticket that isn't closed. */
|
||||
async function findOpenTicketForCustomer(customerId: string) {
|
||||
return db.query.tickets.findFirst({
|
||||
@@ -146,6 +177,54 @@ export async function recordTelegramInboundMessage(params: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an inbound email. Prefers exact thread matching via
|
||||
* In-Reply-To/References (so a reply to a specific closed ticket lands on
|
||||
* that ticket, not a random other open one); falls back to the same
|
||||
* find-or-create-open-ticket heuristic used for Telegram.
|
||||
*/
|
||||
export async function recordEmailInboundMessage(params: {
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
messageId: string | null;
|
||||
referencedMessageIds: string[];
|
||||
}) {
|
||||
const customer = await findOrCreateCustomerByEmail(params.fromEmail, params.fromName);
|
||||
|
||||
const matchedTicketId = await findTicketIdByEmailReferences(params.referencedMessageIds);
|
||||
let ticket = matchedTicketId
|
||||
? await db.query.tickets.findFirst({ where: eq(tickets.id, matchedTicketId) })
|
||||
: await findOpenTicketForCustomer(customer.id);
|
||||
|
||||
let ticketIsNew = false;
|
||||
if (!ticket) {
|
||||
[ticket] = await db
|
||||
.insert(tickets)
|
||||
.values({
|
||||
subject: params.subject || "(без темы)",
|
||||
channel: "email" satisfies TicketChannel,
|
||||
customerId: customer.id,
|
||||
})
|
||||
.returning();
|
||||
ticketIsNew = true;
|
||||
} else if (ticket.status === "closed") {
|
||||
ticket = await touchTicket(ticket.id, { status: "open" });
|
||||
}
|
||||
|
||||
return appendMessage({
|
||||
ticket,
|
||||
ticketIsNew,
|
||||
authorType: "customer",
|
||||
authorId: customer.id,
|
||||
authorName: params.fromName,
|
||||
body: params.body,
|
||||
direction: "in",
|
||||
emailMessageId: params.messageId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Appends the customer's reply to a specific, already-known ticket (portal thread). */
|
||||
export async function appendCustomerReply(params: {
|
||||
ticketId: string;
|
||||
@@ -177,6 +256,7 @@ export async function recordAgentReply(params: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
body: string;
|
||||
emailMessageId?: string;
|
||||
}) {
|
||||
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
|
||||
if (!existing) throw new Error("Ticket not found");
|
||||
@@ -192,6 +272,7 @@ export async function recordAgentReply(params: {
|
||||
authorName: params.agentName,
|
||||
body: params.body,
|
||||
direction: "out",
|
||||
emailMessageId: params.emailMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -243,6 +324,27 @@ export async function getTicketWithMessages(ticketId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Recipient address + Message-ID to reply to, for sending an agent's email reply. */
|
||||
export async function getEmailThreadContext(ticketId: string) {
|
||||
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, ticketId) });
|
||||
if (!ticket) return null;
|
||||
|
||||
const customer = await db.query.customers.findFirst({ where: eq(customers.id, ticket.customerId) });
|
||||
if (!customer?.email) return null;
|
||||
|
||||
const lastEmailMessage = await db.query.messages.findFirst({
|
||||
where: (m, { eq: eqOp, and: andOp, isNotNull }) =>
|
||||
andOp(eqOp(m.ticketId, ticketId), isNotNull(m.emailMessageId)),
|
||||
orderBy: desc(messages.createdAt),
|
||||
});
|
||||
|
||||
return {
|
||||
customerEmail: customer.email,
|
||||
subject: ticket.subject,
|
||||
inReplyTo: lastEmailMessage?.emailMessageId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAgents() {
|
||||
return db.query.users.findMany({ orderBy: users.name });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type TicketStatus = "new" | "open" | "pending" | "closed";
|
||||
export type TicketPriority = "low" | "normal" | "high" | "urgent";
|
||||
export type TicketChannel = "telegram" | "portal" | "manual";
|
||||
export type TicketChannel = "telegram" | "portal" | "manual" | "email";
|
||||
export type MessageAuthorType = "customer" | "agent" | "system";
|
||||
export type MessageDirection = "in" | "out";
|
||||
|
||||
|
||||
Reference in new issue
Block a user