Check component props for untranslated literals, and fail on a finding

i18n-literals checked title, aria-label, placeholder and alt on elements,
but not the same English passed to a component, so a MenuItem label or a
Popover ariaLabel written as a literal went through. It also excused a
literal that happened to be a catalogue key. That exemption is meant for
English held in a constant and translated where it renders, and a literal
written straight into a JSX attribute has no such render site. No
component passes its props through t(). And neither half of i18n:check
was run with --check, so a finding printed and the script still exited 0.

Component props are checked now, a key no longer excuses a literal in an
attribute, and both scripts run with --check. That found 28 strings
rendering English in every language: 19 already had keys and are wrapped,
and 9 are new keys in all nine catalogues. The contact editor's Save and
Saving… buttons are wrapped as well, on the same line.
This commit is contained in:
2026-09-14 08:03:12 -07:00
parent af092b3942
commit ac4c4bd267
20 changed files with 117 additions and 23 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ in all nine languages. Nothing errors. The only signal is the fallback count
going up, so read it: going up, so read it:
```sh ```sh
npm run i18n:check # literals wrapped, and catalogue health npm run i18n:check # literals wrapped, and catalogue health; exits 1 on a finding
node scripts/i18n-catalog-check.mjs # per-language: translated / used / falling back node scripts/i18n-catalog-check.mjs # per-language: translated / used / falling back
``` ```
+1 -1
View File
@@ -62,7 +62,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value. - **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. - **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. **The check had the same blind spot one level down (2026-09-14).** It looked at `title=`, `aria-label=`, `placeholder=` and `alt=` on elements, but not at props passed to components, so `<MenuItem label={x ? "Collapse all" : "Expand all"}>` passed. It also accepted a JSX literal that was a catalogue key, although no component here runs its props through `t()`, so 19 strings with translations in every catalogue (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with `--check`, which `npm run i18n:check` never passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with `--check`. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogues. Still not covered: English built with a template literal inside an attribute, such as ``aria-label={`Remove ${email}`}``. Such a string can't be a catalogue key as written, and a handful remain in the calendar and contacts sidebars, the recipient chips, the address menu, attachment titles and the contact editor's "Edit …" title.
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers. - **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
+1 -1
View File
@@ -23,7 +23,7 @@
"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": "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\"", "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 && node scripts/i18n-literals.mjs", "i18n:check": "node scripts/i18n-catalog-check.mjs --check && node scripts/i18n-literals.mjs --check",
"dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"" "dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
}, },
"devDependencies": { "devDependencies": {
+17 -4
View File
@@ -40,7 +40,13 @@ const UI_PROPS = new Set([
"title", "message", "label", "confirmLabel", "cancelLabel", "ariaLabel", "title", "message", "label", "confirmLabel", "cancelLabel", "ariaLabel",
"placeholder", "hint", "occurrenceLabel", "occurrenceHint", "seriesLabel", "seriesHint", "placeholder", "hint", "occurrenceLabel", "occurrenceHint", "seriesLabel", "seriesHint",
]); ]);
const UI_ATTRS = new Set(["title", "aria-label", "placeholder", "alt"]); /*
* A JSX attribute is shown whether it lands on an element or on a component:
* `<MenuItem label="Collapse all">` renders its label as given, exactly as
* `<button title="…">` does. Checking only the DOM spellings let every
* component prop through, so the props are checked here too.
*/
const UI_ATTRS = new Set(["title", "aria-label", "placeholder", "alt", ...UI_PROPS]);
const TOASTS = new Set(["error", "success", "info", "show"]); const TOASTS = new Set(["error", "success", "info", "show"]);
const WRAPPERS = ["t", "tc", "tNode", "translate", "plural"]; const WRAPPERS = ["t", "tc", "tNode", "translate", "plural"];
const EQUALITY = new Set([ const EQUALITY = new Set([
@@ -74,8 +80,15 @@ const keys = new Set();
const found = []; const found = [];
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) { 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 src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const report = (node, text) => { /*
if (!looksLikeUi(text) || keys.has(text) || NEVER_TRANSLATED.has(text)) return; * `strict` withdraws the catalogue-key exemption. It exists for English held
* in a constant and translated where it renders; a literal written straight
* into a JSX attribute has no later render site to be translated at -- no
* component here passes its props through t() -- so being a key only means
* a translation exists that this string never reaches.
*/
const report = (node, text, strict = false) => {
if (!looksLikeUi(text) || (!strict && keys.has(text)) || NEVER_TRANSLATED.has(text)) return;
const { line } = src.getLineAndCharacterOfPosition(node.getStart(src)); const { line } = src.getLineAndCharacterOfPosition(node.getStart(src));
found.push({ file, line: line + 1, text }); found.push({ file, line: line + 1, text });
}; };
@@ -110,7 +123,7 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
} }
if (ts.isJsxAttribute(n) && n.initializer && UI_ATTRS.has(n.name.getText(src))) { if (ts.isJsxAttribute(n) && n.initializer && UI_ATTRS.has(n.name.getText(src))) {
const walk = (x) => { const walk = (x) => {
if (ts.isStringLiteral(x) && !wrapped.has(x)) report(x, x.text); if (ts.isStringLiteral(x) && !wrapped.has(x)) report(x, x.text, true);
if (!ts.isCallExpression(x)) ts.forEachChild(x, walk); if (!ts.isCallExpression(x)) ts.forEachChild(x, walk);
}; };
walk(n.initializer); walk(n.initializer);
+9
View File
@@ -1214,6 +1214,15 @@ export const catalog: Catalog = {
"Resize panes": "Bereichsgröße ändern", "Resize panes": "Bereichsgröße ändern",
"Resize message list": "Größe der Nachrichtenliste ändern", "Resize message list": "Größe der Nachrichtenliste ändern",
"Resize sidebar": "Größe der Seitenleiste ändern", "Resize sidebar": "Größe der Seitenleiste ändern",
"Hide from list": "Aus der Liste ausblenden",
"Show in list": "In der Liste einblenden",
"Stop trusting sender images": "Bildern des Absenders nicht mehr vertrauen",
"Always show images from sender": "Bilder dieses Absenders immer anzeigen",
"Collapse all": "Alle einklappen",
"Expand all": "Alle ausklappen",
"Send now instead": "Stattdessen jetzt senden",
"Switch to plain text": "Zu Nur-Text wechseln",
"Switch to rich text": "Zu formatiertem Text wechseln",
"QR code": "QR-Code", "QR code": "QR-Code",
"Draft discarded": "Entwurf verworfen", "Draft discarded": "Entwurf verworfen",
"Draft saved": "Entwurf gespeichert", "Draft saved": "Entwurf gespeichert",
+9
View File
@@ -1187,6 +1187,15 @@ export const catalog: Catalog = {
"Resize panes": "Cambiar el tamaño de los paneles", "Resize panes": "Cambiar el tamaño de los paneles",
"Resize message list": "Cambiar el tamaño de la lista de mensajes", "Resize message list": "Cambiar el tamaño de la lista de mensajes",
"Resize sidebar": "Cambiar el tamaño de la barra lateral", "Resize sidebar": "Cambiar el tamaño de la barra lateral",
"Hide from list": "Ocultar de la lista",
"Show in list": "Mostrar en la lista",
"Stop trusting sender images": "Dejar de confiar en las imágenes del remitente",
"Always show images from sender": "Mostrar siempre las imágenes del remitente",
"Collapse all": "Contraer todo",
"Expand all": "Expandir todo",
"Send now instead": "Enviar ahora, sin programar",
"Switch to plain text": "Cambiar a texto sin formato",
"Switch to rich text": "Cambiar a texto enriquecido",
"QR code": "Código QR", "QR code": "Código QR",
"Draft discarded": "Borrador descartado", "Draft discarded": "Borrador descartado",
"Draft saved": "Borrador guardado", "Draft saved": "Borrador guardado",
+9
View File
@@ -1192,6 +1192,15 @@ export const catalog: Catalog = {
"Resize panes": "Redimensionner les volets", "Resize panes": "Redimensionner les volets",
"Resize message list": "Redimensionner la liste des messages", "Resize message list": "Redimensionner la liste des messages",
"Resize sidebar": "Redimensionner la barre latérale", "Resize sidebar": "Redimensionner la barre latérale",
"Hide from list": "Masquer de la liste",
"Show in list": "Afficher dans la liste",
"Stop trusting sender images": "Ne plus faire confiance aux images de l'expéditeur",
"Always show images from sender": "Toujours afficher les images de l'expéditeur",
"Collapse all": "Tout réduire",
"Expand all": "Tout développer",
"Send now instead": "Envoyer tout de suite",
"Switch to plain text": "Passer en texte brut",
"Switch to rich text": "Passer en texte enrichi",
"QR code": "QR code", "QR code": "QR code",
"Draft discarded": "Brouillon abandonné", "Draft discarded": "Brouillon abandonné",
"Draft saved": "Brouillon enregistré", "Draft saved": "Brouillon enregistré",
+9
View File
@@ -1195,6 +1195,15 @@ export const catalog: Catalog = {
"Resize panes": "ペインの大きさを変更", "Resize panes": "ペインの大きさを変更",
"Resize message list": "メール一覧の大きさを変更", "Resize message list": "メール一覧の大きさを変更",
"Resize sidebar": "サイドバーの大きさを変更", "Resize sidebar": "サイドバーの大きさを変更",
"Hide from list": "一覧で非表示にする",
"Show in list": "一覧に表示する",
"Stop trusting sender images": "差出人の画像を信頼しない",
"Always show images from sender": "差出人の画像を常に表示",
"Collapse all": "すべて折りたたむ",
"Expand all": "すべて展開",
"Send now instead": "予約をやめて今すぐ送信",
"Switch to plain text": "プレーンテキストに切り替え",
"Switch to rich text": "リッチテキストに切り替え",
"QR code": "QR コード", "QR code": "QR コード",
"Draft discarded": "下書きを破棄しました", "Draft discarded": "下書きを破棄しました",
"Draft saved": "下書きを保存しました", "Draft saved": "下書きを保存しました",
+9
View File
@@ -1183,6 +1183,15 @@ export const catalog: Catalog = {
"Resize panes": "Vensterdelen verslepen", "Resize panes": "Vensterdelen verslepen",
"Resize message list": "Grootte van de berichtenlijst wijzigen", "Resize message list": "Grootte van de berichtenlijst wijzigen",
"Resize sidebar": "Grootte van de zijbalk wijzigen", "Resize sidebar": "Grootte van de zijbalk wijzigen",
"Hide from list": "Verbergen in de lijst",
"Show in list": "Tonen in de lijst",
"Stop trusting sender images": "Afbeeldingen van afzender niet meer vertrouwen",
"Always show images from sender": "Afbeeldingen van afzender altijd tonen",
"Collapse all": "Alles samenvouwen",
"Expand all": "Alles uitvouwen",
"Send now instead": "Toch nu verzenden",
"Switch to plain text": "Overschakelen naar platte tekst",
"Switch to rich text": "Overschakelen naar opgemaakte tekst",
"QR code": "QR-code", "QR code": "QR-code",
"Draft discarded": "Concept weggegooid", "Draft discarded": "Concept weggegooid",
"Draft saved": "Concept opgeslagen", "Draft saved": "Concept opgeslagen",
+9
View File
@@ -1190,6 +1190,15 @@ export const catalog: Catalog = {
"Resize panes": "Redimensionar os painéis", "Resize panes": "Redimensionar os painéis",
"Resize message list": "Redimensionar a lista de mensagens", "Resize message list": "Redimensionar a lista de mensagens",
"Resize sidebar": "Redimensionar a barra lateral", "Resize sidebar": "Redimensionar a barra lateral",
"Hide from list": "Ocultar da lista",
"Show in list": "Mostrar na lista",
"Stop trusting sender images": "Deixar de confiar nas imagens do remetente",
"Always show images from sender": "Sempre mostrar as imagens do remetente",
"Collapse all": "Recolher tudo",
"Expand all": "Expandir tudo",
"Send now instead": "Enviar agora mesmo",
"Switch to plain text": "Mudar para texto simples",
"Switch to rich text": "Mudar para texto formatado",
"QR code": "Código QR", "QR code": "Código QR",
"Draft discarded": "Rascunho descartado", "Draft discarded": "Rascunho descartado",
"Draft saved": "Rascunho salvo", "Draft saved": "Rascunho salvo",
+9
View File
@@ -1189,6 +1189,15 @@ export const catalog: Catalog = {
"Resize panes": "Изменить размер панелей", "Resize panes": "Изменить размер панелей",
"Resize message list": "Изменить размер списка писем", "Resize message list": "Изменить размер списка писем",
"Resize sidebar": "Изменить размер боковой панели", "Resize sidebar": "Изменить размер боковой панели",
"Hide from list": "Скрыть из списка",
"Show in list": "Показать в списке",
"Stop trusting sender images": "Больше не доверять изображениям отправителя",
"Always show images from sender": "Всегда показывать изображения отправителя",
"Collapse all": "Свернуть все",
"Expand all": "Развернуть все",
"Send now instead": "Отправить сейчас",
"Switch to plain text": "Переключиться на обычный текст",
"Switch to rich text": "Переключиться на форматированный текст",
"QR code": "QR-код", "QR code": "QR-код",
"Draft discarded": "Черновик удалён", "Draft discarded": "Черновик удалён",
"Draft saved": "Черновик сохранён", "Draft saved": "Черновик сохранён",
+9
View File
@@ -1183,6 +1183,15 @@ export const catalog: Catalog = {
"Resize panes": "Змінити розмір панелей", "Resize panes": "Змінити розмір панелей",
"Resize message list": "Змінити розмір списку листів", "Resize message list": "Змінити розмір списку листів",
"Resize sidebar": "Змінити розмір бічної панелі", "Resize sidebar": "Змінити розмір бічної панелі",
"Hide from list": "Сховати зі списку",
"Show in list": "Показати в списку",
"Stop trusting sender images": "Більше не довіряти зображенням відправника",
"Always show images from sender": "Завжди показувати зображення відправника",
"Collapse all": "Згорнути все",
"Expand all": "Розгорнути все",
"Send now instead": "Надіслати зараз",
"Switch to plain text": "Перейти на звичайний текст",
"Switch to rich text": "Перейти на форматований текст",
"QR code": "QR-код", "QR code": "QR-код",
"Draft discarded": "Чернетку видалено", "Draft discarded": "Чернетку видалено",
"Draft saved": "Чернетку збережено", "Draft saved": "Чернетку збережено",
+9
View File
@@ -1194,6 +1194,15 @@ export const catalog: Catalog = {
"Resize panes": "调整窗格大小", "Resize panes": "调整窗格大小",
"Resize message list": "调整邮件列表大小", "Resize message list": "调整邮件列表大小",
"Resize sidebar": "调整侧边栏大小", "Resize sidebar": "调整侧边栏大小",
"Hide from list": "从列表中隐藏",
"Show in list": "在列表中显示",
"Stop trusting sender images": "不再信任发件人的图片",
"Always show images from sender": "始终显示该发件人的图片",
"Collapse all": "全部折叠",
"Expand all": "全部展开",
"Send now instead": "改为立即发送",
"Switch to plain text": "切换为纯文本",
"Switch to rich text": "切换为富文本",
"QR code": "二维码", "QR code": "二维码",
"Draft discarded": "草稿已放弃", "Draft discarded": "草稿已放弃",
"Draft saved": "草稿已保存", "Draft saved": "草稿已保存",
+2 -2
View File
@@ -239,7 +239,7 @@ export function DateField({ value, onChange, className, disabled, required, id,
<CalIcon size={15} /> <CalIcon size={15} />
</button> </button>
{anchor && ( {anchor && (
<Popover anchor={anchor} onClose={() => { setAnchor(null); inputRef.current?.focus(); }} role="dialog" className="dp-pop" closeOnClick={false} ariaLabel="Choose a date"> <Popover anchor={anchor} onClose={() => { setAnchor(null); inputRef.current?.focus(); }} role="dialog" className="dp-pop" closeOnClick={false} ariaLabel={translate("Choose a date")}>
<CalendarGrid <CalendarGrid
selected={selected} selected={selected}
onClose={() => { setAnchor(null); inputRef.current?.focus(); }} onClose={() => { setAnchor(null); inputRef.current?.focus(); }}
@@ -335,7 +335,7 @@ export function DateTimeField({ value, onChange, className, disabled, required,
<span className="dp-open" aria-hidden="true"><Clock size={15} /></span> <span className="dp-open" aria-hidden="true"><Clock size={15} /></span>
</span> </span>
{anchor && ( {anchor && (
<Popover anchor={anchor} onClose={close} role="dialog" className="dp-pop dp-pop-wide" closeOnClick={false} ariaLabel="Choose a date and time"> <Popover anchor={anchor} onClose={close} role="dialog" className="dp-pop dp-pop-wide" closeOnClick={false} ariaLabel={translate("Choose a date and time")}>
<div className="dp-split"> <div className="dp-split">
<CalendarGrid <CalendarGrid
selected={valid} selected={valid}
+2 -2
View File
@@ -289,7 +289,7 @@ export function Composer({ draft }: { draft: Draft }) {
<button className="btn btn-primary" onClick={sendMenu.open} aria-label={translate("Send options")}><ChevronDown size={16} /></button> <button className="btn btn-primary" onClick={sendMenu.open} aria-label={translate("Send options")}><ChevronDown size={16} /></button>
</span> </span>
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={280}> <Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={280}>
<MenuItem icon={<Send size={16} />} label={d.sendAt !== null ? "Send now instead" : "Send"} kbd={d.sendAt !== null ? undefined : "Ctrl+↵"} onClick={() => { if (d.sendAt !== null) patch({ sendAt: null }); sendMenu.close(); void doSend(); }} /> <MenuItem icon={<Send size={16} />} label={d.sendAt !== null ? translate("Send now instead") : translate("Send")} kbd={d.sendAt !== null ? undefined : "Ctrl+↵"} onClick={() => { if (d.sendAt !== null) patch({ sendAt: null }); sendMenu.close(); void doSend(); }} />
<MenuItem icon={<Clock size={16} />} label={translate("Undo window: {seconds}s", { seconds: settings.undoSendSeconds })} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} /> <MenuItem icon={<Clock size={16} />} label={translate("Undo window: {seconds}s", { seconds: settings.undoSendSeconds })} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
{canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />} {canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />}
</Popover> </Popover>
@@ -323,7 +323,7 @@ export function Composer({ draft }: { draft: Draft }) {
</Popover> </Popover>
<button className="icon-btn" onClick={moreMenu.open} aria-label={translate("More options")}><MoreVertical size={18} /></button> <button className="icon-btn" onClick={moreMenu.open} aria-label={translate("More options")}><MoreVertical size={18} /></button>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} side="top" width={260}> <Popover anchor={moreMenu.anchor} onClose={moreMenu.close} side="top" width={260}>
<MenuItem icon={<Type size={16} />} label={d.format === "html" ? "Switch to plain text" : "Switch to rich text"} onClick={toggleFormat} /> <MenuItem icon={<Type size={16} />} label={d.format === "html" ? translate("Switch to plain text") : translate("Switch to rich text")} onClick={toggleFormat} />
<MenuItem icon={<CheckCheck size={16} />} label={translate("Request read receipt")} checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} /> <MenuItem icon={<CheckCheck size={16} />} label={translate("Request read receipt")} checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} />
<MenuSep /> <MenuSep />
<MenuTitle>{translate("Priority")}</MenuTitle> <MenuTitle>{translate("Priority")}</MenuTitle>
+1 -1
View File
@@ -156,7 +156,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
const photoSrc = photo?.dataUrl ?? (!removePhoto && existingPhoto ? (existingPhoto.uri?.startsWith("data:") ? existingPhoto.uri : existingPhoto.blobId ? client.downloadUrl(contacts.accountId!, existingPhoto.blobId, "photo", existingPhoto.mediaType ?? "image/jpeg", true) : null) : null); const photoSrc = photo?.dataUrl ?? (!removePhoto && existingPhoto ? (existingPhoto.uri?.startsWith("data:") ? existingPhoto.uri : existingPhoto.blobId ? client.downloadUrl(contacts.accountId!, existingPhoto.blobId, "photo", existingPhoto.mediaType ?? "image/jpeg", true) : null) : null);
return ( return (
<Dialog open onClose={onClose} title={isNew ? "New contact" : `Edit ${contactDisplayName(card as ContactCard)}`} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}> <Dialog open onClose={onClose} title={isNew ? t("New contact") : `Edit ${contactDisplayName(card as ContactCard)}`} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? t("Saving…") : t("Save")}</button></>}>
<div className="contact-form"> <div className="contact-form">
<div className="row" style={{ gap: 16, marginBottom: 12 }}> <div className="row" style={{ gap: 16, marginBottom: 12 }}>
<label className="avatar xl" style={{ background: "var(--bg-sunken)", color: "var(--fg-muted)", cursor: "pointer", position: "relative" }} title={t("Change photo")}> <label className="avatar xl" style={{ background: "var(--bg-sunken)", color: "var(--fg-muted)", cursor: "pointer", position: "relative" }} title={t("Change photo")}>
+1 -1
View File
@@ -471,7 +471,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
)} )}
<MenuItem icon={<FolderPlus size={16} />} label={t("New subfolder")} onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} /> <MenuItem icon={<FolderPlus size={16} />} label={t("New subfolder")} onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} /> <MenuItem icon={<Pencil size={16} />} label={t("Rename")} onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} /> <MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and {/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
stores the share, and it never reaches the other account -- its own stores the share, and it never reaches the other account -- its own
docs list calendars, address books and files as shareable and not mail docs list calendars, address books and files as shareable and not mail
+4 -4
View File
@@ -299,7 +299,7 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
<Popover anchor={selMenu.anchor} onClose={selMenu.close} align="end" width={240}> <Popover anchor={selMenu.anchor} onClose={selMenu.close} align="end" width={240}>
<MenuItem <MenuItem
icon={mailbox?.role === "junk" ? <ShieldCheck size={16} /> : <AlertOctagon size={16} />} icon={mailbox?.role === "junk" ? <ShieldCheck size={16} /> : <AlertOctagon size={16} />}
label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} label={mailbox?.role === "junk" ? t("Not spam") : t("Report spam")}
onClick={() => void actions.spam()} onClick={() => void actions.spam()}
/> />
<MenuItem icon={<Mail size={16} />} label={t("Mark as unread")} onClick={() => void actions.read(false)} /> <MenuItem icon={<Mail size={16} />} label={t("Mark as unread")} onClick={() => void actions.read(false)} />
@@ -532,10 +532,10 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
<MenuItem icon={<CalendarRange size={16} />} label={archiveDateLabel("year")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "year")} /> <MenuItem icon={<CalendarRange size={16} />} label={archiveDateLabel("year")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "year")} />
<MenuItem icon={<CalendarDays size={16} />} label={archiveDateLabel("month")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "month")} /> <MenuItem icon={<CalendarDays size={16} />} label={archiveDateLabel("month")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "month")} />
<MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} /> <MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} />
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} /> <MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? t("Not spam") : t("Report spam")} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
<MenuSep /> <MenuSep />
<MenuItem icon={someUnread ? <MailOpen size={16} /> : <Mail size={16} />} label={someUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(someUnread, ctxTargets)} /> <MenuItem icon={someUnread ? <MailOpen size={16} /> : <Mail size={16} />} label={someUnread ? t("Mark as read") : t("Mark as unread")} onClick={() => void actions.read(someUnread, ctxTargets)} />
<MenuItem icon={<Star size={16} />} label={someUnstarred ? "Add star" : "Remove star"} kbd="s" onClick={() => void actions.star(someUnstarred, ctxTargets)} /> <MenuItem icon={<Star size={16} />} label={someUnstarred ? t("Add star") : t("Remove star")} kbd="s" onClick={() => void actions.star(someUnstarred, ctxTargets)} />
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} kbd="v" onClick={() => actions.move(ctxTargets)} /> <MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} kbd="v" onClick={() => actions.move(ctxTargets)} />
<MenuItem icon={<Tag size={16} />} label={t("Label…")} kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} /> <MenuItem icon={<Tag size={16} />} label={t("Label…")} kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
<MenuSep /> <MenuSep />
+2 -2
View File
@@ -338,7 +338,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
the other three rather than down among the read-only actions. */} the other three rather than down among the read-only actions. */}
<MenuItem icon={<MailPlus size={16} />} label={translate("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(e)} /> <MenuItem icon={<MailPlus size={16} />} label={translate("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(e)} />
<MenuSep /> <MenuSep />
<MenuItem icon={<Mail size={16} />} label={e.keywords.$seen ? "Mark as unread" : "Mark as read"} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} /> <MenuItem icon={<Mail size={16} />} label={e.keywords.$seen ? translate("Mark as unread") : translate("Mark as read")} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} />
<MenuItem icon={<Trash2 size={16} />} label={translate("Delete this message")} onClick={() => void useMail.getState().trash([e.id])} /> <MenuItem icon={<Trash2 size={16} />} label={translate("Delete this message")} onClick={() => void useMail.getState().trash([e.id])} />
<MenuSep /> <MenuSep />
<MenuItem icon={<Eye size={16} />} label={translate("Show original")} onClick={() => void openSource()} /> <MenuItem icon={<Eye size={16} />} label={translate("Show original")} onClick={() => void openSource()} />
@@ -351,7 +351,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{from && ( {from && (
<> <>
<MenuSep /> <MenuSep />
<MenuItem icon={<Ban size={16} />} label={senderTrusted ? "Stop trusting sender images" : "Always show images from sender"} onClick={() => updateSettings({ trustedImageSenders: senderTrusted ? settings.trustedImageSenders.filter((x) => x !== from.email.toLowerCase()) : [...settings.trustedImageSenders, from.email.toLowerCase()] })} /> <MenuItem icon={<Ban size={16} />} label={senderTrusted ? translate("Stop trusting sender images") : translate("Always show images from sender")} onClick={() => updateSettings({ trustedImageSenders: senderTrusted ? settings.trustedImageSenders.filter((x) => x !== from.email.toLowerCase()) : [...settings.trustedImageSenders, from.email.toLowerCase()] })} />
</> </>
)} )}
</Popover> </Popover>
+4 -4
View File
@@ -245,17 +245,17 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
<ArrowLeft size={20} /> <ArrowLeft size={20} />
</button> </button>
<button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button> <button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button>
<button className="icon-btn" title={inJunk ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam(rowIds)}>{inJunk ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button> <button className="icon-btn" title={inJunk ? t("Not spam") : t("Report spam (!)")} onClick={() => void actions.spam(rowIds)}>{inJunk ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
<button className="icon-btn" title={t("Delete (#)")} onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button> <button className="icon-btn" title={t("Delete (#)")} onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button>
<span className="tb-sep hide-mobile" /> <span className="tb-sep hide-mobile" />
<button className="icon-btn hide-mobile" title={anyUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(anyUnread, rowIds)}>{anyUnread ? <MailOpen size={19} /> : <Mail size={19} />}</button> <button className="icon-btn hide-mobile" title={anyUnread ? t("Mark as read") : t("Mark as unread")} onClick={() => void actions.read(anyUnread, rowIds)}>{anyUnread ? <MailOpen size={19} /> : <Mail size={19} />}</button>
<button className="icon-btn hide-mobile" title={t("Move to (v)")} onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button> <button className="icon-btn hide-mobile" title={t("Move to (v)")} onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button>
<button className="icon-btn hide-mobile" title={t("Labels (l)")} onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button> <button className="icon-btn hide-mobile" title={t("Labels (l)")} onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
<button className="icon-btn" onClick={moreMenu.open} aria-label={t("More")}><MoreVertical size={19} /></button> <button className="icon-btn" onClick={moreMenu.open} aria-label={t("More")}><MoreVertical size={19} /></button>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="start" width={240}> <Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="start" width={240}>
<MenuItem icon={<Star size={16} />} label={anyStarred ? "Remove star" : "Add star"} onClick={() => void actions.star(!anyStarred, rowIds)} /> <MenuItem icon={<Star size={16} />} label={anyStarred ? t("Remove star") : t("Add star")} onClick={() => void actions.star(!anyStarred, rowIds)} />
<MenuItem icon={<Tag size={16} />} label={t("Label…")} onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} /> <MenuItem icon={<Tag size={16} />} label={t("Label…")} onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} />
<MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? "Collapse all" : "Expand all"} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} /> <MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? t("Collapse all") : t("Expand all")} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} />
<MenuSep /> <MenuSep />
<MenuItem icon={<Printer size={16} />} label={t("Print conversation")} onClick={() => window.print()} /> <MenuItem icon={<Printer size={16} />} label={t("Print conversation")} onClick={() => window.print()} />
{last && accountId && ( {last && accountId && (