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:
1 parent
93e17c37c7
commit
8d1f91187a
7 files changed
+109
-31
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-17
@@ -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;
|
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) {
|
||||||
emailMessageId = await sendTicketReplyEmail({
|
try {
|
||||||
to: context.customerEmail,
|
emailMessageId = await sendTicketReplyEmail({
|
||||||
subject: context.subject,
|
to: context.customerEmail,
|
||||||
body: params.body,
|
subject: context.subject,
|
||||||
inReplyTo: context.inReplyTo,
|
body: params.body,
|
||||||
attachment: params.attachment
|
inReplyTo: context.inReplyTo,
|
||||||
? { filename: params.attachment.filename, content: params.attachment.buffer }
|
attachment: params.attachment
|
||||||
: undefined,
|
? { 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),
|
where: eq(customers.id, result.ticket.customerId),
|
||||||
});
|
});
|
||||||
if (customer?.telegramChatId) {
|
if (customer?.telegramChatId) {
|
||||||
if (params.attachment) {
|
try {
|
||||||
await sendTelegramFile(
|
if (params.attachment) {
|
||||||
customer.telegramChatId,
|
await sendTelegramFile(
|
||||||
{ buffer: params.attachment.buffer, filename: params.attachment.filename, mimeType: params.attachment.mimeType },
|
customer.telegramChatId,
|
||||||
params.body,
|
{ buffer: params.attachment.buffer, filename: params.attachment.filename, mimeType: params.attachment.mimeType },
|
||||||
);
|
params.body,
|
||||||
} else {
|
);
|
||||||
await sendTelegramMessage(customer.telegramChatId, params.body);
|
} else {
|
||||||
|
await sendTelegramMessage(customer.telegramChatId, params.body);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to deliver reply via Telegram for ticket ${params.ticketId}:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user