Extract 515 strings by codemod, and the two bugs only a screenshot caught

Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy
itself, and a parser does not get bored. scripts/i18n-extract.mjs does the
mechanical part -- JSX text and the attributes a person actually reads -- and
refuses the rest rather than guessing. 78% now: 515 wrapped, 143 left.

What it refuses matters as much as what it does. Text split around an
interpolation arrives as separate fragments, and wrapping each on its own
produces "Move " and " messages", which no translator can do anything with;
those are listed for a person to rebuild as sentences. So is anything
containing a double quote, which would end the literal.

Three things it had to be taught, each found by running it:

- <code>, <kbd> and <pre> are not prose. The first run wrapped `label:name`
  inside <code> -- a search operator, where translating it breaks the thing it
  documents. Subtrees marked translate="no" are skipped for the same reason.
- `t` is a natural name for a callback parameter and several files already use
  it, so an import called `t` is shadowed inside those callbacks -- silently,
  wherever the local happens to be callable. The name is checked per file now
  and aliased to `translate` where it is taken.
- JSX decodes HTML entities and a JS string literal does not, so
  `Language &amp; region` moved into t("...") and rendered the entity on screen.

That last one is the one worth remembering. Typecheck passed, 443 tests
passed, and the page said "Language &amp; region" in plain sight. It took
looking at a screenshot, and then a sweep of ten views to find the second
occurrence in a sentence I had written by hand earlier the same day. Nothing
in the toolchain was ever going to catch it: it is valid TypeScript rendering
valid text that happens to be wrong.

