Backfill start-window indicator, account-delete spinner, app-password Help popup

- Backfill: show the sidebar spinner + "Preparing…" banner during the brief window
  after account creation when state=backfilling but total is not yet counted
  (total===0), so a freshly-added account shows progress immediately instead of
  looking done until a refresh. The stuck-at-100% guard is preserved.
- Settings: a centered spinner overlay ("Removing account…") covers the screen from
  confirm until the (slow) server cascade + accounts refetch complete, so the row
  no longer appears frozen before it disappears.
- Add-account wizard: the manual password hint now includes Outlook and gains a
  "Help" link opening a popup with per-provider direct links to enable 2-step
  verification and generate the app password (Gmail, Outlook, Yahoo, iCloud).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-07 07:59:26 +02:00
co-authored by Claude Opus 4.8
parent 12a67825cc
commit 719eeef378
5 changed files with 144 additions and 18 deletions
+16 -5
View File
@@ -13,9 +13,12 @@ export function BackfillBanner() {
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const importing = (accounts.data ?? []).filter(
// Also require done < total so a completed account whose state event was missed
// (e.g. a dropped WS) never leaves a stuck "X of X" banner.
(a) => a.state === "backfilling" && a.backfill_total > 0 && a.backfill_done < a.backfill_total,
// Show while backfilling — including the brief start window before the worker has
// counted the total (total === 0). Hide only once done, so a completed account
// whose state event was missed (e.g. a dropped WS) never leaves a stuck banner.
(a) =>
a.state === "backfilling" &&
(a.backfill_total === 0 || a.backfill_done < a.backfill_total),
);
const active = importing.length > 0;
@@ -40,8 +43,10 @@ export function BackfillBanner() {
}`}
>
{importing.map((a) => {
// total === 0: the worker hasn't counted yet — show an indeterminate bar.
const preparing = a.backfill_total === 0;
const done = Math.min(a.backfill_done, a.backfill_total);
const pct = Math.round((done / a.backfill_total) * 100);
const pct = preparing ? 0 : Math.round((done / a.backfill_total) * 100);
return (
<div
key={a.id}
@@ -52,16 +57,22 @@ export function BackfillBanner() {
<span className="truncate text-xs font-medium text-app-fg">{a.email}</span>
</div>
<div className="mb-1.5 text-xs text-app-muted">
{t("backfill.importing", {
{preparing
? t("backfill.preparing")
: t("backfill.importing", {
done: done.toLocaleString(),
total: a.backfill_total.toLocaleString(),
})}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-app-hover">
{preparing ? (
<div className="h-full w-full animate-pulse rounded-full bg-app-accent/60" />
) : (
<div
className="h-full rounded-full bg-app-accent transition-all duration-500"
style={{ width: `${pct}%` }}
/>
)}
</div>
</div>
);
+11 -1
View File
@@ -236,7 +236,15 @@ export const en = {
"wizard.port": "Port",
"wizard.security": "Security",
"wizard.accountPassword": "Account password",
"wizard.appPasswordHint": "For Gmail/Yahoo/iCloud use an app password.",
"wizard.appPasswordHint": "For Gmail, Yahoo, iCloud, or Outlook use an app password.",
"wizard.help": "Help",
"wizard.appPasswordHelp.title": "Set up an app password",
"wizard.appPasswordHelp.intro":
"These providers need a one-time app password instead of your normal password:",
"wizard.appPasswordHelp.prefix": "Make sure to",
"wizard.appPasswordHelp.twoStep": "turn on 2-step verification",
"wizard.appPasswordHelp.middle": "if you haven't done it yet, and then",
"wizard.appPasswordHelp.generate": "generate the app password",
"wizard.test": "Test & continue",
"wizard.testing": "Testing connection…",
"wizard.testOk": "Connection works.",
@@ -303,6 +311,7 @@ export const en = {
"settings.downloadAllMail": "Sync remaining mail",
"settings.syncedPartially": "Synced partially",
"backfill.importing": "Importing messages… {done} of {total}",
"backfill.preparing": "Preparing to import messages…",
"settings.appearance": "Appearance",
"settings.handle": "Handle",
"settings.recoveryEmail": "Recovery email",
@@ -376,6 +385,7 @@ export const en = {
"settings.addAccount": "Add account",
"settings.syncState": "State: {state}",
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
"settings.removingAccount": "Removing account…",
"settings.editAccount": "Edit account",
"settings.color": "Color",
"settings.serverSettings": "Change server settings / password",
+1 -2
View File
@@ -583,8 +583,7 @@ export function MailLayout() {
color={a.color}
syncing={
a.state === "backfilling" &&
a.backfill_total > 0 &&
a.backfill_done < a.backfill_total
(a.backfill_total === 0 || a.backfill_done < a.backfill_total)
}
total={a.backfill_total}
done={a.backfill_done}
+23 -2
View File
@@ -251,9 +251,15 @@ function AccountsSection() {
const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>(
null,
);
// Which account is being deleted — drives the blocking spinner overlay until the
// (slow) server cascade finishes and the refetched list no longer includes it.
const [deletingId, setDeletingId] = useState<string | null>(null);
const remove = useMutation({
mutationFn: (id: string) => api.deleteAccount(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ["accounts"] });
setDeletingId(null);
},
});
const downloadAll = useMutation({
mutationFn: (id: string) => api.updateAccount(id, { sync_window: "all" }),
@@ -325,7 +331,10 @@ function AccountsSection() {
<button
className="text-xs text-red-500"
onClick={() => {
if (confirm(t("settings.confirmRemoveAccount"))) remove.mutate(a.id);
if (confirm(t("settings.confirmRemoveAccount"))) {
setDeletingId(a.id);
remove.mutate(a.id);
}
}}
>
{t("common.remove")}
@@ -336,6 +345,18 @@ function AccountsSection() {
</li>
))}
</ul>
{deletingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3 rounded-xl bg-app-elevated px-8 py-6 shadow-2xl">
<span
className="h-8 w-8 animate-spin rounded-full border-2 border-app-border border-t-app-accent"
role="status"
aria-label={t("settings.removingAccount")}
/>
<span className="text-sm text-app-muted">{t("settings.removingAccount")}</span>
</div>
</div>
)}
</section>
);
}
+86 -1
View File
@@ -10,6 +10,31 @@ import { ProviderCredentialPanel } from "./ProviderCredentialPanel";
const PALETTE = ["#2563eb", "#16a34a", "#d4762a", "#9333ea", "#dc2626", "#0891b2"];
const EMPTY: ServerConfig = { host: "", port: 993, tls: "ssl", auth: "password" };
// Providers that need an app password, with direct links to enable 2-step
// verification and to generate the app password (shown in the Help popup).
const APP_PASSWORD_HELP: { name: string; twoStep: string; appPassword: string }[] = [
{
name: "Gmail",
twoStep: "https://myaccount.google.com/signinoptions/two-step-verification",
appPassword: "https://myaccount.google.com/apppasswords",
},
{
name: "Outlook",
twoStep: "https://account.microsoft.com/security",
appPassword: "https://account.live.com/proofs/AppPassword",
},
{
name: "Yahoo",
twoStep: "https://login.yahoo.com/account/security",
appPassword: "https://login.yahoo.com/account/security/app-passwords",
},
{
name: "iCloud",
twoStep: "https://account.apple.com/account/manage",
appPassword: "https://account.apple.com/account/manage",
},
];
// 1 = email, 15 = provider app-password panel (step "1.5"), 2 = details, 3 = done.
type Step = 1 | 15 | 2 | 3;
@@ -38,6 +63,7 @@ export function AccountWizard({ embedded = false, onDone }: WizardProps = {}) {
const [usedPanel, setUsedPanel] = useState(false);
const [syncWindow, setSyncWindow] = useState<SyncWindow>("all");
const [syncSpam, setSyncSpam] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [tested, setTested] = useState(false);
const [error, setError] = useState("");
@@ -303,7 +329,16 @@ export function AccountWizard({ embedded = false, onDone }: WizardProps = {}) {
setTested(false);
}}
/>
<p className="text-xs text-app-faint">{t("wizard.appPasswordHint")}</p>
<p className="text-xs text-app-faint">
{t("wizard.appPasswordHint")}{" "}
<button
type="button"
className="text-app-accent hover:underline"
onClick={() => setHelpOpen(true)}
>
{t("wizard.help")}
</button>
</p>
{detailsBlock}
{tested && <p className="text-sm text-emerald-500">{t("wizard.testOk")}</p>}
<div className="flex gap-2">
@@ -356,6 +391,56 @@ export function AccountWizard({ embedded = false, onDone }: WizardProps = {}) {
)}
{error && <p className="mt-3 text-sm text-red-500">{error}</p>}
{helpOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => setHelpOpen(false)}
>
<div
className="w-full max-w-4xl rounded-xl border border-app-border bg-app-elevated p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold">{t("wizard.appPasswordHelp.title")}</h2>
<button
type="button"
className="text-app-muted hover:text-app-fg"
onClick={() => setHelpOpen(false)}
aria-label={t("common.close")}
>
✕
</button>
</div>
<p className="mb-3 text-sm text-app-muted">{t("wizard.appPasswordHelp.intro")}</p>
<ul className="space-y-3 text-sm text-app-fg">
{APP_PASSWORD_HELP.map((p) => (
<li key={p.name}>
<span className="font-bold">{p.name}:</span> {t("wizard.appPasswordHelp.prefix")}{" "}
<a
href={p.twoStep}
target="_blank"
rel="noopener noreferrer"
className="text-app-accent hover:underline"
>
{t("wizard.appPasswordHelp.twoStep")}
</a>{" "}
{t("wizard.appPasswordHelp.middle")}{" "}
<a
href={p.appPassword}
target="_blank"
rel="noopener noreferrer"
className="text-app-accent hover:underline"
>
{t("wizard.appPasswordHelp.generate")}
</a>
.
</li>
))}
</ul>
</div>
</div>
)}
</div>
);
}