Fix dark-mode email readability, silent reply failures, and add an IMAP connection watchdog

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-10-07 07:24:14 +00:00
1 parent 93e17c37c7
commit 8d1f91187a
7 files changed
+92 -14

No files matched your search

@@ -37,6 +37,7 @@ export function TicketThread({
const [visibility, setVisibility] = useState<"public" | "internal">("public"); const [visibility, setVisibility] = useState<"public" | "internal">("public");
const [pendingFile, setPendingFile] = useState<File | null>(null); const [pendingFile, setPendingFile] = useState<File | null>(null);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [cannedResponses, setCannedResponses] = useState<{ id: string; title: string; body: string }[]>([]); const [cannedResponses, setCannedResponses] = useState<{ id: string; title: string; body: string }[]>([]);
const [cannedOpen, setCannedOpen] = useState(false); const [cannedOpen, setCannedOpen] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
@@ -82,6 +83,7 @@ export function TicketThread({
async function sendReply() { async function sendReply() {
if (!draft.trim() && !pendingFile) return; if (!draft.trim() && !pendingFile) return;
setSending(true); setSending(true);
setSendError(null);
let res: Response; let res: Response;
if (pendingFile) { if (pendingFile) {
@@ -103,6 +105,9 @@ export function TicketThread({
setDraft(""); setDraft("");
setPendingFile(null); setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
} else {
const data = await res.json().catch(() => null);
setSendError(data?.error ?? "Не удалось отправить сообщение");
} }
} }
@@ -210,6 +215,7 @@ export function TicketThread({
</button> </button>
</div> </div>
)} )}
{sendError && <p className="mb-1.5 text-xs text-danger-soft-text">{sendError}</p>}
{pendingFile && ( {pendingFile && (
<div className="mb-1.5 flex items-center gap-2 rounded-md border border-border bg-surface-hover px-2.5 py-1.5 text-xs"> <div className="mb-1.5 flex items-center gap-2 rounded-md border border-border bg-surface-hover px-2.5 py-1.5 text-xs">
<Paperclip size={12} /> <Paperclip size={12} />
@@ -60,7 +60,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
}, },
}); });
return NextResponse.json(result, { status: 201 }); return NextResponse.json(result, { status: 201 });
} catch { } catch (err) {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 }); const message = err instanceof Error ? err.message : "Не удалось отправить вложение";
return NextResponse.json({ error: message }, { status: 400 });
} }
} }
+3 -2
View File
@@ -44,7 +44,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
visibility, visibility,
}); });
return NextResponse.json(result, { status: 201 }); return NextResponse.json(result, { status: 201 });
} catch { } catch (err) {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 }); const message = err instanceof Error ? err.message : "Не удалось отправить сообщение";
return NextResponse.json({ error: message }, { status: 400 });
} }
} }
+20
View File
@@ -203,6 +203,26 @@
color-scheme: dark; 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 /* Theme-toggle icon: both render always, CSS picks which shows — same
data-theme cascade the palette above uses, so it never needs JS state. */ data-theme cascade the palette above uses, so it never needs JS state. */
.theme-toggle-btn .theme-icon-sun { .theme-toggle-btn .theme-icon-sun {
+1 -1
View File
@@ -5,7 +5,7 @@
export function MessageBodyHtml({ html, className = "" }: { html: string; className?: string }) { export function MessageBodyHtml({ html, className = "" }: { html: string; className?: string }) {
return ( return (
<div <div
className={`max-w-full overflow-x-auto text-sm break-words [&_img]:max-w-full [&_img]:cursor-zoom-in [&_table]:max-w-full [&_td]:break-words [&_th]:break-words ${className}`} className={`message-html max-w-full overflow-x-auto text-sm break-words [&_img]:max-w-full [&_img]:cursor-zoom-in [&_table]:max-w-full [&_td]:break-words [&_th]:break-words ${className}`}
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
); );
+44 -9
View File
@@ -224,11 +224,54 @@ async function processUnseenMessages(client: ImapFlow): Promise<void> {
} }
} }
/** 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<never>((_, 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<void> { async function runIdleLoop(client: ImapFlow): Promise<void> {
try { try {
await client.mailboxOpen("INBOX"); await client.mailboxOpen("INBOX");
console.log("[mail] IDLE listener connected, watching INBOX"); console.log("[mail] IDLE listener connected, watching INBOX");
await processUnseenMessages(client); await processUnseenMessages(client);
startWatchdog(client);
// New mail is signaled by the 'exists' event while IDLE is active — it's // New mail is signaled by the 'exists' event while IDLE is active — it's
// independent of when idle() itself resolves (that happens on periodic // independent of when idle() itself resolves (that happens on periodic
@@ -243,15 +286,7 @@ async function runIdleLoop(client: ImapFlow): Promise<void> {
await client.idle(); await client.idle();
} }
} catch (err) { } catch (err) {
if (state.client === client) { scheduleReconnect(client, "IMAP idle loop error", err);
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);
}
}
} }
} }
+15
View File
@@ -49,10 +49,18 @@ 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; let emailMessageId: string | undefined;
if (ticket.channel === "email") { if (ticket.channel === "email") {
const context = await getEmailThreadContext(params.ticketId); const context = await getEmailThreadContext(params.ticketId);
if (context) { if (context) {
try {
emailMessageId = await sendTicketReplyEmail({ emailMessageId = await sendTicketReplyEmail({
to: context.customerEmail, to: context.customerEmail,
subject: context.subject, subject: context.subject,
@@ -62,6 +70,9 @@ export async function deliverAgentMessage(params: {
? { filename: params.attachment.filename, content: params.attachment.buffer } ? { filename: params.attachment.filename, content: params.attachment.buffer }
: undefined, : undefined,
}); });
} catch (err) {
console.error(`Failed to send reply email for ticket ${params.ticketId}:`, err);
}
} }
} }
@@ -79,6 +90,7 @@ export async function deliverAgentMessage(params: {
where: eq(customers.id, result.ticket.customerId), where: eq(customers.id, result.ticket.customerId),
}); });
if (customer?.telegramChatId) { if (customer?.telegramChatId) {
try {
if (params.attachment) { if (params.attachment) {
await sendTelegramFile( await sendTelegramFile(
customer.telegramChatId, customer.telegramChatId,
@@ -88,6 +100,9 @@ export async function deliverAgentMessage(params: {
} else { } else {
await sendTelegramMessage(customer.telegramChatId, params.body); await sendTelegramMessage(customer.telegramChatId, params.body);
} }
} catch (err) {
console.error(`Failed to deliver reply via Telegram for ticket ${params.ticketId}:`, err);
}
} }
} }