Unreachable mail servers: send retries in Scheduled, real action retries, account health

Sending: a send the server could not take (unreachable, timeout, 4xx) is no
longer failed at once. It waits in Scheduled ("retrying") and is tried again
after 1, 2, 5, 10, 30 min, then hourly, for 24 hours — on the server, so it
works with the browser closed. A permanent refusal (5xx, wrong password) is
"failed" at once, with the server's reason. GET /outbox lists both;
POST /outbox/{id}/retry (Retry now) and /cancel (Edit, Delete) act on them,
and they are left out of Drafts. Mail delivered but not filed in Sent now
counts as sent, and a conditional claim keeps two jobs from sending one
message twice. The app says when a send failed and when it finally went.

Actions: arq retries a job only on Retry, never on a plain exception, so the
"retry x5, then revert" for flags and moves never happened. Flag, move,
archive, restore, delete and empty-folder jobs now raise Retry while the
server is unreachable (~24 hours of growing gaps), skip a change a later one
replaced, stop at the first connection failure instead of 20 s per message,
and delete/empty keep the rows until the server has the messages gone.

Account health: 3 failed connections in a row mark an account unreachable
(amber in the sidebar and Settings, cleared on the next success); 3 refused
sign-ins stop its syncing as auth_failed until the password is entered
again, so the provider does not lock the account. Sync loops back off 30 s
doubling to 10 min and log one line per expected failure; a refused
connection fails at once with its reason (the connect task is kept), and a
silently dead IDLE connection is noticed within ~11 min instead of ~29.

Also: the empty reading pane is clipped to its width when dragged very
narrow; tests no longer leave live accounts and outbox rows in the database.

