Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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 } 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 [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,
|
||||
});
|
||||
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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user