Surface channel delivery failures on agent replies instead of swallowing them

The previous fix made a broken SMTP/Telegram account non-fatal — the
reply gets recorded either way now — but that also made delivery
failures completely invisible: an agent sees their reply "sent"
successfully with no indication the customer never actually received it.
Added messages.deliveryFailed, set whenever the outbound send throws, and
a warning badge on the message bubble so this is now visible instead of
silent.

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 08:26:54 +00:00
1 parent 8d1f91187a
commit b92d9801ff
8 files changed
+1258 -14

No files matched your search

+10 -1
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft, Send, StickyNote, Paperclip, X, MessageSquareText } from "lucide-react";
import { ArrowLeft, Send, StickyNote, Paperclip, X, MessageSquareText, TriangleAlert } from "lucide-react";
import { useTicketEvents } from "@/lib/events/use-ticket-events";
import { formatRelativeTime } from "@/lib/format";
import { Avatar } from "@/components/avatar";
@@ -373,6 +373,15 @@ function MessageBubble({ message }: { message: MessageDTO }) {
<p className={`text-[11px] ${isAgent ? "text-white/70" : "text-text-faint"}`}>
{message.authorName} · {formatRelativeTime(message.createdAt)}
</p>
{message.deliveryFailed && (
<p
className="mt-1 flex items-center gap-1 text-[11px] font-medium text-danger"
title="Сообщение сохранено, но не дошло до клиента — проверьте настройки почты/Telegram"
>
<TriangleAlert size={11} />
Не доставлено клиенту
</p>
)}
</div>
</motion.div>
);
@@ -0,0 +1 @@
ALTER TABLE `messages` ADD `delivery_failed` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -85,6 +85,13 @@
"when": 1788763637424,
"tag": "0011_cute_boomer",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1791361267938,
"tag": "0012_flawless_lord_tyger",
"breakpoints": true
}
]
}
+5
View File
@@ -147,6 +147,11 @@ export const messages = sqliteTable("messages", {
// 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"),
// Set when this is an outbound agent reply whose channel delivery
// (email send / Telegram send) failed — the reply itself is always
// recorded regardless (see deliverAgentMessage), but the agent needs a
// way to tell "saved" from "the customer actually got this".
deliveryFailed: integer("delivery_failed", { mode: "boolean" }).notNull().default(false),
createdAt: timestamps.createdAt,
});
+20 -13
View File
@@ -56,7 +56,13 @@ export async function deliverAgentMessage(params: {
// 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.
// A ticket only ever has one channel, so at most one of the two branches
// below runs — this single flag is the outcome of whichever one does, and
// is what lets the agent tell "saved" from "the customer actually got
// this" in the UI (see messages.deliveryFailed).
let emailMessageId: string | undefined;
let deliveryFailed = false;
if (ticket.channel === "email") {
const context = await getEmailThreadContext(params.ticketId);
if (context) {
@@ -72,22 +78,12 @@ export async function deliverAgentMessage(params: {
});
} catch (err) {
console.error(`Failed to send reply email for ticket ${params.ticketId}:`, err);
deliveryFailed = true;
}
}
}
const result = await recordAgentReply({
ticketId: params.ticketId,
agentId: params.agentId,
agentName: params.agentName,
body: params.body,
emailMessageId,
attachments: attachmentInput ? [attachmentInput] : undefined,
});
if (result.ticket.channel === "telegram") {
} else if (ticket.channel === "telegram") {
const customer = await db.query.customers.findFirst({
where: eq(customers.id, result.ticket.customerId),
where: eq(customers.id, ticket.customerId),
});
if (customer?.telegramChatId) {
try {
@@ -102,9 +98,20 @@ export async function deliverAgentMessage(params: {
}
} catch (err) {
console.error(`Failed to deliver reply via Telegram for ticket ${params.ticketId}:`, err);
deliveryFailed = true;
}
}
}
const result = await recordAgentReply({
ticketId: params.ticketId,
agentId: params.agentId,
agentName: params.agentName,
body: params.body,
emailMessageId,
deliveryFailed,
attachments: attachmentInput ? [attachmentInput] : undefined,
});
return result;
}
+5
View File
@@ -75,6 +75,7 @@ function toMessageDTO(message: typeof messages.$inferSelect, attachments: Attach
visibility: message.visibility,
attachments,
createdAt: message.createdAt.getTime(),
deliveryFailed: message.deliveryFailed,
};
}
@@ -142,6 +143,7 @@ async function appendMessage(params: {
direction: "in" | "out";
visibility?: "public" | "internal";
emailMessageId?: string;
deliveryFailed?: boolean;
attachments?: AttachmentInput[];
}) {
const [message] = await db
@@ -155,6 +157,7 @@ async function appendMessage(params: {
bodyHtml: params.bodyHtml ?? null,
direction: params.direction,
visibility: params.visibility ?? "public",
deliveryFailed: params.deliveryFailed ?? false,
emailMessageId: params.emailMessageId,
})
.returning();
@@ -398,6 +401,7 @@ export async function recordAgentReply(params: {
agentName: string;
body: string;
emailMessageId?: string;
deliveryFailed?: boolean;
attachments?: AttachmentInput[];
}) {
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
@@ -415,6 +419,7 @@ export async function recordAgentReply(params: {
body: params.body,
direction: "out",
emailMessageId: params.emailMessageId,
deliveryFailed: params.deliveryFailed,
attachments: params.attachments,
});
}
+1
View File
@@ -51,4 +51,5 @@ export interface MessageDTO {
visibility: MessageVisibility;
attachments: AttachmentDTO[];
createdAt: number;
deliveryFailed: boolean;
}