Migration 0021: outbox attempts / next_attempt_at / retry_until, accounts
sync_error / sync_error_since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 22:08:42 +02:00
co-authored by Claude Opus 5.5
parent e1bda37188
commit 609e7c58f9
31 changed files with 1375 additions and 151 deletions
+23 -10
View File
@@ -200,24 +200,37 @@ export function OfflineController() {
role="status"
className="wm-offline-notice pointer-events-none fixed bottom-6 left-1/2 z-[70] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-2.5 rounded-xl border border-app-border bg-app-elevated px-4 py-2.5 text-sm text-app-fg shadow-lg"
>
{current.key === "scheduled" || current.key === "sendingScheduled" ? (
{current.key === "scheduledSent" ? (
<span className="text-emerald-500">
<Check size={18} />
</span>
) : current.key === "scheduled" ||
current.key === "sendingScheduled" ||
current.key === "sendRetrying" ? (
<span className="text-app-accent">
<Clock size={18} />
</span>
) : current.key === "sendFailed" ? (
<span className="text-red-500">
<Clock size={18} />
</span>
) : (
<span className="text-amber-500">
<CloudOff size={18} />
</span>
)}
{current.key === "offline"
? t("offline.noticeSaved")
: current.key === "offlineNotSaved"
? t("offline.noticeNotSaved")
: current.key === "scheduled"
? t("offline.noticeScheduled")
: current.key === "sendingScheduled"
? t("offline.noticeSendingScheduled", current.vars)
: t("offline.notAvailable")}
{
{
offline: t("offline.noticeSaved"),
offlineNotSaved: t("offline.noticeNotSaved"),
notAvailable: t("offline.notAvailable"),
scheduled: t("offline.noticeScheduled"),
sendingScheduled: t("offline.noticeSendingScheduled", current.vars),
sendRetrying: t("send.noticeRetrying", current.vars),
sendFailed: t("send.noticeFailed", current.vars),
scheduledSent: t("send.noticeSent", current.vars),
}[current.key]
}
</div>
);
}
+43 -8
View File
@@ -1,7 +1,7 @@
import { useT } from "../../i18n";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { LocalMessage } from "../../lib/scheduled";
import type { DraftOut, RemoteDraftOut } from "../../lib/types";
import type { DraftOut, OutboxItem, RemoteDraftOut } from "../../lib/types";
function timeLabel(iso: string): string {
const d = new Date(iso);
@@ -152,24 +152,32 @@ export function DraftsList({
);
}
// The Scheduled folder: messages sent while offline, waiting for the connection.
// The Scheduled folder: what was sent but has not gone out yet — messages sent
// while offline (kept on this device), and sends the mail server could not take
// (retrying on the server, or failed for good).
export function ScheduledList({
messages,
serverItems = [],
selectedId,
onOpen,
onOpenServer,
}: {
messages: LocalMessage[];
serverItems?: OutboxItem[];
selectedId?: string;
onOpen: (msg: LocalMessage) => void;
onOpenServer: (item: OutboxItem) => void;
}) {
const t = useT();
if (messages.length === 0) {
if (messages.length === 0 && serverItems.length === 0) {
return <div className="p-6 text-sm text-app-faint">{t("scheduled.empty")}</div>;
}
return (
<div className="h-full overflow-y-auto" data-testid="scheduled-list">
<div className="border-b border-app-border px-6 py-2 text-xs text-app-faint">{t("scheduled.help")}</div>
{messages.map((m) => (
type Row = { key: string; at: number; node: JSX.Element };
const rows: Row[] = [
...messages.map((m) => ({
key: m.id,
at: m.updatedAt,
node: (
<DraftRow
key={m.id}
recipients={m.input.payload.to.join(", ") || t("compose.noRecipient")}
@@ -187,7 +195,34 @@ export function ScheduledList({
selected={m.id === selectedId}
onClick={() => onOpen(m)}
/>
))}
),
})),
...serverItems.map((o) => ({
key: o.outbox_id,
at: new Date(o.sent_at).getTime(),
node: (
<DraftRow
key={o.outbox_id}
recipients={o.to.join(", ") || t("compose.noRecipient")}
subject={o.subject || t("compose.noSubject")}
preview={o.state === "failed" && o.error ? o.error : o.preview.trim()}
time={timeLabel(o.sent_at)}
badge={
o.state === "failed"
? t("scheduled.failed")
: t("scheduled.retrying", { when: timeLabel(o.next_attempt_at ?? o.sent_at) })
}
badgeTone={o.state === "failed" ? "error" : "accent"}
selected={o.outbox_id === selectedId}
onClick={() => onOpenServer(o)}
/>
),
})),
].sort((a, b) => b.at - a.at);
return (
<div className="h-full overflow-y-auto" data-testid="scheduled-list">
<div className="border-b border-app-border px-6 py-2 text-xs text-app-faint">{t("scheduled.help")}</div>
{rows.map((r) => r.node)}
</div>
);
}
@@ -0,0 +1,112 @@
// The reading pane for a send waiting in Scheduled on the server: why it has
// not gone out, and what to do — Retry now, Edit (back to a draft), Delete.
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { DraftOut, OutboxItem } from "../../lib/types";
function when(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (d.toDateString() === new Date().toDateString()) return formatClock(d);
return `${d.toLocaleDateString(getDateLocale(), { day: "numeric", month: "short" })}, ${formatClock(d)}`;
}
export function ScheduledSendPane({
item,
onClose,
onEdit,
}: {
item: OutboxItem;
onClose: () => void;
/** The send was stopped; open its draft to change it. */
onEdit: (draft: DraftOut) => void;
}) {
const t = useT();
const queryClient = useQueryClient();
const [busy, setBusy] = useState(false);
const refresh = () => {
for (const key of [["outbox"], ["drafts"], ["folder-counts"]]) {
void queryClient.invalidateQueries({ queryKey: key });
}
};
const run = async (fn: () => Promise<void>) => {
setBusy(true);
try {
await fn();
} finally {
setBusy(false);
refresh();
}
};
const retry = () =>
run(async () => {
await api.retryOutbox(item.outbox_id);
onClose();
});
const edit = () =>
run(async () => {
const { draft_id } = await api.cancelOutbox(item.outbox_id);
const draft = (await api.listDrafts()).find((d) => d.id === draft_id);
if (draft) onEdit(draft);
else onClose();
});
const remove = () =>
run(async () => {
const { draft_id } = await api.cancelOutbox(item.outbox_id);
await api.deleteDraft(draft_id);
onClose();
});
const button =
"rounded-md border border-app-border px-3 py-1.5 text-sm text-app-fg hover:bg-app-hover disabled:opacity-50";
return (
<div className="flex h-full flex-col overflow-y-auto bg-app-bg" data-testid="scheduled-send">
<div className="sticky top-0 flex h-[52px] shrink-0 items-center gap-2 border-b border-app-border bg-app-surface px-4">
<h2 className="min-w-0 flex-1 truncate text-[17px] font-semibold text-app-fg">
{item.subject || t("compose.noSubject")}
</h2>
<button className="wm-online-only rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50" disabled={busy} onClick={() => void retry()}>
{t("scheduled.retryNow")}
</button>
<button className={`wm-online-only ${button}`} disabled={busy} onClick={() => void edit()}>
{t("scheduled.edit")}
</button>
<button className={`wm-online-only ${button} text-red-500`} disabled={busy} onClick={() => void remove()}>
{t("scheduled.delete")}
</button>
</div>
<div className="space-y-3 p-4 text-sm">
<div
className={`rounded-lg border px-3 py-2 ${
item.state === "failed"
? "border-red-500/40 bg-red-500/10 text-red-500"
: "border-app-accent/40 bg-app-accent/10 text-app-fg"
}`}
>
{item.state === "failed"
? t("scheduled.statusFailed", { error: item.error ?? "" })
: t("scheduled.statusRetrying", {
count: item.attempts,
when: when(item.next_attempt_at),
until: when(item.retry_until),
})}
{item.state === "retrying" && item.error && (
<div className="mt-1 text-xs text-app-faint">{t("scheduled.lastError", { error: item.error })}</div>
)}
</div>
<div className="text-app-muted">
<span className="font-semibold text-app-fg">{t("scheduled.to")}</span> {item.to.join(", ")}
</div>
<div className="whitespace-pre-wrap rounded-lg border border-app-border bg-app-surface p-3 text-app-fg">
{item.preview}
</div>
</div>
</div>
);
}
+17 -1
View File
@@ -564,11 +564,27 @@ export const en = {
"compose.attachOnDevice": "On this device — uploaded when you're back online",
"compose.onDevice": "On this device",
"nav.scheduled": "Scheduled",
"scheduled.help": "Sent automatically when you're back online.",
"scheduled.help": "These go out by themselves — when you're back online, or once the mail server takes them.",
"scheduled.empty": "Nothing scheduled.",
"scheduled.waiting": "Waiting",
"scheduled.sending": "Sending…",
"scheduled.failed": "Not sent",
"scheduled.retrying": "Retrying {when}",
"scheduled.retryNow": "Retry now",
"scheduled.edit": "Edit",
"scheduled.delete": "Delete",
"scheduled.statusRetrying":
"The mail server couldn't take this message yet ({count} tries). Next try {when}; it keeps trying until {until}.",
"scheduled.statusFailed": "Not sent: {error}",
"scheduled.lastError": "Last error: {error}",
"scheduled.to": "To",
"send.noticeRetrying": "Couldn't send “{subject}” — it's in Scheduled and will be retried.",
"send.noticeFailed": "Couldn't send “{subject}” — it's in Scheduled: {error}",
"send.noticeSent": "Sent “{subject}”.",
"account.unreachable": "Can't reach the mail server since {when}",
"account.authFailed": "Sign-in failed — open Settings → Accounts → Configure account and enter the password again",
"settings.stateUnreachable": "Can't reach the server since {when}",
"settings.stateAuthFailed": "Sign-in failed — use Configure account to enter the password again",
"offline.searchPlaceholder": "Search (offline)",
"offline.cantReach": "Can't reach the server.",
"offline.settingTitle": "Offline access",
+6
View File
@@ -14,6 +14,7 @@ import type {
DraftOut,
FolderCounts,
MailboxOut,
OutboxItem,
QuotaOut,
RecipientSuggestion,
RegistrationStatus,
@@ -368,6 +369,11 @@ export const api = {
emptyFolder: (special: string) =>
apiFetch<{ status: string }>(`/folders/${special}/empty`, { method: "POST" }),
listDrafts: () => apiFetch<DraftOut[]>("/drafts"),
// Sends waiting in Scheduled on the server (retrying, or failed for good).
outbox: () => apiFetch<OutboxItem[]>("/outbox"),
retryOutbox: (id: string) => apiFetch<{ status: string }>(`/outbox/${id}/retry`, { method: "POST" }),
cancelOutbox: (id: string) =>
apiFetch<{ status: string; draft_id: string }>(`/outbox/${id}/cancel`, { method: "POST" }),
remoteDrafts: () => apiFetch<RemoteDraftOut[]>("/drafts/remote"),
adoptDraft: (messageId: string) =>
apiFetch<DraftOut>(`/drafts/adopt/${messageId}`, { method: "POST" }),
+9 -1
View File
@@ -4,7 +4,15 @@
import { useSyncExternalStore } from "react";
export type NoticeKey = "offline" | "offlineNotSaved" | "notAvailable" | "scheduled" | "sendingScheduled";
export type NoticeKey =
| "offline"
| "offlineNotSaved"
| "notAvailable"
| "scheduled"
| "sendingScheduled"
| "sendRetrying"
| "sendFailed"
| "scheduledSent";
export interface Notice {
key: NoticeKey;
+20
View File
@@ -122,6 +122,26 @@ export interface AccountOut {
sync_window: SyncWindow;
backfill_total: number;
backfill_done: number;
/** "unreachable" while the mail server has not answered for a while. */
sync_error?: string | null;
sync_error_since?: string | null;
state_detail?: string;
}
/** A send the server could not take: waiting in Scheduled (retrying, or failed). */
export interface OutboxItem {
outbox_id: string;
draft_id: string;
account_id: string;
state: "retrying" | "failed";
attempts: number;
error: string | null;
next_attempt_at: string | null;
retry_until: string | null;
sent_at: string;
to: string[];
subject: string;
preview: string;
}
export interface MailboxOut {
+19
View File
@@ -5,6 +5,8 @@ import type { QueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { signedOut } from "./auth";
import { showOfflineNotice } from "./offlineNotice";
import type { OutboxItem } from "./types";
import { isOffline, onOnline } from "./offline";
const frameSchema = z.object({
@@ -121,6 +123,23 @@ export function connectWs(queryClient: QueryClient): () => void {
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
window.dispatchEvent(new Event("webmail:labels-changed"));
}
if (name === "outbox.state_changed") {
// A send the server could not take waits in Scheduled (retrying, or
// failed); say so, and say when one of those finally goes out.
const subject = String(payload.subject ?? "") || "(no subject)";
const waiting = queryClient.getQueryData<OutboxItem[]>(["outbox"]) ?? [];
if (payload.state === "retrying" && payload.attempts === 1) {
showOfflineNotice("sendRetrying", 8000, { subject });
} else if (payload.state === "failed") {
showOfflineNotice("sendFailed", 10000, { subject, error: String(payload.error ?? "") });
} else if (payload.state === "sent") {
const was = waiting.find((w) => w.outbox_id === payload.outbox_id);
if (was) showOfflineNotice("scheduledSent", 5000, { subject: was.subject || "(no subject)" });
}
for (const key of [["outbox"], ["drafts"], ["folder-counts"]]) {
queryClient.invalidateQueries({ queryKey: key });
}
}
if (name === "message.sent") {
// The sent message's draft is deleted server-side; refresh the Drafts list.
queryClient.invalidateQueries({ queryKey: ["drafts"] });
+72 -11
View File
@@ -28,13 +28,14 @@ import { Clock, NotSavedOffline, OfflineIndicator } from "../components/Offline"
import { ThreadView } from "../components/thread/ThreadView";
import { MailboxDisplay } from "../components/mail/MailboxDisplay";
import { RightPanel, type PanelTab } from "../components/mail/RightPanel";
import { ScheduledSendPane } from "../components/mail/ScheduledSendPane";
import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
import { api, isOfflineError } from "../lib/api";
import { clearOfflineData, useOffline } from "../lib/offline";
import { flushLocal, useLocalMessages, wipeLocal } from "../lib/scheduled";
import { setTimeFormat } from "../lib/timeFormat";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { formatClock, getDateLocale, setTimeFormat } from "../lib/timeFormat";
import type { AccountOut, QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useReadingPane } from "../lib/readingPane";
import { useIsMobile } from "../lib/useIsMobile";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
@@ -223,7 +224,14 @@ export function MailLayout() {
const isScheduled = view === "local:scheduled";
const localMessages = useLocalMessages();
const deviceDrafts = localMessages.filter((m) => m.state === "draft");
// Plus sends the mail server could not take, retrying on the server (or failed).
const outboxQuery = useQuery({ queryKey: ["outbox"], queryFn: api.outbox, retry: false });
const serverScheduled = outboxQuery.data ?? [];
const scheduled = localMessages.filter((m) => m.state !== "draft");
const scheduledCount = scheduled.length + serverScheduled.length;
// The waiting send open in the reading pane (its own pane, not a composer).
const [scheduledPaneId, setScheduledPaneId] = useState<string | null>(null);
const scheduledPane = serverScheduled.find((o) => o.outbox_id === scheduledPaneId) ?? null;
// Which special-use folder the reading pane is showing (drives the toolbar).
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
@@ -836,7 +844,7 @@ export function MailLayout() {
) : null}
</SidebarItem>
{/* Scheduled: shown while something waits to be sent (or is open). */}
{f.view === "folder:drafts" && (scheduled.length > 0 || isScheduled) && (
{f.view === "folder:drafts" && (scheduledCount > 0 || isScheduled) && (
<SidebarItem
active={isScheduled}
collapsed={collapsed}
@@ -847,8 +855,8 @@ export function MailLayout() {
<Clock size={18} />
</span>
{!collapsed && <span className="flex-1 truncate">{t("nav.scheduled")}</span>}
{!collapsed && scheduled.length > 0 && (
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{scheduled.length}</span>
{!collapsed && scheduledCount > 0 && (
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{scheduledCount}</span>
)}
</SidebarItem>
)}
@@ -876,7 +884,8 @@ export function MailLayout() {
total={a.backfill_total}
done={a.backfill_done}
/>
<span className="truncate">{a.email}</span>
<span className="flex-1 truncate">{a.email}</span>
<AccountProblem account={a} />
</SidebarItem>
))}
{savedSearches.length > 0 && (
@@ -1188,8 +1197,17 @@ export function MailLayout() {
{isScheduled ? (
<ScheduledList
messages={scheduled}
selectedId={paneDraft?.localId}
onOpen={(msg) => openDraftInPane(localToInit(msg))}
serverItems={serverScheduled}
selectedId={paneDraft?.localId ?? scheduledPane?.outbox_id}
onOpen={(msg) => {
setScheduledPaneId(null);
openDraftInPane(localToInit(msg));
}}
onOpenServer={(item) => {
setPaneDraft(null);
setScheduledPaneId(item.outbox_id);
if (threadId) navigate("/mail");
}}
/>
) : isDrafts && !term.trim() ? (
<DraftsList
@@ -1299,7 +1317,16 @@ export function MailLayout() {
ref={paneRef}
className={`relative min-h-0 min-w-0 flex-1 bg-app-surface ${single && !paneShown ? "hidden" : ""}`}
>
{paneDraft ? (
{scheduledPane && isScheduled && !paneDraft ? (
<ScheduledSendPane
item={scheduledPane}
onClose={() => setScheduledPaneId(null)}
onEdit={(draft) => {
setScheduledPaneId(null);
openDraftInPane(draftToInit(draft));
}}
/>
) : paneDraft ? (
// Keyed by draft: picking a different one must build a fresh editor,
// because RichTextEditor seeds its body exactly once.
<Composer
@@ -1441,7 +1468,9 @@ export function MailLayout() {
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
// Clipped to the pane: dragged very narrow, the picture and the line
// are cut at its edges instead of spilling over the list beside it.
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-hidden text-app-faint">
{!rightOpen && !panelToggleInList && (
<button
className="absolute right-3 top-3 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
@@ -1457,7 +1486,9 @@ export function MailLayout() {
)}
{/* Under the list the pane is short: a smaller picture and line. */}
<EmptyIllustration className={bottom ? "w-32 max-w-[40%]" : "w-56 max-w-[55%]"} />
<span className={bottom ? "text-xl" : "text-3xl"}>{t("mail.selectConversation")}</span>
<span className={`px-3 text-center ${bottom ? "text-xl" : "text-3xl"}`}>
{t("mail.selectConversation")}
</span>
</div>
)}
</main>
@@ -1506,6 +1537,36 @@ export function MailLayout() {
// Account bullet in the sidebar. While the account is backfilling it becomes a
// spinning ring that fills with the account colour to show import progress
// (SPEC §12 M2); once live it's the plain colour-filled dot.
// An account's mail server not answering, or refusing the password: a small
// warning after its name, explained on hover.
function AccountProblem({ account }: { account: AccountOut }) {
const t = useT();
const authFailed = account.state === "auth_failed";
if (!authFailed && account.sync_error !== "unreachable") return null;
const title = authFailed
? t("account.authFailed")
: t("account.unreachable", { when: sinceLabel(account.sync_error_since) });
return (
<span
title={title}
aria-label={title}
className={`ml-auto shrink-0 ${authFailed ? "text-red-500" : "text-amber-500"}`}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 3.5L2.5 20h19L12 3.5z" />
<path d="M12 10v4.5M12 17.5v.01" />
</svg>
</span>
);
}
function sinceLabel(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (d.toDateString() === new Date().toDateString()) return formatClock(d);
return `${d.toLocaleDateString(getDateLocale(), { day: "numeric", month: "short" })}, ${formatClock(d)}`;
}
function AccountDot({
color,
syncing,
+21 -4
View File
@@ -21,7 +21,7 @@ import {
} from "../../lib/offline";
import { prefetchRecent } from "../../lib/offlinePrefetch";
import { useTheme } from "../../lib/theme";
import { setTimeFormat } from "../../lib/timeFormat";
import { formatClock, getDateLocale, setTimeFormat } from "../../lib/timeFormat";
import { PluginSlot } from "../../plugins/PluginSlot";
import { usePluginSettingsSections } from "../../plugins/manifest";
import { AccountWizard } from "../wizard/AccountWizard";
@@ -223,6 +223,13 @@ function Row({ label, value }: { label: string; value: string }) {
);
}
function sinceLabel(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (d.toDateString() === new Date().toDateString()) return formatClock(d);
return `${d.toLocaleDateString(getDateLocale(), { day: "numeric", month: "short" })}, ${formatClock(d)}`;
}
function formatMB(bytes: number): string {
return bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
@@ -494,9 +501,19 @@ function AccountsSection() {
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
<span>{a.email}</span>
<span className="text-xs text-app-faint">
{t("settings.syncState", { state: a.state })}
</span>
{a.state === "auth_failed" ? (
<span className="text-xs text-red-500" title={a.state_detail || undefined}>
{t("settings.stateAuthFailed")}
</span>
) : a.sync_error === "unreachable" ? (
<span className="text-xs text-amber-500">
{t("settings.stateUnreachable", { when: sinceLabel(a.sync_error_since) })}
</span>
) : (
<span className="text-xs text-app-faint">
{t("settings.syncState", { state: a.state })}
</span>
)}
{a.sync_window !== "all" && (
<>
<span className="text-xs text-app-faint">·</span>