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:
ogrechkoandClaude Sonnet 5 committed 2026-07-26 13:01:21 +00:00
1 parent cf0b6cf58e
commit 7e62ed739b
18 files changed
+1739 -7

No files matched your search

+2 -2
View File
@@ -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)",
+3 -2
View File
@@ -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>
);
}
+15
View File
@@ -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>
);
}
+62
View File
@@ -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 });
}
+21 -1
View File
@@ -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") {
+3
View File
@@ -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": {}
}
}
+7
View File
@@ -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
View File
@@ -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,
});
+70
View File
@@ -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,
};
}
+148
View File
@@ -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();
}
+49
View File
@@ -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;
}
+102
View File
@@ -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 -1
View File
@@ -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";