The codemod decodes entities now, and checks for a quote after decoding rather
than before.
This commit is contained in:
2026-08-31 09:58:33 -07:00
parent 95dcb96086
commit 8ea611f7f7
50 changed files with 900 additions and 713 deletions
+13 -12
View File
@@ -2,6 +2,7 @@ import { useSession } from "@/store/session";
import { client } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
import { t } from "@/lib/i18n";
export function AboutSettings() {
const session = useSession((s) => s.session);
@@ -10,28 +11,28 @@ export function AboutSettings() {
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
return (
<div>
<h1>About ihasmail</h1>
<p className="lead">A fast, friendly, open-source webmail for <a href="https://stalw.art" target="_blank" rel="noreferrer">Stalwart Mail Server</a>, built on JMAP.</p>
<h1>{t("About ihasmail")}</h1>
<p className="lead">A fast, friendly, open-source webmail for <a href="https://stalw.art" target="_blank" rel="noreferrer">{t("Stalwart Mail Server")}</a>, built on JMAP.</p>
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
<img src="/img/logo.png" alt="ihasmail" width={96} />
<img src="/img/logo.png" alt={t("ihasmail")} width={96} />
<div>
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail v{APP_VERSION}</div>
<div className="hint">AGPL-3.0-or-later · <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a></div>
</div>
</div>
<h2>Server</h2>
<h2>{t("Server")}</h2>
<table className="sessions-table">
<tbody>
<tr><td>Signed in as</td><td>{session?.username}</td></tr>
<tr><td>Stalwart</td><td>{describeServer(session?.ihasmail?.server)}</td></tr>
<tr><td>Accounts</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
<tr><td>Max upload</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr>
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
<tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr>
<tr><td>{t("Stalwart")}</td><td>{describeServer(session?.ihasmail?.server)}</td></tr>
<tr><td>{t("Accounts")}</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
<tr><td>{t("Max upload")}</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr>
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
</tbody>
</table>
<p className="hint" style={{ marginTop: 6 }}>Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.</p>
<p className="hint">ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: <strong>v2026.8.30+pr129</strong> was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead <code>+g1fa6578</code>. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.</p>
<h2>Server capabilities</h2>
<p className="hint" style={{ marginTop: 6 }}>{t("Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.")}</p>
<p className="hint">ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: <strong>{t("v2026.8.30+pr129")}</strong> was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead <code>+g1fa6578</code>. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.</p>
<h2>{t("Server capabilities")}</h2>
<div className="row wrap gap-4">
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
</div>
+35 -30
View File
@@ -2,6 +2,7 @@ import { useSettings } from "@/store/settings";
import { Switch, useIsTouch } from "@/ui/misc";
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
import { UI_LANGUAGES } from "@/lib/languages";
import { t as translate } from "@/lib/i18n";
/**
* The theme cards, each previewing the background it actually paints. Kept as
@@ -32,9 +33,9 @@ export function AppearanceSettings() {
const isTouch = useIsTouch();
return (
<div>
<h1>Appearance</h1>
<p className="lead">Make ihasmail yours.</p>
<h2>Theme</h2>
<h1>{translate("Appearance")}</h1>
<p className="lead">{translate("Make ihasmail yours.")}</p>
<h2>{translate("Theme")}</h2>
<div className="theme-grid">
{THEMES.map((t) => (
<button key={t.id} className={`theme-card ${s.theme === t.id ? "active" : ""}`} onClick={() => update({ theme: t.id })}>
@@ -44,43 +45,43 @@ export function AppearanceSettings() {
))}
</div>
<p className="hint" style={{ marginTop: 10 }}>
<strong>ihasmail</strong> is the palette from <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a>, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.
<strong>{translate("ihasmail")}</strong> is the palette from <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{translate("ihasmail.org")}</a>, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.
</p>
<Switch
checked={s.themeMessageBody}
onChange={(v) => update({ themeMessageBody: v })}
label="Apply the theme to messages too"
hint="Plain-text mail already follows the theme. With this on, HTML mail that brings no colours of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them."
label={translate("Apply the theme to messages too")}
hint={translate("Plain-text mail already follows the theme. With this on, HTML mail that brings no colours of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them.")}
/>
<h2>Accent color</h2>
<h2>{translate("Accent color")}</h2>
<div className="swatches">
{ACCENTS.map((a) => (
<button key={a.id} className={`swatch ${s.accent === a.id ? "active" : ""}`} style={{ background: a.color }} onClick={() => update({ accent: a.id })} aria-label={a.id} title={a.id} />
))}
</div>
<h2>Density & text</h2>
<h2>{translate("Density & text")}</h2>
<div className="field-row">
<div className="field">
<label>Display density</label>
<label>{translate("Display density")}</label>
<select className="select" value={s.density} onChange={(e) => update({ density: e.target.value as typeof s.density })}>
<option value="comfortable">Comfortable</option>
<option value="cozy">Cozy (default)</option>
<option value="compact">Compact</option>
<option value="comfortable">{translate("Comfortable")}</option>
<option value="cozy">{translate("Cozy (default)")}</option>
<option value="compact">{translate("Compact")}</option>
</select>
</div>
<div className="field">
<label>Text size</label>
<label>{translate("Text size")}</label>
<select className="select" value={s.fontSize} onChange={(e) => update({ fontSize: e.target.value as typeof s.fontSize })}>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
<option value="small">{translate("Small")}</option>
<option value="medium">{translate("Medium")}</option>
<option value="large">{translate("Large")}</option>
</select>
</div>
</div>
<h2>Language</h2>
<h2>{translate("Language")}</h2>
<div className="field" style={{ maxWidth: 320 }}>
<label htmlFor="ui-language">Interface language</label>
<label htmlFor="ui-language">{translate("Interface language")}</label>
<select id="ui-language" className="select" value={s.uiLanguage} onChange={(e) => update({ uiLanguage: e.target.value })}>
{UI_LANGUAGES.map((l) => (
<option key={l.tag} value={l.tag}>{l.name}</option>
@@ -93,19 +94,21 @@ export function AppearanceSettings() {
more are coming is a roadmap.
*/}
<p className="hint">
Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them a language offered without strings behind it would leave the page claiming to be in a language it is not.
{translate("Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.")}
</p>
<p className="hint">
This is separate from <strong>Language &amp; region</strong> in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.
This is separate from <strong>{translate("Language & region")}</strong> in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.
</p>
<h2>Swiping</h2>
<h2>{translate("Swiping")}</h2>
<p className="hint">
On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead.
{translate("On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead.")}
</p>
<div className="field-row">
<div className="field">
<label htmlFor="swipe-right">Swipe right</label>
<label htmlFor="swipe-right">{translate("Swipe right")}</label>
<select id="swipe-right" className="select" value={s.swipeRight} onChange={(e) => update({ swipeRight: e.target.value as SwipeAction })}>
{SWIPE_CHOICES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
@@ -113,7 +116,7 @@ export function AppearanceSettings() {
</select>
</div>
<div className="field">
<label htmlFor="swipe-left">Swipe left</label>
<label htmlFor="swipe-left">{translate("Swipe left")}</label>
<select id="swipe-left" className="select" value={s.swipeLeft} onChange={(e) => update({ swipeLeft: e.target.value as SwipeAction })}>
{SWIPE_CHOICES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
@@ -129,17 +132,19 @@ export function AppearanceSettings() {
*/}
{!isTouch && (
<p className="hint">
This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.
{translate("This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.")}
</p>
)}
<p className="hint">
Holding a message selects it, and holding a folder opens its menu. Pull the top of the message list down to check for new mail.
{translate("Holding a message selects it, and holding a folder opens its menu. Pull the top of the message list down to check for new mail.")}
</p>
<h2>Sidebar</h2>
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label="Show labels in the sidebar" />
<Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label="Show unsubscribed (hidden) folders" />
<Switch checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label="Collapse sidebar to icons" />
<h2>{translate("Sidebar")}</h2>
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label={translate("Show labels in the sidebar")} />
<Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label={translate("Show unsubscribed (hidden) folders")} />
<Switch checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label={translate("Collapse sidebar to icons")} />
</div>
);
}
+35 -34
View File
@@ -2,84 +2,85 @@ import { useSettings } from "@/store/settings";
import { ColorSwatches, CALENDAR_COLORS } from "@/ui/misc";
import { promptDialog } from "@/ui/dialog";
import { Plus, Trash2 } from "lucide-react";
import { t } from "@/lib/i18n";
export function CalendarSettings() {
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
return (
<div>
<h1>Calendar & contacts</h1>
<p className="lead">Defaults for the calendar views and new events.</p>
<h1>{t("Calendar & contacts")}</h1>
<p className="lead">{t("Defaults for the calendar views and new events.")}</p>
<div className="field-row">
<div className="field">
<label>Default view</label>
<label>{t("Default view")}</label>
<select className="select" value={s.calendarDefaultView} onChange={(e) => update({ calendarDefaultView: e.target.value as typeof s.calendarDefaultView })}>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
<option value="agenda">Agenda</option>
<option value="day">{t("Day")}</option>
<option value="week">{t("Week")}</option>
<option value="month">{t("Month")}</option>
<option value="agenda">{t("Agenda")}</option>
</select>
</div>
<div className="field">
<label>Default event length</label>
<label>{t("Default event length")}</label>
<select className="select" value={String(s.defaultEventDuration)} onChange={(e) => update({ defaultEventDuration: Number(e.target.value) })}>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="45">45 minutes</option>
<option value="60">1 hour</option>
<option value="90">1.5 hours</option>
<option value="120">2 hours</option>
<option value="15">{t("15 minutes")}</option>
<option value="30">{t("30 minutes")}</option>
<option value="45">{t("45 minutes")}</option>
<option value="60">{t("1 hour")}</option>
<option value="90">{t("1.5 hours")}</option>
<option value="120">{t("2 hours")}</option>
</select>
</div>
<div className="field">
<label>Default reminder</label>
<label>{t("Default reminder")}</label>
<select className="select" value={String(s.defaultAlertMinutes)} onChange={(e) => update({ defaultAlertMinutes: Number(e.target.value) })}>
<option value="-1">None</option>
<option value="0">At time of event</option>
<option value="5">5 minutes before</option>
<option value="10">10 minutes before</option>
<option value="15">15 minutes before</option>
<option value="30">30 minutes before</option>
<option value="60">1 hour before</option>
<option value="1440">1 day before</option>
<option value="-1">{t("None")}</option>
<option value="0">{t("At time of event")}</option>
<option value="5">{t("5 minutes before")}</option>
<option value="10">{t("10 minutes before")}</option>
<option value="15">{t("15 minutes before")}</option>
<option value="30">{t("30 minutes before")}</option>
<option value="60">{t("1 hour before")}</option>
<option value="1440">{t("1 day before")}</option>
</select>
</div>
</div>
<h2>Colour categories</h2>
<p className="hint">Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.</p>
<h2>{t("Colour categories")}</h2>
<p className="hint">{t("Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.")}</p>
{s.eventCategories.map((c, i) => (
<div key={c.name} className="card">
<div className="card-head">
<span className="label-dot" style={{ background: c.color, width: 14, height: 14 }} />
<h3>{c.name}</h3>
<button className="icon-btn sm" title="Rename" onClick={async () => { const n = await promptDialog({ title: "Rename category", defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}></button>
<button className="icon-btn sm danger" aria-label="Delete category" onClick={() => update({ eventCategories: s.eventCategories.filter((_, j) => j !== i) })}><Trash2 size={16} /></button>
<button className="icon-btn sm" title={t("Rename")} onClick={async () => { const n = await promptDialog({ title: "Rename category", defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}></button>
<button className="icon-btn sm danger" aria-label={t("Delete category")} onClick={() => update({ eventCategories: s.eventCategories.filter((_, j) => j !== i) })}><Trash2 size={16} /></button>
</div>
<div style={{ marginTop: 8 }}><ColorSwatches value={c.color} onChange={(col) => update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, color: col } : x)) })} /></div>
</div>
))}
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> New category</button>
<h2>Working hours</h2>
<h2>{t("Working hours")}</h2>
<div className="field-row">
<div className="field">
<label>Working hours start</label>
<label>{t("Working hours start")}</label>
<select className="select" value={String(s.workDayStart)} onChange={(e) => update({ workDayStart: Number(e.target.value) })}>
{[...Array(24)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
</select>
</div>
<div className="field">
<label>Working hours end</label>
<label>{t("Working hours end")}</label>
<select className="select" value={String(s.workDayEnd)} onChange={(e) => update({ workDayEnd: Number(e.target.value) })}>
{[...Array(25)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
</select>
</div>
<div className="field">
<label>Week starts on</label>
<label>{t("Week starts on")}</label>
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
<option value="1">Monday</option>
<option value="0">Sunday</option>
<option value="6">Saturday</option>
<option value="1">{t("Monday")}</option>
<option value="0">{t("Sunday")}</option>
<option value="6">{t("Saturday")}</option>
</select>
</div>
</div>
+25 -24
View File
@@ -9,6 +9,7 @@ import { confirmDialog, promptDialog } from "@/ui/dialog";
import { Switch, Spinner } from "@/ui/misc";
import { toast } from "@/ui/toast";
import type { SieveScript } from "@/jmap/types";
import { t } from "@/lib/i18n";
export function FiltersSettings() {
const sieve = useSieve();
@@ -21,16 +22,16 @@ export function FiltersSettings() {
if (!sieve.available) {
return (
<div>
<h1>Filters & rules</h1>
<p className="lead">Sieve filtering is not available for this account.</p>
<h1>{t("Filters & rules")}</h1>
<p className="lead">{t("Sieve filtering is not available for this account.")}</p>
</div>
);
}
return (
<div>
<h1>Filters & rules</h1>
<p className="lead">Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.</p>
<h1>{t("Filters & rules")}</h1>
<p className="lead">{t("Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.")}</p>
<div className="view-switch" style={{ marginBottom: 16 }}>
<button className={tab === "rules" ? "active" : ""} onClick={() => setTab("rules")}><Wand2 size={15} /> Rules</button>
<button className={tab === "scripts" ? "active" : ""} onClick={() => setTab("scripts")}><Code size={15} /> Scripts (advanced)</button>
@@ -86,9 +87,9 @@ function RulesEditor() {
if (damage) {
return (
<div className="warn-box">
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>Only part of your filter script arrived.</b></div>
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>{t("Only part of your filter script arrived.")}</b></div>
<p style={{ margin: "0 0 8px" }}>It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.</p>
<button className="btn" onClick={() => window.location.reload()}>Reload</button>
<button className="btn" onClick={() => window.location.reload()}>{t("Reload")}</button>
</div>
);
}
@@ -97,8 +98,8 @@ function RulesEditor() {
return (
<div className="warn-box">
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>Your active script “{script?.name}” was written by hand.</b></div>
<p style={{ margin: "0 0 8px" }}>The visual rule editor only manages scripts it created. You can edit the script in the <b>Scripts</b> tab, or start fresh with rules (the existing script will be kept but deactivated).</p>
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Switch to rules?", message: `“${script?.name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.`, confirmLabel: "Continue" })) void save([]); }}>Start with rules</button>
<p style={{ margin: "0 0 8px" }}>The visual rule editor only manages scripts it created. You can edit the script in the <b>{t("Scripts")}</b> tab, or start fresh with rules (the existing script will be kept but deactivated).</p>
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Switch to rules?", message: `“${script?.name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.`, confirmLabel: "Continue" })) void save([]); }}>{t("Start with rules")}</button>
</div>
);
}
@@ -106,7 +107,7 @@ function RulesEditor() {
return (
<div>
{activeIsOther && <div className="warn-box mb-16">Another script (“{script?.name}”) is active. Saving rules here will activate the “ihasmail” script instead.</div>}
{list.length === 0 && <div className="empty" style={{ padding: 32 }}><Wand2 size={32} /><h3>No filters yet</h3><p>Create a rule to move newsletters to a folder, flag important senders, or forward mail.</p></div>}
{list.length === 0 && <div className="empty" style={{ padding: 32 }}><Wand2 size={32} /><h3>{t("No filters yet")}</h3><p>{t("Create a rule to move newsletters to a folder, flag important senders, or forward mail.")}</p></div>}
{list.map((r, i) => (
<div
key={r.id}
@@ -132,7 +133,7 @@ function RulesEditor() {
<div className="row">
<span
className="drag-handle"
title="Drag to reorder"
title={t("Drag to reorder")}
aria-hidden="true"
onPointerDown={() => setArmed(r.id)}
onPointerUp={() => setArmed(null)}
@@ -142,21 +143,21 @@ function RulesEditor() {
<div style={{ fontWeight: 600 }}>{r.name}</div>
<div className="hint truncate">{describeRule(r)}</div>
</div>
<button className="icon-btn sm" disabled={i === 0} aria-label="Move up" onClick={() => { const n = [...list]; [n[i - 1], n[i]] = [n[i]!, n[i - 1]!]; setLocal(n); }}><ArrowUp size={16} /></button>
<button className="icon-btn sm" disabled={i === list.length - 1} aria-label="Move down" onClick={() => { const n = [...list]; [n[i + 1], n[i]] = [n[i]!, n[i + 1]!]; setLocal(n); }}><ArrowDown size={16} /></button>
<button className="icon-btn sm danger" aria-label="Delete rule" onClick={() => setLocal(list.filter((x) => x.id !== r.id))}><Trash2 size={16} /></button>
<button className="icon-btn sm" disabled={i === 0} aria-label={t("Move up")} onClick={() => { const n = [...list]; [n[i - 1], n[i]] = [n[i]!, n[i - 1]!]; setLocal(n); }}><ArrowUp size={16} /></button>
<button className="icon-btn sm" disabled={i === list.length - 1} aria-label={t("Move down")} onClick={() => { const n = [...list]; [n[i + 1], n[i]] = [n[i]!, n[i + 1]!]; setLocal(n); }}><ArrowDown size={16} /></button>
<button className="icon-btn sm danger" aria-label={t("Delete rule")} onClick={() => setLocal(list.filter((x) => x.id !== r.id))}><Trash2 size={16} /></button>
</div>
</div>
))}
<div className="row" style={{ marginTop: 12 }}>
<button className="btn" onClick={() => setEditing(newRule())}><Plus size={16} /> New rule</button>
<span className="spacer" />
{dirty && <button className="btn btn-ghost" onClick={() => setLocal(null)}>Discard changes</button>}
{dirty && <button className="btn btn-ghost" onClick={() => setLocal(null)}>{t("Discard changes")}</button>}
<button className="btn btn-primary" disabled={!dirty || saving} onClick={() => void save(list)}>{saving ? "Saving…" : "Save filters"}</button>
</div>
{content && (
<details style={{ marginTop: 20 }}>
<summary className="hint" style={{ cursor: "pointer" }}>Preview generated Sieve script</summary>
<summary className="hint" style={{ cursor: "pointer" }}>{t("Preview generated Sieve script")}</summary>
<pre className="code notranslate" translate="no" style={{ minHeight: 120, marginTop: 8 }}>{rulesToSieve(list)}</pre>
</details>
)}
@@ -227,18 +228,18 @@ function ScriptsEditor() {
if (sel !== null || name !== "" || content !== "") {
return (
<div>
<div className="field"><label>Script name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={Boolean(sel)} /></div>
<div className="field"><label>{t("Script name")}</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={Boolean(sel)} /></div>
<div className="field">
<label>Sieve source</label>
<label>{t("Sieve source")}</label>
<textarea className="code notranslate" translate="no" value={content} onChange={(e) => setContent(e.target.value)} spellCheck={false} style={{ minHeight: 320 }} />
</div>
{validation && <div className="error-box mb-16">{validation}</div>}
<div className="row">
<button className="btn btn-ghost" onClick={() => { setSel(null); setName(""); setContent(""); }}>Cancel</button>
<button className="btn btn-ghost" onClick={() => { setSel(null); setName(""); setContent(""); }}>{t("Cancel")}</button>
<button className="btn" disabled={busy} onClick={async () => { setBusy(true); const err = await sieve.validate(content); setValidation(err); setBusy(false); if (!err) toast.success("Script is valid"); }}><Play size={14} /> Validate</button>
<span className="spacer" />
<button className="btn" disabled={busy} onClick={() => void save(false)}>Save</button>
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>Save & activate</button>
<button className="btn" disabled={busy} onClick={() => void save(false)}>{t("Save")}</button>
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>{t("Save & activate")}</button>
</div>
</div>
);
@@ -247,14 +248,14 @@ function ScriptsEditor() {
return (
<div>
<p className="hint">Advanced: manage raw Sieve scripts. Only one script can be active at a time.</p>
<p className="hint">{t("Advanced: manage raw Sieve scripts. Only one script can be active at a time.")}</p>
{sieve.scripts.map((s) => (
<div key={s.id} className="card">
<div className="card-head">
<h3><span>{s.name} </span>{s.isActive && <span className="tag" style={{ background: "var(--success)" }}>active</span>}</h3>
<button className="btn btn-sm" onClick={() => void open(s)}>Edit</button>
<h3><span>{s.name} </span>{s.isActive && <span className="tag" style={{ background: "var(--success)" }}>{t("active")}</span>}</h3>
<button className="btn btn-sm" onClick={() => void open(s)}>{t("Edit")}</button>
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? "Deactivate" : "Activate"}</button>
<button className="icon-btn sm danger" aria-label="Delete script" onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={t("Delete script")} onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</div>
))}
+7 -6
View File
@@ -6,6 +6,7 @@ import { toast } from "@/ui/toast";
import { formatSize } from "@/lib/format";
import { ShareDialog } from "./ShareDialog";
import type { Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
export function FoldersSettings() {
const mailboxes = useMail((s) => s.mailboxes);
@@ -33,23 +34,23 @@ export function FoldersSettings() {
return (
<div>
<h1>Folders</h1>
<h1>{t("Folders")}</h1>
<p className="lead">Create, rename and hide folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> New folder</button>
<table className="sessions-table">
<thead><tr><th>Folder</th><th>Messages</th><th>Unread</th><th /></tr></thead>
<thead><tr><th>{t("Folder")}</th><th>{t("Messages")}</th><th>{t("Unread")}</th><th /></tr></thead>
<tbody>
{list.map(({ m, path }) => (
<tr key={m.id}>
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{path}</span>{!m.isSubscribed && <span className="badge muted">hidden</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{path}</span>{!m.isSubscribed && <span className="badge muted">{t("hidden")}</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
<td>{m.totalEmails.toLocaleString()}</td>
<td>{m.unreadEmails.toLocaleString()}</td>
<td>
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
<button className="icon-btn sm" title="Rename" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
<button className="icon-btn sm" title={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title="Stop sharing" onClick={() => setShare(m)}><Share2 size={16} /></button>}
<button className="icon-btn sm danger" title="Delete" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title={t("Stop sharing")} onClick={() => setShare(m)}><Share2 size={16} /></button>}
<button className="icon-btn sm danger" title={t("Delete")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</td>
</tr>
+64 -63
View File
@@ -3,6 +3,7 @@ import { Switch } from "@/ui/misc";
import { browserTimeZone, listTimeZones } from "@/lib/dates";
import { toast } from "@/ui/toast";
import { useState } from "react";
import { t } from "@/lib/i18n";
import {
canUnregisterMailtoHandler,
isInstalledApp,
@@ -45,109 +46,108 @@ export function GeneralSettings() {
return (
<div>
<h1>General</h1>
<p className="lead">Reading, sending and list behaviour. Settings are stored in this browser.</p>
<h1>{t("General")}</h1>
<p className="lead">{t("Reading, sending and list behaviour. Settings are stored in this browser.")}</p>
<h2>Reading</h2>
<h2>{t("Reading")}</h2>
<div className="field-row">
<div className="field">
<label>Reading pane</label>
<label>{t("Reading pane")}</label>
<select className="select" value={s.readingPane} onChange={(e) => update({ readingPane: e.target.value as typeof s.readingPane })}>
<option value="right">Right of the list</option>
<option value="bottom">Below the list</option>
<option value="off">Off (open messages full width)</option>
<option value="right">{t("Right of the list")}</option>
<option value="bottom">{t("Below the list")}</option>
<option value="off">{t("Off (open messages full width)")}</option>
</select>
</div>
<div className="field">
<label>Mark as read</label>
<label>{t("Mark as read")}</label>
<select className="select" value={String(s.markReadDelay)} onChange={(e) => update({ markReadDelay: Number(e.target.value) })}>
<option value="0">Immediately when opened</option>
<option value="2">After 2 seconds</option>
<option value="5">After 5 seconds</option>
<option value="-1">Never automatically</option>
<option value="0">{t("Immediately when opened")}</option>
<option value="2">{t("After 2 seconds")}</option>
<option value="5">{t("After 5 seconds")}</option>
<option value="-1">{t("Never automatically")}</option>
</select>
</div>
<div className="field">
<label>After archiving or deleting</label>
<label>{t("After archiving or deleting")}</label>
<select className="select" value={s.autoAdvance} onChange={(e) => update({ autoAdvance: e.target.value as typeof s.autoAdvance })}>
<option value="list">Go back to the list</option>
<option value="older">Open the next (older) conversation</option>
<option value="newer">Open the previous (newer) conversation</option>
<option value="list">{t("Go back to the list")}</option>
<option value="older">{t("Open the next (older) conversation")}</option>
<option value="newer">{t("Open the previous (newer) conversation")}</option>
</select>
</div>
<div className="field">
<label>Remote images</label>
<label>{t("Remote images")}</label>
<select className="select" value={s.imagePolicy} onChange={(e) => update({ imagePolicy: e.target.value as typeof s.imagePolicy })}>
<option value="ask">Ask before showing (recommended)</option>
<option value="contacts">Show automatically from my contacts</option>
<option value="always">Always show</option>
<option value="ask">{t("Ask before showing (recommended)")}</option>
<option value="contacts">{t("Show automatically from my contacts")}</option>
<option value="always">{t("Always show")}</option>
</select>
</div>
</div>
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label="Conversation view" hint="Group messages from the same thread together." />
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label="Show message snippets" hint="Preview the first line of each message in the list." />
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label="Show sender avatars" />
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label="Confirm before deleting" />
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label={t("Conversation view")} hint={t("Group messages from the same thread together.")} />
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label={t("Show message snippets")} hint={t("Preview the first line of each message in the list.")} />
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} />
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label={t("Confirm before deleting")} />
<h2>Composing</h2>
<h2>{t("Composing")}</h2>
<div className="field-row">
<div className="field">
<label>Default format</label>
<label>{t("Default format")}</label>
<select className="select" value={s.composeFormat} onChange={(e) => update({ composeFormat: e.target.value as typeof s.composeFormat })}>
<option value="html">Rich text (HTML)</option>
<option value="text">Plain text</option>
<option value="html">{t("Rich text (HTML)")}</option>
<option value="text">{t("Plain text")}</option>
</select>
</div>
<div className="field">
<label>Undo send window</label>
<label>{t("Undo send window")}</label>
<select className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}>
<option value="0">Off</option>
<option value="5">5 seconds</option>
<option value="8">8 seconds</option>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
<option value="0">{t("Off")}</option>
<option value="5">{t("5 seconds")}</option>
<option value="8">{t("8 seconds")}</option>
<option value="15">{t("15 seconds")}</option>
<option value="30">{t("30 seconds")}</option>
</select>
</div>
</div>
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label="Quote original message in replies" />
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label="Place signature above quoted text" />
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label="Attachment reminder" hint="Warn when the message mentions an attachment but none is attached." />
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label="Always request read receipts" />
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label={t("Attachment reminder")} hint={t("Warn when the message mentions an attachment but none is attached.")} />
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label={t("Always request read receipts")} />
<div className="field">
<label>When someone requests a read receipt</label>
<label>{t("When someone requests a read receipt")}</label>
<select className="select" value={s.readReceiptPolicy} onChange={(e) => update({ readReceiptPolicy: e.target.value as ReadReceiptPolicy })}>
<option value="ask">Ask me on each message</option>
<option value="never">Never send one</option>
<option value="ask">{t("Ask me on each message")}</option>
<option value="never">{t("Never send one")}</option>
</select>
<p className="hint">
A receipt tells whoever asked that this address is live and when the message was read, and the sender
chooses where it goes so there is no automatic option. Bulk mail, mailing lists and anything marked
auto-submitted are never offered one at all.
{t("A receipt tells whoever asked that this address is live and when the message was read, and the sender chooses where it goes — so there is no automatic option. Bulk mail, mailing lists and anything marked auto-submitted are never offered one at all.")}
</p>
</div>
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label="Spell check while typing" />
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
<h2>Locale</h2>
<h2>{t("Locale")}</h2>
<div className="field-row">
<div className="field">
<label>Time zone</label>
<label>{t("Time zone")}</label>
<select className="select" value={s.timeZone ?? ""} onChange={(e) => update({ timeZone: e.target.value || null })}>
<option value="">Browser default ({browserTimeZone})</option>
{listTimeZones().map((tz) => <option key={tz} value={tz}>{tz}</option>)}
</select>
</div>
<div className="field">
<label>Week starts on</label>
<label>{t("Week starts on")}</label>
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
<option value="1">Monday</option>
<option value="0">Sunday</option>
<option value="6">Saturday</option>
<option value="1">{t("Monday")}</option>
<option value="0">{t("Sunday")}</option>
<option value="6">{t("Saturday")}</option>
</select>
</div>
</div>
<div className="field-row">
<div className="field">
<label>Language &amp; region</label>
<label>{t("Language & region")}</label>
<select className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}>
<option value="">Automatic ({localeLabel(autoLocale)})</option>
{localeOptions().map((o) => <option key={o.tag} value={o.tag}>{o.label} {o.tag}</option>)}
@@ -155,7 +155,7 @@ export function GeneralSettings() {
<p className="hint">{serverLocale ? `Your mail server reports ${localeLabel(serverLocale)} (${serverLocale}).` : "Your mail server does not report a locale, so the browser's is used."} Dates, times and month names follow this choice.</p>
</div>
<div className="field">
<label>Date format</label>
<label>{t("Date format")}</label>
<select className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
{DATE_FORMATS.map((f) => (
<option key={f.value} value={f.value}>
@@ -165,27 +165,27 @@ export function GeneralSettings() {
</select>
</div>
<div className="field">
<label>Time format</label>
<label>{t("Time format")}</label>
<select className="select" value={s.timeFormat} onChange={(e) => update({ timeFormat: e.target.value as typeof s.timeFormat })}>
<option value="auto">Automatic ({withPrefs({ locale: s.locale, timeFormat: "auto" }, () => formatClock(SAMPLE))})</option>
<option value="24">24-hour clock (18:23)</option>
<option value="12">12-hour clock (6:23 PM)</option>
<option value="24">{t("24-hour clock (18:23)")}</option>
<option value="12">{t("12-hour clock (6:23 PM)")}</option>
</select>
</div>
</div>
<p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p>
<h2>Default mail app</h2>
<h2>{t("Default mail app")}</h2>
<MailHandlerSettings />
<h2>Backup</h2>
<h2>{t("Backup")}</h2>
<div className="row wrap">
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>Export settings</button>
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>{t("Export settings")}</button>
<label className="btn">
Import settings
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? "Settings imported" : "Invalid settings file"); e.target.value = ""; }} />
</label>
<button className="btn btn-ghost" onClick={() => { reset(); toast.show("Settings reset to defaults"); }}>Reset to defaults</button>
<button className="btn btn-ghost" onClick={() => { reset(); toast.show("Settings reset to defaults"); }}>{t("Reset to defaults")}</button>
</div>
</div>
);
@@ -231,12 +231,13 @@ function MailHandlerSettings() {
</p>
<div className="row wrap">
<button className="btn btn-primary" onClick={ask}>{requested ? "Ask again" : "Make ihasmail the default mail app"}</button>
{requested && canUnregisterMailtoHandler() && <button className="btn btn-ghost" onClick={remove}>Remove</button>}
{requested && canUnregisterMailtoHandler() && <button className="btn btn-ghost" onClick={remove}>{t("Remove")}</button>}
</div>
{requested && <p className="hint mt-8">Requested in this browser. Whether it took effect is up to the browser check its settings if mail links still open elsewhere.</p>}
{requested && <p className="hint mt-8">{t("Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.")}</p>}
{!isInstalledApp() && (
<p className="hint mt-8">
For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.
{t("For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.")}
</p>
)}
</>
+14 -13
View File
@@ -12,6 +12,7 @@ import { htmlToText } from "@/lib/text";
import { sanitizeEditorHtml } from "@/lib/html";
import { externalizeDataImages, storeSignatureHtml, uploadSignatureImage } from "@/lib/signatureImages";
import { buildMarkerSignature, byteLength, compactHtml, signatureTooLong, SIGNATURE_LIMIT } from "@/lib/signatureHtml";
import { t } from "@/lib/i18n";
export function IdentitiesSettings() {
const identities = useMail((s) => s.identities);
@@ -30,12 +31,12 @@ export function IdentitiesSettings() {
return (
<div>
<h1>Identities & signatures</h1>
<p className="lead">Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.</p>
<h1>{t("Identities & signatures")}</h1>
<p className="lead">{t("Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.")}</p>
{identities.map((i) => (
<div key={i.id} className="card clickable" onClick={() => setEditing(i)}>
<div className="card-head">
<h3>{i.name ? `${i.name} <${i.email}>` : i.email} {i.id === defaultId && <span className="tag" style={{ background: "var(--accent)", color: "var(--accent-fg)", marginLeft: 6 }}>Default</span>}</h3>
<h3>{i.name ? `${i.name} <${i.email}>` : i.email} {i.id === defaultId && <span className="tag" style={{ background: "var(--accent)", color: "var(--accent-fg)", marginLeft: 6 }}>{t("Default")}</span>}</h3>
{i.id !== defaultId && (
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}><Star size={14} /> Make default</button>
)}
@@ -53,16 +54,16 @@ export function IdentitiesSettings() {
{hidden.includes(i.id) ? <><Eye size={14} /> Show when composing</> : <><EyeOff size={14} /> Hide when composing</>}
</button>
{i.mayDelete && (
<button className="icon-btn sm danger" aria-label="Delete identity" onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={t("Delete identity")} onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
)}
</div>
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>Not offered when composing. It still receives mail, and you can still send from it by showing it again.</div>}
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>{t("Not offered when composing. It still receives mail, and you can still send from it by showing it again.")}</div>}
{(i.htmlSignature || i.textSignature) && <div className="hint" style={{ marginTop: 4 }}>{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}</div>}
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
</div>
))}
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
<p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p>
<p className="hint mt-8">{t("New identities must use an address this account is allowed to send from (aliases configured on the server).")}</p>
{hidden.length > 0 && (
<p className="hint">
{`${hidden.length} ${hidden.length === 1 ? "identity is" : "identities are"} hidden from the compose picker. Hiding every one of them would leave nothing to choose from, so in that case they are all offered again.`}
@@ -113,19 +114,19 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
}
};
return (
<Dialog open onClose={onClose} title={identity.id ? "Edit identity" : "New identity"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
<Dialog open onClose={onClose} title={identity.id ? "Edit identity" : "New identity"} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
<div className="field-row">
<div className="field"><label>Display name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>Email address</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
<div className="field"><label>{t("Display name")}</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>{t("Email address")}</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
</div>
<div className="field"><label>Reply-To (optional)</label><input className="input" value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="[email protected]" /><span className="hint">Replies to mail sent from this identity go here instead of the From address.</span></div>
<div className="field"><label>{t("Reply-To (optional)")}</label><input className="input" value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder={t("[email protected]")} /><span className="hint">{t("Replies to mail sent from this identity go here instead of the From address.")}</span></div>
<div className="field">
<label>Signature</label>
<label>{t("Signature")}</label>
<div style={{ border: `1px solid ${tooLong ? "var(--danger)" : "var(--border-strong)"}`, borderRadius: 8, minHeight: 180, display: "flex", flexDirection: "column" }}>
<RichEditor ref={ref} html={html} onChange={setHtml} placeholder="Your signature…" showToolbar imageUpload={uploadSignatureImage} />
<RichEditor ref={ref} html={html} onChange={setHtml} placeholder={t("Your signature…")} showToolbar imageUpload={uploadSignatureImage} />
</div>
<div className="row" style={{ justifyContent: "space-between" }}>
<span className="hint">Images are stored in your Files (folder ihasmail) and embedded when you send.</span>
<span className="hint">{t("Images are stored in your Files (folder “ihasmail”) and embedded when you send.")}</span>
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
</div>
{tooLong && <div className="warn-box mt-8">This signature is larger than the server's {SIGNATURE_LIMIT}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server other mail clients will see the plain-text version.</div>}
+4 -3
View File
@@ -3,6 +3,7 @@ import { Plus, Trash2 } from "lucide-react";
import { useSettings } from "@/store/settings";
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
import { promptDialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
export function LabelsSettings() {
const labels = useSettings((s) => s.settings.labels);
@@ -19,8 +20,8 @@ export function LabelsSettings() {
return (
<div>
<h1>Labels</h1>
<p className="lead">Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.</p>
<h1>{t("Labels")}</h1>
<p className="lead">{t("Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.")}</p>
{labels.map((l) => (
<div key={l.keyword} className="card">
<div className="card-head">
@@ -30,7 +31,7 @@ export function LabelsSettings() {
) : (
<h3 style={{ cursor: "text" }} onClick={() => setEditing(l.keyword)}>{l.name} <span className="hint" style={{ fontWeight: 400 }}>({l.keyword})</span></h3>
)}
<button className="icon-btn sm danger" aria-label="Delete label" onClick={() => update({ labels: labels.filter((x) => x.keyword !== l.keyword) })}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={t("Delete label")} onClick={() => update({ labels: labels.filter((x) => x.keyword !== l.keyword) })}><Trash2 size={16} /></button>
</div>
<div style={{ marginTop: 8 }}>
<ColorSwatches value={l.color} onChange={(c) => update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, color: c } : x)) })} />
+30 -29
View File
@@ -5,6 +5,7 @@ import { HEADER_CHOICES, HEADER_OPS, type SieveAction, type SieveRule, type Siev
import { Dialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import type { Id } from "@/jmap/types";
import { t as translate } from "@/lib/i18n";
export interface RuleDialogProps {
rule: SieveRule;
@@ -36,13 +37,13 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
<span>Also apply to existing messages in <b>{applyMailbox.name}</b></span>
</label>
)}
<button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={() => onSave(r, applyNow && Boolean(applyMailbox))} disabled={!r.name.trim()}>{saveLabel ?? "Done"}</button></>}>
<div className="field"><label>Rule name</label><input className="input" value={r.name} onChange={(e) => setR({ ...r, name: e.target.value })} autoFocus /></div>
<button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" onClick={() => onSave(r, applyNow && Boolean(applyMailbox))} disabled={!r.name.trim()}>{saveLabel ?? "Done"}</button></>}>
<div className="field"><label>{translate("Rule name")}</label><input className="input" value={r.name} onChange={(e) => setR({ ...r, name: e.target.value })} autoFocus /></div>
<div className="row" style={{ marginBottom: 8 }}>
<span className="label">When</span>
<span className="label">{translate("When")}</span>
<select className="select" style={{ width: "auto" }} value={r.join} onChange={(e) => setR({ ...r, join: e.target.value as "allof" | "anyof" })}>
<option value="allof">all of the following match</option>
<option value="anyof">any of the following match</option>
<option value="allof">{translate("all of the following match")}</option>
<option value="anyof">{translate("any of the following match")}</option>
</select>
</div>
{r.tests.map((t, i) => {
@@ -60,35 +61,35 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
else setTest(i, { type: "header", header: v === "__custom__" ? "" : v, op: "contains", value: "" });
}}>
{HEADER_CHOICES.map((h) => <option key={h.value} value={h.value}>{h.label}</option>)}
<option value="address">Sender domain</option>
<option value="size">Message size</option>
<option value="body">Body text</option>
<option value="true">Always (all messages)</option>
<option value="address">{translate("Sender domain")}</option>
<option value="size">{translate("Message size")}</option>
<option value="body">{translate("Body text")}</option>
<option value="true">{translate("Always (all messages)")}</option>
</select>
{customHeader && t.type === "header" && (
<input className="input" placeholder="Header name" aria-label="Header name" value={t.header} onChange={(e) => setTest(i, { ...t, header: e.target.value })} />
<input className="input" placeholder={translate("Header name")} aria-label={translate("Header name")} value={t.header} onChange={(e) => setTest(i, { ...t, header: e.target.value })} />
)}
{t.type === "size" ? (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "over" | "under" })}><option value="over">is larger than</option><option value="under">is smaller than</option></select>
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "over" | "under" })}><option value="over">{translate("is larger than")}</option><option value="under">{translate("is smaller than")}</option></select>
) : t.type === "body" ? (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "contains" | "notcontains" })}><option value="contains">contains</option><option value="notcontains">does not contain</option></select>
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "contains" | "notcontains" })}><option value="contains">{translate("contains")}</option><option value="notcontains">{translate("does not contain")}</option></select>
) : t.type === "true" ? <span /> : (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as SieveTest extends { op: infer O } ? O : never })}>
{HEADER_OPS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
)}
{t.type === "size" ? (
<div className="row"><input className="input" type="number" min={1} value={Math.round(t.value / 1024)} onChange={(e) => setTest(i, { ...t, value: Number(e.target.value) * 1024 })} /><span className="muted">KB</span></div>
<div className="row"><input className="input" type="number" min={1} value={Math.round(t.value / 1024)} onChange={(e) => setTest(i, { ...t, value: Number(e.target.value) * 1024 })} /><span className="muted">{translate("KB")}</span></div>
) : t.type === "true" ? <span /> : t.type === "header" && (t.op === "exists" || t.op === "notexists") ? <span /> : (
<input className="input" placeholder={t.type === "address" ? "example.com" : "value"} value={(t as { value: string }).value} onChange={(e) => setTest(i, { ...t, value: e.target.value } as SieveTest)} />
)}
<button className="icon-btn sm danger" aria-label="Remove condition" onClick={() => setR({ ...r, tests: r.tests.filter((_, j) => j !== i) })} disabled={r.tests.length <= 1}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={translate("Remove condition")} onClick={() => setR({ ...r, tests: r.tests.filter((_, j) => j !== i) })} disabled={r.tests.length <= 1}><Trash2 size={16} /></button>
</div>
);
})}
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, tests: [...r.tests, { type: "header", header: "subject", op: "contains", value: "" }] })}><Plus size={14} /> Add condition</button>
<div className="row" style={{ margin: "16px 0 8px" }}><span className="label">Then</span></div>
<div className="row" style={{ margin: "16px 0 8px" }}><span className="label">{translate("Then")}</span></div>
{r.actions.map((a, i) => (
<div key={i} className="rule-row actions">
<select className="select" value={a.type} onChange={(e) => {
@@ -96,15 +97,15 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
const next: SieveAction = v === "fileinto" ? { type: "fileinto", mailbox: folders[0]?.path ?? "INBOX" } : v === "redirect" ? { type: "redirect", address: "" } : v === "reject" ? { type: "reject", reason: "" } : v === "addflag" ? { type: "addflag", flag: "" } : ({ type: v } as SieveAction);
setAction(i, next);
}}>
<option value="fileinto">Move to folder</option>
<option value="markread">Mark as read</option>
<option value="flag">Star</option>
<option value="addflag">Add label / keyword</option>
<option value="redirect">Forward to</option>
<option value="keep">Keep in Inbox</option>
<option value="discard">Delete</option>
<option value="reject">Reject with message</option>
<option value="stop">Stop processing more rules</option>
<option value="fileinto">{translate("Move to folder")}</option>
<option value="markread">{translate("Mark as read")}</option>
<option value="flag">{translate("Star")}</option>
<option value="addflag">{translate("Add label / keyword")}</option>
<option value="redirect">{translate("Forward to")}</option>
<option value="keep">{translate("Keep in Inbox")}</option>
<option value="discard">{translate("Delete")}</option>
<option value="reject">{translate("Reject with message")}</option>
<option value="stop">{translate("Stop processing more rules")}</option>
</select>
{a.type === "fileinto" ? (
<div className="row">
@@ -138,21 +139,21 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
>
{folders.map((f) => <option key={f.id} value={f.path}>{f.path}</option>)}
{!folders.some((f) => f.path === a.mailbox) && <option value={a.mailbox}>{a.mailbox}</option>}
<option value="__new__"> New folder</option>
<option value="__new__">{translate(" New folder…")}</option>
</select>
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
</div>
) : a.type === "redirect" ? (
<div className="row">
<input className="input" type="email" placeholder="[email protected]" value={a.address} onChange={(e) => setAction(i, { ...a, address: e.target.value })} />
<input className="input" type="email" placeholder={translate("[email protected]")} value={a.address} onChange={(e) => setAction(i, { ...a, address: e.target.value })} />
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
</div>
) : a.type === "reject" ? (
<input className="input" placeholder="Reason" value={a.reason} onChange={(e) => setAction(i, { ...a, reason: e.target.value })} />
<input className="input" placeholder={translate("Reason")} value={a.reason} onChange={(e) => setAction(i, { ...a, reason: e.target.value })} />
) : a.type === "addflag" || a.type === "setflag" || a.type === "removeflag" ? (
<input className="input" placeholder="keyword (e.g. $important, work)" value={a.flag} onChange={(e) => setAction(i, { ...a, flag: e.target.value })} />
<input className="input" placeholder={translate("keyword (e.g. $important, work)")} value={a.flag} onChange={(e) => setAction(i, { ...a, flag: e.target.value })} />
) : <span />}
<button className="icon-btn sm danger" aria-label="Remove action" onClick={() => setR({ ...r, actions: r.actions.filter((_, j) => j !== i) })} disabled={r.actions.length <= 1}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={translate("Remove action")} onClick={() => setR({ ...r, actions: r.actions.filter((_, j) => j !== i) })} disabled={r.actions.length <= 1}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, actions: [...r.actions, { type: "stop" }] })}><Plus size={14} /> Add action</button>
+36 -34
View File
@@ -5,6 +5,7 @@ import { useSession } from "@/store/session";
import { formatFullDate } from "@/lib/format";
import { toast } from "@/ui/toast";
import { confirmDialog, Dialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
interface SessionRow {
id: string;
@@ -57,10 +58,10 @@ export function SecuritySettings() {
return (
<div>
<h1>Security & sessions</h1>
<h1>{t("Security & sessions")}</h1>
<p className="lead">You're signed in as <b>{session?.username}</b>. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.</p>
<h2>Password</h2>
<h2>{t("Password")}</h2>
{unsupported ? (
<p className="hint">{unsupported}</p>
) : (
@@ -69,26 +70,26 @@ export function SecuritySettings() {
{!unsupported && state?.otpEnabled && (
<>
<h2>Two-factor authentication</h2>
<h2>{t("Two-factor authentication")}</h2>
<TwoFactorOff reload={async () => { await loadSecurity(); await load(); }} />
</>
)}
<h2>App passwords</h2>
<h2>{t("App passwords")}</h2>
{unsupported ? (
<p className="hint">App passwords are managed by your mail administrator.</p>
<p className="hint">{t("App passwords are managed by your mail administrator.")}</p>
) : (
<AppPasswords state={state} reload={loadSecurity} />
)}
<h2>Active webmail sessions</h2>
{rows === null ? <p className="hint">Loading…</p> : (
<h2>{t("Active webmail sessions")}</h2>
{rows === null ? <p className="hint">{t("Loading…")}</p> : (
<table className="sessions-table">
<thead><tr><th>Device</th><th>IP</th><th>Last active</th><th>Expires</th><th /></tr></thead>
<thead><tr><th>{t("Device")}</th><th>{t("IP")}</th><th>{t("Last active")}</th><th>{t("Expires")}</th><th /></tr></thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td><div className="truncate" style={{ maxWidth: 320 }} title={r.userAgent}>{shortUa(r.userAgent)}</div>{r.id === current && <span className="badge" style={{ marginTop: 2 }}>this device</span>}</td>
<td><div className="truncate" style={{ maxWidth: 320 }} title={r.userAgent}>{shortUa(r.userAgent)}</div>{r.id === current && <span className="badge" style={{ marginTop: 2 }}>{t("this device")}</span>}</td>
<td className="mono small">{r.ip}</td>
<td>{formatFullDate(new Date(r.lastSeenAt).toISOString())}</td>
<td>{`${formatFullDate(new Date(r.expiresAt).toISOString())}${r.remember ? " (remembered)" : ""}`}</td>
@@ -99,8 +100,8 @@ export function SecuritySettings() {
</table>
)}
<div className="row mt-16">
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>Sign out all other sessions</button>
<button className="btn btn-ghost" onClick={() => void logout()}>Sign out here</button>
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>{t("Sign out all other sessions")}</button>
<button className="btn btn-ghost" onClick={() => void logout()}>{t("Sign out here")}</button>
</div>
</div>
);
@@ -139,24 +140,24 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
return (
<form onSubmit={submit}>
<p className="hint" style={{ marginBottom: 12 }}>Changing your password signs out your other webmail sessions. Any app passwords keep working.</p>
<p className="hint" style={{ marginBottom: 12 }}>{t("Changing your password signs out your other webmail sessions. Any app passwords keep working.")}</p>
<div className="field" style={{ maxWidth: 380 }}>
<label htmlFor="pw-current">Current password</label>
<label htmlFor="pw-current">{t("Current password")}</label>
<input id="pw-current" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
</div>
{otpEnabled && (
<div className="field" style={{ maxWidth: 380 }}>
<label htmlFor="pw-code">Code from your authenticator</label>
<label htmlFor="pw-code">{t("Code from your authenticator")}</label>
<input id="pw-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" required />
</div>
)}
<div className="field-row" style={{ maxWidth: 780 }}>
<div className="field">
<label htmlFor="pw-new">New password</label>
<label htmlFor="pw-new">{t("New password")}</label>
<input id="pw-new" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} required />
</div>
<div className="field">
<label htmlFor="pw-confirm">Confirm new password</label>
<label htmlFor="pw-confirm">{t("Confirm new password")}</label>
<input id="pw-confirm" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
</div>
</div>
@@ -198,27 +199,27 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
return (
<div>
<p className="hint" style={{ marginBottom: 12 }}>
This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another
device needs an app password — or you can turn two-factor authentication off here.
{t("This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.")}
</p>
<div className="row" style={{ alignItems: "center", gap: 10 }}>
<ShieldCheck size={18} />
<b>Enabled</b>
<button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>Turn off</button>
<b>{t("Enabled")}</b>
<button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>{t("Turn off")}</button>
</div>
<Dialog open={disabling} onClose={() => setDisabling(false)} title="Turn off two-factor authentication" size="sm"
<Dialog open={disabling} onClose={() => setDisabling(false)} title={t("Turn off two-factor authentication")} size="sm"
footer={<>
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>Cancel</button>
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>{t("Cancel")}</button>
<button className="btn btn-danger" disabled={busy || !password || code.length < 6} onClick={() => void disable()}>{busy ? "Working…" : "Turn off"}</button>
</>}>
<p>Your password alone will be enough to sign in again.</p>
<p>{t("Your password alone will be enough to sign in again.")}</p>
<div className="field">
<label htmlFor="tfa-off-pw">Your password</label>
<label htmlFor="tfa-off-pw">{t("Your password")}</label>
<input id="tfa-off-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div className="field">
<label htmlFor="tfa-off-code">Current code</label>
<label htmlFor="tfa-off-code">{t("Current code")}</label>
<input id="tfa-off-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />
</div>
</Dialog>
@@ -233,7 +234,7 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
const [busy, setBusy] = useState(false);
const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null);
if (!state) return <p className="hint">Loading…</p>;
if (!state) return <p className="hint">{t("Loading…")}</p>;
const create = async (e: React.FormEvent) => {
e.preventDefault();
@@ -273,17 +274,18 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
return (
<div>
<p className="hint" style={{ marginBottom: 12 }}>
A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one.
{t("A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one.")}
</p>
{state.appPasswords.length > 0 && (
<table className="sessions-table">
<thead><tr><th>Name</th><th>Created</th><th /></tr></thead>
<thead><tr><th>{t("Name")}</th><th>{t("Created")}</th><th /></tr></thead>
<tbody>
{state.appPasswords.map((row) => (
<tr key={row.id}>
<td><KeyRound size={14} style={{ verticalAlign: "-2px", marginRight: 6 }} />{row.description}</td>
<td>{row.createdAt ? formatFullDate(row.createdAt) : ""}</td>
<td style={{ textAlign: "right" }}><button className="btn btn-sm btn-ghost" onClick={() => void revoke(row)}>Revoke</button></td>
<td style={{ textAlign: "right" }}><button className="btn btn-sm btn-ghost" onClick={() => void revoke(row)}>{t("Revoke")}</button></td>
</tr>
))}
</tbody>
@@ -291,14 +293,14 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
)}
<form onSubmit={create} className="row mt-16" style={{ gap: 8, alignItems: "flex-end", flexWrap: "wrap" }}>
<div className="field" style={{ marginBottom: 0, minWidth: 240 }}>
<label htmlFor="ap-name">New app password for</label>
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Thunderbird on my laptop" required />
<label htmlFor="ap-name">{t("New app password for")}</label>
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("Thunderbird on my laptop")} required />
</div>
<button className="btn" disabled={busy || !name.trim()}>{busy ? "Creating" : "Create"}</button>
</form>
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title="Your new app password" size="sm"
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>Done</button>}>
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title={t("Your new app password")} size="sm"
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>{t("Done")}</button>}>
{issued && (
<div>
<p>Copy it into <b>{issued.description}</b> now — it isn't shown again.</p>
@@ -318,7 +320,7 @@ function CopyableSecret({ value }: { value: string }) {
<button
type="button"
className="btn btn-sm btn-ghost"
title="Copy"
title={t("Copy")}
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success("Copied"), () => toast.error("Could not copy"))}
>
<Copy size={14} />
+5 -4
View File
@@ -13,6 +13,7 @@ import { SecuritySettings } from "./SecuritySettings";
import { AboutSettings } from "./AboutSettings";
import { ShortcutsSettings } from "./ShortcutsSettings";
import { CalendarSettings } from "./CalendarSettings";
import { t } from "@/lib/i18n";
const FiltersSettings = lazy(() => import("./FiltersSettings").then((m) => ({ default: m.FiltersSettings })));
const VacationSettings = lazy(() => import("./VacationSettings").then((m) => ({ default: m.VacationSettings })));
@@ -38,16 +39,16 @@ export function SettingsView({ section }: { section?: string }) {
const current = SECTIONS.find((s) => s.id === section);
return (
<div className={`settings-layout ${section ? "section" : "root"}`}>
<nav className="settings-nav" aria-label="Settings">
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Settings</span></div>
<nav className="settings-nav" aria-label={t("Settings")}>
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Settings")}</span></div>
{SECTIONS.map((s) => (
<Link key={s.id} href={`/settings/${s.id}`} className={`nav-item ${section === s.id ? "active" : ""}`}>
{s.icon}
<span className="nav-label">{s.label}</span>
</Link>
))}
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Shortcuts</span></div>
<Link href="/contacts" className="nav-item"><Users size={18} /><span className="nav-label">Address books</span></Link>
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Shortcuts")}</span></div>
<Link href="/contacts" className="nav-item"><Users size={18} /><span className="nav-label">{t("Address books")}</span></Link>
</nav>
<div className="settings-content">
{section && (
+9 -7
View File
@@ -8,6 +8,7 @@ import { useFiles } from "@/store/files";
import { client, setErrorMessage } from "@/jmap/client";
import { toast } from "@/ui/toast";
import type { Id, Principal } from "@/jmap/types";
import { t } from "@/lib/i18n";
/* The JMAP type name, used verbatim as the `/set` method prefix. */
type Kind = "Mailbox" | "Calendar" | "AddressBook" | "FileNode";
@@ -103,7 +104,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
};
return (
<Dialog open onClose={onClose} title={`Share “${name}`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>Save</button></>}>
<Dialog open onClose={onClose} title={`Share “${name}`} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{t("Save")}</button></>}>
{/* The list of who it is shared with is rendered whether or not anybody
can be *added*. It used to sit inside the branch below, so a server
with directory queries switched off -- which is the default, and which
@@ -111,20 +112,21 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
share could not be seen, let alone removed. */}
{!principals.length && (
<p className="hint" style={{ marginBottom: 12 }}>
No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.
{t("No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.")}
</p>
)}
{principals.length > 0 && (
<>
<div className="row" style={{ marginBottom: 12 }}>
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
<option value="">Add a person or group</option>
<option value="">{t("Add a person or group…")}</option>
{available.map((p) => (
<option key={p.id} value={p.id}>{`${p.name}${p.email ? ` <${p.email}>` : ""}${p.type !== "individual" ? ` (${p.type})` : ""}`}</option>
))}
</select>
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>Viewer</button>
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>Editor</button>
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>{t("Viewer")}</button>
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>{t("Editor")}</button>
</div>
</>
)}
@@ -134,7 +136,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
<div key={pid} className="card">
<div className="card-head">
<h3><span>{p?.name ?? pid}</span>{p?.email ? <span className="hint" style={{ fontWeight: 400 }}> · {p.email}</span> : null}</h3>
<button className="icon-btn sm danger" onClick={() => { const n = { ...rights }; delete n[pid]; setRights(n); }} aria-label="Remove"><Trash2 size={16} /></button>
<button className="icon-btn sm danger" onClick={() => { const n = { ...rights }; delete n[pid]; setRights(n); }} aria-label={t("Remove")}><Trash2 size={16} /></button>
</div>
<div className="row wrap" style={{ marginTop: 8 }}>
{RIGHTS[kind].map((rt) => (
@@ -147,7 +149,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
</div>
);
})}
{!Object.keys(rights).length && <p className="hint">Not shared with anyone yet.</p>}
{!Object.keys(rights).length && <p className="hint">{t("Not shared with anyone yet.")}</p>}
</Dialog>
);
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { keyboard } from "@/lib/keyboard";
import { Kbd } from "@/ui/misc";
import { t } from "@/lib/i18n";
export function ShortcutsSettings() {
const list = useMemo(() => keyboard.list(), []);
@@ -15,7 +16,7 @@ export function ShortcutsSettings() {
}, [list]);
return (
<div>
<h1>Keyboard shortcuts</h1>
<h1>{t("Keyboard shortcuts")}</h1>
<p className="lead">Gmail-style shortcuts are always on. Press <kbd className="kbd">?</kbd> anywhere to see this list.</p>
<div className="shortcut-grid">
{groups.map(([group, items]) => (
@@ -26,7 +27,7 @@ export function ShortcutsSettings() {
))}
</div>
))}
{!groups.length && <p className="hint">Open the Mail view to see all shortcuts.</p>}
{!groups.length && <p className="hint">{t("Open the Mail view to see all shortcuts.")}</p>}
</div>
</div>
);
+9 -8
View File
@@ -4,6 +4,7 @@ import { useSettings, type Template } from "@/store/settings";
import { Dialog } from "@/ui/dialog";
import { RichEditor } from "../compose/RichEditor";
import { htmlToText } from "@/lib/text";
import { t as translate } from "@/lib/i18n";
export function TemplatesSettings() {
const templates = useSettings((s) => s.settings.templates);
@@ -11,13 +12,13 @@ export function TemplatesSettings() {
const [editing, setEditing] = useState<Template | null>(null);
return (
<div>
<h1>Templates</h1>
<p className="lead">Canned responses you can insert into any message from the composer's template button.</p>
<h1>{translate("Templates")}</h1>
<p className="lead">{translate("Canned responses you can insert into any message from the composer's template button.")}</p>
{templates.map((t) => (
<div key={t.id} className="card clickable" onClick={() => setEditing(t)}>
<div className="card-head">
<h3>{t.name}</h3>
<button className="icon-btn sm danger" aria-label="Delete template" onClick={(e) => { e.stopPropagation(); update({ templates: templates.filter((x) => x.id !== t.id) }); }}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={translate("Delete template")} onClick={(e) => { e.stopPropagation(); update({ templates: templates.filter((x) => x.id !== t.id) }); }}><Trash2 size={16} /></button>
</div>
{t.subject && <div className="hint">Subject: {t.subject}</div>}
<div className="hint truncate">{htmlToText(t.html).slice(0, 140)}</div>
@@ -25,15 +26,15 @@ export function TemplatesSettings() {
))}
<button className="btn" onClick={() => setEditing({ id: `t${Date.now()}`, name: "", subject: "", html: "" })}><Plus size={16} /> New template</button>
{editing && (
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? "Edit template" : "New template"} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>Cancel</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>Save</button></>}>
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? "Edit template" : "New template"} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>{translate("Save")}</button></>}>
<div className="field-row">
<div className="field"><label>Name</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
<div className="field"><label>Subject (optional)</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>
<div className="field"><label>{translate("Name")}</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
<div className="field"><label>{translate("Subject (optional)")}</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>
</div>
<div className="field">
<label>Body</label>
<label>{translate("Body")}</label>
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 8, minHeight: 200, display: "flex", flexDirection: "column" }}>
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder="Template text…" />
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder={translate("Template text…")} />
</div>
</div>
</Dialog>
+10 -9
View File
@@ -5,6 +5,7 @@ import { toast } from "@/ui/toast";
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
import { DateTimeField } from "@/ui/datefield";
import { client, CAP } from "@/jmap/client";
import { t } from "@/lib/i18n";
export function VacationSettings() {
const vacation = useMail((s) => s.vacation);
@@ -30,7 +31,7 @@ export function VacationSettings() {
setTo(vacation.toDate ? toInputDateTime(new Date(vacation.toDate)) : "");
}, [vacation]);
if (!available) return <div><h1>Out of office</h1><p className="lead">Vacation responses are not available for this account.</p></div>;
if (!available) return <div><h1>{t("Out of office")}</h1><p className="lead">{t("Vacation responses are not available for this account.")}</p></div>;
const submit = async () => {
setBusy(true);
@@ -53,16 +54,16 @@ export function VacationSettings() {
return (
<div>
<h1>Out of office</h1>
<p className="lead">Automatically reply to people who email you while you're away. Each sender gets at most one reply.</p>
<Switch checked={enabled} onChange={setEnabled} label="Auto-reply enabled" />
<h1>{t("Out of office")}</h1>
<p className="lead">{t("Automatically reply to people who email you while you're away. Each sender gets at most one reply.")}</p>
<Switch checked={enabled} onChange={setEnabled} label={t("Auto-reply enabled")} />
<div className="field-row mt-16">
<div className="field"><label>Starts (optional)</label><DateTimeField aria-label="Starts" value={from} onChange={setFrom} /></div>
<div className="field"><label>Ends (optional)</label><DateTimeField aria-label="Ends" value={to} onChange={setTo} /></div>
<div className="field"><label>{t("Starts (optional)")}</label><DateTimeField aria-label={t("Starts")} value={from} onChange={setFrom} /></div>
<div className="field"><label>{t("Ends (optional)")}</label><DateTimeField aria-label={t("Ends")} value={to} onChange={setTo} /></div>
</div>
<div className="field"><label>Subject</label><input className="input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Out of office" /></div>
<div className="field"><label>Message</label><textarea className="textarea" rows={7} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Thanks for your message. I'm away until and will reply when I'm back." /></div>
<button className="btn btn-primary" disabled={busy} onClick={() => void submit()}>Save</button>
<div className="field"><label>{t("Subject")}</label><input className="input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder={t("Out of office")} /></div>
<div className="field"><label>{t("Message")}</label><textarea className="textarea" rows={7} value={body} onChange={(e) => setBody(e.target.value)} placeholder={t("Thanks for your message. I'm away until … and will reply when I'm back.")} /></div>
<button className="btn btn-primary" disabled={busy} onClick={() => void submit()}>{t("Save")}</button>
</div>
);
}