diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index 60f17511a..65383c2ba 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -990,6 +990,7 @@ const LEAD_META: Record< completed: { label: "Done", dot: "bg-indigo-500", text: "text-indigo-700", Icon: CheckIcon }, replied: { label: "Replied", dot: "bg-emerald-500", text: "text-emerald-700", Icon: CornerUpLeftIcon }, bounced: { label: "Bounced", dot: "bg-rose-500", text: "text-rose-600", Icon: AlertTriangleIcon }, + failed: { label: "Failed", dot: "bg-rose-500", text: "text-rose-600", Icon: AlertTriangleIcon }, unsubscribed: { label: "Unsubscribed", dot: "bg-slate-300", text: "text-slate-400", Icon: BanIcon }, }; @@ -997,9 +998,16 @@ function LeadStatusPill({ lead }: { lead?: ContactCampaignProgress | null }) { const status: LeadStatus = lead?.status ?? "pending"; const meta = LEAD_META[status]; const Icon = meta.Icon; + // A failed lead carries the worker's reason; surface it on hover since the + // pill itself only has room for the word. + const title = + status === "failed" && lead?.failure_reason + ? `Could not send: ${lead.failure_reason}` + : undefined; return ( {status === "active" ? ( @@ -1030,6 +1038,7 @@ function LeadProgressStrip({ completed: 0, replied: 0, bounced: 0, + failed: 0, unsubscribed: 0, }; for (const ct of contacts) c[ct.campaign_lead?.status ?? "pending"]++; @@ -1045,6 +1054,7 @@ function LeadProgressStrip({ { key: "replied", color: "bg-emerald-500" }, { key: "pending", color: "bg-slate-300" }, { key: "bounced", color: "bg-rose-400" }, + { key: "failed", color: "bg-rose-500" }, { key: "unsubscribed", color: "bg-slate-200" }, ]; @@ -1069,6 +1079,7 @@ function LeadProgressStrip({ + {counts.failed > 0 && }
diff --git a/web/src/hooks/useRealtimeEvents.ts b/web/src/hooks/useRealtimeEvents.ts index 353466646..524a297f9 100644 --- a/web/src/hooks/useRealtimeEvents.ts +++ b/web/src/hooks/useRealtimeEvents.ts @@ -171,6 +171,24 @@ export function useRealtimeEvents() { return } + // A user-addressed mailbox/send error (a compose or reply the worker + // could not send, a mailbox that needs re-authorizing). Nothing else + // would tell the user, so toast it, then refresh the mailbox views. + if (event === 'ERROR') { + const data = payload.data as Record | undefined + const title = + typeof data?.title === 'string' && data.title + ? data.title + : (getString('message') ?? 'Something went wrong') + const detail = typeof data?.message === 'string' ? data.message : '' + toast.error(detail ? `${title}: ${detail}` : title, { + id: `email-error-${getString('task_id') ?? getString('email_id') ?? 'general'}`, + duration: 8000, + }) + invalidate([['emails', 'list'], ['unibox']]) + return + } + if (includes('ACCOUNT', 'EMAIL_STATUS', 'EMAIL_ERROR', 'WARMUP')) { // ACCOUNT_SYNC_STATE: the mailbox's import finished or fair use // started/stopped holding it; the drawer's sync card refetches. diff --git a/web/src/lib/api/models/app/contacts/Contact.ts b/web/src/lib/api/models/app/contacts/Contact.ts index e90519ea6..b0f78532f 100644 --- a/web/src/lib/api/models/app/contacts/Contact.ts +++ b/web/src/lib/api/models/app/contacts/Contact.ts @@ -1,15 +1,17 @@ import type MiniCampaign from "../campaigns/MiniCampaign"; import type MiniCategory from "./MiniCategory"; -// LeadStatus mirrors models.ContactCampaignProgress.Status — a contact's +// LeadStatus mirrors models.ContactCampaignProgress.Status, a contact's // processing state inside a single campaign. "completed" = every step sent, no -// reply (done); "active" = some but not all steps sent (still processing). +// reply (done); "active" = some but not all steps sent (still processing); +// "failed" = the mailbox could not send a step after every retry. export type LeadStatus = | "pending" | "active" | "completed" | "replied" | "bounced" + | "failed" | "unsubscribed"; // ContactCampaignProgress is set only on contacts returned by a single-campaign @@ -25,6 +27,9 @@ export interface ContactCampaignProgress { // Label of the step the lead is on now (latest step sent). Empty when the // lead hasn't been contacted yet. current_step?: string; + // The worker's reason for the last failed send; set only when status is + // "failed". + failure_reason?: string; } export default interface Contact {