diff --git a/package.json b/package.json
index df65d48..82e0a57 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/i18n-catalog-check.mjs b/scripts/i18n-catalog-check.mjs
new file mode 100644
index 0000000..30b03f5
--- /dev/null
+++ b/scripts/i18n-catalog-check.mjs
@@ -0,0 +1,74 @@
+#!/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);
+ 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);
diff --git a/scripts/i18n-strings.mjs b/scripts/i18n-strings.mjs
new file mode 100644
index 0000000..0089e7a
--- /dev/null
+++ b/scripts/i18n-strings.mjs
@@ -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`);
+}
diff --git a/web/src/lib/__tests__/languages.test.ts b/web/src/lib/__tests__/languages.test.ts
index eda00fa..a864580 100644
--- a/web/src/lib/__tests__/languages.test.ts
+++ b/web/src/lib/__tests__/languages.test.ts
@@ -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);
});
diff --git a/web/src/lib/languages.ts b/web/src/lib/languages.ts
index 3260cd6..87cbca3 100644
--- a/web/src/lib/languages.ts
+++ b/web/src/lib/languages.ts
@@ -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";
/**
diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts
new file mode 100644
index 0000000..251ca96
--- /dev/null
+++ b/web/src/locales/de.ts
@@ -0,0 +1,860 @@
+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",
+ },
+ 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" },
+ },
+};
diff --git a/web/src/store/__tests__/lang-attribute.test.ts b/web/src/store/__tests__/lang-attribute.test.ts
index 272c118..b3a1c90 100644
--- a/web/src/store/__tests__/lang-attribute.test.ts
+++ b/web/src/store/__tests__/lang-attribute.test.ts
@@ -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");
});
diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx
index 3a0eb7b..d2c709b 100644
--- a/web/src/views/AppShell.tsx
+++ b/web/src/views/AppShell.tsx
@@ -81,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{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 })}
) : loaded ? ( -{t("Your active Sieve script was written by hand, so rules can't be added automatically. Open")} {t("Settings → Filters & rules")} {t("to edit the script or switch to managed rules.")}
+{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: {t("Settings → Filters & rules")} })}
) : ({t("Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.")}
)} diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx index d00d75f..732e124 100644 --- a/web/src/views/mail/MailView.tsx +++ b/web/src/views/mail/MailView.tsx @@ -16,7 +16,7 @@ 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"; export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) { const [, navigate] = useLocation(); @@ -353,8 +353,8 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; ) : (
- + {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: {translate("tell us about it")}, + })} +
+ )}- {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.")}
- {translate("This is separate from")} {translate("Language & region")} {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: {translate("Language & region")} })}
{t("Tip: press")} l {t("on a conversation to apply labels. Search with")} label:name.
{tNode("Tip: press {key} on a conversation to apply labels. Search with {operator}.", { key: l, operator: label:name })}
{t("You're signed in as")} {session?.username}{t(". Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.")}
+{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: {session?.username} })}
{t("Copy it into")} {issued.description} {t("now — it isn't shown again.")}
+{tNode("Copy it into {name} now — it isn't shown again.", { name: {issued.description} })}
{t("Gmail-style shortcuts are always on. Press")} ? {t("anywhere to see this list.")}
+{tNode("Gmail-style shortcuts are always on. Press {key} anywhere to see this list.", { key: ? })}