fixing translations
This commit is contained in:
@@ -19,6 +19,8 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
|
|||||||
- `lib/api.ts` — typed `fetch` wrapper: Bearer header from `localStorage`,
|
- `lib/api.ts` — typed `fetch` wrapper: Bearer header from `localStorage`,
|
||||||
`{ error }` parsing, `ApiError` with status, auto logout+reload on 401
|
`{ error }` parsing, `ApiError` with status, auto logout+reload on 401
|
||||||
(except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`.
|
(except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`.
|
||||||
|
- Boot shows `LoadingScreen` for at least 2.5 s (`MIN_SPLASH_MS` in `App.tsx`)
|
||||||
|
so the bloom animation always plays, even when the session restores instantly.
|
||||||
- Sending: ensure a server conversation exists (created lazily on first send),
|
- Sending: ensure a server conversation exists (created lazily on first send),
|
||||||
optimistic user message, stream tokens into `streaming`, then append the full
|
optimistic user message, stream tokens into `streaming`, then append the full
|
||||||
assistant message and refresh the list (server auto-titles new chats).
|
assistant message and refresh the list (server auto-titles new chats).
|
||||||
|
|||||||
+13
-4
@@ -33,6 +33,10 @@ function reviewIdFromPath(): string | null {
|
|||||||
return m ? m[1]! : null;
|
return m ? m[1]! : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimum time the boot splash stays visible, so the bloom animation
|
||||||
|
* actually plays even when the session restores instantly. */
|
||||||
|
const MIN_SPLASH_MS = 2500;
|
||||||
|
|
||||||
function applyTheme(theme: string) {
|
function applyTheme(theme: string) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const dark =
|
const dark =
|
||||||
@@ -43,7 +47,7 @@ function applyTheme(theme: string) {
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !REVIEW_ID && !IS_ADMIN_PAGE && !!getToken());
|
const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !REVIEW_ID && !IS_ADMIN_PAGE);
|
||||||
const [models, setModels] = useState<DriverModel[]>([]);
|
const [models, setModels] = useState<DriverModel[]>([]);
|
||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
const [convs, setConvs] = useState<Conversation[]>([]);
|
const [convs, setConvs] = useState<Conversation[]>([]);
|
||||||
@@ -90,10 +94,15 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Session restore
|
// Session restore (boot splash stays at least MIN_SPLASH_MS)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const done = () => {
|
||||||
|
const wait = Math.max(0, MIN_SPLASH_MS - (Date.now() - t0));
|
||||||
|
window.setTimeout(() => setChecking(false), wait);
|
||||||
|
};
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
setChecking(false);
|
done();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api.verify()
|
api.verify()
|
||||||
@@ -112,7 +121,7 @@ export function App() {
|
|||||||
if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id);
|
if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setChecking(false));
|
.finally(done);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load messages for active conversation
|
// Load messages for active conversation
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
|||||||
{totpToken ? (
|
{totpToken ? (
|
||||||
<form onSubmit={submitTotp} className="mt-6 space-y-3">
|
<form onSubmit={submitTotp} className="mt-6 space-y-3">
|
||||||
<p className="text-center text-sm opacity-70">
|
<p className="text-center text-sm opacity-70">
|
||||||
{t("login.totpIntro", { user: "@" + totpUsername })}
|
{t("login.totpIntro", { user: totpUsername })}
|
||||||
</p>
|
</p>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<KeyRound size={17} className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-stone-400 dark:text-zinc-500" />
|
<KeyRound size={17} className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-stone-400 dark:text-zinc-500" />
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function RecoverDialog({ open, onClose, from }: { open: boolean; onClose:
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm opacity-80">
|
<p className="text-sm opacity-80">
|
||||||
{t("dlg.recoverSent", {
|
{t("dlg.recoverSent", {
|
||||||
user: "@" + (username.trim().toLowerCase() || "…"),
|
user: username.trim().toLowerCase() || "…",
|
||||||
fromPart: from ? t("dlg.recoverSentFrom", { from }) : "",
|
fromPart: from ? t("dlg.recoverSentFrom", { from }) : "",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
@@ -253,35 +253,35 @@ export function RegisterDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose} title="Create an account" icon={UserPlus}>
|
<Modal open={open} onClose={onClose} title={t("dlg.registerTitle")} icon={UserPlus}>
|
||||||
{key ? (
|
{key ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm opacity-80">
|
<p className="text-sm opacity-80">
|
||||||
Welcome, <strong>@{username.trim().toLowerCase()}</strong>! Your access key — copy it now, it won't be shown again:
|
{t("dlg.regWelcome", { user: username.trim().toLowerCase() })}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-sm dark:bg-zinc-800">{key}</code>
|
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-sm dark:bg-zinc-800">{key}</code>
|
||||||
<CopyButton text={key} />
|
<CopyButton text={key} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button size="sm" onClick={() => { onDone(username.trim().toLowerCase(), key); onClose(); }}>Copy & continue to login</Button>
|
<Button size="sm" onClick={() => { onDone(username.trim().toLowerCase(), key); onClose(); }}>{t("dlg.regContinue")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} className="space-y-4">
|
<form onSubmit={submit} className="space-y-4">
|
||||||
<Field label="Username">
|
<Field label={t("common.username")}>
|
||||||
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" maxLength={32} required />
|
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" maxLength={32} required />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Display name">
|
<Field label={t("common.displayName")}>
|
||||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" maxLength={60} />
|
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" maxLength={60} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Email (optional, for key recovery)">
|
<Field label={t("dlg.emailOpt")}>
|
||||||
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" />
|
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" />
|
||||||
</Field>
|
</Field>
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
|
<Button variant="secondary" size="sm" type="button" onClick={onClose}>{t("common.cancel")}</Button>
|
||||||
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Register"}</Button>
|
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : t("login.register")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -292,6 +292,7 @@ export function RegisterDialog({
|
|||||||
/* ---------- Share ---------- */
|
/* ---------- Share ---------- */
|
||||||
|
|
||||||
export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) {
|
export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) {
|
||||||
|
const { t } = useT();
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -305,7 +306,7 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo
|
|||||||
setError("");
|
setError("");
|
||||||
api.share(conv.id)
|
api.share(conv.id)
|
||||||
.then((r) => setUrl(window.location.origin + r.url))
|
.then((r) => setUrl(window.location.origin + r.url))
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : "Share failed"))
|
.catch((err) => setError(err instanceof Error ? err.message : t("dlg.reqFailed")))
|
||||||
.finally(() => setBusy(false));
|
.finally(() => setBusy(false));
|
||||||
}, [conv]);
|
}, [conv]);
|
||||||
|
|
||||||
@@ -316,20 +317,20 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={conv !== null} onClose={onClose} title="Share publicly" icon={Globe}>
|
<Modal open={conv !== null} onClose={onClose} title={t("dlg.shareTitle")} icon={Globe}>
|
||||||
{busy && <p className="flex items-center gap-2 text-sm opacity-70"><Spinner size={15} /> Creating public link…</p>}
|
{busy && <p className="flex items-center gap-2 text-sm opacity-70"><Spinner size={15} /> {t("dlg.shareCreating")}</p>}
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
{url && (
|
{url && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm opacity-70">Anyone with this link can read <strong>{conv?.title}</strong>. No login required.</p>
|
<p className="text-sm opacity-70">{t("dlg.shareIntro", { title: conv?.title ?? "" })}</p>
|
||||||
<div className="flex items-center gap-2 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm dark:border-zinc-700 dark:bg-zinc-800">
|
<div className="flex items-center gap-2 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
<Link2 size={15} className="shrink-0 opacity-50" />
|
<Link2 size={15} className="shrink-0 opacity-50" />
|
||||||
<span className="min-w-0 flex-1 truncate">{url}</span>
|
<span className="min-w-0 flex-1 truncate">{url}</span>
|
||||||
<CopyButton text={url} />
|
<CopyButton text={url} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Button variant="ghost" size="sm" onClick={unshare}><Unplug size={15} /> Unshare</Button>
|
<Button variant="ghost" size="sm" onClick={unshare}><Unplug size={15} /> {t("dlg.unshare")}</Button>
|
||||||
<Button size="sm" onClick={onClose}>Done</Button>
|
<Button size="sm" onClick={onClose}>{t("common.done")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -350,6 +351,7 @@ export function SettingsModal({
|
|||||||
onSaved: (u: User) => void;
|
onSaved: (u: User) => void;
|
||||||
onKeyRotated?: () => void;
|
onKeyRotated?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useT();
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [avatarUrl, setAvatarUrl] = useState("");
|
const [avatarUrl, setAvatarUrl] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -365,10 +367,10 @@ export function SettingsModal({
|
|||||||
// Two-column settings: categories on the left, content on the right.
|
// Two-column settings: categories on the left, content on the right.
|
||||||
// Searching filters the category list and jumps to the first match.
|
// Searching filters the category list and jumps to the first match.
|
||||||
const CATS = [
|
const CATS = [
|
||||||
{ id: "profile", title: "Profile", icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "display", "email", "account"] },
|
{ id: "profile", title: t("settings.profile"), icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "display", "email", "account"] },
|
||||||
{ id: "appearance", title: "Appearance", icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette"] },
|
{ id: "appearance", title: t("settings.appearance"), icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette", "language", "langue", "idioma", "lingua", "язык"] },
|
||||||
{ id: "features", title: "Features", icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] },
|
{ id: "features", title: t("settings.features"), icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] },
|
||||||
{ id: "security", title: "Security", icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] },
|
{ id: "security", title: t("settings.security"), icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] },
|
||||||
] as const;
|
] as const;
|
||||||
type CatId = (typeof CATS)[number]["id"];
|
type CatId = (typeof CATS)[number]["id"];
|
||||||
const [cat, setCat] = useState<CatId>("profile");
|
const [cat, setCat] = useState<CatId>("profile");
|
||||||
@@ -395,11 +397,11 @@ export function SettingsModal({
|
|||||||
async function uploadFile(f: File) {
|
async function uploadFile(f: File) {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
if (f.size > 5 * 1024 * 1024) {
|
if (f.size > 5 * 1024 * 1024) {
|
||||||
setUploadError("File too large (max 5MB).");
|
setUploadError(t("sec.avatarTooBig"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^image\/(jpeg|png|webp)$/.test(f.type)) {
|
if (!/^image\/(jpeg|png|webp)$/.test(f.type)) {
|
||||||
setUploadError("Only jpg, png or webp images are accepted.");
|
setUploadError(t("sec.avatarType"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
@@ -411,7 +413,7 @@ export function SettingsModal({
|
|||||||
// preview (this modal, sidebar, chat bubbles) re-fetches immediately.
|
// preview (this modal, sidebar, chat bubbles) re-fetches immediately.
|
||||||
bumpCdnVersion();
|
bumpCdnVersion();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setUploadError(err instanceof Error ? err.message : "Upload failed");
|
setUploadError(err instanceof Error ? err.message : t("attach.uploadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -427,28 +429,28 @@ export function SettingsModal({
|
|||||||
onSaved(res.user);
|
onSaved(res.user);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Save failed");
|
setError(err instanceof Error ? err.message : t("common.saveFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={user !== null} onClose={onClose} title="Settings" icon={Settings2} wide>
|
<Modal open={user !== null} onClose={onClose} title={t("settings.title")} icon={Settings2} wide>
|
||||||
<form onSubmit={save} className="space-y-4">
|
<form onSubmit={save} className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-50" />
|
<Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-50" />
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Search settings…"
|
placeholder={t("settings.searchPh")}
|
||||||
aria-label="Search settings"
|
aria-label={t("settings.searchAria")}
|
||||||
className="w-full rounded-2xl border border-stone-200/70 bg-stone-100 py-2 pl-9 pr-8 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 focus:bg-white dark:border-zinc-800 dark:bg-zinc-800/60 dark:placeholder:text-zinc-500 dark:focus:bg-zinc-900"
|
className="w-full rounded-2xl border border-stone-200/70 bg-stone-100 py-2 pl-9 pr-8 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 focus:bg-white dark:border-zinc-800 dark:bg-zinc-800/60 dark:placeholder:text-zinc-500 dark:focus:bg-zinc-900"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Clear settings search"
|
aria-label={t("settings.clearSearch")}
|
||||||
onClick={() => setSearch("")}
|
onClick={() => setSearch("")}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 opacity-60 transition hover:opacity-100"
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 opacity-60 transition hover:opacity-100"
|
||||||
>
|
>
|
||||||
@@ -489,11 +491,11 @@ export function SettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-1.5 block text-sm font-medium opacity-80">Avatar upload</span>
|
<span className="mb-1.5 block text-sm font-medium opacity-80">{t("settings.avatarUpload")}</span>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Upload avatar"
|
aria-label={t("settings.avatarUpload")}
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
onKeyDown={(e) => e.key === "Enter" && fileRef.current?.click()}
|
onKeyDown={(e) => e.key === "Enter" && fileRef.current?.click()}
|
||||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
|||||||
+68
@@ -8,6 +8,74 @@ export function mailEnabled(): boolean {
|
|||||||
return config.smtpHost.length > 0;
|
return config.smtpHost.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sanitized snapshot for the Admin Center mail viewer (admin-only route). */
|
||||||
|
export function smtpStatus(): {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
hasPass: boolean;
|
||||||
|
from: string;
|
||||||
|
appUrl: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
enabled: mailEnabled(),
|
||||||
|
host: config.smtpHost,
|
||||||
|
port: config.smtpPort,
|
||||||
|
secure: config.smtpSecure,
|
||||||
|
user: config.smtpUser,
|
||||||
|
pass: config.smtpPass,
|
||||||
|
hasPass: config.smtpPass.length > 0,
|
||||||
|
from: config.smtpFrom,
|
||||||
|
appUrl: config.appUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify SMTP connectivity (connect + auth, no mail sent). Throws on failure. */
|
||||||
|
export async function verifySmtp(): Promise<void> {
|
||||||
|
if (!mailEnabled()) throw new Error("SMTP_HOST not set — mail is disabled");
|
||||||
|
const mailer = await loadMailer();
|
||||||
|
if (!mailer) throw new Error("nodemailer not installed — run `bun install` for email support");
|
||||||
|
const t = mailer.createTransport({
|
||||||
|
host: config.smtpHost,
|
||||||
|
port: config.smtpPort,
|
||||||
|
secure: config.smtpSecure,
|
||||||
|
auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await t.verify();
|
||||||
|
} finally {
|
||||||
|
t.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a connectivity test mail to `to`. Throws on failure, returns messageId. */
|
||||||
|
export async function sendSmtpTestMail(to: string): Promise<string> {
|
||||||
|
if (!mailEnabled()) throw new Error("SMTP_HOST not set — mail is disabled");
|
||||||
|
const mailer = await loadMailer();
|
||||||
|
if (!mailer) throw new Error("nodemailer not installed — run `bun install` for email support");
|
||||||
|
const t = mailer.createTransport({
|
||||||
|
host: config.smtpHost,
|
||||||
|
port: config.smtpPort,
|
||||||
|
secure: config.smtpSecure,
|
||||||
|
auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const info = await t.sendMail({
|
||||||
|
from: config.smtpFrom,
|
||||||
|
to,
|
||||||
|
subject: "KisAssistant — SMTP test",
|
||||||
|
text: "This is a KisAssistant SMTP connectivity test. If you received it, outgoing mail works.",
|
||||||
|
html: `<p>This is a <strong>KisAssistant</strong> SMTP connectivity test. If you received it, outgoing mail works.</p>`,
|
||||||
|
});
|
||||||
|
return info.messageId ?? "";
|
||||||
|
} finally {
|
||||||
|
t.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lazy import: the backend boots (and serves) fine without node_modules
|
// Lazy import: the backend boots (and serves) fine without node_modules
|
||||||
// installed; SMTP is only needed when actually sending.
|
// installed; SMTP is only needed when actually sending.
|
||||||
async function loadMailer(): Promise<typeof import("nodemailer") | null> {
|
async function loadMailer(): Promise<typeof import("nodemailer") | null> {
|
||||||
|
|||||||
Reference in New Issue
Block a user