From 8d1f91187a281354f742322ff2be8f94ceed0cc1 Mon Sep 17 00:00:00 2001 From: ogrechko Date: Wed, 7 Oct 2026 07:24:14 +0000 Subject: [PATCH] Fix dark-mode email readability, silent reply failures, and add an IMAP connection watchdog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dark theme: inbound email HTML intentionally keeps its own inline colors for fidelity (sanitize-html.ts), but a sender's hardcoded dark text (the common case) was unreadable once the surrounding chrome went dark. Now forced to the theme's own text color in dark mode only; light mode keeps full fidelity as before. Send button: deliverAgentMessage awaited the outbound email/Telegram send *before* recording the reply, so a broken mail/bot account (e.g. bad SMTP creds) made the reply vanish entirely — not sent, not saved, no error shown — with the API's catch-all masking the real cause as a generic "Ticket not found". Channel delivery is now best-effort: the reply is always recorded even if outbound delivery fails, and real errors surface to the compose box instead of failing silently. IMAP watchdog: a live IDLE connection has gone quietly unresponsive in production more than once, with mail landing server-side but never noticed here and no error ever firing. Added a periodic forced NOOP round-trip with its own deadline, independent of ImapFlow's internal socket timeout, that tears down and reconnects a connection that stops answering. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG --- .../(admin)/tickets/[id]/ticket-thread.tsx | 6 +++ src/app/api/tickets/[id]/attachments/route.ts | 5 +- src/app/api/tickets/[id]/messages/route.ts | 5 +- src/app/globals.css | 20 +++++++ src/components/message-body-html.tsx | 2 +- src/lib/mail/imap.ts | 53 +++++++++++++++---- src/lib/tickets/delivery.ts | 49 +++++++++++------ 7 files changed, 109 insertions(+), 31 deletions(-) diff --git a/src/app/(admin)/tickets/[id]/ticket-thread.tsx b/src/app/(admin)/tickets/[id]/ticket-thread.tsx index a169535..98a5931 100644 --- a/src/app/(admin)/tickets/[id]/ticket-thread.tsx +++ b/src/app/(admin)/tickets/[id]/ticket-thread.tsx @@ -37,6 +37,7 @@ export function TicketThread({ const [visibility, setVisibility] = useState<"public" | "internal">("public"); const [pendingFile, setPendingFile] = useState(null); const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); const [cannedResponses, setCannedResponses] = useState<{ id: string; title: string; body: string }[]>([]); const [cannedOpen, setCannedOpen] = useState(false); const bottomRef = useRef(null); @@ -82,6 +83,7 @@ export function TicketThread({ async function sendReply() { if (!draft.trim() && !pendingFile) return; setSending(true); + setSendError(null); let res: Response; if (pendingFile) { @@ -103,6 +105,9 @@ export function TicketThread({ setDraft(""); setPendingFile(null); if (fileInputRef.current) fileInputRef.current.value = ""; + } else { + const data = await res.json().catch(() => null); + setSendError(data?.error ?? "Не удалось отправить сообщение"); } } @@ -210,6 +215,7 @@ export function TicketThread({ )} + {sendError &&

{sendError}

} {pendingFile && (
diff --git a/src/app/api/tickets/[id]/attachments/route.ts b/src/app/api/tickets/[id]/attachments/route.ts index 1636114..bc5d08e 100644 --- a/src/app/api/tickets/[id]/attachments/route.ts +++ b/src/app/api/tickets/[id]/attachments/route.ts @@ -60,7 +60,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: }, }); return NextResponse.json(result, { status: 201 }); - } catch { - return NextResponse.json({ error: "Ticket not found" }, { status: 404 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Не удалось отправить вложение"; + return NextResponse.json({ error: message }, { status: 400 }); } } diff --git a/src/app/api/tickets/[id]/messages/route.ts b/src/app/api/tickets/[id]/messages/route.ts index cd612ca..1b394c5 100644 --- a/src/app/api/tickets/[id]/messages/route.ts +++ b/src/app/api/tickets/[id]/messages/route.ts @@ -44,7 +44,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: visibility, }); return NextResponse.json(result, { status: 201 }); - } catch { - return NextResponse.json({ error: "Ticket not found" }, { status: 404 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Не удалось отправить сообщение"; + return NextResponse.json({ error: message }, { status: 400 }); } } diff --git a/src/app/globals.css b/src/app/globals.css index b6059e0..dd82def 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -203,6 +203,26 @@ color-scheme: dark; } +/* Inbound email HTML intentionally keeps its own inline colors (see the + sanitizeEmailHtml comment in lib/mail/sanitize-html.ts — full fidelity, + "exactly as the person sent it") — but a sender's hardcoded dark text, + the common case, becomes unreadable once the surrounding chrome goes + dark. Force every descendant in a message bubble back to the theme's + own readable text color in dark mode only; light mode is untouched and + keeps full fidelity as designed. */ +:root[data-theme="dark"] .message-html, +:root[data-theme="dark"] .message-html * { + color: var(--text) !important; + background-color: transparent !important; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .message-html, + :root:not([data-theme="light"]) .message-html * { + color: var(--text) !important; + background-color: transparent !important; + } +} + /* Theme-toggle icon: both render always, CSS picks which shows — same data-theme cascade the palette above uses, so it never needs JS state. */ .theme-toggle-btn .theme-icon-sun { diff --git a/src/components/message-body-html.tsx b/src/components/message-body-html.tsx index 50a340a..b71a4fb 100644 --- a/src/components/message-body-html.tsx +++ b/src/components/message-body-html.tsx @@ -5,7 +5,7 @@ export function MessageBodyHtml({ html, className = "" }: { html: string; className?: string }) { return (
); diff --git a/src/lib/mail/imap.ts b/src/lib/mail/imap.ts index 2931064..8d0b0f2 100644 --- a/src/lib/mail/imap.ts +++ b/src/lib/mail/imap.ts @@ -224,11 +224,54 @@ async function processUnseenMessages(client: ImapFlow): Promise { } } +/** Shared by the idle loop's own catch and the watchdog below — only one of them should ever win the race to reconnect. */ +function scheduleReconnect(client: ImapFlow, reason: string, err?: unknown): void { + if (state.client !== client) return; // already superseded (new client) or stopped — nothing to do + console.error(`[mail] ${reason}, will retry shortly:`, err ?? ""); + state.client = null; + if (!state.stopping) { + setTimeout(() => { + ensureMailListenerStarted().catch((e) => console.error("Mail listener retry failed:", e)); + }, 15_000); + } +} + +const WATCHDOG_INTERVAL_MS = 5 * 60 * 1000; +const WATCHDOG_TIMEOUT_MS = 20_000; + +/** + * A live IDLE connection has twice gone quietly unresponsive in production — + * mail kept landing server-side but this process never noticed — without + * ImapFlow's own error handler ever firing. A plain IDLE loop has no way to + * detect that on its own: if the socket is dead but no TCP-level event + * arrives, `client.idle()` just hangs forever. This periodically forces a + * real round trip (NOOP, which ImapFlow transparently breaks IDLE for and + * resumes after) with its own hard deadline, independent of whatever + * ImapFlow's internal socket timeout is doing — if it doesn't come back in + * time, the connection is presumed dead and torn down for a fresh reconnect. + */ +function startWatchdog(client: ImapFlow): void { + const interval = setInterval(() => { + if (state.client !== client) { + clearInterval(interval); + return; + } + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("watchdog NOOP timed out")), WATCHDOG_TIMEOUT_MS), + ); + Promise.race([client.noop(), timeout]).catch((err) => { + clearInterval(interval); + scheduleReconnect(client, "IMAP watchdog check failed", err); + }); + }, WATCHDOG_INTERVAL_MS); +} + async function runIdleLoop(client: ImapFlow): Promise { try { await client.mailboxOpen("INBOX"); console.log("[mail] IDLE listener connected, watching INBOX"); await processUnseenMessages(client); + startWatchdog(client); // New mail is signaled by the 'exists' event while IDLE is active — it's // independent of when idle() itself resolves (that happens on periodic @@ -243,15 +286,7 @@ async function runIdleLoop(client: ImapFlow): Promise { await client.idle(); } } 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); - } - } + scheduleReconnect(client, "IMAP idle loop error", err); } } diff --git a/src/lib/tickets/delivery.ts b/src/lib/tickets/delivery.ts index 2c523a2..c2dcb95 100644 --- a/src/lib/tickets/delivery.ts +++ b/src/lib/tickets/delivery.ts @@ -49,19 +49,30 @@ export async function deliverAgentMessage(params: { }); } + // Channel delivery (email/Telegram) is best-effort: the agent's reply is + // the thing that must never be lost. Previously an SMTP/Telegram failure + // here threw straight out of this function, so recordAgentReply below was + // never reached — the reply vanished entirely (not recorded, nothing + // shown in the thread) and the API route's catch-all turned it into a + // misleading "Ticket not found". A broken mail/bot account should degrade + // to "reply saved internally, not delivered" — never silent data loss. let emailMessageId: string | undefined; if (ticket.channel === "email") { const context = await getEmailThreadContext(params.ticketId); if (context) { - emailMessageId = await sendTicketReplyEmail({ - to: context.customerEmail, - subject: context.subject, - body: params.body, - inReplyTo: context.inReplyTo, - attachment: params.attachment - ? { filename: params.attachment.filename, content: params.attachment.buffer } - : undefined, - }); + try { + emailMessageId = await sendTicketReplyEmail({ + to: context.customerEmail, + subject: context.subject, + body: params.body, + inReplyTo: context.inReplyTo, + attachment: params.attachment + ? { filename: params.attachment.filename, content: params.attachment.buffer } + : undefined, + }); + } catch (err) { + console.error(`Failed to send reply email for ticket ${params.ticketId}:`, err); + } } } @@ -79,14 +90,18 @@ export async function deliverAgentMessage(params: { where: eq(customers.id, result.ticket.customerId), }); if (customer?.telegramChatId) { - if (params.attachment) { - await sendTelegramFile( - customer.telegramChatId, - { buffer: params.attachment.buffer, filename: params.attachment.filename, mimeType: params.attachment.mimeType }, - params.body, - ); - } else { - await sendTelegramMessage(customer.telegramChatId, params.body); + try { + if (params.attachment) { + await sendTelegramFile( + customer.telegramChatId, + { buffer: params.attachment.buffer, filename: params.attachment.filename, mimeType: params.attachment.mimeType }, + params.body, + ); + } else { + await sendTelegramMessage(customer.telegramChatId, params.body); + } + } catch (err) { + console.error(`Failed to deliver reply via Telegram for ticket ${params.ticketId}:`, err); } } }