Files
webmail-client/frontend/src/routes/wizard/AccountWizard.tsx
T
lukasandClaude Opus 4.8 49738740c0 M2: Meili search + saved searches, sync windows + backfill progress, threading & image fixes
Search:
- Meilisearch backend (app/search/meili.py) + factory wiring; pinned SDK 2.7
- Search-as-you-type: debounced query + Postgres prefix matching, built from
  the DB tokenizer so full email addresses match (email stays one lexeme)
- Saved searches API + sidebar virtual mailboxes; save affordance in the box

Account sync windows (SPEC §12 M2):
- Choose at setup: All (recent 30d first, then remainder), Last 3 months, New only
- Per-account sync_window + "Sync remaining mail" from settings (idempotent re-run)
- Provider windowed backfill (SINCE/BEFORE), message_count, newest_uid
- migration 0012: accounts.sync_window + backfill_total/backfill_done

Backfill progress UX:
- Floating progress card (auto-dissolves after 10s) + spinning sidebar ring that
  fills with the account colour; driven by sync.progress over WS

Thread list pagination:
- Server-side offset + accurate grand total, with filter/order in SQL; working
  prev/next across all synced mail

Reading panel:
- "Load images" one-off now actually renders images (?images=1) without trusting
  the sender; envelope carries images_allowed so a trusted sender shows no prompt

Threading:
- Subject fallback no longer merges messages that declare an explicit (different)
  parent; true siblings still group via a shared-parent reference match. Fixes
  unrelated auto-replies with the same subject landing in one thread

Backend 257 tests green, ruff + mypy --strict clean; frontend tsc/eslint/vitest clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
2026-07-23 23:00:08 +02:00

249 lines
8.4 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import type { ServerConfig, SyncWindow } from "../../lib/types";
const PALETTE = ["#2563eb", "#16a34a", "#d4762a", "#9333ea", "#dc2626", "#0891b2"];
const EMPTY: ServerConfig = { host: "", port: 993, tls: "ssl", auth: "password" };
export function AccountWizard() {
const t = useT();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [step, setStep] = useState<1 | 2 | 3>(1);
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [color, setColor] = useState(PALETTE[0]);
const [imap, setImap] = useState<ServerConfig>(EMPTY);
const [smtp, setSmtp] = useState<ServerConfig>({ ...EMPTY, port: 465 });
const [source, setSource] = useState<string>("");
const [syncWindow, setSyncWindow] = useState<SyncWindow>("all");
const [busy, setBusy] = useState(false);
const [tested, setTested] = useState(false);
const [error, setError] = useState("");
const discover = async () => {
setBusy(true);
setError("");
try {
const res = await api.discover(email);
const cand = res.candidates[0];
if (cand) {
setImap({ ...cand.imap });
setSmtp({ ...cand.smtp });
setSource(cand.source);
} else {
setSource("manual");
}
setStep(2);
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e));
} finally {
setBusy(false);
}
};
const test = async () => {
setBusy(true);
setError("");
setTested(false);
try {
await api.testAccount({ imap, smtp, email, password });
setTested(true);
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e));
} finally {
setBusy(false);
}
};
const create = async () => {
setBusy(true);
setError("");
try {
await api.createAccount({
email,
display_name: displayName || email,
color,
imap,
smtp,
password,
sync_window: syncWindow,
});
await queryClient.invalidateQueries({ queryKey: ["accounts"] });
setStep(3);
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e));
} finally {
setBusy(false);
}
};
const serverFields = (
cfg: ServerConfig,
set: (c: ServerConfig) => void,
label: string,
) => (
<fieldset className="rounded-lg border border-app-border p-3">
<legend className="px-1 text-xs text-app-muted">{label}</legend>
<div className="flex gap-2">
<input
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
placeholder={t("wizard.host")}
value={cfg.host}
onChange={(e) => set({ ...cfg, host: e.target.value })}
/>
<input
className="w-20 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
type="number"
value={cfg.port}
onChange={(e) => set({ ...cfg, port: Number(e.target.value) })}
/>
<select
className="rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
value={cfg.tls}
onChange={(e) => set({ ...cfg, tls: e.target.value })}
>
<option value="ssl">SSL</option>
<option value="starttls">STARTTLS</option>
</select>
</div>
</fieldset>
);
return (
<div className="mx-auto mt-16 max-w-lg px-4">
<h1 className="mb-6 text-2xl font-semibold">{t("wizard.title")}</h1>
{step === 1 && (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
discover();
}}
>
<label className="block text-sm text-app-muted">{t("wizard.step1")}</label>
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2"
type="email"
placeholder={t("wizard.emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoFocus
/>
<button
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
disabled={busy || !email.includes("@")}
>
{busy ? t("wizard.discovering") : t("wizard.discover")}
</button>
</form>
)}
{step === 2 && (
<div className="space-y-3">
{source && source !== "manual" && (
<p className="text-sm text-emerald-500">{t("wizard.foundVia", { source })}</p>
)}
{serverFields(imap, setImap, t("wizard.imapServer"))}
{serverFields(smtp, setSmtp, t("wizard.smtpServer"))}
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
placeholder={t("wizard.displayName")}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
type="password"
placeholder={t("wizard.accountPassword")}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setTested(false);
}}
/>
<p className="text-xs text-app-faint">{t("wizard.appPasswordHint")}</p>
<div className="flex items-center gap-2">
<span className="text-xs text-app-muted">Color</span>
{PALETTE.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`h-5 w-5 rounded-full ${color === c ? "ring-2 ring-offset-1" : ""}`}
style={{ backgroundColor: c }}
aria-label={c}
/>
))}
</div>
<fieldset className="rounded-lg border border-app-border p-3">
<legend className="px-1 text-xs text-app-muted">{t("wizard.syncWindow")}</legend>
<div className="space-y-1">
{(["all", "3months", "new"] as const).map((w) => (
<label key={w} className="flex cursor-pointer items-start gap-2 text-sm">
<input
type="radio"
name="sync-window"
className="mt-1"
checked={syncWindow === w}
onChange={() => setSyncWindow(w)}
/>
<span>
<span className="font-medium">{t(`wizard.syncWindow.${w}`)}</span>
<span className="block text-xs text-app-faint">
{t(`wizard.syncWindow.${w}.hint`)}
</span>
</span>
</label>
))}
</div>
</fieldset>
{tested && <p className="text-sm text-emerald-500">{t("wizard.testOk")}</p>}
<div className="flex gap-2">
{!tested ? (
<button
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
onClick={test}
disabled={busy || !password || !imap.host}
>
{busy ? t("wizard.testing") : t("wizard.test")}
</button>
) : (
<button
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
onClick={create}
disabled={busy}
>
{busy ? t("wizard.creating") : t("wizard.create")}
</button>
)}
<button className="px-3 py-2 text-sm text-app-muted" onClick={() => setStep(1)}>
{t("common.back")}
</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<p className="text-emerald-500">{t("wizard.created")}</p>
<button
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg"
onClick={() => navigate("/mail")}
>
{t("wizard.startReading")}
</button>
</div>
)}
{error && <p className="mt-3 text-sm text-red-500">{error}</p>}
</div>
);
}