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

+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") {