Merge pull request #150 from Coffey-Labs/i18n-folder-names

Folder names follow the language, and three bugs that found
This commit is contained in:
Coffey Labs
2026-08-31 11:27:29 -07:00
committed by GitHub
28 changed files with 1380 additions and 49 deletions
+2 -1
View File
@@ -22,7 +22,8 @@
"mock": "npm run mock -w server",
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"i18n:coverage": "node scripts/i18n-coverage.mjs"
"i18n:coverage": "node scripts/i18n-coverage.mjs",
"i18n:check": "node scripts/i18n-catalog-check.mjs"
},
"devDependencies": {
"concurrently": "^9.1.2",
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
/*
* Check a catalogue against the strings the code actually asks for.
*
* Two failures, and only one of them is visible without this.
*
* A *missing* key renders English. That is the designed fallback and shows up
* as an untranslated word on screen, which somebody will eventually notice.
*
* A *stale* key -- one whose English no longer exists, usually because it was
* mistyped when the catalogue was written -- is silent. The translation sits
* in the file looking correct, is never looked up, and the app renders English
* for ever. Nothing warns, because a catalogue is only ever read by key.
*/
import ts from "typescript";
import { readFileSync, globSync } from "node:fs";
const wanted = new Set();
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const visit = (n) => {
/*
* Labels held in a constant and translated where they render -- t(s.label)
* -- reach t() as a variable, so there is no literal for this to find and
* every one of them looked "stale". They are collected from the constants
* instead: a `label:` property, or a value in an object of them. Without
* this the stale check cried wolf 33 times and would have been switched
* off, which is the only outcome worse than not having it.
*/
if (ts.isPropertyAssignment(n) && n.name.getText(src) === "label" && ts.isStringLiteral(n.initializer)) wanted.add(n.initializer.text);
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && /_LABELS?$/.test(n.name.text)) {
const walk = (x) => { if (ts.isStringLiteral(x)) wanted.add(x.text); ts.forEachChild(x, walk); };
if (n.initializer) walk(n.initializer);
}
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
const fn = n.expression.text, a0 = n.arguments[0];
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text);
// tc(context, source) keys the catalogue on both, joined by the same
// control character tc() uses. Without this the contextual entries all
// looked stale, which is the checker's own false alarm rather than a
// catalogue problem.
if (fn === "tc" && a0 && ts.isStringLiteral(a0) && n.arguments[1] && ts.isStringLiteral(n.arguments[1])) {
// Only the contextual key is required. The plain one is tc()'s
// fallback, not a second obligation -- asking for both would report
// work that does not exist.
wanted.add(`${a0.text}\u0004${n.arguments[1].text}`);
}
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
for (const p of n.arguments[1].properties) {
if (ts.isPropertyAssignment(p) && p.name.getText(src) === "other" && ts.isStringLiteral(p.initializer)) wanted.add(p.initializer.text);
}
}
}
ts.forEachChild(n, visit);
};
visit(src);
}
let failed = false;
for (const file of globSync("web/src/locales/*.ts")) {
const tag = file.split("/").pop().replace(".ts", "");
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const have = new Set();
const visit = (n) => {
if (ts.isPropertyAssignment(n) && ts.isStringLiteral(n.name)) have.add(n.name.text);
ts.forEachChild(n, visit);
};
visit(src);
const stale = [...have].filter((k) => !wanted.has(k) && !["one", "other", "few", "many", "zero", "two"].includes(k));
const missing = [...wanted].filter((k) => !have.has(k));
const pct = Math.round(((wanted.size - missing.length) / wanted.size) * 100);
console.log(`${tag}: ${wanted.size - missing.length}/${wanted.size} translated (${pct}%), ${missing.length} falling back to English`);
if (stale.length) {
failed = true;
console.log(`\n ${stale.length} STALE key(s) — translated but never looked up, so they do nothing:`);
for (const k of stale.slice(0, 25)) console.log(` ${JSON.stringify(k)}`);
if (stale.length > 25) console.log(` …and ${stale.length - 25} more`);
}
if (process.argv.includes("--missing")) {
console.log(`\n missing:`);
for (const k of missing) console.log(` ${JSON.stringify(k)}`);
}
}
if (failed && process.argv.includes("--check")) process.exit(1);
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/*
* Every source string a catalogue needs, straight out of the calls.
*
* The English text is the key, so the catalogue's keys are not a list somebody
* maintains -- they are whatever t(), tNode() and plural() are actually asked
* for. Reading them from the code means a catalogue can never drift out of
* step with the app in the one direction that matters: a key that no longer
* exists is dead weight, but a call with no key is an untranslated string
* nobody noticed.
*/
import ts from "typescript";
import { readFileSync, globSync } from "node:fs";
const strings = new Set();
const plurals = new Set();
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__"))) {
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const visit = (node) => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
const fn = node.expression.text;
const a0 = node.arguments[0];
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) strings.add(a0.text);
if (fn === "plural" && node.arguments[1] && ts.isObjectLiteralExpression(node.arguments[1])) {
const other = node.arguments[1].properties.find((p) => ts.isPropertyAssignment(p) && p.name.getText(src) === "other");
const forms = {};
for (const p of node.arguments[1].properties) {
if (ts.isPropertyAssignment(p) && ts.isStringLiteral(p.initializer)) forms[p.name.getText(src)] = p.initializer.text;
}
if (other) plurals.add(JSON.stringify(forms));
}
}
ts.forEachChild(node, visit);
};
visit(src);
}
const out = { strings: [...strings].sort(), plurals: [...plurals].map((p) => JSON.parse(p)) };
if (process.argv.includes("--json")) console.log(JSON.stringify(out, null, 2));
else {
console.log(`${out.strings.length} strings, ${out.plurals.length} plural sets`);
const short = out.strings.filter((s) => s.length <= 30).length;
console.log(` ${short} short (<=30 chars), ${out.strings.length - short} longer`);
}
+42 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { currentLanguage, interpolate, plural, setCatalog, t, tNode, type Catalog } from "@/lib/i18n";
import { CONTEXT_SEPARATOR, currentLanguage, interpolate, plural, setCatalog, subscribeForTest, t, tc, tNode, type Catalog } from "@/lib/i18n";
const de: Catalog = {
strings: {
@@ -109,3 +109,44 @@ describe("tNode", () => {
expect(render(tNode("{count} of {scheme}", { scheme: <b>x</b> }, { count: 3 }))).toBe("3 of <b>x</b>");
});
});
describe("setCatalog", () => {
it("does not announce a change that did not happen", () => {
/*
* The root keys its tree on the language version, so every publish
* remounts the app -- which re-runs the effect that loads the account's
* settings, which calls applyLang, which lands back in setCatalog with the
* same language. Publishing that non-change looped for ever, and from the
* outside it looked like the message list refreshing without end.
*/
const seen: number[] = [];
const stop = subscribeForTest(() => seen.push(1));
const cat: Catalog = { strings: { Archive: "Archivieren" }, plurals: {} };
setCatalog("de", cat);
setCatalog("de", cat);
setCatalog("de", cat);
expect(seen.length).toBe(1);
setCatalog("en", { strings: {}, plurals: {} });
expect(seen.length).toBe(2);
stop();
});
});
describe("tc", () => {
it("tells apart an English word doing two jobs", () => {
// "Archive" is the button and the folder; German wants a different word
// for each, and one key cannot hold both.
setCatalog("de", {
strings: { "Archive": "Archivieren", [`folder${CONTEXT_SEPARATOR}Archive`]: "Archiv" },
plurals: {},
});
expect(t("Archive")).toBe("Archivieren");
expect(tc("folder", "Archive")).toBe("Archiv");
});
it("falls back to the plain translation, then to English", () => {
setCatalog("de", { strings: { "Drafts": "Entwürfe" }, plurals: {} });
expect(tc("folder", "Drafts")).toBe("Entwürfe"); // no context entry yet
expect(tc("folder", "Sent")).toBe("Sent"); // nothing at all
});
});
+10 -2
View File
@@ -20,12 +20,20 @@ describe("resolveUiLanguage", () => {
it("refuses a language whose strings are not shipped", () => {
// The account travels between machines and can outlive a catalogue. A
// page that says lang="de" while rendering English is worse than one that
// page that says lang="fr" while rendering English is worse than one that
// admits to English: it stops the reader translating it themselves.
expect(resolveUiLanguage("de")).toBe("en");
expect(resolveUiLanguage("fr")).toBe("en");
expect(resolveUiLanguage("xx-XX")).toBe("en");
});
it("carries the Beta flag until a person has signed the language off", () => {
// Not a completeness measure. A catalogue can be word-for-word finished
// and still read like a machine wrote it, which is what this marks.
const de = UI_LANGUAGES.find((l) => l.tag === "de");
expect(de?.beta).toBe(true);
expect(UI_LANGUAGES.find((l) => l.tag === "en")?.beta).toBeUndefined();
});
it("honours one that is", () => {
for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag);
});
+68
View File
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from "vitest";
import { isLocalisedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
import { setCatalog, type Catalog } from "@/lib/i18n";
import type { Mailbox } from "@/jmap/types";
/**
* Stalwart names the standard folders once, at account creation, and never
* renames them — so a German reader on an English-provisioned account would
* otherwise see "Deleted Items" in an otherwise German app. The role is what
* lets ihasmail say "Papierkorb" without writing anything to the server.
*/
const de: Catalog = {
strings: { Inbox: "Posteingang", "Deleted Items": "Papierkorb", Drafts: "Entwürfe" },
plurals: {},
};
const mb = (id: string, name: string, role: string | null = null, parentId: string | null = null) =>
({ id, name, role, parentId } as unknown as Mailbox);
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
describe("mailboxDisplayName", () => {
it("is the server's name until a catalogue says otherwise", () => {
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
});
it("follows the interface language for a folder carrying a role", () => {
setCatalog("de", de);
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Papierkorb");
expect(mailboxDisplayName(mb("2", "Inbox", "inbox"))).toBe("Posteingang");
});
it("leaves a folder somebody made alone", () => {
// "Newsletters" is their word. Translating it would name a folder they
// never created, and it would not match what any other client shows.
setCatalog("de", de);
expect(mailboxDisplayName(mb("3", "Newsletters"))).toBe("Newsletters");
expect(mailboxDisplayName(mb("4", "Work", "subscribed"))).toBe("Work");
});
it("survives a missing mailbox rather than printing undefined", () => {
expect(mailboxDisplayName(null)).toBe("");
expect(mailboxDisplayName(undefined)).toBe("");
});
});
describe("isLocalisedName", () => {
it("tells an editor when the name on screen is not the server's", () => {
// A rename box prefilled with "Papierkorb" would rename the folder to that
// the moment somebody pressed Save — a real change made by accident.
expect(isLocalisedName(mb("1", "Deleted Items", "trash"))).toBe(true);
expect(isLocalisedName(mb("2", "Newsletters"))).toBe(false);
expect(isLocalisedName(mb("3", "Work", "subscribed"))).toBe(false);
});
});
describe("mailboxDisplayPath", () => {
it("localises each part that has a role and leaves the rest", () => {
setCatalog("de", de);
const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
});
it("stops rather than looping on a parent cycle", () => {
// A malformed tree from the server must not hang the folder picker.
const all: Record<string, Mailbox> = { a: mb("a", "A", null, "b"), b: mb("b", "B", null, "a") };
expect(mailboxDisplayPath(all.a!, all)).toBe("B / A");
});
});
+25 -3
View File
@@ -24,6 +24,28 @@ export interface DateTimePrefs {
const DEFAULT_PREFS: DateTimePrefs = { locale: "", dateFormat: "auto", timeFormat: "auto" };
/**
* The interface language, when one has been chosen over the default.
*
* Formatting and interface language are separate settings on purpose -- German
* dates with an English interface is a real preference. But somebody who picks
* German and is then shown "September" and "Monday" has not got what they
* asked for: choosing a language *is* a statement about language, and month
* names are language.
*
* So it joins the automatic chain, ahead of the server and the browser, and
* only while the formatting locale is left on "Automatic". Setting one
* explicitly still wins over everything, which is what that setting is for.
* English is not counted, because it is the default nobody has to choose --
* an English interface on a German browser should keep German dates, as it
* always has.
*/
let uiLanguage: string | null = null;
export function setUiLanguageForFormatting(tag: string | null | undefined): void {
uiLanguage = tag && tag !== "en" ? tag : null;
}
let prefs: DateTimePrefs = DEFAULT_PREFS;
let serverLocale: string | null = null;
@@ -90,9 +112,9 @@ export function normalizeLocale(raw: string | null | undefined): string | null {
}
}
/** The locale Intl should use: explicit choice → server → browser default. */
/** Explicit choice → chosen interface language → server → browser default. */
export function resolvedLocale(): string | undefined {
return prefs.locale || serverLocale || undefined;
return prefs.locale || uiLanguage || serverLocale || undefined;
}
/** Where the effective locale came from — used to label the "Automatic" option. */
@@ -524,7 +546,7 @@ let optionsExtras = "";
* outside the generated list is still selectable).
*/
export function localeOptions(): LocaleOption[] {
const extras = `${serverLocale ?? ""}|${prefs.locale}`;
const extras = `${serverLocale ?? ""}|${prefs.locale}|${uiLanguage ?? ""}`;
if (optionsCache && optionsExtras === extras) return optionsCache;
const tags = new Set<string>(LOCALE_TAGS);
if (serverLocale) tags.add(serverLocale);
+47
View File
@@ -61,6 +61,30 @@ export function t(source: string, vars?: Vars): string {
return interpolate(current.strings[source] ?? source, vars);
}
/**
* Translate where the English word is doing two jobs.
*
* English-as-key has one real weakness and this is it: "Archive" is the button
* that archives a message and the folder the message lands in, and German
* needs "Archivieren" for the first and "Archiv" for the second. One key
* cannot hold both. "Important" is the same — a priority tag and a folder.
*
* So a context can be given, and the lookup becomes context + source while the
* fallback stays the plain English. A translator sees the context and knows
* which sense to render; a catalogue that has not got round to it still
* renders the English word, which was right in English all along.
*
* The separator is a control character rather than a punctuation mark, which
* is the gettext convention and for the same reason: no English string can
* contain it by accident.
*/
export const CONTEXT_SEPARATOR = "\u0004";
export function tc(context: string, source: string, vars?: Vars): string {
const keyed = current.strings[`${context}${CONTEXT_SEPARATOR}${source}`];
return interpolate(keyed ?? current.strings[source] ?? source, vars);
}
/**
* Translate a counted thing.
*
@@ -119,6 +143,12 @@ export function tNode(source: string, parts: Record<string, ReactNode>, vars?: V
return out;
}
/** Subscribe to catalogue changes without React. Used by the tests. */
export function subscribeForTest(fn: () => void): () => void {
listeners.add(fn);
return () => void listeners.delete(fn);
}
/** The language in force, for anything that needs the tag itself. */
export function currentLanguage(): string {
return currentTag;
@@ -132,6 +162,23 @@ export function currentLanguage(): string {
* language's rules against another's forms.
*/
export function setCatalog(tag: string, catalog: Catalog): void {
/*
* Publishing only when something actually changed is not an optimisation
* here, it is the thing that stops an infinite loop.
*
* The root keys its tree on the language version, so a publish remounts
* everything. Remounting re-runs the effect that fetches the account's
* settings file, which calls `hydrate`, which calls `applyLang`, which lands
* back here -- with the identical tag and the identical catalogue. Publishing
* that non-change bumped the version again and went round for ever: the
* message list refetched on every pass, which is what it looked like from
* the outside.
*
* Reference equality is enough. `EMPTY` is a module constant and a
* dynamically imported catalogue is cached, so the same language really does
* hand back the same object.
*/
if (currentTag === tag && current === catalog) return;
currentTag = tag;
current = catalog;
publish();
+14
View File
@@ -23,12 +23,26 @@ export interface UiLanguage {
tag: string;
/** The language's name in that language, which is how a picker should read. */
name: string;
/**
* Machine-translated and not yet checked by somebody who speaks it.
*
* Stays true until a native speaker has actually read the catalogue and said
* so. It is not a measure of how complete the file is -- a catalogue can be
* word-for-word finished and still read like a machine wrote it, which is
* the thing this flag is about. Removing it is a deliberate act by a person,
* not something a coverage number earns.
*/
beta?: boolean;
}
export const UI_LANGUAGES: readonly UiLanguage[] = [
{ tag: "en", name: "English" },
{ tag: "de", name: "Deutsch", beta: true },
];
/** Where to report a bad translation. Beta languages depend on it. */
export const TRANSLATION_ISSUE_URL = "https://github.com/Coffey-Labs/ihasmail/issues/new?title=Translation%3A%20";
export const DEFAULT_UI_LANGUAGE = "en";
/**
+79
View File
@@ -0,0 +1,79 @@
import { tc } from "@/lib/i18n";
import type { Mailbox } from "@/jmap/types";
/**
* What to call a folder on screen.
*
* Stalwart names the standard folders once, when the account is created, in
* whatever language the server was set up in — and never renames them
* afterwards, because the name is stored data every other client has mapped.
* So a German reader on an English-provisioned account sees "Deleted Items"
* in an otherwise German app, and there is nothing the server can be asked to
* do about it: the account locale exists in `x:AccountSettings`, but writing it
* needs `sysAccountSettingsSet`, which the built-in user role does not carry.
*
* The role is the way out. JMAP tags the standard folders — `inbox`, `trash`,
* `drafts` and the rest — and ihasmail already trusts the role rather than the
* name everywhere it matters, so the display name can follow the interface
* language without anything being written to the server.
*
* Only the roles. A folder somebody made and called "Newsletters" keeps that
* name, because those are their words and translating them would be inventing
* a folder they never made.
*
* The cost, and it is real: another client on the same account still shows
* "Deleted Items", because that is what the folder is called. Within ihasmail
* this stays consistent — everything that names a folder goes through here,
* including the "moved to …" toast, which exists precisely so that message
* does not name somewhere the reader cannot find.
*/
/*
* Every one of these is translated in the "folder" context, including the
* unambiguous ones. Two of them genuinely need it -- "Archive" is also the
* button that archives, "Important" is also a priority tag, and German wants a
* different word for each -- and applying it to only those two would leave the
* next person to notice which. A context on all of them is one rule.
*/
const ROLE_NAMES: Record<string, () => string> = {
inbox: () => tc("folder", "Inbox"),
archive: () => tc("folder", "Archive"),
drafts: () => tc("folder", "Drafts"),
sent: () => tc("folder", "Sent"),
trash: () => tc("folder", "Deleted Items"),
junk: () => tc("folder", "Junk Mail"),
important: () => tc("folder", "Important"),
all: () => tc("folder", "All mail"),
};
/** The folder's name as the reader should see it. */
export function mailboxDisplayName(mailbox: { name: string; role?: string | null } | null | undefined): string {
if (!mailbox) return "";
const localised = mailbox.role ? ROLE_NAMES[mailbox.role] : undefined;
return localised ? localised() : mailbox.name;
}
/**
* Whether this folder's displayed name is ihasmail's rather than the server's.
*
* Anything that *edits* the name has to know: a rename dialog prefilled with
* "Papierkorb" would rename the folder to that on the server the moment
* somebody pressed Save, which is a real change made by accident to a folder
* they were only looking at. Renaming a role folder is refused anyway, but
* relying on that would be relying on a rule enforced somewhere else.
*/
export function isLocalisedName(mailbox: { role?: string | null } | null | undefined): boolean {
return Boolean(mailbox?.role && mailbox.role in ROLE_NAMES);
}
/** A path of folder names, for a picker that shows where a folder sits. */
export function mailboxDisplayPath(mailbox: Mailbox, all: Record<string, Mailbox>): string {
const parts: string[] = [];
let cur: Mailbox | undefined = mailbox;
const seen = new Set<string>();
while (cur && !seen.has(cur.id)) {
seen.add(cur.id);
parts.unshift(mailboxDisplayName(cur));
cur = cur.parentId ? all[cur.parentId] : undefined;
}
return parts.join(" / ");
}
+884
View File
@@ -0,0 +1,884 @@
import type { Catalog } from "@/lib/i18n";
/**
* German — generated by AI, and not reviewed by a native speaker.
*
* That is stated in the app as well as here, because it is the fact a reader
* needs in order to judge what they are looking at. Somebody told the
* translation is unchecked forgives an odd sentence and reports it; somebody
* told it was reviewed reasonably concludes the product is sloppy. The report
* link in Settings is the entire review process, and it only works if the app
* is honest about needing one.
*
* It was written against the standard terminology every German mail client
* already uses rather than invented, and the glossary below is the part that
* makes it read as one voice. But "written carefully" and "correct" are
* different claims and only the first is being made. The language stays marked
* Beta until a person who speaks German has read it and said otherwise --
* which is a decision somebody makes, not one a completeness percentage
* earns.
*
* A string missing from here renders its English source, so this file can be
* incomplete or wrong in places without the app breaking. Deleting an entry is
* a valid fix.
*
* ── Decisions this file is consistent about ──────────────────────────────
*
* Register: **Sie**, throughout. Thunderbird and Outlook use it, and ihasmail
* is as often a company's mail as somebody's own — "du" from a mail client
* that a workplace deployed reads as presumptuous in a way "Sie" never does.
* Where a string can avoid the question entirely it does ("Nachricht löschen?"
* rather than "Möchten Sie die Nachricht löschen?"), which is ordinary good
* German UI and sidesteps the choice; the choice is still pinned for the
* strings that cannot.
*
* Terminology, fixed once so it cannot drift:
*
* Inbox Posteingang Archive (verb) archivieren
* Drafts Entwürfe Delete löschen
* Sent Gesendet Move to verschieben nach
* Deleted Items Papierkorb Reply antworten
* Junk / Spam Spam Reply all allen antworten
* Folder Ordner Forward weiterleiten
* Label Label Star markieren
* Conversation Konversation Read / unread gelesen / ungelesen
* Message Nachricht Settings Einstellungen
* Attachment Anhang Signature Signatur
* Contact Kontakt Identity Identität
*
* "Label" is left in English because IMAP keywords are what they are and every
* German mail client leaves it: translating it to "Etikett" would name
* something the reader cannot find anywhere else. "Spam" likewise — "Junk" is
* the English word Germans do not use, "Spam" is the one they do.
*
* Product names are never translated: ihasmail, Stalwart, JMAP, Sieve, vCard.
*/
export const catalog: Catalog = {
strings: {
// ── Actions ────────────────────────────────────────────────────────
"Archive": "Archivieren",
"Archive (e)": "Archivieren (e)",
"Delete": "Löschen",
"Delete (#)": "Löschen (#)",
"Reply": "Antworten",
"Reply (r)": "Antworten (r)",
"Reply all": "Allen antworten",
"Forward": "Weiterleiten",
"Move to…": "Verschieben nach…",
"Move to (v)": "Verschieben nach (v)",
"Move to folder": "In Ordner verschieben",
"Move here": "Hierher verschieben",
"Mark as read": "Als gelesen markieren",
"Mark as read (Shift+I)": "Als gelesen markieren (Umschalt+I)",
"Mark as unread": "Als ungelesen markieren",
"Mark as unread (Shift+U)": "Als ungelesen markieren (Umschalt+U)",
"Mark all as read": "Alle als gelesen markieren",
"Mark all as read, incl. subfolders": "Alle als gelesen markieren, inkl. Unterordner",
"Star": "Markieren",
"Labels": "Labels",
"Labels (l)": "Labels (l)",
"Label as": "Label vergeben",
"Label…": "Label…",
"Compose": "Verfassen",
"Compose message": "Nachricht verfassen",
"Send": "Senden",
"Send at": "Senden am",
"Cancel send": "Senden abbrechen",
"Schedule send": "Senden planen",
"Save": "Speichern",
"Save & activate": "Speichern & aktivieren",
"Save & close (Esc)": "Speichern & schließen (Esc)",
"Save as template": "Als Vorlage speichern",
"Save draft now": "Entwurf jetzt speichern",
"Cancel": "Abbrechen",
"Close": "Schließen",
"Done": "Fertig",
"Continue": "Weiter",
"Edit": "Bearbeiten",
"Edit…": "Bearbeiten…",
"Rename": "Umbenennen",
"Remove": "Entfernen",
"Restore": "Wiederherstellen",
"Retry": "Erneut versuchen",
"Reload": "Neu laden",
"Refresh": "Aktualisieren",
"Copy": "Kopieren",
"Copy email address": "E-Mail-Adresse kopieren",
"Download": "Herunterladen",
"Download all": "Alle herunterladen",
"Download (.eml)": "Herunterladen (.eml)",
"Download latest as .eml": "Neueste als .eml herunterladen",
"Upload": "Hochladen",
"Upload files…": "Dateien hochladen…",
"Print": "Drucken",
"Print conversation": "Konversation drucken",
"Undo (Ctrl+Z)": "Rückgängig (Strg+Z)",
"Redo": "Wiederherstellen",
"Dismiss": "Schließen",
"Discard changes": "Änderungen verwerfen",
"Discard draft": "Entwurf verwerfen",
"Duplicate": "Duplizieren",
"Validate": "Prüfen",
"Revoke": "Widerrufen",
"Turn off": "Deaktivieren",
"Clear": "Leeren",
"Clear selection": "Auswahl aufheben",
"Clear custom colour": "Eigene Farbe entfernen",
"Select": "Auswählen",
"Select all": "Alle auswählen",
"Unsubscribe": "Abbestellen",
"Share…": "Freigeben…",
"Stop sharing": "Freigabe aufheben",
"Open": "Öffnen",
"Open in new tab": "In neuem Tab öffnen",
"Open in calendar": "Im Kalender öffnen",
"Back": "Zurück",
"Back (u)": "Zurück (u)",
"Back to list": "Zurück zur Liste",
"Back to my files": "Zurück zu meinen Dateien",
"Go back to the list": "Zurück zur Liste",
"Next": "Weiter",
"Previous": "Zurück",
"More": "Mehr",
"More actions": "Weitere Aktionen",
"More options": "Weitere Optionen",
"Move up": "Nach oben",
"Move down": "Nach unten",
"Drag to reorder": "Zum Umsortieren ziehen",
"Right-click for options": "Rechtsklick für Optionen",
// ── Mail ───────────────────────────────────────────────────────────
"Mail": "E-Mail",
"Message": "Nachricht",
"Messages": "Nachrichten",
"Message body": "Nachrichtentext",
"Message headers": "Kopfzeilen",
"Message size": "Nachrichtengröße",
"Message-ID": "Message-ID",
"Original message": "Ursprüngliche Nachricht",
"Delete this message": "Diese Nachricht löschen",
"New message to this address": "Neue Nachricht an diese Adresse",
"Conversation view": "Konversationsansicht",
"Draft": "Entwurf",
"Unread": "Ungelesen",
"Unread only": "Nur ungelesene",
"All mail": "Alle Nachrichten",
"Sender": "Absender",
"Sender domain": "Absenderdomäne",
"Recipients": "Empfänger",
"From": "Von",
"To": "An",
"Cc": "Cc",
"Bcc": "Bcc",
"Subject": "Betreff",
"Subject (optional)": "Betreff (optional)",
"Body": "Text",
"Body text": "Fließtext",
"Attach files": "Dateien anhängen",
"Attach from Files": "Aus Dateien anhängen",
"Remove attachment": "Anhang entfernen",
"Has attachment": "Hat Anhang",
"Has the words": "Enthält die Wörter",
"Header name": "Name der Kopfzeile",
"Show headers": "Kopfzeilen anzeigen",
"Show original": "Original anzeigen",
"Show details": "Details anzeigen",
"Show images": "Bilder anzeigen",
"Remote images": "Externe Bilder",
"Remote images are blocked to protect your privacy.": "Externe Bilder werden zum Schutz Ihrer Privatsphäre blockiert.",
"Always from {email}": "Immer von {email}",
"This looks like a mailing list.": "Das sieht nach einer Mailingliste aus.",
"This folder is empty": "Dieser Ordner ist leer",
"This folder is empty.": "Dieser Ordner ist leer.",
"Delete all spam now": "Gesamten Spam jetzt löschen",
"Deleting spam is permanent — it does not go to Deleted Items first.": "Das Löschen von Spam ist endgültig — er wandert nicht zuerst in den Papierkorb.",
"Keep in Inbox": "Im Posteingang behalten",
"Newer (k)": "Neuere (k)",
"Older (j)": "Ältere (j)",
"Open the next (older) conversation": "Nächste (ältere) Konversation öffnen",
"Open the previous (newer) conversation": "Vorherige (neuere) Konversation öffnen",
"Loading conversation…": "Konversation wird geladen…",
"Important": "Wichtig",
"Unverified": "Nicht verifiziert",
"Priority": "Priorität",
"High": "Hoch",
"Normal": "Normal",
"Low": "Niedrig",
"to {recipients}": "an {recipients}",
"From: {sender}": "Von: {sender}",
"Waiting on the server — goes out {when}.": "Wartet auf dem Server — geht {when} raus.",
"Scheduled — click to clear the schedule": "Geplant — zum Aufheben klicken",
"Nothing scheduled": "Nichts geplant",
"The message waits on the server, so it goes out whether or not ihasmail is open.": "Die Nachricht wartet auf dem Server und wird gesendet, ob ihasmail geöffnet ist oder nicht.",
"This server holds a message for up to {span}.": "Dieser Server hält eine Nachricht bis zu {span} zurück.",
"Date and time to send": "Datum und Uhrzeit für den Versand",
"Undo send window": "Zeitfenster zum Rückgängigmachen",
"Read receipt requested": "Lesebestätigung angefordert",
"The sender asked for a read receipt.": "Der Absender hat eine Lesebestätigung angefordert.",
"Request read receipt": "Lesebestätigung anfordern",
"Always request read receipts": "Immer Lesebestätigungen anfordern",
"Receipt": "Bestätigung",
"Never send one": "Nie senden",
"Not this time": "Diesmal nicht",
"It would go to {address}, which is not where the message came from.": "Sie ginge an {address} — nicht dorthin, woher die Nachricht kam.",
"Use “Show original” for the complete raw message.": "Nutzen Sie „Original anzeigen“ für die vollständige Rohnachricht.",
// ── Folders, calendar, contacts, files ─────────────────────────────
"Folder": "Ordner",
"Folders": "Ordner",
"Folder options": "Ordneroptionen",
"New folder": "Neuer Ordner",
"New subfolder": "Neuer Unterordner",
"Delete folder": "Ordner löschen",
"No matching folders": "Keine passenden Ordner",
"No subfolders here.": "Hier gibt es keine Unterordner.",
"Type a folder name…": "Ordnernamen eingeben…",
" New folder…": " Neuer Ordner…",
"Create, rename and hide folders.": "Ordner erstellen, umbenennen und ausblenden.",
"Show unsubscribed (hidden) folders": "Nicht abonnierte (ausgeblendete) Ordner anzeigen",
"Storage: {used} of {total} used.": "Speicher: {used} von {total} belegt.",
"{used} of {total}": "{used} von {total}",
"Calendar": "Kalender",
"My calendars": "Meine Kalender",
"New calendar": "Neuer Kalender",
"Calendar options": "Kalenderoptionen",
"Calendar & contacts": "Kalender & Kontakte",
"Calendar is not available": "Kalender ist nicht verfügbar",
"Event": "Termin",
"New event": "Neuer Termin",
"(new event)": "(neuer Termin)",
"New all-day event": "Neuer ganztägiger Termin",
"Add title": "Titel hinzufügen",
"Add location": "Ort hinzufügen",
"Add to calendar": "Zum Kalender hinzufügen",
"Add to my calendar": "Zu meinem Kalender hinzufügen",
"Remove from calendar": "Aus dem Kalender entfernen",
"Remove from my calendar": "Aus meinem Kalender entfernen",
"All day": "Ganztägig",
"all-day": "ganztägig",
"Starts": "Beginnt",
"Starts (optional)": "Beginnt (optional)",
"Ends": "Endet",
"Ends (optional)": "Endet (optional)",
"Day": "Tag",
"Week": "Woche",
"Month": "Monat",
"Agenda": "Agenda",
"Today": "Heute",
"Go to day": "Zum Tag",
"Go to week": "Zur Woche",
"Previous month": "Voriger Monat",
"Next month": "Nächster Monat",
"Does not repeat": "Wiederholt sich nicht",
"Daily": "Täglich",
"Every weekday": "Jeden Wochentag",
"Yearly": "Jährlich",
"Custom…": "Benutzerdefiniert…",
"Weekly on {weekday}": "Wöchentlich am {weekday}",
"Monthly on day {day}": "Monatlich am {day}.",
"Repeat every": "Wiederholen alle",
"Repeat until": "Wiederholen bis",
"after N times": "nach N Malen",
"on date": "am Datum",
"never": "nie",
"day(s)": "Tag(e)",
"week(s)": "Woche(n)",
"month(s)": "Monat(e)",
"year(s)": "Jahr(e)",
"Reminders": "Erinnerungen",
"Add reminder": "Erinnerung hinzufügen",
"Remove reminder": "Erinnerung entfernen",
"Default reminder": "Standarderinnerung",
"At time of event": "Zum Zeitpunkt des Termins",
"5 minutes before": "5 Minuten vorher",
"10 minutes before": "10 Minuten vorher",
"15 minutes before": "15 Minuten vorher",
"30 minutes before": "30 Minuten vorher",
"1 hour before": "1 Stunde vorher",
"1 day before": "1 Tag vorher",
"15 minutes": "15 Minuten",
"30 minutes": "30 Minuten",
"45 minutes": "45 Minuten",
"1 hour": "1 Stunde",
"1.5 hours": "1,5 Stunden",
"2 hours": "2 Stunden",
"Default event length": "Standarddauer für Termine",
"Default view": "Standardansicht",
"Guests": "Teilnehmer",
"Add guests by name or email": "Teilnehmer nach Name oder E-Mail hinzufügen",
"Send invitation emails to guests": "Einladungen per E-Mail an Teilnehmer senden",
"Going?": "Nehmen Sie teil?",
"Yes": "Ja",
"No": "Nein",
"Maybe": "Vielleicht",
"Confirmed": "Zugesagt",
"Tentative": "Vorläufig",
"Cancelled": "Abgesagt",
"organizer": "Organisator",
"Organizer: {name}": "Organisator: {name}",
"Free": "Frei",
"Busy": "Gebucht",
"Free/busy": "Frei/Gebucht",
"Show as": "Anzeigen als",
"Availability on {date}": "Verfügbarkeit am {date}",
"Count all events as busy": "Alle Termine als gebucht zählen",
"Only events I'm attending": "Nur Termine, an denen ich teilnehme",
"Don't include in availability": "Nicht in die Verfügbarkeit einbeziehen",
"Meeting link": "Besprechungslink",
"No events in the next 60 days.": "Keine Termine in den nächsten 60 Tagen.",
"Working hours": "Arbeitszeiten",
"Working hours start": "Arbeitszeit beginnt",
"Working hours end": "Arbeitszeit endet",
"Colour categories": "Farbkategorien",
"Category": "Kategorie",
"No category": "Keine Kategorie",
"New category": "Neue Kategorie",
"Delete category": "Kategorie löschen",
"Manage categories…": "Kategorien verwalten…",
"Use category color": "Kategoriefarbe verwenden",
"Use calendar color": "Kalenderfarbe verwenden",
"Use the default colour": "Standardfarbe verwenden",
"+{n} more": "+{n} weitere",
"Contacts": "Kontakte",
"Contacts are not available": "Kontakte sind nicht verfügbar",
"New contact": "Neuer Kontakt",
"Edit contact": "Kontakt bearbeiten",
"Select a contact": "Kontakt auswählen",
"All contacts": "Alle Kontakte",
"Search contacts": "Kontakte durchsuchen",
"Search contacts to add…": "Kontakte zum Hinzufügen suchen…",
"Loading contacts…": "Kontakte werden geladen…",
"Add to contacts": "Zu Kontakten hinzufügen",
"Add to my contacts": "Zu meinen Kontakten hinzufügen",
"Remove from my contacts": "Aus meinen Kontakten entfernen",
"Address book": "Adressbuch",
"Address books": "Adressbücher",
"All address books": "Alle Adressbücher",
"My address books": "Meine Adressbücher",
"New address book": "Neues Adressbuch",
"No address books yet.": "Noch keine Adressbücher.",
"Choose from address books": "Aus Adressbüchern wählen",
"Import vCard": "vCard importieren",
"Export all": "Alle exportieren",
"Export book": "Adressbuch exportieren",
"Email group": "Gruppe anschreiben",
"Email everyone": "Alle anschreiben",
"Members": "Mitglieder",
"Members ({count})": "Mitglieder ({count})",
"Group": "Gruppe",
"· group": "· Gruppe",
"Person": "Person",
"First name": "Vorname",
"Last name": "Nachname",
"More name fields": "Weitere Namensfelder",
"Nickname": "Spitzname",
"Prefix": "Anrede",
"Suffix": "Namenszusatz",
"Dr.": "Dr.",
"Jr.": "Jr.",
"Display name": "Anzeigename",
"Job title": "Position",
"Organization": "Organisation",
"Company": "Firma",
"Birthday": "Geburtstag",
"Notes": "Notizen",
"Website": "Website",
"Phone": "Telefon",
"Add phone": "Telefon hinzufügen",
"Add email": "E-Mail hinzufügen",
"Add address": "Adresse hinzufügen",
"Address": "Adresse",
"Street": "Straße",
"City": "Stadt",
"State / Region": "Bundesland / Region",
"Postal code": "Postleitzahl",
"Country": "Land",
"Change photo": "Foto ändern",
"Remove photo": "Foto entfernen",
"Updated {date}": "Aktualisiert am {date}",
"Search names and addresses": "Namen und Adressen durchsuchen",
"Add a person or group…": "Person oder Gruppe hinzufügen…",
"Choose recipients": "Empfänger auswählen",
"Available to add": "Zum Hinzufügen verfügbar",
"vCard": "vCard",
"vCard attachment": "vCard-Anhang",
"Files": "Dateien",
"My files": "Meine Dateien",
"File storage is not available": "Dateispeicher ist nicht verfügbar",
"Drag files here or use Upload.": "Dateien hierher ziehen oder „Hochladen“ nutzen.",
"Shared": "Freigegeben",
"Shared with me": "Für mich freigegeben",
"Nothing is shared with you.": "Für Sie ist nichts freigegeben.",
"Not shared with anyone yet.": "Noch für niemanden freigegeben.",
"Check for new shares": "Nach neuen Freigaben suchen",
"Shared files are copied to your account when attached.": "Freigegebene Dateien werden beim Anhängen in Ihr Konto kopiert.",
"Viewer": "Leseberechtigt",
"Editor": "Bearbeitungsberechtigt",
"Size": "Größe",
// ── Settings ───────────────────────────────────────────────────────
"Settings": "Einstellungen",
"All settings": "Alle Einstellungen",
"Sections": "Bereiche",
"General": "Allgemein",
"Appearance": "Darstellung",
"Make ihasmail yours.": "Machen Sie ihasmail zu Ihrem.",
"Reading": "Lesen",
"Reading pane": "Lesebereich",
"Reading, sending and list behaviour. Settings are stored in this browser.": "Verhalten beim Lesen, Senden und in der Liste. Die Einstellungen werden in diesem Browser gespeichert.",
"Right of the list": "Rechts von der Liste",
"Below the list": "Unter der Liste",
"Hidden (open full width)": "Ausgeblendet (in voller Breite öffnen)",
"Off (open messages full width)": "Aus (Nachrichten in voller Breite öffnen)",
"Off": "Aus",
"Composing": "Verfassen",
"Default format": "Standardformat",
"Rich text (HTML)": "Formatierter Text (HTML)",
"Plain text": "Nur Text",
"Quote original message in replies": "Ursprüngliche Nachricht in Antworten zitieren",
"Place signature above quoted text": "Signatur über dem zitierten Text platzieren",
"Attachment reminder": "Anhang-Erinnerung",
"Warn when the message mentions an attachment but none is attached.": "Warnen, wenn die Nachricht einen Anhang erwähnt, aber keiner angehängt ist.",
"Spell check while typing": "Rechtschreibprüfung während der Eingabe",
"Confirm before deleting": "Vor dem Löschen bestätigen",
"Show message snippets": "Nachrichtenvorschau anzeigen",
"Preview the first line of each message in the list.": "Die erste Zeile jeder Nachricht in der Liste anzeigen.",
"Show sender avatars": "Absenderbilder anzeigen",
"Group messages from the same thread together.": "Nachrichten desselben Threads zusammenfassen.",
"After archiving or deleting": "Nach Archivieren oder Löschen",
"Ask before showing (recommended)": "Vor dem Anzeigen fragen (empfohlen)",
"Always (all messages)": "Immer (alle Nachrichten)",
"Show automatically from my contacts": "Bei meinen Kontakten automatisch anzeigen",
"Immediately when opened": "Sofort beim Öffnen",
"After 2 seconds": "Nach 2 Sekunden",
"After 5 seconds": "Nach 5 Sekunden",
"Never automatically": "Nie automatisch",
"When someone requests a read receipt": "Wenn jemand eine Lesebestätigung anfordert",
"Ask me on each message": "Bei jeder Nachricht fragen",
"5 seconds": "5 Sekunden",
"8 seconds": "8 Sekunden",
"15 seconds": "15 Sekunden",
"30 seconds": "30 Sekunden",
"Locale": "Regionales",
"Language": "Sprache",
"Interface language": "Sprache der Oberfläche",
"Language & region": "Sprache & Region",
"Date format": "Datumsformat",
"Time format": "Zeitformat",
"Time zone": "Zeitzone",
"Week starts on": "Woche beginnt am",
"Monday": "Montag",
"Saturday": "Samstag",
"Sunday": "Sonntag",
"12-hour clock (6:23 PM)": "12-Stunden-Format (6:23 PM)",
"24-hour clock (18:23)": "24-Stunden-Format (18:23)",
"Browser default ({zone})": "Browser-Standard ({zone})",
"Default ({zone})": "Standard ({zone})",
"Automatic ({example})": "Automatisch ({example})",
"Automatic ({locale})": "Automatisch ({locale})",
"Preview: {example}": "Vorschau: {example}",
"Dates, times and month names follow this choice.": "Datum, Uhrzeit und Monatsnamen richten sich nach dieser Auswahl.",
"Your mail server reports {name} ({tag}).": "Ihr Mailserver meldet {name} ({tag}).",
"Your mail server does not report a locale, so the browser's is used.": "Ihr Mailserver meldet keine Spracheinstellung, daher wird die des Browsers verwendet.",
"Dates": "Datum",
"Date": "Datum",
"Time": "Uhrzeit",
"When": "Wann",
"Then": "Dann",
"then": "dann",
"Theme": "Design",
"Accent color": "Akzentfarbe",
"Color": "Farbe",
"Colour": "Farbe",
"Text color": "Textfarbe",
"Density & text": "Dichte & Text",
"Display density": "Anzeigedichte",
"Comfortable": "Komfortabel",
"Cozy (default)": "Ausgewogen (Standard)",
"Compact": "Kompakt",
"Text size": "Schriftgröße",
"Font size": "Schriftgröße",
"Small": "Klein",
"Medium": "Mittel",
"Large": "Groß",
"Huge": "Sehr groß",
"Sidebar": "Seitenleiste",
"Show labels in the sidebar": "Labels in der Seitenleiste anzeigen",
"Collapse sidebar to icons": "Seitenleiste auf Symbole verkleinern",
"Apply the theme to messages too": "Design auch auf Nachrichten anwenden",
"Swiping": "Wischgesten",
"Swipe left": "Nach links wischen",
"Swipe right": "Nach rechts wischen",
"Backup": "Sicherung",
"Export settings": "Einstellungen exportieren",
"Import settings": "Einstellungen importieren",
"Settings imported": "Einstellungen importiert",
"Invalid settings file": "Ungültige Einstellungsdatei",
"Reset to defaults": "Auf Standard zurücksetzen",
"Default mail app": "Standard-E-Mail-Programm",
"Documentation": "Dokumentation",
"About ihasmail": "Über ihasmail",
"Server": "Server",
"Server capabilities": "Server-Funktionen",
"Accounts": "Konten",
"Account": "Konto",
"Max upload": "Maximaler Upload",
"{size} MB": "{size} MB",
"KB": "KB",
"Image privacy proxy": "Bild-Datenschutz-Proxy",
"enabled": "aktiviert",
"disabled": "deaktiviert",
"Enabled": "Aktiviert",
"active": "aktiv",
"hidden": "ausgeblendet",
"connected": "verbunden",
"reconnecting…": "Verbindung wird wiederhergestellt…",
"AGPL-3.0 source": "AGPL-3.0-Quellcode",
// ── Identities, templates, filters ─────────────────────────────────
"Identities & signatures": "Identitäten & Signaturen",
"Add identity": "Identität hinzufügen",
"Delete identity": "Identität löschen",
"Make default": "Als Standard festlegen",
"Default": "Standard",
"Show when composing": "Beim Verfassen anzeigen",
"Hide when composing": "Beim Verfassen ausblenden",
"Signature": "Signatur",
"Your signature…": "Ihre Signatur…",
"Reply-To": "Antwort an",
"Reply-To (optional)": "Antwort an (optional)",
"Reply-To: {addresses}": "Antwort an: {addresses}",
"Replies go to…": "Antworten gehen an…",
"Set a Reply-To address": "Antwortadresse festlegen",
"{email} is now your default identity": "{email} ist jetzt Ihre Standardidentität",
"Templates": "Vorlagen",
"New template": "Neue Vorlage",
"Delete template": "Vorlage löschen",
"Insert template": "Vorlage einfügen",
"Template text…": "Vorlagentext…",
"Subject: {subject}": "Betreff: {subject}",
"Filters & rules": "Filter & Regeln",
"Filters unavailable": "Filter nicht verfügbar",
"Rules": "Regeln",
"Rule name": "Regelname",
"New rule": "Neue Regel",
"Delete rule": "Regel löschen",
"No filters yet": "Noch keine Filter",
"Add condition": "Bedingung hinzufügen",
"Remove condition": "Bedingung entfernen",
"Add action": "Aktion hinzufügen",
"Remove action": "Aktion entfernen",
"all of the following match": "alle folgenden zutreffen",
"any of the following match": "eine der folgenden zutrifft",
"contains": "enthält",
"does not contain": "enthält nicht",
"is larger than": "ist größer als",
"is smaller than": "ist kleiner als",
"Stop processing more rules": "Keine weiteren Regeln verarbeiten",
"keep copy": "Kopie behalten",
"Forward to": "Weiterleiten an",
"Reject with message": "Mit Nachricht abweisen",
"Scripts": "Skripte",
"Scripts (advanced)": "Skripte (fortgeschritten)",
"Script name": "Skriptname",
"New script": "Neues Skript",
"Delete script": "Skript löschen",
"Sieve source": "Sieve-Quelltext",
"Preview generated Sieve script": "Erzeugtes Sieve-Skript ansehen",
"Start with rules": "Mit Regeln beginnen",
"Switch to rules?": "Zu Regeln wechseln?",
"Create filter": "Filter erstellen",
"Filter messages like this": "Nachrichten wie diese filtern",
"Filter messages like this…": "Nachrichten wie diese filtern…",
"Also apply to existing messages in": "Auch auf vorhandene Nachrichten anwenden in",
"keyword (e.g. $important, work)": "Schlüsselwort (z. B. $important, work)",
"Out of office": "Abwesenheit",
"Auto-reply enabled": "Automatische Antwort aktiviert",
// ── Security, sessions, notifications ──────────────────────────────
"Security & sessions": "Sicherheit & Sitzungen",
"Password": "Passwort",
"Your password": "Ihr Passwort",
"Current password": "Aktuelles Passwort",
"New password": "Neues Passwort",
"Confirm new password": "Neues Passwort bestätigen",
"Current code": "Aktueller Code",
"Code from your authenticator": "Code aus Ihrer Authenticator-App",
"Two-factor authentication": "Zwei-Faktor-Authentifizierung",
"Turn off two-factor authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
"Your password alone will be enough to sign in again.": "Ihr Passwort allein genügt dann wieder zum Anmelden.",
"App passwords": "App-Passwörter",
"New app password for": "Neues App-Passwort für",
"Your new app password": "Ihr neues App-Passwort",
"Secret": "Geheimnis",
"Thunderbird on my laptop": "Thunderbird auf meinem Laptop",
"Active webmail sessions": "Aktive Webmail-Sitzungen",
"Sign out": "Abmelden",
"Sign out here": "Hier abmelden",
"Sign out all other sessions": "Alle anderen Sitzungen abmelden",
"Signed in as": "Angemeldet als",
"This is my own device": "Das ist mein eigenes Gerät",
"this device": "dieses Gerät",
"Device": "Gerät",
"IP": "IP",
"Last active": "Zuletzt aktiv",
"Created": "Erstellt",
"Expires": "Läuft ab",
"Status": "Status",
"Online": "Online",
"Reason": "Grund",
"Type": "Typ",
"Email or username": "E-Mail oder Benutzername",
"Use your usual address as the username.": "Verwenden Sie Ihre gewohnte Adresse als Benutzernamen.",
"Fast, friendly webmail. Your mailbox, your way.": "Schnelle, freundliche Webmail. Ihr Postfach, wie Sie es wollen.",
"Notifications": "Benachrichtigungen",
"Notifications are blocked in your browser settings.": "Benachrichtigungen sind in Ihren Browsereinstellungen blockiert.",
"Not supported in this browser.": "In diesem Browser nicht unterstützt.",
"Desktop notifications while ihasmail is open": "Desktop-Benachrichtigungen, solange ihasmail geöffnet ist",
"Notify me even when ihasmail is closed": "Auch benachrichtigen, wenn ihasmail geschlossen ist",
"Play a sound for new mail": "Ton bei neuer E-Mail abspielen",
"Test notification": "Testbenachrichtigung",
"Background notifications are on": "Hintergrundbenachrichtigungen sind aktiviert",
"The tab title and favicon always show your unread Inbox count.": "Tab-Titel und Favicon zeigen immer die Anzahl ungelesener Nachrichten im Posteingang.",
"Live updates are delivered via JMAP push ({state}).": "Live-Aktualisierungen kommen über JMAP-Push ({state}).",
"Shows a system notification when new mail arrives in your Inbox while the tab is in the background.": "Zeigt eine Systembenachrichtigung, wenn neue Nachrichten im Posteingang eintreffen, während der Tab im Hintergrund ist.",
// ── Editor, search, shortcuts, misc ────────────────────────────────
"Formatting": "Formatierung",
"Formatting options": "Formatierungsoptionen",
"Remove formatting": "Formatierung entfernen",
"Bold (Ctrl+B)": "Fett (Strg+B)",
"Italic (Ctrl+I)": "Kursiv (Strg+I)",
"Underline (Ctrl+U)": "Unterstrichen (Strg+U)",
"Strikethrough": "Durchgestrichen",
"Highlight": "Hervorheben",
"Bulleted list": "Aufzählung",
"Numbered list": "Nummerierte Liste",
"Increase indent": "Einzug vergrößern",
"Decrease indent": "Einzug verkleinern",
"Align left": "Linksbündig",
"Align right": "Rechtsbündig",
"Center": "Zentriert",
"Quote": "Zitat",
"Code block": "Codeblock",
"Normal text": "Normaler Text",
"Insert link (Ctrl+K)": "Link einfügen (Strg+K)",
"Insert image": "Bild einfügen",
"Link": "Link",
"List": "Liste",
"Emoji": "Emoji",
"Write your message…": "Schreiben Sie Ihre Nachricht…",
"Search": "Suchen",
"Search mail": "E-Mail durchsuchen",
"Advanced search": "Erweiterte Suche",
"Keyboard shortcuts": "Tastenkürzel",
"Keyboard shortcuts (?)": "Tastenkürzel (?)",
"Shortcuts": "Kürzel",
"Go to": "Gehe zu",
"Menu": "Menü",
"Options": "Optionen",
"Send options": "Sendeoptionen",
"Name": "Name",
"Email": "E-Mail",
"Email address": "E-Mail-Adresse",
"Description": "Beschreibung",
"Location": "Ort",
"Visibility": "Sichtbarkeit",
"Private": "Privat",
"Work": "Arbeit",
"Loading…": "Wird geladen…",
"None": "Keine",
"optional": "optional",
"Always show": "Immer anzeigen",
"to": "an",
"Received": "Empfangen",
"In-Reply-To": "In-Reply-To",
"References": "References",
"Add label / keyword": "Label / Schlüsselwort hinzufügen",
"Manage labels": "Labels verwalten",
"Create “{name}”": "„{name}“ erstellen",
"Type a name to create your first label.": "Geben Sie einen Namen ein, um Ihr erstes Label zu erstellen.",
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Labels sind IMAP-Schlüsselwörter, die in Ihren Nachrichten gespeichert werden und daher mit anderen Clients synchronisiert werden. Namen und Farben bleiben in diesem Browser.",
"PDF": "PDF",
"Large attachments may be rejected by some servers": "Große Anhänge werden von manchen Servern abgelehnt",
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Bilder werden in Ihren Dateien (Ordner „ihasmail“) gespeichert und beim Senden eingebettet.",
"Thanks for your message. I'm away until … and will reply when I'm back.": "Vielen Dank für Ihre Nachricht. Ich bin bis … abwesend und melde mich nach meiner Rückkehr.",
"Automatically reply to people who email you while you're away. Each sender gets at most one reply.": "Automatisch auf E-Mails antworten, während Sie abwesend sind. Jeder Absender erhält höchstens eine Antwort.",
"Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.": "Eingehende Nachrichten automatisch sortieren. Die Regeln laufen auf dem Server (Sieve) und gelten daher für jeden Client, den Sie nutzen.",
"Canned responses you can insert into any message from the composer's template button.": "Vorgefertigte Antworten, die Sie über die Vorlagen-Schaltfläche im Editor in jede Nachricht einfügen können.",
"Create a rule to move newsletters to a folder, flag important senders, or forward mail.": "Erstellen Sie eine Regel, um Newsletter in einen Ordner zu verschieben, wichtige Absender zu markieren oder Nachrichten weiterzuleiten.",
"Advanced: manage raw Sieve scripts. Only one script can be active at a time.": "Fortgeschritten: Sieve-Skripte direkt verwalten. Es kann immer nur ein Skript aktiv sein.",
"Only part of your filter script arrived.": "Ihr Filterskript ist nur teilweise angekommen.",
"Your active script “{name}” was written by hand.": "Ihr aktives Skript „{name}“ wurde von Hand geschrieben.",
"Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.": "Ein anderes Skript („{name}“) ist aktiv. Wenn Sie hier Regeln speichern, wird stattdessen das Skript „ihasmail“ aktiviert.",
"“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.": "„{name}“ wird deaktiviert (nicht gelöscht) und ein neues Skript „ihasmail“ übernimmt.",
"Sieve filtering is not available for this account.": "Sieve-Filterung ist für dieses Konto nicht verfügbar.",
"Sieve filtering is not enabled for this account.": "Sieve-Filterung ist für dieses Konto nicht aktiviert.",
"Vacation responses are not available for this account.": "Abwesenheitsnotizen sind für dieses Konto nicht verfügbar.",
"This account does not have the JMAP calendars capability.": "Dieses Konto verfügt nicht über die JMAP-Kalenderfunktion.",
"This account does not have the JMAP contacts capability.": "Dieses Konto verfügt nicht über die JMAP-Kontaktfunktion.",
"This account does not have the JMAP file storage capability.": "Dieses Konto verfügt nicht über die JMAP-Dateispeicherfunktion.",
"tell us about it": "sagen Sie uns Bescheid",
"This translation was generated by AI and has not been checked by a native speaker, so it is marked Beta until somebody who speaks it signs it off. Anything that reads wrongly is worth reporting — {report}.": "Diese Übersetzung wurde von einer KI erstellt und nicht von einer Person mit Muttersprache Deutsch geprüft; sie ist daher als Beta gekennzeichnet, bis jemand sie freigibt. Alles, was falsch klingt, ist eine Meldung wert — {report}.",
// ── Longer prose ───────────────────────────────────────────────────
// Product names, example addresses and bare URLs are deliberately absent:
// they fall back to English because that is what they should say.
"After": "Nach",
"Before": "Vor",
"Modified": "Geändert",
"Middle name": "Zweiter Vorname",
"Minimize": "Minimieren",
"New label": "Neues Label",
"Delete label": "Label löschen",
"Choose a date": "Datum wählen",
"Choose a date and time": "Datum und Uhrzeit wählen",
"Pick date and time…": "Datum und Uhrzeit wählen…",
"Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)": "E-Mail durchsuchen (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)",
"Settings → Filters & rules": "Einstellungen → Filter & Regeln",
"Open the Mail view to see all shortcuts.": "Öffnen Sie die E-Mail-Ansicht, um alle Tastenkürzel zu sehen.",
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Tastenkürzel im Gmail-Stil sind immer aktiv. Drücken Sie überall {key}, um diese Liste zu sehen.",
"Select a conversation to read it here · Press {key} for shortcuts": "Wählen Sie eine Konversation, um sie hier zu lesen · {key} für Tastenkürzel",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tipp: Drücken Sie {key} auf einer Konversation, um Labels zu vergeben. Suchen Sie mit {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Eine schnelle, freundliche Open-Source-Webmail für {server}, auf JMAP aufgebaut.",
"Defaults for the calendar views and new events.": "Vorgaben für die Kalenderansichten und neue Termine.",
"Replies will go to this address instead of the From address": "Antworten gehen an diese Adresse statt an die Absenderadresse",
"Replies to mail sent from this identity go here instead of the From address.": "Antworten auf Nachrichten von dieser Identität gehen hierhin statt an die Absenderadresse.",
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Neue Identitäten müssen eine Adresse verwenden, von der dieses Konto senden darf (auf dem Server eingerichtete Aliase).",
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Wird beim Verfassen nicht angeboten. Die Adresse empfängt weiterhin Nachrichten, und Sie können wieder von ihr senden, indem Sie sie erneut einblenden.",
"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.": "Jede Identität ist eine Absenderadresse mit eigenem Namen, eigener Antwortadresse und eigener Signatur. Die Standardidentität ist beim Verfassen vorausgewählt; legen Sie eine Antwortadresse fest, wenn Antworten woanders hingehen sollen als an die Absenderadresse.",
"This signature is larger than the server's {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.": "Diese Signatur überschreitet das Limit des Servers von {limit} Byte. ihasmail behält die vollständige Fassung in Ihren Dateien und speichert eine kurze Textfassung auf dem Server — andere E-Mail-Programme sehen die Nur-Text-Fassung.",
"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.": "Kategorien im Outlook-Stil, die Sie Terminen über das Rechtsklick-Menü oder den Termin-Editor zuweisen können. Der Kategoriename wird im Termin gespeichert und daher mit anderen Clients synchronisiert.",
"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.": "Nur-Text-Nachrichten folgen dem Design bereits. Ist dies aktiviert, gilt das auch für HTML-Nachrichten ohne eigene Farben, statt sie auf einer weißen Fläche darzustellen. Nachrichten mit eigener Gestaltung bleiben genau so, wie der Absender sie entworfen hat.",
"This is separate from {setting} 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.": "Das ist unabhängig von {setting} unter „Allgemein“, wo festgelegt wird, wie Datum, Uhrzeit und Zahlen geschrieben werden. Sie können eine englische Oberfläche mit deutschen Datumsangaben lesen — oder umgekehrt.",
"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.": "Auf einem Touchscreen ziehen Sie eine Nachricht zur Seite, um sie zu bearbeiten. Jede Richtung kann eine Aktion ausführen — oder keine. Die Einstellung folgt Ihrem Konto, sodass Telefon und Tablet übereinstimmen; mit der Maus wird sie ignoriert und Nachrichten werden weiterhin in Ordner gezogen.",
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Dieser Bildschirm hat keinen Touchscreen, hier ändert sich also nichts. Ihr Telefon oder Tablet übernimmt diese Einstellungen.",
"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.": "Eine Nachricht gedrückt halten wählt sie aus, einen Ordner gedrückt halten öffnet dessen Menü. Ziehen Sie die Nachrichtenliste nach unten, um nach neuer Post zu sehen.",
"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.": "Eine Bestätigung verrät dem Anfragenden, dass diese Adresse aktiv ist und wann die Nachricht gelesen wurde, und der Absender bestimmt, wohin sie geht — deshalb gibt es keine automatische Option. Bei Massensendungen, Mailinglisten und allem, was als automatisch versendet gekennzeichnet ist, wird sie nie angeboten.",
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "Dieser Browser kann keine Programme für {scheme}-Links registrieren. Safari hat insbesondere keine solche Schnittstelle — Sie können ihasmail dennoch über Ihr Betriebssystem als Standard festlegen, wenn Sie es als App installieren.",
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Für die Registrierung von {scheme}-Links ist eine sichere Verbindung (HTTPS) erforderlich.",
"Open {scheme} links — in web pages, documents and other apps — in ihasmail instead of a desktop mail client. Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings Privacy and security Site settings Protocol handlers; Firefox: Settings General Applications).": "{scheme}-Links — auf Webseiten, in Dokumenten und anderen Programmen — in ihasmail öffnen statt in einem Desktop-Mailprogramm. Ihr Browser fragt nach einer Bestätigung, und Sie können das später in seinen eigenen Einstellungen ändern (Chrome: Einstellungen Datenschutz und Sicherheit Website-Einstellungen Protokoll-Handler; Firefox: Einstellungen Allgemein Anwendungen).",
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "In diesem Browser angefordert. Ob es gewirkt hat, entscheidet der Browser — prüfen Sie dessen Einstellungen, falls E-Mail-Links weiterhin anderswo geöffnet werden.",
"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.": "Für einen systemweiten Standard installieren Sie ihasmail zuerst als App (in Chrome: das Installationssymbol in der Adressleiste). Ihr Betriebssystem kann ihasmail dann überall dort direkt anbieten, wo es nach einem E-Mail-Programm fragt.",
"Needs a browser with the Push API and a mail server that publishes a push key.": "Erfordert einen Browser mit Push-API und einen Mailserver, der einen Push-Schlüssel veröffentlicht.",
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Ihr Mailserver stellt diese direkt an Ihren Browser zu, sodass sie auch ohne geöffneten ihasmail-Tab ankommen — mit Absender und Betreff. Ihr Browser muss dennoch laufen: Beenden Sie ihn vollständig, warten die Benachrichtigungen und kommen an, sobald Sie ihn wieder öffnen.",
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Ihr Mailserver kann diesen Browser wecken, übermittelt aber weder Absender noch Betreff. Ihr Browser muss dennoch laufen.",
"This is what a new-mail notification looks like.": "So sieht eine Benachrichtigung über neue Post aus.",
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Sie sind als {user} angemeldet. Ihr Passwort wird nie im Browser gespeichert; der Server hält es pro Sitzung verschlüsselt vor, um mit Stalwart zu kommunizieren.",
"App passwords are managed by your mail administrator.": "App-Passwörter werden von Ihrer Mail-Administration verwaltet.",
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Wenn Sie Ihr Passwort ändern, werden Ihre anderen Webmail-Sitzungen abgemeldet. App-Passwörter funktionieren weiterhin.",
"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.": "Für dieses Konto ist die Zwei-Faktor-Authentifizierung aktiviert. ihasmail kann Sie noch nicht per Code anmelden; die Anmeldung auf einem anderen Gerät benötigt daher ein App-Passwort — oder Sie deaktivieren die Zwei-Faktor-Authentifizierung hier.",
"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.": "Ein eigenes Passwort für ein E-Mail-Programm oder Gerät, das Sie einzeln widerrufen können. App-Passwörter umgehen Zwei-Faktor-Codes und funktionieren daher auch in Programmen, die keinen abfragen können.",
"Copy it into {name} now — it isn't shown again.": "Übertragen Sie es jetzt nach {name} — es wird nicht erneut angezeigt.",
"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.": "Im Verzeichnis wurden keine weiteren Benutzer gefunden, es kann also niemand Neues hinzugefügt werden. Bestehende Freigaben sind unten aufgeführt und können weiterhin entfernt werden.",
"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.": "Stalwart gibt seine Versionsnummer nicht an E-Mail-Programme weiter, daher nennt ihasmail die Edition, sofern der Server eine angibt. ihasmail benötigt 0.16 oder neuer; die Anmeldung verweigert ältere Versionen.",
"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.": "Es {damage}, daher können die enthaltenen Regeln weder angezeigt noch bearbeitet werden — das Speichern des angekommenen Teils würde den Rest überschreiben. Laden Sie die Seite neu und versuchen Sie es erneut. Ihre Regeln liegen weiterhin auf dem Server; hier wurde nichts daran geändert.",
"The visual rule editor only manages scripts it created. You can edit the script in the {tab} tab, or start fresh with rules (the existing script will be kept but deactivated).": "Der visuelle Regeleditor verwaltet nur Skripte, die er selbst erstellt hat. Sie können das Skript im Reiter {tab} bearbeiten oder neu mit Regeln beginnen (das vorhandene Skript bleibt erhalten, wird aber deaktiviert).",
"Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.": "Ihr Filterskript {damage}, daher ist nur ein Teil angekommen. Eine Regel hinzuzufügen würde diesen Teil über das Ganze schreiben. Laden Sie die Seite neu und versuchen Sie es erneut.",
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Ihr Filterskript konnte gerade nicht gelesen werden; eine Regel hinzuzufügen würde riskieren, es zu überschreiben. Laden Sie die Seite neu und versuchen Sie es erneut.",
"Your active Sieve script was written by hand, so rules can't be added automatically. Open {where} to edit the script or switch to managed rules.": "Ihr aktives Sieve-Skript wurde von Hand geschrieben, daher können Regeln nicht automatisch hinzugefügt werden. Öffnen Sie {where}, um das Skript zu bearbeiten oder zu verwalteten Regeln zu wechseln.",
"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.": "Hier erscheinen nur Sprachen, in die ihasmail übersetzt wurde; die Liste wächst also mit den Übersetzungen und nicht vorab — eine Sprache ohne hinterlegte Texte würde die Seite behaupten lassen, sie sei in einer Sprache, in der sie nicht ist.",
// ── Labels defined as constants, translated where they render ──────
// The catalogue checker cannot see these: they reach t() as a variable,
// so there is no string literal for it to find. Listed here deliberately.
"About": "Über",
"Add": "Hinzufügen",
"Add files": "Dateien hinzufügen",
"Automatic": "Automatisch",
"Create subfolders": "Unterordner erstellen",
"Dark": "Dunkel",
"Light": "Hell",
"Match system": "System folgen",
"Day.Month.Year": "Tag.Monat.Jahr",
"Day/Month/Year": "Tag/Monat/Jahr",
"Month/Day/Year": "Monat/Tag/Jahr",
"Year-Month-Day (ISO 8601)": "Jahr-Monat-Tag (ISO 8601)",
"Edit all": "Alles bearbeiten",
"Edit contents": "Inhalte bearbeiten",
"Edit own": "Eigene bearbeiten",
"Flag": "Markieren",
"Mark read": "Als gelesen markieren",
"Private props": "Private Eigenschaften",
"Read": "Lesen",
"Read events": "Termine lesen",
"RSVP": "Zusagen",
"See free/busy": "Frei/Gebucht sehen",
"Share": "Freigeben",
"Write": "Schreiben",
"Live updates connected": "Live-Aktualisierungen verbunden",
"Live updates reconnecting…": "Live-Aktualisierungen werden wieder verbunden…",
"Live updates off — checking periodically instead": "Live-Aktualisierungen aus — es wird stattdessen regelmäßig geprüft",
"Mark as read / unread": "Als gelesen / ungelesen markieren",
"Star / unstar": "Markieren / Markierung entfernen",
"Report spam / not spam": "Als Spam / Nicht-Spam melden",
"Report spam": "Als Spam melden",
"Not spam": "Kein Spam",
"Nothing": "Nichts",
"No conversation selected": "Keine Konversation ausgewählt",
"Drop here for the top level": "Hierher ziehen für die oberste Ebene",
// ── Remaining prose ────────────────────────────────────────────────
"{name} is the palette from {site}, 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.": "{name} ist die Farbpalette von {site} und das, womit ein neues Konto startet. Es ist ein dunkles Design und zählt daher überall dort als dunkel, wo das eine Rolle spielt; die Akzentfarbe unten wirkt weiterhin darauf.",
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} 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 — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "Die Version von ihasmail ist das Datum des Commits, aus dem es gebaut wurde, gefolgt davon, woher dieser Commit stammt: {example} wurde aus einem Commit vom 30. August 2026 gebaut, der über Pull Request 129 kam. Ein Commit, der nicht über einen solchen kam, trägt stattdessen seinen kurzen SHA — {sha}. Die Version sagt bewusst nichts über Stalwart aus; was dieser Build vom Server benötigt, steht in der Zeile darüber.",
// ── Weekdays, schedule presets, rule operators ─────────────────────
// Header names (List-Id, X-Spam-Status) stay English: they are the actual
// field names in the message, not words.
"Tuesday": "Dienstag",
"Wednesday": "Mittwoch",
"Thursday": "Donnerstag",
"Friday": "Freitag",
"Later today": "Später heute",
"Tomorrow morning": "Morgen früh",
"Tomorrow afternoon": "Morgen Nachmittag",
"Monday morning": "Montagmorgen",
"Open draft": "Entwurf öffnen",
"Undo": "Rückgängig",
"Deleted Items": "Papierkorb",
"Other header…": "Andere Kopfzeile…",
"is": "ist",
"is not": "ist nicht",
"matches (wildcards * ?)": "entspricht (Platzhalter * ?)",
"does not match": "entspricht nicht",
"matches regex": "entspricht regulärem Ausdruck",
"does not match regex": "entspricht regulärem Ausdruck nicht",
"exists": "ist vorhanden",
"does not exist": "ist nicht vorhanden",
// ── Folder names shown for a JMAP role (see lib/mailboxName.ts) ────
// Not what the server calls them: Stalwart names these once at account
// creation and cannot rename them afterwards. Custom folders keep the
// reader's own words and are never translated.
// Keyed with the "folder" context (see tc() in lib/i18n.ts), because two
// of these are also something else in English: "Archive" is the button
// that archives a message, and "Important" is a priority tag. German wants
// a different word for each, and one key cannot hold both.
"folder\u0004Inbox": "Posteingang",
"folder\u0004Archive": "Archiv",
"folder\u0004Drafts": "Entwürfe",
"folder\u0004Sent": "Gesendet",
"folder\u0004Deleted Items": "Papierkorb",
"folder\u0004Junk Mail": "Spam",
"folder\u0004Important": "Wichtig",
"folder\u0004All mail": "Alle Nachrichten",
"folder": "Ordner",
"“{name}” moved into “{parent}”": "„{name}“ wurde nach „{parent}“ verschoben",
"“{name}” moved to the top level": "„{name}“ wurde auf die oberste Ebene verschoben",
"Could not move “{name}”: {reason}": "„{name}“ konnte nicht verschoben werden: {reason}",
"Delete “{name}”?": "„{name}“ löschen?",
"Rename folder": "Ordner umbenennen",
"Search: {query}": "Suche: {query}",
},
plurals: {
"{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" },
"{n} selected": { one: "{n} ausgewählt", other: "{n} ausgewählt" },
"{n} conversations": { one: "{n} Konversation", other: "{n} Konversationen" },
},
};
@@ -34,8 +34,15 @@ describe("applyLang", () => {
expect(document.documentElement.lang).toBe("en");
});
it("falls back to English rather than claiming a language it cannot render", () => {
it("serves a language whose catalogue is shipped", () => {
applyLang({ ...DEFAULT_SETTINGS, uiLanguage: "de" });
expect(document.documentElement.lang).toBe("de");
});
it("falls back to English rather than claiming a language it cannot render", () => {
// A tag no catalogue exists for -- an account carrying a preference from a
// build that shipped more languages than this one.
applyLang({ ...DEFAULT_SETTINGS, uiLanguage: "fr" });
expect(document.documentElement.lang).toBe("en");
});
+5 -1
View File
@@ -21,6 +21,8 @@ import type {
import { toast } from "@/ui/toast";
import { settings, useSettings } from "./settings";
import { useSession } from "./session";
import { mailboxDisplayName } from "@/lib/mailboxName";
import { t } from "@/lib/i18n";
/*
* Named explicitly so `shareWith` comes back, which it does not otherwise --
@@ -478,7 +480,9 @@ export const useMail = create<MailState>((set, get) => ({
// moved to "Trash" or "Spam" on a server whose folders are called
// "Deleted Items" and "Junk Mail" -- naming somewhere that does not
// exist, in the one message whose job is saying where it went.
const name = mailboxes[toMailboxId]?.name ?? opts.label ?? "folder";
// Through the display name, so the message names the folder the reader
// is looking at in the sidebar rather than the server's own word for it.
const name = mailboxDisplayName(mailboxes[toMailboxId]) || opts.label || t("folder");
toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, {
action: {
label: "Undo",
+4 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { create } from "zustand";
import { loadJson, saveJson } from "@/lib/storage";
import { queueSettingsPush } from "@/lib/settingsSync";
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
import type { SwipeAction } from "@/lib/swipe";
import { resolveUiLanguage } from "@/lib/languages";
import { loadLanguage } from "@/lib/i18n";
@@ -344,6 +344,9 @@ export const useSettings = create<SettingsState>((set, get) => ({
}));
function applyDateTimePrefs(s: Settings): void {
// The interface language feeds the automatic locale, so month and weekday
// names follow the language somebody chose rather than staying English.
setUiLanguageForFormatting(resolveUiLanguage(s.uiLanguage));
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
}
+2 -2
View File
@@ -81,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</Link>
<SearchBar />
<div className="topbar-actions">
<span className="push-status hide-mobile" role="img" aria-label={PUSH_LABEL[pushState]} title={PUSH_LABEL[pushState]}>
<span className="push-status hide-mobile" role="img" aria-label={t(PUSH_LABEL[pushState])} title={t(PUSH_LABEL[pushState])}>
<span className={`push-dot ${pushState}`} />
</span>
<button className="icon-btn hide-mobile" aria-label={t("Keyboard shortcuts")} title={t("Keyboard shortcuts (?)")} onClick={() => setHelpOpen(true)}>
@@ -132,7 +132,7 @@ export function AppShell({ children }: { children: ReactNode }) {
}}
>
{section === "files" ? <Upload size={22} /> : section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : section === "files" ? "Upload" : "Compose"}</span>
<span>{section === "calendar" ? t("New event") : section === "contacts" ? t("New contact") : section === "files" ? t("Upload") : t("Compose")}</span>
</button>
<div className="sidebar-scroll">
{(section === "mail" || section === "search") && <MailboxTree />}
+2 -2
View File
@@ -8,7 +8,7 @@ import { RuleDialog } from "../settings/RuleDialog";
import { toast } from "@/ui/toast";
import { Spinner } from "@/ui/misc";
import { Dialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
import { t, tNode } from "@/lib/i18n";
/** "Filter messages like this…" — creates a Sieve rule seeded from a message, optionally applying it to the current folder. */
export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: Email; mailboxId: Id | null; onClose: () => void }) {
@@ -47,7 +47,7 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
{damage ? (
<p>{t("Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.", { damage })}</p>
) : loaded ? (
<p>{t("Your active Sieve script was written by hand, so rules can't be added automatically. Open")} <b>{t("Settings → Filters & rules")}</b> {t("to edit the script or switch to managed rules.")}</p>
<p>{tNode("Your active Sieve script was written by hand, so rules can't be added automatically. Open {where} to edit the script or switch to managed rules.", { where: <b>{t("Settings → Filters & rules")}</b> })}</p>
) : (
<p>{t("Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.")}</p>
)}
+5 -4
View File
@@ -16,7 +16,8 @@ import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { isUnknownMailbox } from "@/lib/mailboxRoute";
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
import { t as translate } from "@/lib/i18n";
import { plural, t as translate, tNode } from "@/lib/i18n";
import { mailboxDisplayName } from "@/lib/mailboxName";
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
const [, navigate] = useLocation();
@@ -306,7 +307,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
[emails, mailboxId, mailboxes, openThread, openDraft],
);
const title = search ? `Search: ${listQuery?.label ?? q}` : (mailboxId && mailboxes[mailboxId]?.name) || "Mail";
const title = search ? translate("Search: {query}", { query: listQuery?.label ?? q }) : (mailboxId && mailboxDisplayName(mailboxes[mailboxId])) || translate("Mail");
const reading = Boolean(threadId);
const paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right";
const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading);
@@ -353,8 +354,8 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
) : (
<div className="no-thread">
<img src="/img/logo.png" alt="" />
<div>{list?.total ? `${list.total} conversation${list.total === 1 ? "" : "s"}` : "No conversation selected"}</div>
<div className="hint">{translate("Select a conversation to read it here · Press")} <kbd className="kbd">?</kbd> {translate("for shortcuts")}</div>
<div>{list?.total ? plural(list.total, { one: "{n} conversation", other: "{n} conversations" }) : translate("No conversation selected")}</div>
<div className="hint">{tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })}</div>
</div>
)}
</div>
+2 -1
View File
@@ -4,6 +4,7 @@ import { useMail } from "@/store/mail";
import { Dialog } from "@/ui/dialog";
import type { Id, Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
import { mailboxDisplayPath } from "@/lib/mailboxName";
export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) {
const mailboxes = useMail((s) => s.mailboxes);
@@ -13,7 +14,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri
const list = useMemo(() => {
const all = Object.values(mailboxes)
.filter((m) => !exclude?.includes(m.id) && m.myRights.mayAddItems)
.map((m) => ({ m, path: mailboxPath(m.id) }))
.map((m) => ({ m, path: mailboxDisplayPath(m, mailboxes) }))
.sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path)));
const ql = q.trim().toLowerCase();
return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all;
+10 -7
View File
@@ -15,6 +15,7 @@ import { loadRaw, saveJson } from "@/lib/storage";
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
import { haptic, useTouchRow } from "@/lib/touch";
import { t } from "@/lib/i18n";
import { mailboxDisplayName } from "@/lib/mailboxName";
const ROLE_ICONS: Record<string, ReactNode> = {
inbox: <Inbox size={20} />,
@@ -63,9 +64,9 @@ export function MailboxTree() {
setExpanded(next);
saveJson("mbx-expanded", next);
}
toast.success(parentId ? `${m?.name}” moved into “${mailboxes[parentId]?.name}` : `${m?.name}” moved to the top level`);
toast.success(parentId ? t("“{name}” moved into “{parent}”", { name: mailboxDisplayName(m), parent: mailboxDisplayName(mailboxes[parentId]) }) : t("“{name}” moved to the top level", { name: mailboxDisplayName(m) }));
} catch (err) {
toast.error(`Could not move “${m?.name}”: ${(err as Error).message}`);
toast.error(t("Could not move “{name}”: {reason}", { name: mailboxDisplayName(m), reason: (err as Error).message }));
}
};
@@ -143,7 +144,7 @@ export function MailboxTree() {
if (id) void moveFolder(id, null);
}}
>
<span>{draggingId && canDropOn(null) ? "Drop here for the top level" : "Folders"}</span>
<span>{draggingId && canDropOn(null) ? t("Drop here for the top level") : t("Folders")}</span>
<button className="icon-btn" title={t("New folder")} aria-label={t("New folder")} onClick={() => void createFolder(null)}>
<Plus size={16} />
</button>
@@ -152,7 +153,7 @@ export function MailboxTree() {
<FolderRow
key={m.id}
mailbox={m}
label={m.name}
label={mailboxDisplayName(m)}
depth={depth}
hasChildren={hasChildren}
open={open}
@@ -332,7 +333,9 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
return n;
});
const rename = async () => {
const name = await promptDialog({ title: "Rename folder", defaultValue: m.name });
const name = await // The server's own name, never the localised one: this box writes
// back whatever it is prefilled with.
promptDialog({ title: t("Rename folder"), defaultValue: m.name });
if (!name?.trim() || name.trim() === m.name) return;
try {
await useMail.getState().updateMailbox(m.id, { name: name.trim() });
@@ -341,7 +344,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
}
};
const remove = async () => {
const ok = await confirmDialog({ title: `Delete “${m.name}”?`, message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true });
const ok = await confirmDialog({ title: t("Delete “{name}”?", { name: mailboxDisplayName(m) }), message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true });
if (!ok) return;
try {
await useMail.getState().destroyMailbox(m.id, true);
@@ -351,7 +354,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
toast.error((err as Error).message);
}
};
const empty = () => confirmAndEmpty({ id: m.id, name: m.name, role: m.role, totalEmails: m.totalEmails });
const empty = () => confirmAndEmpty({ id: m.id, name: mailboxDisplayName(m), role: m.role, totalEmails: m.totalEmails });
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
const color = folderColor(colors, m.id);
const setColor = (c: string | null) => {
+2 -2
View File
@@ -24,7 +24,7 @@ import { useScheduled } from "@/store/scheduled";
import { formatScheduleTime } from "@/lib/schedule";
import { mdnDecision, refusalText } from "@/lib/mdn";
import { sendReadReceipt } from "@/store/mdn";
import { t as translate } from "@/lib/i18n";
import { t as translate, tNode } from "@/lib/i18n";
interface Props {
email: Email;
@@ -226,7 +226,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{translate("The sender asked for a read receipt.")}
{receipt.redirected && (
<> {translate("It would go to")} <strong>{receipt.to!.email}</strong>{translate(", which is not where the message came from.")}</>
<>{tNode("It would go to {address}, which is not where the message came from.", { address: <strong className="notranslate" translate="no">{receipt.to!.email}</strong> })}</>
)}
</span>
<button
+25 -9
View File
@@ -1,8 +1,8 @@
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";
import { TRANSLATION_ISSUE_URL, UI_LANGUAGES } from "@/lib/languages";
import { t as translate, tNode } from "@/lib/i18n";
/**
* The theme cards, each previewing the background it actually paints. Kept as
@@ -31,6 +31,8 @@ export function AppearanceSettings() {
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
const isTouch = useIsTouch();
const chosen = UI_LANGUAGES.find((l) => l.tag === s.uiLanguage);
const betaChosen = Boolean(chosen?.beta);
return (
<div>
<h1>{translate("Appearance")}</h1>
@@ -40,12 +42,12 @@ export function AppearanceSettings() {
{THEMES.map((t) => (
<button key={t.id} className={`theme-card ${s.theme === t.id ? "active" : ""}`} onClick={() => update({ theme: t.id })}>
<div className="preview" style={{ background: t.preview }} />
{t.label}
{translate(t.label)}
</button>
))}
</div>
<p className="hint" style={{ marginTop: 10 }}>
<strong>{translate("ihasmail")}</strong> {translate("is the palette from")} <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{translate("ihasmail.org")}</a>{translate(", 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.")}
{tNode("{name} is the palette from {site}, 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.", { name: <strong className="notranslate" translate="no">ihasmail</strong>, site: <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a> })}
</p>
<Switch
checked={s.themeMessageBody}
@@ -84,7 +86,7 @@ export function AppearanceSettings() {
<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>
<option key={l.tag} value={l.tag}>{l.beta ? `${l.name} (Beta)` : l.name}</option>
))}
</select>
</div>
@@ -93,13 +95,27 @@ export function AppearanceSettings() {
looks broken; a picker with one entry and a sentence explaining that
more are coming is a roadmap.
*/}
{/*
Said plainly rather than buried. A machine translation presented as a
finished one is the version of this that does harm: a reader told it was
unchecked forgives an odd sentence and reports it, while a reader told
it was reviewed reasonably concludes the product is sloppy. The report
link is the entire review process, so it belongs one click from the
thing being complained about.
*/}
{betaChosen && (
<p className="hint">
{tNode("This translation was generated by AI and has not been checked by a native speaker, so it is marked Beta until somebody who speaks it signs it off. Anything that reads wrongly is worth reporting — {report}.", {
report: <a href={`${TRANSLATION_ISSUE_URL}${encodeURIComponent(chosen?.name ?? "")}`} target="_blank" rel="noopener noreferrer">{translate("tell us about it")}</a>,
})}
</p>
)}
<p className="hint">
{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">
{translate("This is separate from")} <strong>{translate("Language & region")}</strong> {translate("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.")}
{tNode("This is separate from {setting} 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.", { setting: <strong>{translate("Language & region")}</strong> })}
</p>
<h2>{translate("Swiping")}</h2>
@@ -112,7 +128,7 @@ export function AppearanceSettings() {
<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>
<option key={c.value} value={c.value}>{translate(c.label)}</option>
))}
</select>
</div>
@@ -120,7 +136,7 @@ export function AppearanceSettings() {
<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>
<option key={c.value} value={c.value}>{translate(c.label)}</option>
))}
</select>
</div>
+5 -2
View File
@@ -7,6 +7,7 @@ import { formatSize } from "@/lib/format";
import { ShareDialog } from "./ShareDialog";
import type { Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
import { mailboxDisplayPath } from "@/lib/mailboxName";
export function FoldersSettings() {
const mailboxes = useMail((s) => s.mailboxes);
@@ -42,12 +43,14 @@ export function FoldersSettings() {
<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">{t("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>{mailboxDisplayPath(m, mailboxes)}</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={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={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await // The server's own name, never the localised one: this box writes
// back whatever it is prefilled with.
promptDialog({ title: t("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={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>
+1 -1
View File
@@ -159,7 +159,7 @@ export function GeneralSettings() {
<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}>
{f.label} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
{t(f.label)} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
</option>
))}
</select>
+2 -2
View File
@@ -3,7 +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";
import { t, tNode } from "@/lib/i18n";
export function LabelsSettings() {
const labels = useSettings((s) => s.settings.labels);
@@ -39,7 +39,7 @@ export function LabelsSettings() {
</div>
))}
<button className="btn" onClick={() => void add()}><Plus size={16} /> {t("New label")}</button>
<p className="hint mt-8">{t("Tip: press")} <kbd className="kbd">l</kbd> {t("on a conversation to apply labels. Search with")} <code>label:name</code>.</p>
<p className="hint mt-8">{tNode("Tip: press {key} on a conversation to apply labels. Search with {operator}.", { key: <kbd className="kbd">l</kbd>, operator: <code>label:name</code> })}</p>
</div>
);
}
+3 -3
View File
@@ -5,7 +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";
import { t, tNode } from "@/lib/i18n";
interface SessionRow {
id: string;
@@ -59,7 +59,7 @@ export function SecuritySettings() {
return (
<div>
<h1>{t("Security & sessions")}</h1>
<p className="lead">{t("You're signed in as")} <b>{session?.username}</b>{t(". Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.")}</p>
<p className="lead">{tNode("You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.", { user: <b className="notranslate" translate="no">{session?.username}</b> })}</p>
<h2>{t("Password")}</h2>
{unsupported ? (
@@ -303,7 +303,7 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>{t("Done")}</button>}>
{issued && (
<div>
<p>{t("Copy it into")} <b>{issued.description}</b> {t("now — it isn't shown again.")}</p>
<p>{tNode("Copy it into {name} now — it isn't shown again.", { name: <b>{issued.description}</b> })}</p>
<CopyableSecret value={issued.secret} />
<p className="hint mt-8"><Smartphone size={13} style={{ verticalAlign: "-2px" }} /> {t("Use your usual address as the username.")}</p>
</div>
+1 -1
View File
@@ -44,7 +44,7 @@ export function SettingsView({ section }: { section?: string }) {
{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>
<span className="nav-label">{t(s.label)}</span>
</Link>
))}
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Shortcuts")}</span></div>
+1 -1
View File
@@ -142,7 +142,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
{RIGHTS[kind].map((rt) => (
<label key={rt.key} className="check" style={{ padding: "2px 6px" }}>
<input type="checkbox" checked={Boolean(r[rt.key])} onChange={(e) => setRights({ ...rights, [pid]: { ...r, [rt.key]: e.target.checked } })} />
<span className="small">{rt.label}</span>
<span className="small">{t(rt.label)}</span>
</label>
))}
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { keyboard } from "@/lib/keyboard";
import { Kbd } from "@/ui/misc";
import { t } from "@/lib/i18n";
import { t, tNode } from "@/lib/i18n";
export function ShortcutsSettings() {
const list = useMemo(() => keyboard.list(), []);
@@ -17,7 +17,7 @@ export function ShortcutsSettings() {
return (
<div>
<h1>{t("Keyboard shortcuts")}</h1>
<p className="lead">{t("Gmail-style shortcuts are always on. Press")} <kbd className="kbd">?</kbd> {t("anywhere to see this list.")}</p>
<p className="lead">{tNode("Gmail-style shortcuts are always on. Press {key} anywhere to see this list.", { key: <kbd className="kbd">?</kbd> })}</p>
<div className="shortcut-grid">
{groups.map(([group, items]) => (
<div key={group}>