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
249 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|