From bed69db2e8fa170aba166a7995b3b9fcc6354e00 Mon Sep 17 00:00:00 2001
From: KazNIISA IT
Date: Tue, 18 Aug 2026 08:18:00 +0500
Subject: [PATCH 001/238] fix(keywords): follow a renamed tag into the filters
that name it
Renaming a tag changes its id - the id is a slug of the display name -
and with it the `$label:` keyword the tag is stored under. The rename
migrated the keyword on every message but left Sieve rules naming the old
id, in both the generated `addflag` line and the metadata block the visual
editor reads back. The rule then went on tagging new mail under an id no
definition names any more: the tag renders as its raw id in grey and a
search for the renamed tag misses everything that arrived since, which
reads as the filter having quietly stopped working.
`renameKeywordInRules` rewrites every `add_label` action naming the old id,
disabled rules included. An external rule reaches the script through its
verbatim `rawBlock`, so that is rewritten too - but only the operand of an
`addflag`, since a test comparing a header against the same literal is the
rule's own logic rather than the tag it applies.
The store fetches, rewrites and uploads the account's active script on its
own rather than through fetchFilters/saveFilters, so a rename from the tag
settings cannot disturb which account an open filter view is showing; that
view is updated in place when it shows the same account. The write still
runs through the same plugin pipeline a save does, or a plugin's managed
section would be dropped from the script this activates. A script that is
not the active one is left alone entirely: writing to it would activate it,
and a tag rename must not switch someone's filters back on.
Whatever still names the old tag in the script that ends up being written -
a rule kept verbatim, a test comparing against the keyword, a section a
plugin just grafted back on - is counted, and the tag settings warn that
some rules still use the old tag, because only the user can fix those.
Mail delivered between the message migration and the rewrite was still
tagged by the old rule, so a successful rewrite is followed by one more
migration sweep, which no rewritten rule can race any more.
---
components/settings/keyword-settings.tsx | 41 +++-
lib/sieve/__tests__/keyword-rename.test.ts | 156 ++++++++++++++
lib/sieve/keyword-rename.ts | 100 +++++++++
locales/ar/common.json | 1 +
locales/ca/common.json | 1 +
locales/cs/common.json | 1 +
locales/da/common.json | 1 +
locales/de/common.json | 1 +
locales/en/common.json | 1 +
locales/es/common.json | 1 +
locales/fa/common.json | 1 +
locales/fr/common.json | 1 +
locales/he/common.json | 1 +
locales/hu/common.json | 1 +
locales/it/common.json | 1 +
locales/ja/common.json | 1 +
locales/ko/common.json | 1 +
locales/lv/common.json | 1 +
locales/mn/common.json | 1 +
locales/nb/common.json | 1 +
locales/nl/common.json | 1 +
locales/pl/common.json | 1 +
locales/pt/common.json | 1 +
locales/ro/common.json | 1 +
locales/ru/common.json | 1 +
locales/sk/common.json | 1 +
locales/tr/common.json | 1 +
locales/uk/common.json | 1 +
locales/zh-TW/common.json | 1 +
locales/zh/common.json | 1 +
.../filter-store-keyword-rename.test.ts | 202 ++++++++++++++++++
stores/filter-store.ts | 93 ++++++++
32 files changed, 617 insertions(+), 2 deletions(-)
create mode 100644 lib/sieve/__tests__/keyword-rename.test.ts
create mode 100644 lib/sieve/keyword-rename.ts
create mode 100644 stores/__tests__/filter-store-keyword-rename.test.ts
diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx
index d106d22b8..ff78e20ef 100644
--- a/components/settings/keyword-settings.tsx
+++ b/components/settings/keyword-settings.tsx
@@ -12,6 +12,7 @@ import {
} from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
+import { useFilterStore } from "@/stores/filter-store";
import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section";
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2, Search } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -522,6 +523,7 @@ export function KeywordSettings() {
useSettingsStore();
const { client } = useAuthStore();
const { fetchTagCounts } = useEmailStore();
+ const renameKeywordInFilters = useFilterStore((state) => state.renameKeywordInFilters);
const [editingId, setEditingId] = useState(null);
const [isAdding, setIsAdding] = useState(false);
const [isMigrating, setIsMigrating] = useState(false);
@@ -571,9 +573,9 @@ export function KeywordSettings() {
if (idChanged && client) {
setIsMigrating(true);
+ const oldJmapKeyword = `$label:${oldId}`;
+ const newJmapKeyword = `$label:${keyword.id}`;
try {
- const oldJmapKeyword = `$label:${oldId}`;
- const newJmapKeyword = `$label:${keyword.id}`;
const migration = await client.migrateKeyword(oldJmapKeyword, newJmapKeyword);
renameKeyword(oldId, keyword);
fetchTagCounts(client);
@@ -590,6 +592,41 @@ export function KeywordSettings() {
setIsMigrating(false);
return;
}
+
+ // The messages now carry the new keyword, so the definition is right
+ // whatever happens next. Filter rules name the same id and would go on
+ // tagging new mail under the old one, so follow the rename into them -
+ // and say so when a rule is left behind, since only the user can fix a
+ // hand-edited script.
+ let rewroteFilters = false;
+ try {
+ const outcome = await renameKeywordInFilters(client, oldId, keyword.id);
+ rewroteFilters = outcome.changed > 0;
+ if (outcome.unhandled > 0) {
+ const toastModule = await import('sonner');
+ toastModule.toast.warning(t("filters_migration_error"));
+ }
+ } catch (error) {
+ console.error("Failed to migrate keyword in filters:", error);
+ const toastModule = await import('sonner');
+ toastModule.toast.warning(t("filters_migration_error"));
+ }
+
+ if (rewroteFilters) {
+ // Mail delivered between the migration above and the rewrite was still
+ // tagged by the old rule, and the migration has already passed it by.
+ // Sweep once more now that no rule can write the old keyword. A failure
+ // here is about the messages, not the filters, so it says so.
+ try {
+ await client.migrateKeyword(oldJmapKeyword, newJmapKeyword);
+ fetchTagCounts(client);
+ } catch (error) {
+ console.error("Failed to sweep up mail tagged during the rename:", error);
+ const toastModule = await import('sonner');
+ toastModule.toast.error(t("migration_error"));
+ }
+ }
+
setIsMigrating(false);
} else {
updateKeyword(oldId, { label: keyword.label, color: keyword.color });
diff --git a/lib/sieve/__tests__/keyword-rename.test.ts b/lib/sieve/__tests__/keyword-rename.test.ts
new file mode 100644
index 000000000..eb89bd348
--- /dev/null
+++ b/lib/sieve/__tests__/keyword-rename.test.ts
@@ -0,0 +1,156 @@
+import { describe, it, expect } from 'vitest';
+import { countKeywordUses, renameKeywordInRules } from '../keyword-rename';
+import type { FilterRule } from '@/lib/jmap/sieve-types';
+
+const makeRule = (overrides: Partial = {}): FilterRule => ({
+ id: 'rule-1',
+ name: 'ISO',
+ enabled: true,
+ matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: 'iso.org' }],
+ actions: [{ type: 'add_label', value: 'red' }],
+ stopProcessing: false,
+ ...overrides,
+});
+
+describe('renameKeywordInRules', () => {
+ it('rewrites an add_label action that names the old tag', () => {
+ const { rules, changed } = renameKeywordInRules([makeRule()], 'red', 'iso');
+ expect(changed).toBe(1);
+ expect(rules[0].actions[0]).toEqual({ type: 'add_label', value: 'iso' });
+ });
+
+ it('leaves actions naming a different tag alone', () => {
+ const rules = [makeRule({ actions: [{ type: 'add_label', value: 'blue' }] })];
+ const result = renameKeywordInRules(rules, 'red', 'iso');
+ expect(result.changed).toBe(0);
+ expect(result.rules[0].actions[0].value).toBe('blue');
+ });
+
+ it('leaves actions of other types alone even when the value collides', () => {
+ const rules = [makeRule({ actions: [{ type: 'move', value: 'red' }] })];
+ const result = renameKeywordInRules(rules, 'red', 'iso');
+ expect(result.changed).toBe(0);
+ expect(result.rules[0].actions[0].value).toBe('red');
+ });
+
+ it('counts every action it rewrites, across rules', () => {
+ const rules = [
+ makeRule({ id: 'r1' }),
+ makeRule({ id: 'r2', actions: [{ type: 'star' }, { type: 'add_label', value: 'red' }] }),
+ ];
+ expect(renameKeywordInRules(rules, 'red', 'iso').changed).toBe(2);
+ });
+
+ it('rewrites a disabled rule too, so enabling it later still tags correctly', () => {
+ const { changed, rules } = renameKeywordInRules([makeRule({ enabled: false })], 'red', 'iso');
+ expect(changed).toBe(1);
+ expect(rules[0].actions[0].value).toBe('iso');
+ });
+
+ it('does nothing when the id is unchanged', () => {
+ const rules = [makeRule()];
+ const result = renameKeywordInRules(rules, 'red', 'red');
+ expect(result).toEqual({ rules, changed: 0 });
+ expect(result.rules).toBe(rules);
+ });
+
+ it('does not mutate the rules it was given', () => {
+ const rules = [makeRule()];
+ renameKeywordInRules(rules, 'red', 'iso');
+ expect(rules[0].actions[0].value).toBe('red');
+ });
+
+ it('rewrites the raw block of an external rule, which is emitted verbatim', () => {
+ const rules = [
+ makeRule({
+ origin: 'external',
+ rawBlock: '# hand written\nif header :contains "From" "iso.org" {\n addflag "$label:red";\n}',
+ }),
+ ];
+ const { rules: renamed, changed } = renameKeywordInRules(rules, 'red', 'iso');
+ expect(changed).toBe(1);
+ expect(renamed[0].rawBlock).toContain('addflag "$label:iso";');
+ expect(renamed[0].rawBlock).not.toContain('$label:red');
+ });
+
+ it('rewrites only the addflag operand of a raw block, not a matching test string', () => {
+ const rules = [
+ makeRule({
+ origin: 'external',
+ rawBlock: 'if header :contains "X-Tag" "$label:red" {\n addflag "$label:red";\n}',
+ }),
+ ];
+ const { rules: renamed, changed } = renameKeywordInRules(rules, 'red', 'iso');
+ expect(changed).toBe(1);
+ expect(renamed[0].rawBlock).toContain('addflag "$label:iso";');
+ expect(renamed[0].rawBlock).toContain('header :contains "X-Tag" "$label:red"');
+ });
+
+ it('leaves an opaque rule, which has no parsed actions, untouched', () => {
+ const rules = [
+ makeRule({
+ origin: 'opaque',
+ actions: [],
+ conditions: [],
+ rawBlock: '# unparseable\nif anyof (true) {\n addflag "$label:red";\n}',
+ }),
+ ];
+ const result = renameKeywordInRules(rules, 'red', 'iso');
+ expect(result.changed).toBe(0);
+ expect(result.rules[0].rawBlock).toContain('$label:red');
+ });
+
+ it('does not touch a raw block whose keyword only shares a prefix with the old id', () => {
+ const rules = [
+ makeRule({
+ origin: 'external',
+ actions: [{ type: 'add_label', value: 'red-alert' }],
+ rawBlock: 'if true {\n addflag "$label:red-alert";\n}',
+ }),
+ ];
+ const { rules: renamed, changed } = renameKeywordInRules(rules, 'red', 'iso');
+ expect(changed).toBe(0);
+ expect(renamed[0].rawBlock).toContain('$label:red-alert');
+ });
+
+ it('rewrites a nested id without treating its separator as a pattern', () => {
+ const rules = [
+ makeRule({
+ actions: [{ type: 'add_label', value: 'work/clients' }],
+ origin: 'external',
+ rawBlock: 'if true {\n addflag "$label:work/clients";\n}',
+ }),
+ ];
+ const { rules: renamed, changed } = renameKeywordInRules(rules, 'work/clients', 'work/acme');
+ expect(changed).toBe(1);
+ expect(renamed[0].actions[0].value).toBe('work/acme');
+ expect(renamed[0].rawBlock).toContain('addflag "$label:work/acme";');
+ });
+});
+
+describe('countKeywordUses', () => {
+ it('counts the keyword as it is written in a script', () => {
+ const script = 'if true {\n addflag "$label:red";\n}\nif false {\n addflag "$label:red";\n}';
+ expect(countKeywordUses(script, 'red')).toBe(2);
+ });
+
+ it('does not count a keyword that merely starts with the id', () => {
+ expect(countKeywordUses('addflag "$label:red-alert";', 'red')).toBe(0);
+ });
+
+ it('returns zero for a script that never names the tag', () => {
+ expect(countKeywordUses('addflag "\\\\Seen";', 'red')).toBe(0);
+ });
+
+ it('counts the tag in a multi-flag argument, which is one Sieve string', () => {
+ // RFC 5232 section 3.2: the argument is a space-separated flag list, so the
+ // keyword is not always followed by the closing quote.
+ expect(countKeywordUses('addflag "$label:red \\\\Seen";', 'red')).toBe(1);
+ expect(countKeywordUses('addflag "\\\\Seen $label:red";', 'red')).toBe(1);
+ });
+
+ it('still does not count a longer tag that starts with the id', () => {
+ expect(countKeywordUses('addflag "$label:red-alert \\\\Seen";', 'red')).toBe(0);
+ });
+});
diff --git a/lib/sieve/keyword-rename.ts b/lib/sieve/keyword-rename.ts
new file mode 100644
index 000000000..834acc00a
--- /dev/null
+++ b/lib/sieve/keyword-rename.ts
@@ -0,0 +1,100 @@
+/**
+ * Following a renamed tag into the filters that name it.
+ *
+ * A tag is stored on messages as the keyword `$label:`, and `id` is a slug
+ * of the display name - so renaming a tag changes its id, and every message
+ * carrying it has to be migrated (`IJMAPClient.migrateKeyword`). A Sieve rule
+ * with an `add_label` action names that same id, in the generated `addflag`
+ * line and in the metadata block the visual editor reads back. Migrating only
+ * the messages leaves the rule writing the old keyword: new mail keeps arriving
+ * under a tag no definition names any more, which reads as "the filter stopped
+ * working" while the rule is in fact running exactly as written.
+ *
+ * This module is the rewrite half. It is pure - the caller fetches the script,
+ * parses it, applies this, and writes the result back.
+ */
+import type { FilterAction, FilterRule } from '@/lib/jmap/sieve-types';
+import { KEYWORD_PREFIX } from '@/lib/thread-utils';
+
+/** The result of following a rename through a set of rules. */
+export interface KeywordRenameResult {
+ /** The rules, with every reference to the old id rewritten. */
+ rules: FilterRule[];
+ /** How many actions were rewritten. Zero means nothing referenced the tag. */
+ changed: number;
+}
+
+/** The keyword an `add_label` action writes, as it appears in a script. */
+function quotedKeyword(id: string): string {
+ return `"${KEYWORD_PREFIX}${id}"`;
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * How many times `script` writes the keyword of the tag `id`.
+ *
+ * Whole-token only: `$label:red-alert` is a different tag from `$label:red`
+ * and must not be counted, which a plain substring search would get wrong. The
+ * token ends at the closing quote or at a space, since the argument of an
+ * `addflag` is a space-separated flag list (RFC 5232 section 3.2) and the tag
+ * need not be the last flag in it.
+ *
+ * Used to tell whether a script this module cannot rewrite - a hand-edited one,
+ * kept verbatim - still refers to a tag that has since been renamed.
+ */
+export function countKeywordUses(script: string, id: string): number {
+ const pattern = new RegExp(`["\\s]${escapeRegExp(KEYWORD_PREFIX + id)}(?=["\\s])`, 'g');
+ return script.match(pattern)?.length ?? 0;
+}
+
+/**
+ * `rules` with every `add_label` action naming `oldId` rewritten to `newId`.
+ *
+ * Disabled rules are rewritten too: leaving one behind would have it tag under
+ * the old id the moment it is switched back on. An external rule - one Bulwark
+ * did not write, re-emitted from its `rawBlock` verbatim - has that block
+ * rewritten as well, since its parsed actions never reach the generated script.
+ * Only the operand of an `addflag` is touched there: a test comparing some
+ * header against the same literal is the rule's own logic, and rewriting it
+ * would change what the rule matches rather than what it tags.
+ *
+ * What is left naming the old id afterwards - that test, or an opaque rule,
+ * which parses to no actions at all - is for the caller to count in the script
+ * it ends up writing, with `countKeywordUses`. The input is left untouched, and
+ * an id that did not actually change returns the very same array, so a caller
+ * can skip writing anything.
+ */
+export function renameKeywordInRules(
+ rules: FilterRule[],
+ oldId: string,
+ newId: string,
+): KeywordRenameResult {
+ if (!oldId || !newId || oldId === newId) return { rules, changed: 0 };
+
+ let changed = 0;
+ const rewritten = rules.map((rule) => {
+ let ruleChanged = 0;
+ const actions: FilterAction[] = rule.actions.map((action) => {
+ if (action.type !== 'add_label' || action.value !== oldId) return action;
+ ruleChanged++;
+ return { ...action, value: newId };
+ });
+ if (ruleChanged === 0) return rule;
+
+ changed += ruleChanged;
+ const next: FilterRule = { ...rule, actions };
+ if (next.rawBlock) next.rawBlock = renameInRawBlock(next.rawBlock, oldId, newId);
+ return next;
+ });
+
+ return changed === 0 ? { rules, changed: 0 } : { rules: rewritten, changed };
+}
+
+/** Retags the `addflag` lines of a block that is otherwise kept verbatim. */
+function renameInRawBlock(block: string, oldId: string, newId: string): string {
+ const pattern = new RegExp(`(addflag\\s+)${escapeRegExp(quotedKeyword(oldId))}`, 'g');
+ return block.replace(pattern, `$1${quotedKeyword(newId)}`);
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index ac7018dc0..515a6852d 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -1099,6 +1099,7 @@
"cancel": "إلغاء",
"migrating": "جارٍ تحديث الوسم على الرسائل الحالية…",
"migration_error": "فشل تحديث الوسم على الرسائل الحالية",
+ "filters_migration_error": "لا تزال بعض قواعد التصفية تستخدم الوسم القديم — حدّثها يدويًا",
"nesting": {
"label": "وسوم متداخلة",
"description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي."
diff --git a/locales/ca/common.json b/locales/ca/common.json
index bb0ce968d..344a4e6ec 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -1099,6 +1099,7 @@
"cancel": "Cancel·la",
"migrating": "Actualitzant l'etiqueta als correus existents…",
"migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents",
+ "filters_migration_error": "Algunes regles de filtre encara fan servir l'etiqueta antiga: actualitza-les manualment",
"nesting": {
"label": "Etiquetes imbricades",
"description": "Imbrica etiquetes sota altres etiquetes i mostra-les com un arbre a la barra lateral."
diff --git a/locales/cs/common.json b/locales/cs/common.json
index a8d69da24..211d41b37 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -1096,6 +1096,7 @@
"cancel": "Zrušit",
"migrating": "Aktualizace štítku v existujících e-mailech…",
"migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech",
+ "filters_migration_error": "Některá pravidla filtrů stále používají starý štítek – upravte je ručně",
"nesting": {
"label": "Vnořené štítky",
"description": "Vnořujte štítky pod jiné štítky a zobrazujte je v postranním panelu jako strom."
diff --git a/locales/da/common.json b/locales/da/common.json
index 5d1eff6fb..3fd6a9fcc 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -1099,6 +1099,7 @@
"cancel": "Annuller",
"migrating": "Opdaterer tag på eksisterende e-mails…",
"migration_error": "Kunne ikke opdatere tag på eksisterende e-mails",
+ "filters_migration_error": "Nogle filterregler bruger stadig det gamle tag – opdatér dem manuelt",
"nesting": {
"label": "Indlejrede tags",
"description": "Indlejr tags under andre tags og vis dem som et træ i sidepanelet."
diff --git a/locales/de/common.json b/locales/de/common.json
index 70331f3ab..4106c86e4 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -1096,6 +1096,7 @@
"cancel": "Abbrechen",
"migrating": "Label auf vorhandenen E-Mails aktualisieren…",
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden",
+ "filters_migration_error": "Einige Filterregeln verwenden noch das alte Tag – aktualisieren Sie sie manuell",
"nesting": {
"label": "Verschachtelte Labels",
"description": "Labels unter anderen Labels verschachteln und als Baum in der Seitenleiste anzeigen."
diff --git a/locales/en/common.json b/locales/en/common.json
index 68874b945..774a3440f 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -1099,6 +1099,7 @@
"cancel": "Cancel",
"migrating": "Updating tag on existing emails…",
"migration_error": "Failed to update tag on existing emails",
+ "filters_migration_error": "Some filter rules still use the old tag — update them manually",
"nesting": {
"label": "Nested Tags",
"description": "Nest tags underneath other tags and show them as a tree in the sidebar."
diff --git a/locales/es/common.json b/locales/es/common.json
index c4172288e..651e771b3 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -1096,6 +1096,7 @@
"cancel": "Cancelar",
"migrating": "Actualizando etiqueta en correos existentes…",
"migration_error": "Error al actualizar la etiqueta en correos existentes",
+ "filters_migration_error": "Algunas reglas de filtro siguen usando la etiqueta anterior: actualízalas manualmente",
"nesting": {
"label": "Etiquetas anidadas",
"description": "Anida etiquetas debajo de otras etiquetas y muéstralas como un árbol en la barra lateral."
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 9dc030aac..94af0e236 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -1099,6 +1099,7 @@
"cancel": "انصراف",
"migrating": "در حال بهروزرسانی برچسب روی ایمیلهای موجود…",
"migration_error": "بهروزرسانی برچسب ناموفق بود",
+ "filters_migration_error": "برخی قوانین فیلتر هنوز از برچسب قدیمی استفاده میکنند — آنها را دستی بهروزرسانی کنید",
"nesting": {
"label": "برچسبهای تودرتو",
"description": "برچسبها را زیر برچسبهای دیگر قرار دهید و آنها را بهصورت درختی در نوار کناری نمایش دهید."
diff --git a/locales/fr/common.json b/locales/fr/common.json
index b844debfc..ee5707014 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -1096,6 +1096,7 @@
"cancel": "Annuler",
"migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants",
+ "filters_migration_error": "Certaines règles de filtrage utilisent encore l'ancienne étiquette — mettez-les à jour manuellement",
"nesting": {
"label": "Étiquettes imbriquées",
"description": "Imbriquez des étiquettes sous d'autres étiquettes et affichez-les sous forme d'arborescence dans la barre latérale."
diff --git a/locales/he/common.json b/locales/he/common.json
index e5e7d311d..e05275ead 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -1061,6 +1061,7 @@
"cancel": "לְבַטֵל",
"migrating": "מעדכן מילת מפתח באימיילים קיימים...",
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות",
+ "filters_migration_error": "חלק מכללי הסינון עדיין משתמשים בתגית הישנה — עדכנו אותם ידנית",
"nesting": {
"label": "תגים מקוננים",
"description": "קנן תגים תחת תגים אחרים והצג אותם כעץ בסרגל הצד."
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 0e07afb34..1c88f8799 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -1099,6 +1099,7 @@
"cancel": "Mégse",
"migrating": "Címke frissítése a meglévő e-maileken...",
"migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken",
+ "filters_migration_error": "Néhány szűrőszabály még a régi címkét használja – frissítse őket kézzel",
"nesting": {
"label": "Beágyazott címkék",
"description": "Ágyazzon címkéket más címkék alá, és jelenítse meg őket fastruktúraként az oldalsávon."
diff --git a/locales/it/common.json b/locales/it/common.json
index 48a3ae4f8..49e2a95e4 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -1096,6 +1096,7 @@
"cancel": "Annulla",
"migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti",
+ "filters_migration_error": "Alcune regole dei filtri usano ancora il vecchio tag: aggiornale manualmente",
"nesting": {
"label": "Etichette nidificate",
"description": "Nidifica le etichette sotto altre etichette e mostrale come un albero nella barra laterale."
diff --git a/locales/ja/common.json b/locales/ja/common.json
index de7e40c0e..df0adf2dd 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -1096,6 +1096,7 @@
"cancel": "キャンセル",
"migrating": "既存のメールのラベルを更新中…",
"migration_error": "既存のメールのラベルの更新に失敗しました",
+ "filters_migration_error": "一部のフィルタールールが古いタグを使用しています。手動で更新してください",
"nesting": {
"label": "ネストされたラベル",
"description": "ラベルを他のラベルの下にネストし、サイドバーにツリーとして表示します。"
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 8cb8e3d75..66da35274 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -1096,6 +1096,7 @@
"cancel": "취소",
"migrating": "기존 이메일의 태그 업데이트 중…",
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다",
+ "filters_migration_error": "일부 필터 규칙이 여전히 이전 태그를 사용합니다. 수동으로 업데이트하세요",
"nesting": {
"label": "중첩 태그",
"description": "태그를 다른 태그 아래에 중첩하고 사이드바에 트리로 표시합니다."
diff --git a/locales/lv/common.json b/locales/lv/common.json
index f38757c6e..7846fce8f 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -1096,6 +1096,7 @@
"cancel": "Atcelt",
"migrating": "Taga atjaunināšana esošajos e-pastos…",
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos",
+ "filters_migration_error": "Dažas filtru kārtulas joprojām izmanto veco tagu — atjauniniet tās manuāli",
"nesting": {
"label": "Ligzdoti tagi",
"description": "Ligzdojiet tagus zem citiem tagiem un rādiet tos sānjoslā kā koku."
diff --git a/locales/mn/common.json b/locales/mn/common.json
index fdfa0ca40..665f1eefa 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -1099,6 +1099,7 @@
"cancel": "Цуцлах",
"migrating": "Одоо байгаа имэйлүүд дээрх шошгыг шинэчилж байна...",
"migration_error": "Одоо байгаа имэйлийн шошгыг шинэчилж чадсангүй",
+ "filters_migration_error": "Зарим шүүлтүүрийн дүрэм хуучин шошгыг ашигласаар байна — гараар шинэчилнэ үү",
"nesting": {
"label": "Оруулсан шошго",
"description": "Тагуудыг бусад шошгоны доор байрлуулж, хажуугийн самбарт мод хэлбэрээр харуул."
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 6e53eec2c..5c2c8525c 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -1099,6 +1099,7 @@
"cancel": "Avbryt",
"migrating": "Oppdaterer etiketten på eksisterende e-poster...",
"migration_error": "Kunne ikke oppdatere etiketten på eksisterende e-poster",
+ "filters_migration_error": "Noen filterregler bruker fortsatt den gamle etiketten – oppdater dem manuelt",
"nesting": {
"label": "Nestede etiketter",
"description": "Plasser etiketter under hverandre og vis dem som et hierarki i sidefeltet."
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 14dd7093d..7296156b5 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -1096,6 +1096,7 @@
"cancel": "Annuleren",
"migrating": "Label bijwerken op bestaande e-mails…",
"migration_error": "Label bijwerken op bestaande e-mails mislukt",
+ "filters_migration_error": "Sommige filterregels gebruiken nog het oude label — werk ze handmatig bij",
"nesting": {
"label": "Geneste labels",
"description": "Nest labels onder andere labels en toon ze als een boomstructuur in de zijbalk."
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 57380fb89..213518f67 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -1096,6 +1096,7 @@
"cancel": "Anuluj",
"migrating": "Aktualizowanie etykiety w istniejących e-mailach…",
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach",
+ "filters_migration_error": "Niektóre reguły filtrów nadal używają starego tagu — zaktualizuj je ręcznie",
"nesting": {
"label": "Zagnieżdżone etykiety",
"description": "Zagnieżdżaj etykiety pod innymi etykietami i wyświetlaj je w panelu bocznym jako drzewo."
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 2c3f35014..399889a1a 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -1096,6 +1096,7 @@
"cancel": "Cancelar",
"migrating": "A atualizar etiqueta nos e-mails existentes…",
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes",
+ "filters_migration_error": "Algumas regras de filtro ainda usam a etiqueta antiga — atualize-as manualmente",
"nesting": {
"label": "Etiquetas aninhadas",
"description": "Aninhe etiquetas sob outras etiquetas e mostre-as como uma árvore na barra lateral."
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 1f6fd1896..119ce3175 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -1099,6 +1099,7 @@
"cancel": "Anulează",
"migrating": "Actualizarea etichetei pentru e-mailurile existente…",
"migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente",
+ "filters_migration_error": "Unele reguli de filtrare folosesc încă eticheta veche — actualizează-le manual",
"nesting": {
"label": "Etichete imbricate",
"description": "Imbricați etichete sub alte etichete și afișați-le ca un arbore în bara laterală."
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 13814a0cc..51727ff43 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -1096,6 +1096,7 @@
"cancel": "Отмена",
"migrating": "Обновление тега в существующих письмах…",
"migration_error": "Не удалось обновить тег в существующих письмах",
+ "filters_migration_error": "Некоторые правила фильтров всё ещё используют старый тег — обновите их вручную",
"nesting": {
"label": "Вложенные теги",
"description": "Вкладывайте теги в другие теги и показывайте их в боковой панели в виде дерева."
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 56f3e76ec..b7ffbc2ca 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -1099,6 +1099,7 @@
"cancel": "Zrušiť",
"migrating": "Aktualizácia štítku v existujúcich e-mailoch…",
"migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch",
+ "filters_migration_error": "Niektoré pravidlá filtrov stále používajú starý štítok – upravte ich ručne",
"nesting": {
"label": "Vnorené štítky",
"description": "Vnorujte štítky pod iné štítky a zobrazujte ich v bočnom paneli ako strom."
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 99c5227fd..0f27dd6d2 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -1096,6 +1096,7 @@
"cancel": "İptal",
"migrating": "Mevcut e-postalardaki etiket güncelleniyor…",
"migration_error": "Mevcut e-postalardaki etiket güncellenemedi",
+ "filters_migration_error": "Bazı filtre kuralları hâlâ eski etiketi kullanıyor — bunları elle güncelleyin",
"nesting": {
"label": "İç içe etiketler",
"description": "Etiketleri başka etiketlerin altına yerleştirin ve kenar çubuğunda ağaç olarak gösterin."
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 723af5fc2..bf3faaa15 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -1096,6 +1096,7 @@
"cancel": "Скасувати",
"migrating": "Оновлення ключового слова в наявних електронних листах…",
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах",
+ "filters_migration_error": "Деякі правила фільтрів досі використовують старий тег — оновіть їх вручну",
"nesting": {
"label": "Вкладені теги",
"description": "Вкладайте теги в інші теги та показуйте їх на бічній панелі у вигляді дерева."
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 57ca6db0f..5ce6ade0b 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -1096,6 +1096,7 @@
"cancel": "取消",
"migrating": "正在更新現有郵件的標籤…",
"migration_error": "更新現有郵件的標籤失敗",
+ "filters_migration_error": "部分篩選規則仍在使用舊標籤,請手動更新",
"nesting": {
"label": "巢狀標籤",
"description": "將標籤巢狀放在其他標籤下,並在側邊欄以樹狀結構顯示。"
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 0d804d91c..f8469e3b3 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -1096,6 +1096,7 @@
"cancel": "取消",
"migrating": "正在更新现有邮件的标签…",
"migration_error": "更新现有邮件的标签失败",
+ "filters_migration_error": "部分过滤规则仍在使用旧标签,请手动更新",
"nesting": {
"label": "嵌套标签",
"description": "将标签嵌套在其他标签之下,并在侧边栏中以树形显示。"
diff --git a/stores/__tests__/filter-store-keyword-rename.test.ts b/stores/__tests__/filter-store-keyword-rename.test.ts
new file mode 100644
index 000000000..ff936c301
--- /dev/null
+++ b/stores/__tests__/filter-store-keyword-rename.test.ts
@@ -0,0 +1,202 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { useFilterStore } from '../filter-store';
+import { generateScript } from '@/lib/sieve/generator';
+import { filterHooks } from '@/lib/plugin-hooks';
+import type { FilterRule } from '@/lib/jmap/sieve-types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+const makeRule = (overrides: Partial = {}): FilterRule => ({
+ id: 'rule-1',
+ name: 'ISO',
+ enabled: true,
+ matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: 'iso.org' }],
+ actions: [{ type: 'add_label', value: 'red' }],
+ stopProcessing: false,
+ ...overrides,
+});
+
+/** A client serving one active script, recording what gets written back. */
+function makeClient(content: string, scriptsOverride?: { id: string; name: string; blobId: string; isActive: boolean }[]) {
+ const updateSieveScript = vi.fn(
+ async (_scriptId: string, _content: string, _activate?: boolean, _accountId?: string) => {},
+ );
+ const client = {
+ supportsSieve: () => true,
+ getSieveAccountId: () => 'primary',
+ getSieveAccounts: () => [{ id: 'primary', name: 'Me', isPrimary: true }],
+ getSieveCapabilities: () => null,
+ getSieveScripts: async () => scriptsOverride ?? [
+ { id: 'vac', name: 'vacation', blobId: 'bv', isActive: false },
+ { id: 's1', name: 'filters', blobId: 'b1', isActive: true },
+ ],
+ getSieveScriptContent: async () => content,
+ updateSieveScript,
+ };
+ return { client: client as unknown as IJMAPClient, updateSieveScript };
+}
+
+describe('filter-store renameKeywordInFilters', () => {
+ beforeEach(() => {
+ useFilterStore.getState().clearState();
+ });
+
+ it('rewrites the tag in the active script and uploads it', async () => {
+ const { client, updateSieveScript } = makeClient(generateScript([makeRule()]));
+
+ const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+
+ expect(outcome).toEqual({ changed: 1, unhandled: 0 });
+ expect(updateSieveScript).toHaveBeenCalledTimes(1);
+ const [scriptId, uploaded, activate, accountId] = updateSieveScript.mock.calls[0];
+ expect(scriptId).toBe('s1');
+ expect(activate).toBe(true);
+ expect(accountId).toBe('primary');
+ expect(uploaded).toContain('addflag "$label:iso";');
+ expect(uploaded).not.toContain('$label:red');
+ });
+
+ it('rewrites the metadata block the visual editor reads back', async () => {
+ const { client, updateSieveScript } = makeClient(generateScript([makeRule()]));
+
+ await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+
+ const uploaded = updateSieveScript.mock.calls[0][1];
+ const metadata = uploaded.match(/@metadata:begin\n([\s\S]*)\n@metadata:end/)?.[1];
+ expect(metadata).toBeDefined();
+ const parsed = JSON.parse(metadata as string) as { rules: FilterRule[] };
+ expect(parsed.rules[0].actions[0]).toEqual({ type: 'add_label', value: 'iso' });
+ });
+
+ it('writes nothing when no rule names the tag', async () => {
+ const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'blue' }] })]);
+ const { client, updateSieveScript } = makeClient(script);
+
+ const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+
+ expect(outcome).toEqual({ changed: 0, unhandled: 0 });
+ expect(updateSieveScript).not.toHaveBeenCalled();
+ });
+
+ it('leaves a hand-edited script alone and reports what it could not rewrite', async () => {
+ const { client, updateSieveScript } = makeClient(
+ '/* @metadata:begin\n{corrupt\n@metadata:end */\naddflag "$label:red";',
+ );
+
+ const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+
+ expect(outcome).toEqual({ changed: 0, unhandled: 1 });
+ expect(updateSieveScript).not.toHaveBeenCalled();
+ });
+
+ it('keeps an open filter view in step when it shows the same account', async () => {
+ const { client } = makeClient(generateScript([makeRule()]));
+ await useFilterStore.getState().fetchFilters(client);
+ expect(useFilterStore.getState().rules[0].actions[0].value).toBe('red');
+
+ await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+
+ expect(useFilterStore.getState().rules[0].actions[0].value).toBe('iso');
+ expect(useFilterStore.getState().rawScript).toContain('$label:iso');
+ });
+
+ it('does not touch loaded state belonging to another account', async () => {
+ const { client } = makeClient(generateScript([makeRule()]));
+ useFilterStore.setState({ selectedAccountId: 'shared', rules: [makeRule({ id: 'other' })] });
+
+ await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+
+ expect(useFilterStore.getState().rules[0].id).toBe('other');
+ expect(useFilterStore.getState().rules[0].actions[0].value).toBe('red');
+ });
+
+ it('does nothing when the account has no script at all', async () => {
+ const client = {
+ supportsSieve: () => true,
+ getSieveAccountId: () => 'primary',
+ getSieveScripts: async () => [],
+ } as unknown as IJMAPClient;
+
+ const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+
+ expect(outcome).toEqual({ changed: 0, unhandled: 0 });
+ });
+
+ it('leaves an inactive script alone rather than activating it by writing to it', async () => {
+ const { client, updateSieveScript } = makeClient(generateScript([makeRule()]), [
+ { id: 's1', name: 'filters', blobId: 'b1', isActive: false },
+ ]);
+
+ const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+
+ expect(outcome).toEqual({ changed: 0, unhandled: 0 });
+ expect(updateSieveScript).not.toHaveBeenCalled();
+ });
+
+ it('does nothing on a server without Sieve', async () => {
+ const client = {
+ supportsSieve: () => false,
+ getSieveAccountId: () => 'primary',
+ getSieveScripts: async () => {
+ throw new Error('should not be called');
+ },
+ } as unknown as IJMAPClient;
+
+ await expect(
+ useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso'),
+ ).resolves.toEqual({ changed: 0, unhandled: 0 });
+ });
+
+ it('reports a reference it could not rewrite alongside the ones it did', async () => {
+ const script = `${generateScript([makeRule()])}\n# unmanaged\nif header :contains "X-Tag" "$label:red" {\n keep;\n}\n`;
+ const { client, updateSieveScript } = makeClient(script);
+
+ const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+
+ expect(outcome.changed).toBe(1);
+ expect(outcome.unhandled).toBe(1);
+ expect(updateSieveScript).toHaveBeenCalledTimes(1);
+ });
+
+ it('counts what a plugin grafted back on as unhandled, since that is what gets written', async () => {
+ const { client } = makeClient(generateScript([makeRule()]));
+ const registration = filterHooks.onSieveScriptGenerate.register(
+ 'test-plugin',
+ (script: string) => `# plugin section\nif true {\n addflag "$label:red";\n}\n${script}`,
+ );
+
+ try {
+ const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+ expect(outcome).toEqual({ changed: 1, unhandled: 1 });
+ } finally {
+ registration.dispose();
+ }
+ });
+
+ it('runs the uploaded script through the plugin generate hook', async () => {
+ const { client, updateSieveScript } = makeClient(generateScript([makeRule()]));
+ const registration = filterHooks.onSieveScriptGenerate.register(
+ 'test-plugin',
+ (script: string) => `# plugin section\n${script}`,
+ );
+
+ try {
+ await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+ } finally {
+ registration.dispose();
+ }
+
+ expect(updateSieveScript.mock.calls[0][1]).toContain('# plugin section');
+ });
+
+ it('propagates a failed upload so the caller can warn', async () => {
+ const { client } = makeClient(generateScript([makeRule()]));
+ (client as unknown as { updateSieveScript: unknown }).updateSieveScript = vi.fn(async () => {
+ throw new Error('over quota');
+ });
+
+ await expect(
+ useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso'),
+ ).rejects.toThrow('over quota');
+ });
+});
diff --git a/stores/filter-store.ts b/stores/filter-store.ts
index 0964aefc1..7142a4b5c 100644
--- a/stores/filter-store.ts
+++ b/stores/filter-store.ts
@@ -3,9 +3,22 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FilterRule, SieveCapabilities, VacationSieveConfig } from '@/lib/jmap/sieve-types';
import { parseScript } from '@/lib/sieve/parser';
import { generateScript } from '@/lib/sieve/generator';
+import { countKeywordUses, renameKeywordInRules } from '@/lib/sieve/keyword-rename';
import { filterHooks } from '@/lib/plugin-hooks';
import { debug } from '@/lib/debug';
+/** What a tag rename managed to do to the filters that name the tag. */
+export interface KeywordRenameOutcome {
+ /** Filter actions rewritten to the new tag id. */
+ changed: number;
+ /**
+ * References to the old tag left in place because the script is hand-edited
+ * and is kept verbatim. The caller should tell the user, who has to fix those
+ * by hand - nothing else will.
+ */
+ unhandled: number;
+}
+
interface SieveAccount {
id: string;
name: string;
@@ -31,6 +44,7 @@ interface FilterStore {
fetchFilters: (client: IJMAPClient, accountId?: string) => Promise;
selectAccount: (client: IJMAPClient, accountId: string) => Promise;
saveFilters: (client: IJMAPClient) => Promise;
+ renameKeywordInFilters: (client: IJMAPClient, oldId: string, newId: string) => Promise;
validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
addRule: (rule: FilterRule) => void;
updateRule: (ruleId: string, updates: Partial) => void;
@@ -176,6 +190,85 @@ export const useFilterStore = create()((set, get) => ({
}
},
+ /**
+ * Follows a renamed tag into this account's filters.
+ *
+ * Renaming a tag changes its id, and therefore the `$label:` keyword a rule
+ * writes. Migrating only the messages (`migrateKeyword`) would leave the rule
+ * tagging new mail under the old id, which nothing names any more - the rule
+ * looks broken while running exactly as written.
+ *
+ * The script is fetched and written back on its own rather than through
+ * `fetchFilters`/`saveFilters`, so a rename from the tag settings never
+ * disturbs which account an open filter view is showing. That view is updated
+ * in place when it happens to be showing this same account.
+ *
+ * A hand-edited (opaque) script is left alone - it has no parsed rules to
+ * rewrite and re-generating it would discard the user's own Sieve - and its
+ * remaining references are reported instead. Upload failures propagate.
+ */
+ renameKeywordInFilters: async (client, oldId, newId) => {
+ const nothing: KeywordRenameOutcome = { changed: 0, unhandled: 0 };
+ if (!oldId || !newId || oldId === newId) return nothing;
+ if (!client.supportsSieve()) return nothing;
+
+ // The account that runs the rules. A rule can only tag mail arriving in
+ // its own account, so this is the one to rewrite even in a session where
+ // the Sieve and mail primaries are different accounts - which RFC 8620
+ // permits, and which the caller's message migration, bound to the mail
+ // primary, would not have covered anyway.
+ const accountId = client.getSieveAccountId();
+ const scripts = (await client.getSieveScripts(accountId)).filter(s => s.name !== 'vacation');
+ const activeScript = scripts.find(s => s.isActive);
+ if (!activeScript) {
+ // Nothing is filtering mail right now. Rewriting an inactive script would
+ // mean activating it - updateSieveScript activates on write - and a tag
+ // rename must not switch someone's filters back on.
+ return nothing;
+ }
+
+ const content = await client.getSieveScriptContent(activeScript.blobId, accountId);
+ const parsed = parseScript(content);
+ if (parsed.isOpaque) {
+ return { changed: 0, unhandled: countKeywordUses(content, oldId) };
+ }
+
+ const { rules, changed } = renameKeywordInRules(parsed.rules, oldId, newId);
+ if (changed === 0) return { changed: 0, unhandled: countKeywordUses(content, oldId) };
+
+ let script = generateScript(rules, parsed.vacation || undefined, {
+ externalRequires: parsed.externalRequires,
+ });
+ // Same plugin pipeline a normal save runs through: a plugin's managed
+ // section is grafted on at generate time, so skipping the transform here
+ // would drop it from the script this write makes active.
+ const transformed = await filterHooks.onSieveScriptGenerate.transform(script, { accountId });
+ if (typeof transformed === 'string' && transformed.trim().length > 0) {
+ script = transformed;
+ }
+
+ // Count what still names the old tag in the script actually being written,
+ // after the plugin transform: a rule kept verbatim, a test comparing
+ // against the keyword, or a section a plugin just grafted back on.
+ const unhandled = countKeywordUses(script, oldId);
+
+ await client.updateSieveScript(activeScript.id, script, true, accountId);
+ debug.log('filters', 'Renamed tag in', changed, 'filter action(s)');
+
+ const state = get();
+ if (state.selectedAccountId === accountId && !state.isOpaque) {
+ set({
+ rules,
+ rawScript: script,
+ activeScriptId: activeScript.id,
+ vacationSettings: parsed.vacation || null,
+ externalRequires: parsed.externalRequires,
+ });
+ }
+ void filterHooks.onSieveScriptChange.emit({ accountId, script });
+ return { changed, unhandled };
+ },
+
validateScript: async (client, content) => {
return client.validateSieveScript(content, get().selectedAccountId || undefined);
},
From c49f13db2868335745923b8088e54c34ceda9b2d Mon Sep 17 00:00:00 2001
From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com>
Date: Fri, 25 Sep 2026 19:41:16 +0200
Subject: [PATCH 002/238] fix: list a shared folder reachable through two
logins only once in cross views
When a shared folder was reachable through two logged-in accounts (owner +
grantee, or two grantees), buildUnifiedAccountClients emitted one entry per
reaching login. All mail / All unread then listed each message twice and the
unread badge counted it twice.
dedupeUnifiedAccounts keeps each (owning JMAP account, mailbox) once: personal
entries win, shared entries keep only folders not already covered and are
dropped when none remain. The owner's own mailbox list is no longer overwritten
in accountMailboxes by a grantee's namespaced copy, so actions on the deduped
messages resolve trash/archive in the right id space.
---
.../tests/15-shared-folder-dedup.spec.ts | 110 ++++++++++++++++++
lib/__tests__/unified-mailbox-dedupe.test.ts | 57 +++++++++
lib/unified-mailbox.ts | 39 +++++++
stores/email-store.ts | 16 ++-
4 files changed, 219 insertions(+), 3 deletions(-)
create mode 100644 integration/tests/15-shared-folder-dedup.spec.ts
create mode 100644 lib/__tests__/unified-mailbox-dedupe.test.ts
diff --git a/integration/tests/15-shared-folder-dedup.spec.ts b/integration/tests/15-shared-folder-dedup.spec.ts
new file mode 100644
index 000000000..988e6b191
--- /dev/null
+++ b/integration/tests/15-shared-folder-dedup.spec.ts
@@ -0,0 +1,110 @@
+import { test, expect, type Page } from '@playwright/test';
+import { ACCOUNTS } from './helpers/config';
+import { sendMail } from './helpers/smtp';
+import { JmapClient } from './helpers/jmap';
+import {
+ login,
+ addAccount,
+ seedSettings,
+ folderRow,
+ openFolder,
+ expectFolderCountsSynced,
+ expectEmailVisible,
+ emailItem,
+ forceSync,
+} from './helpers/app';
+
+/**
+ * A shared folder reachable through TWO logged-in accounts must show up only
+ * once in the cross-account views (All mail / Unread) — both the list rows and
+ * the unread badge.
+ *
+ * Scenarios:
+ * - owner + grantee: alice shares a folder with carol; alice and carol are both
+ * logged in, so the folder is reachable as alice's own folder AND as a
+ * shared folder via carol's session.
+ * - two grantees: alice shares a folder with bob and carol; bob and carol are
+ * logged in (alice is not), so the folder is reachable via both sessions.
+ */
+const { alice, bob, carol } = ACCOUNTS;
+const SHARED = 'DupShared';
+const ALL_MAIL = '__cross_all__';
+const UNREAD = '__cross_unread__';
+
+let seq = 0;
+const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
+
+async function seedCrossViews(page: Page): Promise {
+ await seedSettings(page, {
+ enableUnifiedMailbox: true,
+ enableCrossAllView: true,
+ enableCrossUnreadView: true,
+ includeGroupInUnified: true,
+ unifiedCrossAccount: true,
+ });
+}
+
+async function expectSingleInView(page: Page, view: string, subject: string): Promise {
+ await openFolder(page, { name: view });
+ await forceSync(page);
+ await expectEmailVisible(page, subject);
+ // Give a second (duplicate) row time to render before counting.
+ await page.waitForTimeout(1500);
+ expect.soft(await emailItem(page, subject).count(), `rows for "${subject}" in ${view}`).toBe(1);
+}
+
+test.describe('Shared folder reachable via two logged-in accounts', () => {
+ let ja: JmapClient;
+ let sharedId: string;
+
+ test.beforeEach(async () => {
+ ja = await JmapClient.connect(alice.email, alice.password);
+ for (const a of [alice, bob, carol]) await (await JmapClient.connect(a.email, a.password)).reset();
+ });
+
+ test.afterEach(async () => {
+ // Drop the shared folder so the grants don't leak into other specs.
+ await ja.reset();
+ });
+
+ async function seedIntoShared(subject: string): Promise {
+ await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
+ const m = await ja.waitForEmail(subject);
+ await ja.moveEmail(m.id, sharedId);
+ }
+
+ test('owner + grantee logged in: shared message listed and counted once', async ({ page }) => {
+ sharedId = await ja.createSharedFolder(SHARED, carol.email);
+ const s = subj('dup-owner');
+ await seedIntoShared(s);
+
+ await seedCrossViews(page);
+ await login(page, alice);
+ await addAccount(page, carol);
+
+ await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
+ await expectSingleInView(page, ALL_MAIL, s);
+ await expectSingleInView(page, UNREAD, s);
+
+ await expectFolderCountsSynced(page, { name: UNREAD }, { unread: 1 }, 15000);
+ await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 1 }, 15000);
+ });
+
+ test('two grantees logged in: shared message listed and counted once', async ({ page }) => {
+ sharedId = await ja.createSharedFolder(SHARED, carol.email);
+ await ja.shareMailbox(sharedId, bob.email);
+ const s = subj('dup-grantees');
+ await seedIntoShared(s);
+
+ await seedCrossViews(page);
+ await login(page, bob);
+ await addAccount(page, carol);
+
+ await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
+ await expectSingleInView(page, ALL_MAIL, s);
+ await expectSingleInView(page, UNREAD, s);
+
+ await expectFolderCountsSynced(page, { name: UNREAD }, { unread: 1 }, 15000);
+ await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 1 }, 15000);
+ });
+});
diff --git a/lib/__tests__/unified-mailbox-dedupe.test.ts b/lib/__tests__/unified-mailbox-dedupe.test.ts
new file mode 100644
index 000000000..4499a0b96
--- /dev/null
+++ b/lib/__tests__/unified-mailbox-dedupe.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from 'vitest';
+import type { Mailbox } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import { dedupeUnifiedAccounts, getCrossUnreadTotal, type UnifiedAccountClient } from '@/lib/unified-mailbox';
+
+const mb = (id: string, role: string | undefined, unread = 0, originalId?: string): Mailbox =>
+ ({ id, name: id, role, unreadEmails: unread, totalEmails: 0, originalId } as unknown as Mailbox);
+
+const makeAccount = (over: Partial & { accountId: string }): UnifiedAccountClient => ({
+ accountLabel: over.accountId,
+ mailboxes: [],
+ client: {} as unknown as IJMAPClient,
+ clientAccountId: over.accountId,
+ jmapAccountId: over.accountId,
+ ...over,
+});
+
+// A folder of JMAP account `owner`, as seen through the login `via`.
+const sharedEntry = (owner: string, via: string, mailboxes: Mailbox[]) =>
+ makeAccount({ accountId: owner, jmapAccountId: owner, clientAccountId: via, isShared: true, mailboxes });
+
+describe('dedupeUnifiedAccounts', () => {
+ it('drops a shared entry whose folders the owner login already covers', () => {
+ const alice = makeAccount({ accountId: 'login-a', jmapAccountId: 'A', mailboxes: [mb('inbox', 'inbox'), mb('f1', undefined, 1)] });
+ const carolSeesAlice = sharedEntry('A', 'login-c', [mb('A:f1', undefined, 1, 'f1')]);
+ const carol = makeAccount({ accountId: 'login-c', jmapAccountId: 'C', mailboxes: [mb('inbox', 'inbox')] });
+
+ // The grantee's login may come first - the owner's own entry still wins.
+ const out = dedupeUnifiedAccounts([carol, carolSeesAlice, alice]);
+ expect(out).toEqual([carol, alice]);
+ expect(getCrossUnreadTotal(out)).toBe(1);
+ });
+
+ it('keeps one copy when two grantee logins reach the same shared folder', () => {
+ const viaBob = sharedEntry('A', 'login-b', [mb('A:f1', undefined, 1, 'f1')]);
+ const viaCarol = sharedEntry('A', 'login-c', [mb('A:f1', undefined, 1, 'f1')]);
+ const out = dedupeUnifiedAccounts([viaBob, viaCarol]);
+ expect(out).toEqual([viaBob]);
+ expect(getCrossUnreadTotal(out)).toBe(1);
+ });
+
+ it('keeps folders only one of the grantees can reach', () => {
+ const viaBob = sharedEntry('A', 'login-b', [mb('A:f1', undefined, 1, 'f1')]);
+ const viaCarol = sharedEntry('A', 'login-c', [mb('A:f1', undefined, 1, 'f1'), mb('A:f2', undefined, 2, 'f2')]);
+ const out = dedupeUnifiedAccounts([viaBob, viaCarol]);
+ expect(out.map((a) => a.mailboxes.map((m) => m.id))).toEqual([['A:f1'], ['A:f2']]);
+ expect(out[1].clientAccountId).toBe('login-c');
+ expect(getCrossUnreadTotal(out)).toBe(3);
+ });
+
+ it('does not merge distinct owners or personal accounts with equal mailbox ids', () => {
+ const a = makeAccount({ accountId: 'login-a', jmapAccountId: 'A', mailboxes: [mb('inbox', 'inbox', 1)] });
+ const b = makeAccount({ accountId: 'login-b', jmapAccountId: 'B', mailboxes: [mb('inbox', 'inbox', 1)] });
+ const group = sharedEntry('G', 'login-a', [mb('G:inbox', 'inbox', 1, 'inbox')]);
+ expect(dedupeUnifiedAccounts([a, b, group])).toEqual([a, b, group]);
+ });
+});
diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts
index a9db07bf1..0cc812726 100644
--- a/lib/unified-mailbox.ts
+++ b/lib/unified-mailbox.ts
@@ -659,3 +659,42 @@ export function connectedAccountsGrew(previous: string | null, current: string):
const before = new Set(previous ? previous.split(',').filter(Boolean) : []);
return current.split(',').some((id) => id !== '' && !before.has(id));
}
+
+/**
+ * Drops mailboxes that are reachable through more than one logged-in account,
+ * so each folder contributes to the aggregate views exactly once.
+ *
+ * A folder shared with a second login (owner + grantee both logged in), or
+ * shared with two logins at once, otherwise yields one entry per reaching
+ * login: its messages list twice and its unread count is summed twice.
+ *
+ * A mailbox is identified by its owning JMAP account + server-side id (a shared
+ * entry's `originalId`). Personal entries always win and are never trimmed, so
+ * an owner's folder is read through the owner's own login and honors their
+ * folder selection. Shared entries keep only mailboxes not already covered and
+ * are dropped when none remain. Order is preserved.
+ */
+export function dedupeUnifiedAccounts(accounts: UnifiedAccountClient[]): UnifiedAccountClient[] {
+ const seen = new Set();
+ const key = (jmapAccountId: string, mailboxId: string) => `${jmapAccountId}\u0000${mailboxId}`;
+ for (const account of accounts) {
+ if (account.isShared) continue;
+ for (const m of account.mailboxes) seen.add(key(account.jmapAccountId, m.id));
+ }
+ const out: UnifiedAccountClient[] = [];
+ for (const account of accounts) {
+ if (!account.isShared) {
+ out.push(account);
+ continue;
+ }
+ const mailboxes = account.mailboxes.filter((m) => {
+ const k = key(account.jmapAccountId, m.originalId ?? m.id);
+ if (seen.has(k)) return false;
+ seen.add(k);
+ return true;
+ });
+ if (mailboxes.length === 0) continue;
+ out.push(mailboxes.length === account.mailboxes.length ? account : { ...account, mailboxes });
+ }
+ return out;
+}
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 88365d9a0..c69b8bd1e 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -10,7 +10,7 @@ import { emailHooks } from "@/lib/plugin-hooks";
import { resolveThreadRoute } from "@/lib/thread-routing";
import { threadKeyFor, threadIdFromKey } from "@/lib/thread-utils";
import type { ExternalSearchResult } from "@/lib/plugin-types";
-import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
+import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, dedupeUnifiedAccounts, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
@@ -915,6 +915,9 @@ function resolveDestLocalAccountId(mailbox: Mailbox): string | null {
* `allMailFolderIds` folder selection, restricting the All mail / Unread /
* Starred cross views to the chosen own folders (shared entries are left
* unrestricted so all their folders are included).
+ *
+ * A folder reachable through several logins (owner + grantee, or two grantees)
+ * is kept only once - see `dedupeUnifiedAccounts`.
*/
export async function buildUnifiedAccountClients(
opts: { includeGroup?: boolean; scopeToClientAccountId?: string } = {},
@@ -929,6 +932,11 @@ export async function buildUnifiedAccountClients(
// after the fan-out so single-email actions can resolve role-based
// destinations (trash/archive) in the email's own account (issue #281).
const fetchedMailboxes: Record = {};
+ const ownLoginJmapIds = new Set();
+ for (const a of authAccounts) {
+ const c = allClients.get(a.id);
+ if (c) ownLoginJmapIds.add(c.getAccountId());
+ }
for (const a of authAccounts) {
const c = allClients.get(a.id);
if (!c) continue;
@@ -970,7 +978,9 @@ export async function buildUnifiedAccountClients(
// Cache the owner's mailbox list keyed by its JMAP id so single-email
// and batch actions can resolve role-based destinations (trash/archive)
// in the owner account instead of falling back to the active account.
- fetchedMailboxes[ownerId] = ownerMailboxes;
+ // When the owner is logged in too, its own (un-namespaced) list wins:
+ // dedupeUnifiedAccounts routes that folder through the owner's login.
+ if (!ownLoginJmapIds.has(ownerId)) fetchedMailboxes[ownerId] = ownerMailboxes;
}
}
} catch {
@@ -982,7 +992,7 @@ export async function buildUnifiedAccountClients(
accountMailboxes: { ...state.accountMailboxes, ...fetchedMailboxes },
}));
}
- return built;
+ return dedupeUnifiedAccounts(built);
}
/**
From 888b606fcb939272154b62237fb880a8440c426c Mon Sep 17 00:00:00 2001
From: Aaron Guise
Date: Tue, 29 Sep 2026 18:17:19 +1300
Subject: [PATCH 003/238] fix(email-composer): normalize bracketed content-ids
for inline reply/forward images :bug:
Attachment.cid may still carry RFC angle brackets while body `src="cid:...`
references never do. The viewers already stripped them before matching;
the reply/forward inline-image hydration didn't, so the match silently
failed and the image stayed a blank placeholder in compose and never made
it into the outgoing message. Extracted the stripping into a shared
normalizeCid() and applied it everywhere cids get compared.
---
.../composer-inline-image-cid.test.tsx | 208 ++++++++++++++++++
components/email/email-composer.tsx | 15 +-
components/email/email-viewer.tsx | 6 +-
components/email/thread-conversation-view.tsx | 4 +-
lib/attachment-visibility.ts | 13 +-
5 files changed, 234 insertions(+), 12 deletions(-)
create mode 100644 components/email/__tests__/composer-inline-image-cid.test.tsx
diff --git a/components/email/__tests__/composer-inline-image-cid.test.tsx b/components/email/__tests__/composer-inline-image-cid.test.tsx
new file mode 100644
index 000000000..150a4f3f9
--- /dev/null
+++ b/components/email/__tests__/composer-inline-image-cid.test.tsx
@@ -0,0 +1,208 @@
+import { render, screen, act, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import React from 'react';
+import { EmailComposer } from '../email-composer';
+import { useAuthStore } from '@/stores/auth-store';
+
+// ─── Heavy component mocks (mirrors composer-draft-attachments.test.tsx) ──────
+
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({ useFocusTrap: () => ({ current: null }) }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = { draftSaveEnabled: false, sendRawEmail: async () => ({ sent: true }) };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ sendDelaySeconds: 0,
+ signaturePosition: 'above_quote',
+ signatureSeparatorEnabled: false,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true },
+ onComposeSend: { intercept: async () => true },
+ onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/signature-utils', () => ({
+ appendPlainTextSignature: (body: string) => body,
+ getPlainTextSignature: () => '',
+ plainTextBodyHasSignature: () => false,
+ plainTextBodyWithoutSignature: (body: string) => body,
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+/**
+ * Senders may declare `Content-ID: ` (angle brackets kept)
+ * while the body always references it bracket-free as `src="cid:logo123@sender"`.
+ * The viewers already normalize both sides before matching; the reply/forward
+ * hydration path didn't, so any such image silently stayed a blank placeholder
+ * in the composer and was dropped from the outgoing message on send.
+ */
+describe('reply/forward inline image cid normalization', () => {
+ afterEach(() => {
+ useAuthStore.setState({ client: null });
+ vi.clearAllMocks();
+ });
+
+ it('hydrates an inline image whose attachment cid still carries angle brackets', async () => {
+ const fetchBlobArrayBuffer = vi.fn().mockResolvedValue(
+ new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).buffer, // PNG magic
+ );
+ useAuthStore.setState({
+ client: {
+ fetchBlobArrayBuffer,
+ hasDelayedSend: () => false,
+ getMaxDelayedSend: () => 0,
+ } as never,
+ });
+
+ render(
+ Hi
',
+ quoteHeaderHtml: 'On X, Sender wrote:',
+ attachments: [
+ { blobId: 'blob-1', name: 'logo.png', type: 'image/png', size: 100, cid: '' },
+ ],
+ }}
+ onClose={vi.fn()}
+ />
+ );
+
+ await waitFor(() => expect(fetchBlobArrayBuffer).toHaveBeenCalledTimes(1));
+ expect(fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'logo.png', 'image/png');
+ });
+});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 4a234a895..c0875ca40 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -65,6 +65,7 @@ import {
ICON_MAP,
} from "@/lib/email-composer-utils";
import { isValidEmail } from "@/lib/validation";
+import { normalizeCid } from "@/lib/attachment-visibility";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
@@ -910,15 +911,20 @@ export function EmailComposer({
// lenient (email-viewer.tsx: `att.cid && att.blobId`), so reading and
// replying now agree. The real type is sniffed from the bytes below.
const embeddedCids = collectInlineImageCids(body);
- const inlineAtts = replyTo.attachments.filter((att) =>
- att.cid && att.blobId && embeddedCids.has(att.cid)
- );
+ // Attachment cids may still carry the RFC angle brackets (e.g.
+ // ``) while `src="cid:logo123@sender"` in the body never
+ // does - normalize here the same way email-viewer.tsx and
+ // thread-conversation-view.tsx do, so the comparisons below actually
+ // match (#912: inline images silently missing from every reply/forward).
+ const inlineAtts = replyTo.attachments
+ .filter((att) => att.cid && att.blobId)
+ .map((att) => ({ ...att, cid: normalizeCid(att.cid!) }))
+ .filter((att) => embeddedCids.has(att.cid));
if (inlineAtts.length === 0) return;
// Seed the ref synchronously so a fast Send still attaches the right blobs
// even if the FileReader work below hasn't resolved yet.
for (const att of inlineAtts) {
- if (!att.cid) continue;
if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue;
inlineImagesRef.current.push({
cid: att.cid,
@@ -934,7 +940,6 @@ export function EmailComposer({
(async () => {
const updates = new Map();
for (const att of inlineAtts) {
- if (!att.cid) continue;
try {
const buffer = await composerClient.fetchBlobArrayBuffer(
att.blobId,
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index ceb79774c..7d7f308c3 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -6,7 +6,7 @@ import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilen
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { applyNewTabToAnchor, escapeHtml, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
-import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility";
+import { collectReferencedCids, isEmbeddedInBody, normalizeCid } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { withBasePath } from "@/lib/browser-navigation";
@@ -1546,7 +1546,7 @@ export function EmailViewer({
decryptedCidAttachments.forEach((att) => {
const bytes = getAttachmentContentBytes(att);
if (!bytes) return;
- const cidValue = att.contentId!.replace(/^<|>$/g, '');
+ const cidValue = normalizeCid(att.contentId!);
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
// Sender-typed part: never let a script-bearing MIME type reach a
// blob: URL in our origin (GHSA-xvjh-v9c6-qcvc).
@@ -1582,7 +1582,7 @@ export function EmailViewer({
async function fetchCidBlobs() {
const urls: Record = {};
await Promise.all(cidAttachments.map(async (att) => {
- const cidValue = att.cid!.replace(/^<|>$/g, '');
+ const cidValue = normalizeCid(att.cid!);
try {
// The download URL echoes the sender-declared Content-Type back as
// the Blob type; re-type anything that could execute as our origin
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index 82b8a49e6..f53ac8536 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -5,7 +5,7 @@ import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
-import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility";
+import { collectReferencedCids, isEmbeddedInBody, normalizeCid } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
@@ -299,7 +299,7 @@ function EmailCard({
async function fetchCidBlobs() {
const urls: Record = {};
await Promise.all(cidAttachments.map(async (att) => {
- const cidValue = att.cid!.replace(/^<|>$/g, '');
+ const cidValue = normalizeCid(att.cid!);
try {
// Re-type sender-declared script-bearing parts before they become a
// blob: URL in our origin (GHSA-xvjh-v9c6-qcvc).
diff --git a/lib/attachment-visibility.ts b/lib/attachment-visibility.ts
index e20044777..85fc8e746 100644
--- a/lib/attachment-visibility.ts
+++ b/lib/attachment-visibility.ts
@@ -21,6 +21,16 @@ const CID_REFERENCE = /\bcid:([^"'\s)]+)/gi;
const EMPTY: ReadonlySet = new Set();
+/**
+ * Strips the RFC angle brackets a Content-ID header may still carry (e.g.
+ * ``). Body `cid:` references never have them, so every
+ * comparison between an attachment's `cid`/`contentId` and a body reference
+ * must normalize through this first.
+ */
+export function normalizeCid(cid: string): string {
+ return cid.replace(/^<|>$/g, '');
+}
+
/**
* Content-IDs a raw HTML body references via `cid:` URLs (img src, table
* background, style url(...) - anything), exactly as written. `null` (the
@@ -65,6 +75,5 @@ export function isEmbeddedInBody(
if (att.disposition === 'attachment') return false;
const genericType = !type || type === 'application/octet-stream';
if (!isImage && !genericType) return false;
- // The Content-ID may still carry its angle brackets; body references never do.
- return referencedCids.has(att.cid.replace(/^<|>$/g, ''));
+ return referencedCids.has(normalizeCid(att.cid));
}
From 115c66ec8dc820e39e1ae147671ee784c18b9265 Mon Sep 17 00:00:00 2001
From: Aaron Guise
Date: Tue, 29 Sep 2026 18:18:01 +1300
Subject: [PATCH 004/238] chore(email-composer): drop unused test imports
flagged by lint :broom:
---
components/email/__tests__/composer-inline-image-cid.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/components/email/__tests__/composer-inline-image-cid.test.tsx b/components/email/__tests__/composer-inline-image-cid.test.tsx
index 150a4f3f9..4bc7c97ae 100644
--- a/components/email/__tests__/composer-inline-image-cid.test.tsx
+++ b/components/email/__tests__/composer-inline-image-cid.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, act, waitFor } from '@testing-library/react';
+import { render, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
From adf30bef406f3c38428f0dd87f1d2c6fae1f9448 Mon Sep 17 00:00:00 2001
From: Aaron Guise
Date: Tue, 29 Sep 2026 18:30:25 +1300
Subject: [PATCH 005/238] fix(email-composer): normalize forwarded inline-image
cids to prevent duplicate blobs :bug:
The forward attachment-list filter compared the raw (possibly bracketed)
Content-ID against the body's bracket-free cid: references, so a
bracketed cid with no inline disposition (Foxmail's octet-stream case)
slipped through as a regular attachment chip *and* got re-attached as
the matched inline part, sending the same blob twice. Normalize before
that comparison too, and strengthen the regression test to exercise Send
and assert on the actual outgoing htmlBody/attachments for both the
reply and forward paths.
---
.../composer-inline-image-cid.test.tsx | 83 +++++++++++++++++--
components/email/email-composer.tsx | 5 +-
2 files changed, 79 insertions(+), 9 deletions(-)
diff --git a/components/email/__tests__/composer-inline-image-cid.test.tsx b/components/email/__tests__/composer-inline-image-cid.test.tsx
index 4bc7c97ae..52d4b62ee 100644
--- a/components/email/__tests__/composer-inline-image-cid.test.tsx
+++ b/components/email/__tests__/composer-inline-image-cid.test.tsx
@@ -1,4 +1,4 @@
-import { render, waitFor } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
@@ -174,10 +174,10 @@ describe('reply/forward inline image cid normalization', () => {
vi.clearAllMocks();
});
- it('hydrates an inline image whose attachment cid still carries angle brackets', async () => {
- const fetchBlobArrayBuffer = vi.fn().mockResolvedValue(
- new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).buffer, // PNG magic
- );
+ const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).buffer;
+
+ function mockClient() {
+ const fetchBlobArrayBuffer = vi.fn().mockResolvedValue(PNG_MAGIC);
useAuthStore.setState({
client: {
fetchBlobArrayBuffer,
@@ -185,10 +185,17 @@ describe('reply/forward inline image cid normalization', () => {
getMaxDelayedSend: () => 0,
} as never,
});
+ return { fetchBlobArrayBuffer };
+ }
+
+ it('sends a reply with the image inlined by cid, not duplicated as a regular attachment', async () => {
+ const { fetchBlobArrayBuffer } = mockClient();
+ const onSend = vi.fn();
render(
{
/>
);
- await waitFor(() => expect(fetchBlobArrayBuffer).toHaveBeenCalledTimes(1));
- expect(fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'logo.png', 'image/png');
+ // Hydration: the placeholder gets swapped for the fetched blob's data URL.
+ await waitFor(() => expect(fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'logo.png', 'image/png'));
+
+ // Reply auto-fills "to" from replyTo.from, so Send is reachable immediately.
+ fireEvent.click(screen.getAllByTestId('composer-send')[0]);
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+
+ const sent = onSend.mock.calls[0][0] as { htmlBody?: string; attachments?: Array<{ cid?: string; blobId: string; disposition?: string }> };
+ // The data URL must have been rewritten back to a cid: reference for sending.
+ expect(sent.htmlBody).toContain('cid:logo123@sender');
+ expect(sent.htmlBody).not.toContain('data:image');
+ // Exactly one attachment for the one image - a bracket-mismatch used to
+ // mean it was never matched at all; matching on both regular AND inline
+ // paths would instead send the same blob twice (see forward test below).
+ expect(sent.attachments).toEqual([
+ expect.objectContaining({ blobId: 'blob-1', cid: 'logo123@sender', disposition: 'inline' }),
+ ]);
+ });
+
+ it('does not duplicate a forwarded inline image (declared octet-stream, no disposition) as a regular attachment', async () => {
+ const { fetchBlobArrayBuffer } = mockClient();
+ const onSend = vi.fn();
+
+ render(
+ Hi ',
+ quoteHeaderHtml: 'Forwarded message',
+ // Foxmail-style part (#543): no inline disposition, generic type -
+ // only the cid: body reference marks it as embedded.
+ attachments: [
+ { blobId: 'blob-1', name: 'logo.png', type: 'application/octet-stream', size: 100, cid: '' },
+ ],
+ }}
+ onClose={vi.fn()}
+ />
+ );
+
+ await waitFor(() => expect(fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'logo.png', 'application/octet-stream'));
+
+ // The forward attachment-list filter must have recognized blob-1 as
+ // embedded and kept it out of the regular attachment chips.
+ expect(screen.queryByText('logo.png')).not.toBeInTheDocument();
+
+ // Forward doesn't auto-fill "to" - add a recipient so Send is reachable.
+ // A trailing delimiter is needed: a bare single-address paste is left as
+ // uncommitted input text rather than immediately chipped.
+ fireEvent.paste(screen.getByPlaceholderText('to_placeholder'), {
+ clipboardData: { getData: () => 'bob@example.com;' },
+ });
+
+ fireEvent.click(screen.getAllByTestId('composer-send')[0]);
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+
+ const sent = onSend.mock.calls[0][0] as { attachments?: Array<{ cid?: string; blobId: string }> };
+ // Same blobId must appear exactly once, as the re-typed inline part -
+ // never also as a plain attachment (that would send it twice).
+ expect(sent.attachments).toEqual([
+ expect.objectContaining({ blobId: 'blob-1', cid: 'logo123@sender' }),
+ ]);
});
});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index c0875ca40..a9c781fb3 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -610,9 +610,10 @@ export function EmailComposer({
const embeddedCids = collectInlineImageCids(body);
return replyTo.attachments
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
- // (matches the viewer's hideInlineImageAttachments logic).
+ // (matches the viewer's hideInlineImageAttachments logic). att.cid may still
+ // carry angle brackets while embeddedCids (parsed from the body) never does.
.filter(att => !(att.cid && (
- embeddedCids.has(att.cid) ||
+ embeddedCids.has(normalizeCid(att.cid)) ||
(att.disposition === 'inline' && (att.type || '').startsWith('image/'))
)))
.map(att => ({
From d1350758a41157d0047353ba19bacb9827424de7 Mon Sep 17 00:00:00 2001
From: VasilisNtovolos
Date: Tue, 6 Oct 2026 12:49:58 +0300
Subject: [PATCH 006/238] fix: route bulk archive by owning account
---
lib/__tests__/jmap-archive-results.test.ts | 133 ++++
lib/jmap/archive-error.ts | 7 +
lib/jmap/client.ts | 69 +-
.../email-store-bulk-archive.test.ts | 604 ++++++++++++++++++
stores/email-store.ts | 318 +++++++--
5 files changed, 1062 insertions(+), 69 deletions(-)
create mode 100644 lib/__tests__/jmap-archive-results.test.ts
create mode 100644 lib/jmap/archive-error.ts
create mode 100644 stores/__tests__/email-store-bulk-archive.test.ts
diff --git a/lib/__tests__/jmap-archive-results.test.ts b/lib/__tests__/jmap-archive-results.test.ts
new file mode 100644
index 000000000..146cab025
--- /dev/null
+++ b/lib/__tests__/jmap-archive-results.test.ts
@@ -0,0 +1,133 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+import { ArchiveEmailsError } from '../jmap/archive-error';
+import type { Mailbox } from '../jmap/types';
+
+const emails = [
+ { id: 'accepted-email', receivedAt: '2026-09-01T12:00:00Z' },
+ { id: 'refused-email', receivedAt: '2026-09-02T12:00:00Z' },
+];
+const json = (body: unknown) => new Response(JSON.stringify(body), {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+});
+
+describe('JMAP archive confirmed results', () => {
+ let fetchMock: ReturnType;
+
+ beforeEach(() => { fetchMock = vi.spyOn(globalThis, 'fetch'); });
+ afterEach(() => vi.restoreAllMocks());
+
+ async function connect(maxObjectsInSet = 100) {
+ fetchMock.mockResolvedValueOnce(json({
+ capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInSet } },
+ accounts: { primary: { name: 'Primary', isPersonal: true, accountCapabilities: {} } },
+ primaryAccounts: { 'urn:ietf:params:jmap:mail': 'primary' },
+ apiUrl: 'https://mail.example.test/jmap/api',
+ downloadUrl: 'https://mail.example.test/download/{accountId}/{blobId}/{name}',
+ uploadUrl: 'https://mail.example.test/upload/{accountId}',
+ eventSourceUrl: 'https://mail.example.test/events',
+ }));
+ const client = JMAPClient.withBearer('https://mail.example.test', 'test-token', 'test@example.test');
+ await client.connect();
+ fetchMock.mockReset();
+ return client;
+ }
+
+ it.each(['single', 'year', 'month'] as const)('retains confirmed updates alongside notUpdated in %s mode', async mode => {
+ const client = await connect();
+ const methodResponses: unknown[] = [];
+ if (mode !== 'single') methodResponses.push(['Mailbox/set', {
+ created: { 'year-2026': { id: 'year-folder' }, 'month-2026-09': { id: 'month-folder' } },
+ }, '0']);
+ methodResponses.push(['Email/set', {
+ updated: { 'accepted-email': null },
+ notUpdated: { 'refused-email': { type: 'notFound' } },
+ }, String(methodResponses.length)]);
+ fetchMock.mockResolvedValueOnce(json({ methodResponses }));
+
+ const error = await client.batchArchiveEmails(emails, 'shared-archive', mode, [], 'shared').catch(e => e);
+
+ expect(error).toBeInstanceOf(ArchiveEmailsError);
+ expect(error.updatedIds).toEqual(['accepted-email']);
+ expect(error.message).toMatch(/notFound/);
+ const request = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
+ expect(request.methodCalls.every((call: [string, { accountId: string }]) => call[1].accountId === 'shared')).toBe(true);
+ expect(request.methodCalls.at(-1)[1].update).toEqual({
+ 'accepted-email': { mailboxIds: { [mode === 'single' ? 'shared-archive' : mode === 'year' ? '#year-2026' : '#month-2026-09']: true } },
+ 'refused-email': { mailboxIds: { [mode === 'single' ? 'shared-archive' : mode === 'year' ? '#year-2026' : '#month-2026-09']: true } },
+ });
+ });
+
+ it.each(['single', 'year', 'month'] as const)('preserves earlier batch successes when a later %s request fails', async mode => {
+ const client = await connect(1);
+ const methodResponses: unknown[] = [];
+ if (mode !== 'single') methodResponses.push(['Mailbox/set', {
+ created: { 'year-2026': { id: 'year-folder' }, 'month-2026-09': { id: 'month-folder' } },
+ }, '0']);
+ methodResponses.push(['Email/set', { updated: { 'accepted-email': null } }, String(methodResponses.length)]);
+ fetchMock.mockResolvedValueOnce(json({ methodResponses })).mockRejectedValueOnce(new Error('connection lost'));
+
+ const error = await client.batchArchiveEmails(emails, 'archive', mode, []).catch(e => e);
+
+ expect(error.updatedIds).toEqual(['accepted-email']);
+ expect(error.message).toBe('connection lost');
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ const second = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
+ expect(second.methodCalls[0][1].update['refused-email'].mailboxIds).toEqual({
+ [mode === 'single' ? 'archive' : mode === 'year' ? 'year-folder' : 'month-folder']: true,
+ });
+ });
+
+ it.each(['single', 'year', 'month'] as const)('rejects method errors without confirming rows in %s mode', async mode => {
+ const client = await connect();
+ const methodResponses: unknown[] = mode === 'single' ? [] : [['Mailbox/set', { created: {} }, '0']];
+ methodResponses.push(['error', { type: 'forbidden' }, String(methodResponses.length)]);
+ fetchMock.mockResolvedValueOnce(json({ methodResponses }));
+ const error = await client.batchArchiveEmails(emails, 'archive', mode, []).catch(e => e);
+ expect(error.updatedIds).toEqual([]);
+ expect(error.message).toMatch(/forbidden/);
+ });
+
+ it('does not infer success for IDs omitted from updated', async () => {
+ const client = await connect();
+ fetchMock.mockResolvedValueOnce(json({ methodResponses: [['Email/set', { updated: {} }, '0']] }));
+ const error = await client.batchArchiveEmails(emails, 'archive', 'single', []).catch(e => e);
+ expect(error.updatedIds).toEqual([]);
+ expect(error.message).toMatch(/not confirmed/);
+ });
+
+ it.each(['year', 'month'] as const)('retains Email/set successes when %s folder creation partially fails', async mode => {
+ const client = await connect();
+ fetchMock.mockResolvedValueOnce(json({ methodResponses: [
+ ['Mailbox/set', { notCreated: { 'year-2026': { type: 'forbidden' } } }, '0'],
+ ['Email/set', {
+ updated: { 'accepted-email': null }, notUpdated: { 'refused-email': { type: 'invalidProperties' } },
+ }, '1'],
+ ] }));
+
+ const error = await client.batchArchiveEmails(emails, 'archive', mode, []).catch(e => e);
+
+ expect(error).toBeInstanceOf(ArchiveEmailsError);
+ expect(error.updatedIds).toEqual(['accepted-email']);
+ expect(error.message).toMatch(/Failed to create archive folder.*forbidden/);
+ });
+
+ it.each(['year', 'month'] as const)('reuses existing %s folders and resolves success', async mode => {
+ const client = await connect();
+ const folders = [
+ { id: 'year-folder', name: '2026', parentId: 'archive', accountId: 'primary' },
+ { id: 'month-folder', name: '09', parentId: 'year-folder', accountId: 'primary' },
+ ] as Mailbox[];
+ fetchMock.mockResolvedValueOnce(json({ methodResponses: [['Email/set', {
+ updated: { 'accepted-email': null, 'refused-email': null },
+ }, '0']] }));
+
+ await expect(client.batchArchiveEmails(emails, 'archive', mode, folders)).resolves.toBeUndefined();
+
+ const request = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
+ expect(request.methodCalls).toEqual([['Email/set', {
+ accountId: 'primary',
+ update: Object.fromEntries(emails.map(e => [e.id, { mailboxIds: { [mode === 'year' ? 'year-folder' : 'month-folder']: true } }])),
+ }, '0']]);
+ });
+});
diff --git a/lib/jmap/archive-error.ts b/lib/jmap/archive-error.ts
new file mode 100644
index 000000000..053b89202
--- /dev/null
+++ b/lib/jmap/archive-error.ts
@@ -0,0 +1,7 @@
+/** Archive failed after some messages may already have been confirmed updated. */
+export class ArchiveEmailsError extends Error {
+ constructor(message: string, readonly updatedIds: string[], options?: ErrorOptions) {
+ super(message, options);
+ this.name = 'ArchiveEmailsError';
+ }
+}
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 92db5f63c..3ec211044 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -1,3 +1,4 @@
+import { ArchiveEmailsError } from "./archive-error";
import { generateUUID } from '@/lib/utils';
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
@@ -2811,12 +2812,57 @@ export class JMAPClient implements IJMAPClient {
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
accountId?: string,
+ ): Promise {
+ const updatedIds: string[] = [];
+ try {
+ await this.archiveEmails(emails, archiveMailboxId, mode, existingMailboxes, accountId, updatedIds);
+ } catch (error) {
+ throw new ArchiveEmailsError(
+ error instanceof Error ? error.message : 'Failed to archive emails',
+ updatedIds,
+ { cause: error },
+ );
+ }
+ }
+
+ private confirmArchivedEmails(
+ entry: JMAPResponse['methodResponses'][number] | undefined,
+ requestedIds: string[],
+ updatedIds: string[],
+ ): void {
+ const [name, result] = entry ?? [];
+ if (name === 'Email/set') {
+ updatedIds.push(...requestedIds.filter(id =>
+ Object.prototype.hasOwnProperty.call(result?.updated ?? {}, id) && !result?.notUpdated?.[id],
+ ));
+ }
+ this.assertEmailSetSucceeded({ methodResponses: entry ? [entry] : [] } as JMAPResponse, 'archive emails');
+ if (requestedIds.some(id => !updatedIds.includes(id))) {
+ throw new Error('Failed to archive emails: update was not confirmed');
+ }
+ }
+
+ private async archiveEmails(
+ emails: Array<{ id: string; receivedAt: string }>,
+ archiveMailboxId: string,
+ mode: 'single' | 'year' | 'month',
+ existingMailboxes: Mailbox[],
+ accountId: string | undefined,
+ updatedIds: string[],
): Promise {
if (emails.length === 0) return;
const targetAccountId = accountId || this.accountId;
if (mode === 'single') {
- await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId, targetAccountId);
+ for (const batch of batched(emails, this.getMaxObjectsInSet())) {
+ const response = await this.request([
+ ['Email/set', {
+ accountId: targetAccountId,
+ update: Object.fromEntries(batch.map(e => [e.id, { mailboxIds: { [archiveMailboxId]: true } }])),
+ }, '0'],
+ ]);
+ this.confirmArchivedEmails(response.methodResponses?.[0], batch.map(e => e.id), updatedIds);
+ }
return;
}
@@ -2906,7 +2952,18 @@ export class JMAPClient implements IJMAPClient {
const response = await this.request(methodCalls);
+ let emailError: unknown;
+ try {
+ this.confirmArchivedEmails(response.methodResponses?.[withCreates ? 1 : 0], batch.map(([id]) => id), updatedIds);
+ } catch (error) {
+ emailError = error;
+ }
+
if (withCreates) {
+ if (response.methodResponses?.[0]?.[0] === 'error') {
+ const error = response.methodResponses[0][1];
+ throw new Error(`Failed to create archive folders: ${error?.description || error?.type || 'unknown error'}`);
+ }
const mailboxResult = response.methodResponses?.[0]?.[1];
const notCreated = mailboxResult?.notCreated as Record | undefined;
const failures = notCreated ? Object.entries(notCreated) : [];
@@ -2924,15 +2981,7 @@ export class JMAPClient implements IJMAPClient {
.map(([cid, mailbox]) => [`#${cid}`, mailbox.id!]),
);
}
-
- const emailIdx = withCreates ? 1 : 0;
- const emailResult = response.methodResponses?.[emailIdx]?.[1];
- const notUpdated = emailResult?.notUpdated as Record | undefined;
- const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
- if (emailFailures.length > 0) {
- const [id, err] = emailFailures[0];
- throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
- }
+ if (emailError) throw emailError;
}
}
diff --git a/stores/__tests__/email-store-bulk-archive.test.ts b/stores/__tests__/email-store-bulk-archive.test.ts
new file mode 100644
index 000000000..bddcb1b7a
--- /dev/null
+++ b/stores/__tests__/email-store-bulk-archive.test.ts
@@ -0,0 +1,604 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+const { fetchUnifiedMock, fetchCrossViewMock } = vi.hoisted(() => ({
+ fetchUnifiedMock: vi.fn(), fetchCrossViewMock: vi.fn(),
+}));
+vi.mock('@/lib/unified-mailbox', async (importActual) => ({
+ ...await importActual(),
+ fetchUnifiedEmails: fetchUnifiedMock,
+ fetchCrossViewEmails: fetchCrossViewMock,
+}));
+
+import { ArchiveMailboxNotFoundError, useEmailStore } from '../email-store';
+import { useAuthStore } from '../auth-store';
+import { useSettingsStore } from '../settings-store';
+import { ArchiveEmailsError } from '@/lib/jmap/archive-error';
+import { runBatchEmailAction } from '@/lib/email-action-toast';
+import { toast } from '@/stores/toast-store';
+import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks';
+import { emailKeyFor, threadKeyFor } from '@/lib/thread-utils';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+const originalRefresh = useEmailStore.getState().refreshCurrentMailbox;
+
+const mailbox = (owner: string, role: 'inbox' | 'archive', shared = false) => ({
+ id: shared ? `${owner}:${role}` : `${owner}-${role}`,
+ originalId: shared ? role : undefined,
+ accountId: owner, role, name: role,
+ isShared: shared,
+ myRights: { mayReadItems: true, mayAddItems: true },
+}) as Mailbox;
+
+const email = (id: string, owner: string, login = 'login-a') => ({
+ id, threadId: `thread-${id}`, receivedAt: '2026-09-01T12:00:00Z',
+ sourceClientAccountId: login, sourceAccountId: owner,
+ mailboxIds: { inbox: true }, keywords: {}, size: 100, hasAttachment: false,
+}) as Email;
+
+function makeClient(folders: Record) {
+ return {
+ batchArchiveEmails: vi.fn().mockResolvedValue(undefined),
+ // Missing-folder fixtures leave the created Archive out of the reload,
+ // exercising upstream's create-then-report-not-found behavior.
+ createMailbox: vi.fn().mockResolvedValue({}),
+ getMailboxes: vi.fn(async (owner: string) => folders[owner] ?? []),
+ getAllMailboxes: vi.fn(async () => Object.values(folders).flat()),
+ getAccountId: () => 'primary',
+ } as unknown as IJMAPClient;
+}
+
+describe('bulk archive ownership and reconciliation', () => {
+ let client: IJMAPClient;
+ let folders: Record;
+ let refresh: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ clearAllHooks();
+ folders = {
+ primary: [mailbox('primary', 'inbox'), mailbox('primary', 'archive')],
+ shared: [mailbox('shared', 'inbox', true), mailbox('shared', 'archive', true)],
+ };
+ client = makeClient(folders);
+ useAuthStore.setState({
+ activeAccountId: 'login-a',
+ getClientForAccount: (id: string) => id === 'login-a' ? client as never : undefined,
+ });
+ useSettingsStore.setState({ archiveMode: 'single' });
+ useEmailStore.setState({
+ emails: [email('primary-email', 'primary'), email('shared-email', 'shared')],
+ selectedEmailKeys: new Set([emailKeyFor(email('primary-email', 'primary')), emailKeyFor(email('shared-email', 'shared'))]),
+ selectedEmail: email('primary-email', 'primary'),
+ selectedMailbox: '__unified_inbox__', viewingAccountId: null,
+ isUnifiedView: true, unifiedRole: 'inbox', crossView: null,
+ mailboxes: Object.values(folders).flat(), accountMailboxes: { ...folders },
+ unifiedScope: [], error: null, isLoading: false,
+ selectedKeyword: null, searchQuery: '', totalEmails: 2,
+ threadEmailsCache: new Map(), expandedThreadIds: new Set(), retainedInViewIds: new Set(),
+ });
+ refresh = vi.spyOn(useEmailStore.getState(), 'refreshCurrentMailbox').mockResolvedValue();
+ });
+
+ afterEach(() => {
+ clearAllHooks();
+ vi.restoreAllMocks();
+ useEmailStore.setState({ refreshCurrentMailbox: originalRefresh });
+ });
+
+ it('routes primary and shared IDs separately to each owner’s Archive', async () => {
+ await useEmailStore.getState().batchArchive(client);
+
+ expect(client.batchArchiveEmails).toHaveBeenCalledTimes(2);
+ expect(client.batchArchiveEmails).toHaveBeenCalledWith(
+ [{ id: 'primary-email', receivedAt: expect.any(String) }],
+ 'primary-archive', 'single', expect.any(Array), 'primary',
+ );
+ expect(client.batchArchiveEmails).toHaveBeenCalledWith(
+ [{ id: 'shared-email', receivedAt: expect.any(String) }],
+ 'archive', 'single', expect.any(Array), 'shared',
+ );
+ expect(useEmailStore.getState().emails).toEqual([]);
+ expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
+ expect(useEmailStore.getState().selectedEmail).toBeNull();
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+
+ it.each(['single', 'year', 'month'] as const)('creates each missing owner’s Archive independently in %s mode', async mode => {
+ folders.primary = [mailbox('primary', 'inbox')];
+ folders.shared = [mailbox('shared', 'inbox', true)];
+ useSettingsStore.setState({ archiveMode: mode });
+ vi.mocked(client.createMailbox).mockImplementation(async (_name, _parent, owner) => {
+ const archive = mailbox(owner!, 'archive', owner === 'shared');
+ folders[owner!].push(archive);
+ return archive;
+ });
+
+ await useEmailStore.getState().batchArchive(client);
+
+ expect(client.createMailbox).toHaveBeenCalledTimes(2);
+ expect(client.createMailbox).toHaveBeenCalledWith('Archive', undefined, 'primary', { role: 'archive' });
+ expect(client.createMailbox).toHaveBeenCalledWith('Archive', undefined, 'shared', { role: 'archive' });
+ expect(client.batchArchiveEmails).toHaveBeenCalledWith(
+ [{ id: 'shared-email', receivedAt: expect.any(String) }], 'archive', mode, expect.any(Array), 'shared',
+ );
+ expect(useEmailStore.getState().emails).toEqual([]);
+ expect(useEmailStore.getState().accountMailboxes.shared.map(m => m.id)).toContain('shared:archive');
+ });
+
+ it('keeps other groups valid when an owner refuses Archive creation', async () => {
+ folders.shared = [mailbox('shared', 'inbox', true)];
+ vi.mocked(client.createMailbox).mockRejectedValue(new Error('Archive creation forbidden'));
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toThrow('Archive creation forbidden');
+
+ expect(client.createMailbox).toHaveBeenCalledWith('Archive', undefined, 'shared', { role: 'archive' });
+ expect(client.batchArchiveEmails).toHaveBeenCalledTimes(1);
+ expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['shared-email']);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(email('shared-email', 'shared'))]);
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+
+ it('publishes a newly created Archive even when its email move fails', async () => {
+ folders.primary = [mailbox('primary', 'inbox')];
+ useEmailStore.setState({ emails: [email('primary-email', 'primary')], selectedEmailKeys: new Set([emailKeyFor(email('primary-email', 'primary'))]) });
+ vi.mocked(client.createMailbox).mockImplementation(async () => {
+ const archive = mailbox('primary', 'archive');
+ folders.primary.push(archive);
+ return archive;
+ });
+ vi.mocked(client.batchArchiveEmails).mockRejectedValue(new Error('Email move forbidden'));
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toThrow('Email move forbidden');
+
+ expect(useEmailStore.getState().accountMailboxes['login-a'].map(m => m.id)).toContain('primary-archive');
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(email('primary-email', 'primary'))]);
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it('groups stamped and unstamped rows of one owner and clears its stamped viewer', async () => {
+ const unstamped = { ...email('own-email', 'primary'), sourceAccountId: undefined, sourceClientAccountId: undefined };
+ useEmailStore.setState({
+ emails: [unstamped, email('stamped-email', 'primary')],
+ selectedEmail: email('own-email', 'primary'),
+ selectedEmailKeys: new Set([emailKeyFor(unstamped), emailKeyFor(email('stamped-email', 'primary'))]),
+ selectedMailbox: 'primary-inbox', isUnifiedView: false,
+ });
+
+ await useEmailStore.getState().batchArchive(client);
+
+ expect(client.batchArchiveEmails).toHaveBeenCalledOnce();
+ expect(vi.mocked(client.batchArchiveEmails).mock.calls[0][0].map(e => e.id)).toEqual(['own-email', 'stamped-email']);
+ expect(useEmailStore.getState().selectedEmail).toBeNull();
+ });
+
+ it.each(['year', 'month'] as const)('passes bare shared folder and parent IDs for %s subfolder reuse', async mode => {
+ folders.shared.push({ ...mailbox('shared', 'archive', true), id: 'shared:year', originalId: 'year', name: '2026', role: undefined, parentId: 'shared:archive' });
+ useEmailStore.setState({
+ emails: [email('shared-email', 'shared')], selectedEmailKeys: new Set([emailKeyFor(email('shared-email', 'shared'))]),
+ });
+ useSettingsStore.setState({ archiveMode: mode });
+
+ await useEmailStore.getState().batchArchive(client);
+
+ expect(vi.mocked(client.batchArchiveEmails).mock.calls[0][3]).toContainEqual(
+ expect.objectContaining({ id: 'year', parentId: 'archive', accountId: 'shared' }),
+ );
+ });
+
+ it('removes confirmed pinned retention without changing a failed message’s keywords or thread', async () => {
+ const archived = { ...email('accepted', 'primary'), keywords: { $pinned: true } };
+ const failed = { ...email('refused', 'primary'), threadId: archived.threadId, keywords: { $pinned: true, $seen: true } };
+ useEmailStore.setState({
+ emails: [archived, failed], selectedEmail: failed,
+ selectedEmailKeys: new Set([emailKeyFor(archived), emailKeyFor(failed)]), retainedInViewIds: new Set(['accepted', 'refused']),
+ threadEmailsCache: new Map([[threadKeyFor(archived), [archived, failed]]]),
+ expandedThreadIds: new Set([threadKeyFor(archived)]),
+ });
+ vi.mocked(client.batchArchiveEmails).mockRejectedValue(new ArchiveEmailsError('forbidden', ['accepted']));
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toThrow('forbidden');
+
+ expect(useEmailStore.getState().emails).toEqual([failed]);
+ expect(useEmailStore.getState().selectedEmail).toBe(failed);
+ expect([...useEmailStore.getState().retainedInViewIds]).toEqual(['refused']);
+ expect(useEmailStore.getState().threadEmailsCache.get(threadKeyFor(failed))).toEqual([failed]);
+ expect(useEmailStore.getState().expandedThreadIds.has(threadKeyFor(failed))).toBe(true);
+ });
+
+ it('preserves a failed selected row when the actual refresh omits it', async () => {
+ const failed = email('shared-email', 'shared');
+ useEmailStore.setState({ selectedEmail: failed, refreshCurrentMailbox: originalRefresh });
+ folders.shared = [mailbox('shared', 'inbox', true)];
+ fetchUnifiedMock.mockResolvedValue({ emails: [], total: 0, hasMore: false, errors: new Map() });
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+
+ expect(useEmailStore.getState().emails).toEqual([failed]);
+ expect(useEmailStore.getState().selectedEmail).toBe(failed);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(email('shared-email', 'shared'))]);
+ expect(useEmailStore.getState().totalEmails).toBe(1);
+ });
+
+ it('leaves the shared row selected when its Archive is missing, while primary succeeds', async () => {
+ folders.shared = [mailbox('shared', 'inbox', true)];
+ useEmailStore.setState({ accountMailboxes: { ...folders }, mailboxes: Object.values(folders).flat() });
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+
+ expect(client.batchArchiveEmails).toHaveBeenCalledTimes(1);
+ expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['shared-email']);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(email('shared-email', 'shared'))]);
+ expect(useEmailStore.getState().error).toMatch(/archive mailbox not found/i);
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(client.getMailboxes).toHaveBeenCalledWith('primary');
+ });
+
+ it('reconciles an owner request failure while another owner succeeds', async () => {
+ vi.mocked(client.batchArchiveEmails).mockImplementation(async (_emails, _dest, _mode, _folders, owner) => {
+ if (owner === 'primary') throw new Error('forbidden');
+ });
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toThrow('forbidden');
+
+ expect(client.batchArchiveEmails).toHaveBeenCalledTimes(2);
+ expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['primary-email']);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(email('primary-email', 'primary'))]);
+ expect(useEmailStore.getState().selectedEmail?.id).toBe('primary-email');
+ expect(useEmailStore.getState().error).toBe('forbidden');
+ expect(useEmailStore.getState().isLoading).toBe(false);
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(client.getMailboxes).toHaveBeenCalledWith('shared');
+ });
+ it('removes only confirmed updates within one owner and reports the failure toast', async () => {
+ const failed = email('rejected-email', 'primary');
+ useEmailStore.setState({
+ emails: [email('primary-email', 'primary'), failed],
+ selectedEmailKeys: new Set([emailKeyFor(email('primary-email', 'primary')), emailKeyFor(failed)]),
+ });
+ vi.mocked(client.batchArchiveEmails).mockRejectedValue(
+ new ArchiveEmailsError('Failed to archive emails: notFound', ['primary-email']),
+ );
+ const errorToast = vi.spyOn(toast, 'error');
+ const successToast = vi.spyOn(toast, 'success');
+
+ await runBatchEmailAction(() => useEmailStore.getState().batchArchive(client), {
+ success: 'archived', error: 'archive failed',
+ });
+
+ expect(useEmailStore.getState().emails).toEqual([failed]);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(failed)]);
+ expect(useEmailStore.getState().selectedEmail).toBeNull();
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(vi.mocked(client.getMailboxes).mock.calls).toEqual([['primary'], ['primary']]);
+ expect(errorToast).toHaveBeenCalledWith('archive failed', 'Failed to archive emails: notFound');
+ expect(successToast).not.toHaveBeenCalled();
+ });
+
+ it.each(['single', 'year', 'month'] as const)('preserves ordinary single-owner %s archive', async mode => {
+ const own = { ...email('own-email', 'primary'), sourceAccountId: undefined, sourceClientAccountId: undefined };
+ useEmailStore.setState({
+ emails: [own], selectedEmailKeys: new Set([emailKeyFor(own)]),
+ isUnifiedView: false, selectedMailbox: 'primary-inbox',
+ mailboxes: folders.primary,
+ });
+ useSettingsStore.setState({ archiveMode: mode });
+
+ await useEmailStore.getState().batchArchive(client);
+
+ expect(client.batchArchiveEmails).toHaveBeenCalledOnce();
+ expect(client.batchArchiveEmails).toHaveBeenCalledWith(
+ [{ id: own.id, receivedAt: own.receivedAt }],
+ 'primary-archive', mode, folders.primary, 'primary',
+ );
+ expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
+ expect(useEmailStore.getState().error).toBeNull();
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+
+ it('keeps equal account IDs on different authenticated clients separate', async () => {
+ const otherFolders = { primary: [mailbox('primary', 'inbox'), { ...mailbox('primary', 'archive'), id: 'other-archive' }] };
+ const otherClient = makeClient(otherFolders);
+ useAuthStore.setState({
+ getClientForAccount: (id: string) => (id === 'login-a' ? client : id === 'login-b' ? otherClient : undefined) as never,
+ });
+ useEmailStore.setState({
+ emails: [email('primary-email', 'primary'), email('other-email', 'primary', 'login-b')],
+ selectedEmailKeys: new Set([emailKeyFor(email('primary-email', 'primary')), emailKeyFor(email('other-email', 'primary', 'login-b'))]),
+ // Deliberately ambiguous cache: the client must resolve its own folders.
+ accountMailboxes: otherFolders,
+ });
+
+ await useEmailStore.getState().batchArchive(client);
+
+ expect(client.batchArchiveEmails).toHaveBeenCalledWith(
+ [{ id: 'primary-email', receivedAt: expect.any(String) }],
+ 'primary-archive', 'single', folders.primary, 'primary',
+ );
+ expect(otherClient.batchArchiveEmails).toHaveBeenCalledWith(
+ [{ id: 'other-email', receivedAt: expect.any(String) }],
+ 'other-archive', 'single', otherFolders.primary, 'primary',
+ );
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+
+ it.each(['missing Archive', 'rejected request', 'different login'] as const)(
+ 'preserves the failed owner’s row, selection, viewer, and thread with colliding email IDs: %s',
+ async failure => {
+ const archived = email('same-email-id', 'primary');
+ const failed = failure === 'different login'
+ ? email('same-email-id', 'primary', 'login-b')
+ : email('same-email-id', 'shared');
+ if (failure === 'missing Archive') folders.shared = [mailbox('shared', 'inbox', true)];
+ if (failure === 'rejected request') {
+ vi.mocked(client.batchArchiveEmails).mockImplementation(async (_emails, _dest, _mode, _folders, owner) => {
+ if (owner === 'shared') throw new Error('forbidden');
+ });
+ }
+ if (failure === 'different login') {
+ const otherClient = makeClient({ primary: folders.primary });
+ vi.mocked(otherClient.batchArchiveEmails).mockRejectedValue(new Error('forbidden'));
+ useAuthStore.setState({
+ getClientForAccount: (id: string) => (id === 'login-a' ? client : id === 'login-b' ? otherClient : undefined) as never,
+ });
+ }
+ useEmailStore.setState({
+ emails: [archived, failed],
+ // Both owners are explicitly selected by their independent keys.
+ selectedEmailKeys: new Set([emailKeyFor(archived), emailKeyFor(failed)]),
+ selectedEmail: { ...failed, preview: 'Open failed owner’s message' },
+ threadEmailsCache: new Map([[threadKeyFor(archived), [archived]], [threadKeyFor(failed), [failed]]]),
+ expandedThreadIds: new Set([threadKeyFor(archived), threadKeyFor(failed)]),
+ });
+ const viewer = useEmailStore.getState().selectedEmail;
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toThrow(
+ failure === 'missing Archive' ? /archive mailbox not found/i : /forbidden/,
+ );
+
+ expect(useEmailStore.getState().emails).toEqual([failed]);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(failed)]);
+ expect(useEmailStore.getState().selectedEmail).toBe(viewer);
+ expect([...useEmailStore.getState().threadEmailsCache.keys()]).toEqual([threadKeyFor(failed)]);
+ expect([...useEmailStore.getState().expandedThreadIds]).toEqual([threadKeyFor(failed)]);
+ expect(refresh).toHaveBeenCalledOnce();
+ },
+ );
+
+ it('keeps partial Email/set confirmations scoped to the owner with a colliding ID', async () => {
+ const archived = email('same-email-id', 'primary');
+ const refused = email('refused-primary-email', 'primary');
+ const shared = email('same-email-id', 'shared');
+ folders.shared = [mailbox('shared', 'inbox', true)];
+ vi.mocked(client.batchArchiveEmails).mockRejectedValue(
+ new ArchiveEmailsError('Failed to archive emails: notFound', ['same-email-id']),
+ );
+ useEmailStore.setState({
+ emails: [archived, refused, shared], selectedEmail: { ...shared },
+ selectedEmailKeys: new Set([emailKeyFor(archived), emailKeyFor(refused), emailKeyFor(shared)]),
+ });
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toThrow('notFound');
+
+ expect(useEmailStore.getState().emails).toEqual([refused, shared]);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(refused), emailKeyFor(shared)]);
+ expect(useEmailStore.getState().selectedEmail).toEqual(shared);
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+
+ it('clears the successful owner’s viewer while retaining a colliding failed row’s selection', async () => {
+ const archived = email('same-email-id', 'primary');
+ const failed = email('same-email-id', 'shared');
+ folders.shared = [mailbox('shared', 'inbox', true)];
+ useEmailStore.setState({
+ emails: [archived, failed], selectedEmail: { ...archived },
+ selectedEmailKeys: new Set([emailKeyFor(archived), emailKeyFor(failed)]),
+ });
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+
+ expect(useEmailStore.getState().emails).toEqual([failed]);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(failed)]);
+ expect(useEmailStore.getState().selectedEmail).toBeNull();
+ });
+
+ it.each(['unified', 'cross-account'] as const)('retains a colliding failed row and viewer through the actual %s refresh', async view => {
+ const archived = email('same-email-id', 'primary');
+ const failed = email('same-email-id', 'shared');
+ const viewer = { ...failed, preview: 'Open failed owner’s message' };
+ folders.shared = [mailbox('shared', 'inbox', true)];
+ const loader = view === 'unified' ? fetchUnifiedMock : fetchCrossViewMock;
+ loader.mockResolvedValue({ emails: [{ ...failed }], total: 1, hasMore: false, errors: new Map() });
+ useEmailStore.setState({
+ emails: [archived, failed], selectedEmail: viewer,
+ selectedEmailKeys: new Set([emailKeyFor(archived), emailKeyFor(failed)]),
+ refreshCurrentMailbox: originalRefresh,
+ selectedMailbox: view === 'unified' ? '__unified_inbox__' : '__cross_all__',
+ unifiedRole: view === 'unified' ? 'inbox' : null,
+ crossView: view === 'cross-account' ? 'all' : null,
+ });
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+
+ expect(loader).toHaveBeenCalledOnce();
+ expect(useEmailStore.getState().emails).toEqual([failed]);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(failed)]);
+ expect(useEmailStore.getState().selectedEmail).toBe(viewer);
+ expect(useEmailStore.getState().error).toMatch(/archive mailbox not found/i);
+ });
+
+ it('does not fall back to the active client for a disconnected stamped owner', async () => {
+ useEmailStore.setState({
+ emails: [email('primary-email', 'primary'), email('shared-email', 'shared', 'disconnected')],
+ selectedEmailKeys: new Set([emailKeyFor(email('primary-email', 'primary')), emailKeyFor(email('shared-email', 'shared', 'disconnected'))]),
+ });
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toThrow('not connected');
+ expect(client.batchArchiveEmails).toHaveBeenCalledTimes(1);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(email('shared-email', 'shared', 'disconnected'))]);
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+
+ it('refreshes the actual unified view after partial success and retains the failed selection', async () => {
+ useEmailStore.setState({ refreshCurrentMailbox: originalRefresh });
+ const failed = email('shared-email', 'shared');
+ folders.shared = [mailbox('shared', 'inbox', true)];
+ fetchUnifiedMock.mockResolvedValue({
+ emails: [failed], total: 1, hasMore: false, errors: new Map(),
+ });
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+
+ expect(fetchUnifiedMock).toHaveBeenCalledOnce();
+ expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['shared-email']);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(email('shared-email', 'shared'))]);
+ expect(useEmailStore.getState().error).toMatch(/archive mailbox not found/i);
+ expect(useEmailStore.getState().accountMailboxes.primary).toEqual(folders.primary);
+ expect(useEmailStore.getState().mailboxes.find(m => m.id === 'shared:archive')).toBeDefined();
+ });
+
+ it('leaves a read-only shared Archive untouched without blocking the primary owner', async () => {
+ folders.shared[1].myRights.mayAddItems = false;
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+ expect(client.batchArchiveEmails).toHaveBeenCalledTimes(1);
+ expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['shared-email']);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(email('shared-email', 'shared'))]);
+ });
+
+ it('keeps all rows and selections when no owner succeeds', async () => {
+ vi.mocked(client.batchArchiveEmails).mockRejectedValue(new Error('forbidden'));
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toThrow('forbidden');
+ expect(useEmailStore.getState().emails).toHaveLength(2);
+ expect(useEmailStore.getState().selectedEmailKeys.size).toBe(2);
+ expect(refresh).not.toHaveBeenCalled();
+ expect(useEmailStore.getState().isLoading).toBe(false);
+ });
+
+ it.each(['shared owner', 'different login'] as const)('archives only the selected namesake: %s', async source => {
+ const untouched = email('same-email-id', 'primary');
+ const selected = source === 'shared owner'
+ ? email('same-email-id', 'shared') : email('same-email-id', 'primary', 'login-b');
+ const otherClient = makeClient({ primary: folders.primary });
+ useAuthStore.setState({
+ getClientForAccount: (id: string) => (id === 'login-a' ? client : id === 'login-b' ? otherClient : undefined) as never,
+ });
+ useEmailStore.setState({
+ emails: [untouched, selected], selectedEmail: untouched,
+ selectedEmailKeys: new Set([emailKeyFor(selected)]),
+ threadEmailsCache: new Map([[threadKeyFor(untouched), [untouched]], [threadKeyFor(selected), [selected]]]),
+ expandedThreadIds: new Set([threadKeyFor(untouched), threadKeyFor(selected)]),
+ });
+
+ expect(await useEmailStore.getState().batchArchive(client)).toBe(true);
+
+ const actionClient = source === 'shared owner' ? client : otherClient;
+ expect(actionClient.batchArchiveEmails).toHaveBeenCalledExactlyOnceWith(
+ [{ id: selected.id, receivedAt: selected.receivedAt }],
+ source === 'shared owner' ? 'archive' : 'primary-archive', 'single', expect.any(Array), selected.sourceAccountId,
+ );
+ if (source === 'different login') expect(client.batchArchiveEmails).not.toHaveBeenCalled();
+ const state = useEmailStore.getState();
+ expect(state.emails).toEqual([untouched]);
+ expect(state.selectedEmail).toBe(untouched);
+ expect(state.selectedEmailKeys.size).toBe(0);
+ expect([...state.threadEmailsCache]).toEqual([[threadKeyFor(untouched), [untouched]]]);
+ expect([...state.expandedThreadIds]).toEqual([threadKeyFor(untouched)]);
+ });
+
+ it('archives a selected expanded-thread member without archiving its unselected row', async () => {
+ const row = email('row', 'shared');
+ const member = { ...email('member', 'shared'), threadId: row.threadId };
+ useEmailStore.setState({
+ emails: [row], selectedEmail: member,
+ selectedEmailKeys: new Set([emailKeyFor(member)]),
+ threadEmailsCache: new Map([[threadKeyFor(row), [row, member]]]),
+ expandedThreadIds: new Set([threadKeyFor(row)]),
+ });
+
+ await useEmailStore.getState().batchArchive(client);
+
+ expect(client.batchArchiveEmails).toHaveBeenCalledExactlyOnceWith(
+ [{ id: member.id, receivedAt: member.receivedAt }], 'archive', 'single', expect.any(Array), 'shared',
+ );
+ const state = useEmailStore.getState();
+ expect(state.emails).toEqual([row]);
+ expect(state.selectedEmail).toBeNull();
+ expect(state.selectedEmailKeys.size).toBe(0);
+ expect(state.threadEmailsCache.get(threadKeyFor(row))).toEqual([row]);
+ expect(state.expandedThreadIds.has(threadKeyFor(row))).toBe(true);
+ });
+
+ it('retains a failed expanded-thread member through refresh without adding it as a list row', async () => {
+ const archived = email('accepted', 'primary');
+ const row = email('row', 'shared');
+ const member = { ...email('member', 'shared'), threadId: row.threadId, keywords: { $pinned: true } };
+ folders.shared = [mailbox('shared', 'inbox', true)];
+ useEmailStore.setState({
+ emails: [archived, row], selectedEmail: member,
+ selectedEmailKeys: new Set([emailKeyFor(archived), emailKeyFor(member)]),
+ threadEmailsCache: new Map([[threadKeyFor(row), [row, member]]]),
+ expandedThreadIds: new Set([threadKeyFor(row)]),
+ refreshCurrentMailbox: vi.fn(async () => {
+ useEmailStore.setState({ emails: [row], threadEmailsCache: new Map() });
+ }),
+ });
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+
+ const state = useEmailStore.getState();
+ expect(state.emails).toEqual([row]);
+ expect([...state.selectedEmailKeys]).toEqual([emailKeyFor(member)]);
+ expect(state.selectedEmail).toBe(member);
+ expect(state.threadEmailsCache.get(threadKeyFor(row))).toEqual([row, member]);
+ expect(state.expandedThreadIds.has(threadKeyFor(row))).toBe(true);
+ });
+
+ it.each(['folder creation', 'archive move'] as const)('keeps plugin cancellation and confirmed notifications per owner: %s', async veto => {
+ const before = vi.fn(({ emailIds }: { emailIds: string[] }) => !emailIds.includes('shared-email'));
+ const after = vi.fn();
+ emailHooks.onEmailArchive.register('test', after);
+ if (veto === 'folder creation') {
+ folders.shared = [mailbox('shared', 'inbox', true)];
+ emailHooks.onBeforeMailboxCreate.register('test', () => false);
+ } else emailHooks.onBeforeEmailArchive.register('test', before);
+
+ expect(await useEmailStore.getState().batchArchive(client)).toBe(false);
+
+ expect(client.batchArchiveEmails).toHaveBeenCalledTimes(1);
+ expect(client.createMailbox).not.toHaveBeenCalled();
+ expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['shared-email']);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(email('shared-email', 'shared'))]);
+ expect(after).toHaveBeenCalledExactlyOnceWith({ emailIds: ['primary-email'] });
+ expect(useEmailStore.getState().error).toBeNull();
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+
+ it('notifies plugins only of confirmed messages after a partial Email/set failure', async () => {
+ const accepted = email('accepted', 'primary');
+ const refused = email('refused', 'primary');
+ useEmailStore.setState({ emails: [accepted, refused], selectedEmailKeys: new Set([emailKeyFor(accepted), emailKeyFor(refused)]) });
+ const after = vi.fn();
+ emailHooks.onEmailArchive.register('test', after);
+ vi.mocked(client.batchArchiveEmails).mockRejectedValue(new ArchiveEmailsError('forbidden', [accepted.id, 'unrequested-id']));
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toThrow('forbidden');
+
+ expect(after).toHaveBeenCalledExactlyOnceWith({ emailIds: [accepted.id] });
+ expect(useEmailStore.getState().emails).toEqual([refused]);
+ });
+
+ it('preserves other owners in the viewed login cache when refreshing a successful owner', async () => {
+ const other = mailbox('other', 'inbox', true);
+ const merged = [...folders.primary, ...folders.shared, other];
+ useEmailStore.setState({
+ emails: [email('shared-email', 'shared')],
+ selectedEmailKeys: new Set([emailKeyFor(email('shared-email', 'shared'))]),
+ viewingAccountId: 'login-a', accountMailboxes: { 'login-a': merged },
+ });
+
+ await useEmailStore.getState().batchArchive(client);
+
+ expect(useEmailStore.getState().accountMailboxes['login-a']).toEqual([
+ ...folders.primary, other, ...folders.shared,
+ ]);
+ });
+
+});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 4474c3c46..9a07087bc 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -1,3 +1,4 @@
+import { ArchiveEmailsError } from "@/lib/jmap/archive-error";
import { create } from "zustand";
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types";
import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
@@ -4594,70 +4595,269 @@ export const useEmailStore = create((set, get) => ({
},
batchArchive: async (client): Promise => {
- const { selectedEmailKeys, emails } = get();
- const mailboxes = resolveActionMailboxes();
- if (selectedEmailKeys.size === 0) return true;
-
- // Scope the archive folder to the viewed shared/group account (if any) so the
- // move lands on the owner account, not the user's own archive (which appears
- // first in the merged list); see resolveViewAccountId. Own view is unchanged.
- let archiveMailbox: Mailbox | null;
- try {
- archiveMailbox = await ensureArchiveMailbox({
- client: resolveActionClient(client),
- mailboxes,
- selectedMailboxId: get().selectedMailbox,
- refresh: async () => {
- await refreshMailboxesForViewingAccount(client);
- return resolveActionMailboxes();
- },
- });
- } catch (error) {
- set({ error: error instanceof Error ? error.message : 'Failed to archive emails' });
- throw error;
- }
- if (!archiveMailbox) return false;
-
- const mode = useSettingsStore.getState().archiveMode;
- const archiveId = archiveMailbox.originalId || archiveMailbox.id;
-
- const selected = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e)));
+ const { selectedMailbox } = get();
+ const selected = resolveSelectedEmails(get());
if (selected.length === 0) return true;
+ const listKeys = new Set(get().emails.map(emailKeyFor));
- const hookMailboxes = [...resolveActionMailboxes(), archiveMailbox];
- const move = {
- emailIds: [...new Set(selected.map(e => e.id))],
- fromMailboxId: moveSourceMailboxId(selected, hookMailboxes),
- toMailboxId: archiveMailbox.id,
+ // A JMAP id is only unique within its owning account. Keep the reaching client in
+ // the grouping key as well as the owner account, including delegated owners.
+ type Group = ReturnType & {
+ emails: Email[];
+ loginId: string | null;
+ stamped: boolean;
+ updatedIds: Set;
+ createdArchive: boolean;
+ move?: Parameters[0];
+ hookMailboxes?: Mailbox[];
};
- if (!(await allowEmailMove(move, hookMailboxes))) return false;
-
- set({ isLoading: true, error: null });
- try {
- await resolveActionClient(client).batchArchiveEmails(
- selected.map(e => ({ id: e.id, receivedAt: e.receivedAt })),
- archiveId,
- mode,
- mailboxes,
- archiveMailbox.accountId,
- );
-
- const remaining = emails.filter(e => !selectedEmailKeys.has(emailKeyFor(e)));
- set({ emails: remaining, selectedEmailKeys: new Set(), isLoading: false });
- emitEmailMove(move, hookMailboxes);
-
- // Refresh the active or viewed account's mailbox cache after the
- // archive (a year/month archive can create new sub-folders).
- await refreshMailboxesForViewingAccount(client);
- await get().refreshCurrentMailbox(client);
- } catch (error) {
- set({
- error: error instanceof Error ? error.message : 'Failed to archive emails',
- isLoading: false,
+ const groups: Group[] = [];
+ const failures: Error[] = [];
+ let stoppedByPlugin = false;
+ const sourceClients = new Map();
+ const clientForSource = (loginId: string) => {
+ if (!sourceClients.has(loginId)) {
+ sourceClients.set(loginId, useAuthStore.getState().getClientForAccount(loginId));
+ }
+ return sourceClients.get(loginId);
+ };
+ const viewerLoginId = get().selectedEmail?.sourceClientAccountId;
+ if (viewerLoginId) clientForSource(viewerLoginId);
+ for (const email of selected) {
+ const stamped = !!(email.sourceClientAccountId && email.sourceAccountId);
+ if (stamped && !clientForSource(email.sourceClientAccountId!)) {
+ failures.push(new Error('Email account is not connected - cannot archive email'));
+ continue;
+ }
+ const context = resolveEmailActionContext(email, client);
+ // Merge stamped and unstamped rows of the same primary account too.
+ context.accountId ??= context.client.getAccountId?.()
+ || context.mailboxes.find(m => !m.isShared)?.accountId;
+ const group = groups.find(g => g.client === context.client && g.accountId === context.accountId);
+ if (group) {
+ group.emails.push(email);
+ group.stamped ||= stamped;
+ } else groups.push({
+ ...context, emails: [email], stamped, updatedIds: new Set(), createdArchive: false,
+ loginId: email.sourceClientAccountId ?? get().viewingAccountId ?? useAuthStore.getState().activeAccountId,
});
- throw error;
}
- return true;
+
+ const isCurrentView = captureEmailListView();
+ const epoch = currentStoreEpoch();
+ const unstampedContext = resolveEmailActionContext({}, client);
+ const unstampedOwner = unstampedContext.accountId ?? unstampedContext.client.getAccountId?.()
+ ?? unstampedContext.mailboxes.find(m => !m.isShared)?.accountId;
+ const groupForEmail = (email: Email) => {
+ const stamped = !!(email.sourceClientAccountId && email.sourceAccountId);
+ const actionClient = stamped
+ ? clientForSource(email.sourceClientAccountId!)
+ : unstampedContext.client;
+ const owner = stamped ? email.sourceAccountId : unstampedOwner;
+ return groups.find(g => g.client === actionClient && g.accountId === owner);
+ };
+ const mode = useSettingsStore.getState().archiveMode;
+ set({ isLoading: true, error: null });
+ await Promise.all(groups.map(async group => {
+ try {
+ // The owner-only cache is ambiguous across servers/logins. Stamped
+ // selections resolve folders through their authenticated client instead.
+ let mailboxes = group.stamped
+ ? await group.client.getMailboxes(group.accountId)
+ : group.mailboxes;
+ // A primary-account action can start with the merged sidebar list.
+ // Keep only this owner's folders; older own-folder objects may lack
+ // accountId, whereas delegated folders must always name their owner.
+ const primaryOwner = !group.accountId || group.accountId === group.client.getAccountId?.();
+ const ownerMailboxes = (folders: Mailbox[]) => folders
+ .filter(m => group.accountId
+ ? m.accountId === group.accountId || (primaryOwner && !m.accountId && !m.isShared)
+ : !m.isShared)
+ .map(m => m.isShared ? {
+ ...m, id: `${m.accountId}:${m.originalId || m.id}`,
+ originalId: m.originalId || m.id,
+ parentId: m.parentId && !m.parentId.startsWith(`${m.accountId}:`)
+ ? `${m.accountId}:${m.parentId}` : m.parentId,
+ } : m.accountId ? m : { ...m, accountId: group.accountId });
+ mailboxes = ownerMailboxes(mailboxes);
+ const archive = await ensureArchiveMailbox({
+ client: group.client, mailboxes, selectedMailboxId: selectedMailbox,
+ accountId: group.accountId,
+ refresh: async () => {
+ mailboxes = ownerMailboxes(await group.client.getMailboxes(group.accountId));
+ group.createdArchive = !!findArchiveMailbox(mailboxes, selectedMailbox, group.accountId);
+ return mailboxes;
+ },
+ });
+ if (!archive) {
+ stoppedByPlugin = true;
+ return;
+ }
+ if (archive.myRights?.mayReadItems === false || archive.myRights?.mayAddItems === false) {
+ throw new ArchiveMailboxNotFoundError();
+ }
+ group.hookMailboxes = [...mailboxes, archive];
+ group.move = {
+ emailIds: [...new Set(group.emails.map(e => e.id))],
+ fromMailboxId: moveSourceMailboxId(group.emails, group.hookMailboxes),
+ toMailboxId: archive.id,
+ };
+ if (!(await allowEmailMove(group.move, group.hookMailboxes))) {
+ stoppedByPlugin = true;
+ return;
+ }
+ // Store-side shared folders may be namespaced; year/month lookup needs
+ // the server's bare parent ids so existing subfolders can be reused.
+ const jmapMailboxes = mailboxes.map(m => m.originalId ? {
+ ...m, id: m.originalId,
+ parentId: m.parentId?.startsWith(`${m.accountId}:`)
+ ? m.parentId.slice(`${m.accountId}:`.length) : m.parentId,
+ } : m);
+ await group.client.batchArchiveEmails(
+ group.emails.map(e => ({ id: e.id, receivedAt: e.receivedAt })),
+ archive.originalId || archive.id, mode, jmapMailboxes, group.accountId ?? archive.accountId,
+ );
+ group.emails.forEach(e => group.updatedIds.add(e.id));
+ } catch (error) {
+ if (error instanceof ArchiveEmailsError) {
+ // Only accept confirmations belonging to this request's owner.
+ group.emails.forEach(e => {
+ if (error.updatedIds.includes(e.id)) group.updatedIds.add(e.id);
+ });
+ }
+ failures.push(error instanceof Error ? error : new Error('Failed to archive emails'));
+ }
+ if (group.move && group.hookMailboxes && group.updatedIds.size > 0) {
+ emitEmailMove({ ...group.move, emailIds: [...group.updatedIds] }, group.hookMailboxes);
+ }
+ }));
+
+ // Match the actual authenticated client and owner, including an unstamped
+ // list row whose open viewer carries source stamps for that same account.
+ const confirmedEmails = groups.flatMap(g => g.emails.filter(e => g.updatedIds.has(e.id)));
+ const wasArchived = (email: Email) => groupForEmail(email)?.updatedIds.has(email.id) ?? false;
+ const failedEmails = selected.filter(e => !wasArchived(e));
+ if (isCurrentView() && confirmedEmails.length > 0) {
+ set(state => {
+ const remaining = state.emails.filter(e => !wasArchived(e));
+ const remainingIds = new Set(remaining.map(e => e.id));
+ const selectedEmailKeys = new Set(state.selectedEmailKeys);
+ for (const email of confirmedEmails) selectedEmailKeys.delete(emailKeyFor(email));
+ const changedThreads = new Set(confirmedEmails.map(threadKeyFor));
+ state.emails.filter(wasArchived).forEach(e => {
+ changedThreads.add(threadKeyFor(e));
+ selectedEmailKeys.delete(emailKeyFor(e));
+ });
+ const remainingThreads = new Set(remaining.map(threadKeyFor));
+ const threadEmailsCache = new Map(state.threadEmailsCache);
+ for (const [key, members] of threadEmailsCache) {
+ const cached = members.filter(e => !wasArchived(e));
+ if (cached.length === members.length) continue;
+ changedThreads.add(key);
+ members.filter(wasArchived).forEach(e => selectedEmailKeys.delete(emailKeyFor(e)));
+ if (cached.length) threadEmailsCache.set(key, cached);
+ else threadEmailsCache.delete(key);
+ }
+ return {
+ emails: remaining,
+ totalEmails: Math.max(remaining.length, state.totalEmails - (state.emails.length - remaining.length)),
+ selectedEmailKeys,
+ selectedEmail: state.selectedEmail && wasArchived(state.selectedEmail) ? null : state.selectedEmail,
+ threadEmailsCache,
+ expandedThreadIds: new Set([...state.expandedThreadIds].filter(key =>
+ !changedThreads.has(key) || remainingThreads.has(key) || threadEmailsCache.has(key))),
+ retainedInViewIds: new Set([...state.retainedInViewIds].filter(id =>
+ !confirmedEmails.some(e => e.id === id) || remainingIds.has(id))),
+ };
+ });
+ }
+
+ // Refresh every successful owner even if another owner (or another message
+ // in this owner's Email/set) failed. Also publish a newly created Archive
+ // when its email moves fail, as upstream's create-and-refresh path did.
+ await Promise.all(groups.filter(g => g.updatedIds.size > 0 || g.createdArchive).map(async group => {
+ try {
+ const fresh = await group.client.getMailboxes(group.accountId);
+ if (currentStoreEpoch() !== epoch || fresh.length === 0) return;
+ const owner = group.accountId ?? fresh[0]?.accountId;
+ const primary = group.client.getAccountId?.()
+ ? group.client.getAccountId() === owner
+ : !fresh[0]?.isShared;
+ const normalized = fresh.filter(m => !owner || !m.accountId || m.accountId === owner).map(m => m.isShared ? {
+ ...m, id: `${m.accountId}:${m.originalId || m.id}`,
+ originalId: m.originalId || m.id,
+ parentId: m.parentId && !m.parentId.startsWith(`${m.accountId}:`) ? `${m.accountId}:${m.parentId}` : m.parentId,
+ } : m);
+ set(state => {
+ const accountMailboxes = { ...state.accountMailboxes };
+ if (owner) accountMailboxes[owner] = normalized;
+ // Login caches may contain both personal and delegated folders.
+ // Replace this owner only, preserving the rest of that folder tree.
+ const mergeOwner = (folders: Mailbox[]) => [
+ ...folders.filter(m => primary ? m.isShared : m.accountId !== owner), ...normalized,
+ ];
+ if (group.loginId) {
+ accountMailboxes[group.loginId] = mergeOwner(state.accountMailboxes[group.loginId] ?? []);
+ }
+ const activeClient = useAuthStore.getState().getClientForAccount(useAuthStore.getState().activeAccountId ?? '');
+ return {
+ accountMailboxes,
+ mailboxes: activeClient === group.client
+ ? mergeOwner(state.mailboxes)
+ : state.mailboxes,
+ };
+ });
+ } catch (error) {
+ console.error('Failed to refresh mailboxes after archive:', error);
+ failures.push(error instanceof Error ? error : new Error('Failed to refresh mailboxes after archive'));
+ }
+ }));
+ if (confirmedEmails.length > 0 && isCurrentView()) {
+ const failedKeys = new Set(failedEmails.map(emailKeyFor));
+ const failedRows = failedEmails.filter(e => listKeys.has(emailKeyFor(e)));
+ const failedThreads = [...get().threadEmailsCache].filter(([, members]) =>
+ members.some(e => failedKeys.has(emailKeyFor(e))));
+ try {
+ await get().refreshCurrentMailbox(client);
+ } catch (error) {
+ failures.push(error instanceof Error ? error : new Error('Failed to refresh emails after archive'));
+ }
+ if (isCurrentView() && failedEmails.length > 0) {
+ // A refreshed first page can omit a failed selected row (or return an
+ // equal raw id from another owner). Preserve failed objects and their
+ // account-aware selection, including selected expanded-thread members.
+ set(state => {
+ const sameEmail = (a: Email, b: Email) => emailKeyFor(a) === emailKeyFor(b);
+ const emails = state.emails.map(e => failedEmails.find(f => sameEmail(e, f)) ?? e);
+ for (const failed of failedRows) {
+ if (!emails.some(e => sameEmail(e, failed))) emails.push(failed);
+ }
+ const threadEmailsCache = new Map(state.threadEmailsCache);
+ for (const [key, members] of failedThreads) {
+ const merged = new Map(members.map(e => [emailKeyFor(e), e]));
+ for (const e of threadEmailsCache.get(key) ?? []) merged.set(emailKeyFor(e), e);
+ for (const e of failedEmails) {
+ if (threadKeyFor(e) === key) merged.set(emailKeyFor(e), e);
+ }
+ threadEmailsCache.set(key, [...merged.values()]);
+ }
+ return {
+ emails,
+ totalEmails: Math.max(state.totalEmails, emails.length),
+ selectedEmailKeys: new Set([...state.selectedEmailKeys, ...failedEmails.map(emailKeyFor)]),
+ threadEmailsCache,
+ };
+ });
+ }
+ }
+
+ // Publish after refresh so a successful refresh cannot erase the archive
+ // failure, and reject so the existing bulk-action toast reports it.
+ const error = failures.length === 1 ? failures[0]
+ : failures.length > 1 ? new Error(failures.map(e => e.message).join('; ')) : null;
+ if (isCurrentView()) set({ isLoading: false, error: error?.message ?? null });
+ if (error) throw error;
+ return !stoppedByPlugin;
},
// Spam operations
From 69519bf34460e7ad30cb7492bfa799f8f02f912b Mon Sep 17 00:00:00 2001
From: VasilisNtovolos
Date: Wed, 7 Oct 2026 14:02:02 +0300
Subject: [PATCH 007/238] fix: respect account permissions when creating
Archive
---
lib/jmap/client-interface.ts | 2 +
.../email-store-archive-auto-create.test.ts | 66 +++++++++++
.../email-store-bulk-archive.test.ts | 106 ++++++++++++++++++
stores/email-store.ts | 5 +
4 files changed, 179 insertions(+)
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 160d300d7..5ea4cb80e 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -86,6 +86,8 @@ export interface IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
getCapabilities(): Record;
+ /** Account-scoped session capability data; primary account by default. */
+ getAccountCapability?(capability: string, accountId?: string): unknown;
hasAccountCapability(capability: string, accountId?: string): boolean;
/** FileNode naming rules the server publishes (Stalwart 0.16.6+), if any. */
getFileNameRules?(accountId?: string): FileNameRules | null;
diff --git a/stores/__tests__/email-store-archive-auto-create.test.ts b/stores/__tests__/email-store-archive-auto-create.test.ts
index 9e4f20e47..3318e36d9 100644
--- a/stores/__tests__/email-store-archive-auto-create.test.ts
+++ b/stores/__tests__/email-store-archive-auto-create.test.ts
@@ -39,6 +39,72 @@ function makeClient(): IJMAPClient & { createMailbox: ReturnType }
}
describe('ensureArchiveMailbox', () => {
+ it('uses an existing owner Archive when top-level creation is prohibited', async () => {
+ const client = { ...makeClient(), getAccountCapability: vi.fn(() => ({ mayCreateTopLevelMailbox: false })) };
+ const refresh = vi.fn();
+
+ const archive = await ensureArchiveMailbox({
+ client, mailboxes: [ownArchive, sharedInbox, sharedArchive],
+ selectedMailboxId: sharedInbox.id, refresh,
+ });
+
+ expect(archive).toBe(sharedArchive);
+ expect(client.getAccountCapability).not.toHaveBeenCalled();
+ expect(client.createMailbox).not.toHaveBeenCalled();
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it.each(['selected owner', 'explicit owner'] as const)('does not create a missing Archive for a prohibited %s', async scope => {
+ const client = {
+ ...makeClient(),
+ getAccountCapability: vi.fn((_capability: string, owner?: string) => ({
+ mayCreateTopLevelMailbox: owner !== 'owner-x',
+ })),
+ };
+ const refresh = vi.fn(async () => [ownArchive, sharedInbox, sharedArchive]);
+
+ await expect(ensureArchiveMailbox({
+ client, mailboxes: [ownArchive, sharedInbox],
+ selectedMailboxId: scope === 'selected owner' ? sharedInbox.id : ownInbox.id,
+ accountId: scope === 'explicit owner' ? 'owner-x' : undefined, refresh,
+ })).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+
+ expect(client.getAccountCapability).toHaveBeenCalledWith('urn:ietf:params:jmap:mail', 'owner-x');
+ expect(client.createMailbox).not.toHaveBeenCalled();
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ { capability: { mayCreateTopLevelMailbox: true }, label: 'allowed creation' },
+ { capability: {}, label: 'absent flag' },
+ { capability: undefined, label: 'absent mail capability' },
+ ])('preserves Archive creation with $label', async ({ capability }) => {
+ const client = { ...makeClient(), getAccountCapability: vi.fn(() => capability) };
+ const refresh = vi.fn(async () => [ownArchive, sharedInbox, sharedArchive]);
+
+ const archive = await ensureArchiveMailbox({
+ client, mailboxes: [ownArchive, sharedInbox], selectedMailboxId: sharedInbox.id, refresh,
+ });
+
+ expect(archive).toBe(sharedArchive);
+ expect(client.getAccountCapability).toHaveBeenCalledWith('urn:ietf:params:jmap:mail', 'owner-x');
+ expect(client.createMailbox).toHaveBeenCalledWith('Archive', undefined, 'owner-x', { role: 'archive' });
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+
+ it('checks the client’s primary account when the archive owner is implicit', async () => {
+ const client = { ...makeClient(), getAccountCapability: vi.fn(() => ({ mayCreateTopLevelMailbox: false })) };
+ const refresh = vi.fn(async () => [ownInbox, ownArchive]);
+
+ await expect(ensureArchiveMailbox({
+ client, mailboxes: [ownInbox], selectedMailboxId: ownInbox.id, refresh,
+ })).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+
+ expect(client.getAccountCapability).toHaveBeenCalledWith('urn:ietf:params:jmap:mail', undefined);
+ expect(client.createMailbox).not.toHaveBeenCalled();
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
it('returns the existing archive folder without creating one', async () => {
const client = makeClient();
const refresh = vi.fn();
diff --git a/stores/__tests__/email-store-bulk-archive.test.ts b/stores/__tests__/email-store-bulk-archive.test.ts
index bddcb1b7a..93a2df635 100644
--- a/stores/__tests__/email-store-bulk-archive.test.ts
+++ b/stores/__tests__/email-store-bulk-archive.test.ts
@@ -103,6 +103,112 @@ describe('bulk archive ownership and reconciliation', () => {
expect(refresh).toHaveBeenCalledOnce();
});
+ it('skips prohibited owner Archive creation while reconciling the valid primary group', async () => {
+ folders.shared = [mailbox('shared', 'inbox', true)];
+ const getAccountCapability = vi.fn((_capability: string, owner?: string) => ({
+ mayCreateTopLevelMailbox: owner !== 'shared',
+ }));
+ Object.assign(client, { getAccountCapability });
+ const failed = useEmailStore.getState().emails[1];
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+
+ expect(getAccountCapability).toHaveBeenCalledWith('urn:ietf:params:jmap:mail', 'shared');
+ expect(client.createMailbox).not.toHaveBeenCalled();
+ expect(client.batchArchiveEmails).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(client.batchArchiveEmails).mock.calls[0][4]).toBe('primary');
+ expect(useEmailStore.getState().emails).toEqual([failed]);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(failed)]);
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+
+ it.each(['single', 'year', 'month'] as const)('uses an existing Archive in %s mode despite prohibited top-level creation', async mode => {
+ const getAccountCapability = vi.fn(() => ({ mayCreateTopLevelMailbox: false }));
+ Object.assign(client, { getAccountCapability });
+ folders.shared[1].myRights.mayCreateChild = true;
+ useSettingsStore.setState({ archiveMode: mode });
+
+ await useEmailStore.getState().batchArchive(client);
+
+ expect(client.createMailbox).not.toHaveBeenCalled();
+ expect(getAccountCapability).not.toHaveBeenCalled();
+ expect(client.batchArchiveEmails).toHaveBeenCalledWith(
+ [{ id: 'shared-email', receivedAt: expect.any(String) }], 'archive', mode,
+ expect.arrayContaining([expect.objectContaining({ id: 'archive', myRights: expect.objectContaining({ mayCreateChild: true }) })]), 'shared',
+ );
+ expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
+ });
+
+ it('uses the reaching client’s capability for equal owner IDs on different logins', async () => {
+ const otherFolders = { primary: [mailbox('primary', 'inbox')] };
+ const otherClient = makeClient(otherFolders);
+ const deniedCapability = vi.fn(() => ({ mayCreateTopLevelMailbox: false }));
+ const allowedCapability = vi.fn(() => ({ mayCreateTopLevelMailbox: true }));
+ Object.assign(client, { getAccountCapability: deniedCapability });
+ Object.assign(otherClient, { getAccountCapability: allowedCapability });
+ folders.primary = [mailbox('primary', 'inbox')];
+ vi.mocked(otherClient.createMailbox).mockImplementation(async () => {
+ const archive = { ...mailbox('primary', 'archive'), id: 'other-archive' };
+ otherFolders.primary.push(archive);
+ return archive;
+ });
+ useAuthStore.setState({
+ getClientForAccount: (id: string) => (id === 'login-a' ? client : id === 'login-b' ? otherClient : undefined) as never,
+ });
+ const failed = email('failed-email', 'primary');
+ useEmailStore.setState({
+ emails: [failed, email('other-email', 'primary', 'login-b')],
+ selectedEmailKeys: new Set([emailKeyFor(failed), emailKeyFor(email('other-email', 'primary', 'login-b'))]),
+ });
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+
+ expect(deniedCapability).toHaveBeenCalledWith('urn:ietf:params:jmap:mail', 'primary');
+ expect(allowedCapability).toHaveBeenCalledWith('urn:ietf:params:jmap:mail', 'primary');
+ expect(client.createMailbox).not.toHaveBeenCalled();
+ expect(client.batchArchiveEmails).not.toHaveBeenCalled();
+ expect(otherClient.createMailbox).toHaveBeenCalledWith('Archive', undefined, 'primary', { role: 'archive' });
+ expect(vi.mocked(otherClient.batchArchiveEmails).mock.calls[0][1]).toBe('other-archive');
+ expect(useEmailStore.getState().emails).toEqual([failed]);
+ expect([...useEmailStore.getState().selectedEmailKeys]).toEqual([emailKeyFor(failed)]);
+ });
+
+ it.each(['shared owner', 'different login'] as const)('retains a prohibited namesake’s selection, viewer, and thread after partial success: %s', async source => {
+ const archived = email('same-email-id', 'primary');
+ const failed = source === 'shared owner'
+ ? email('same-email-id', 'shared') : email('same-email-id', 'primary', 'login-b');
+ const deniedClient = source === 'shared owner' ? client : makeClient({ primary: [mailbox('primary', 'inbox')] });
+ const getAccountCapability = vi.fn(() => ({ mayCreateTopLevelMailbox: false }));
+ Object.assign(deniedClient, { getAccountCapability });
+ if (source === 'shared owner') folders.shared = [mailbox('shared', 'inbox', true)];
+ useAuthStore.setState({
+ getClientForAccount: (id: string) => (id === 'login-a' ? client : id === 'login-b' ? deniedClient : undefined) as never,
+ });
+ useEmailStore.setState({
+ emails: [archived, failed], selectedEmail: failed,
+ selectedEmailKeys: new Set([emailKeyFor(archived), emailKeyFor(failed)]),
+ threadEmailsCache: new Map([[threadKeyFor(archived), [archived]], [threadKeyFor(failed), [failed]]]),
+ expandedThreadIds: new Set([threadKeyFor(archived), threadKeyFor(failed)]),
+ });
+
+ await expect(useEmailStore.getState().batchArchive(client)).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError);
+
+ expect(getAccountCapability).toHaveBeenCalledExactlyOnceWith('urn:ietf:params:jmap:mail', failed.sourceAccountId);
+ expect(deniedClient.createMailbox).not.toHaveBeenCalled();
+ expect(client.batchArchiveEmails).toHaveBeenCalledExactlyOnceWith(
+ [{ id: archived.id, receivedAt: archived.receivedAt }], 'primary-archive', 'single', expect.any(Array), 'primary',
+ );
+ if (source === 'different login') expect(deniedClient.batchArchiveEmails).not.toHaveBeenCalled();
+ const state = useEmailStore.getState();
+ expect(state.emails).toEqual([failed]);
+ expect([...state.selectedEmailKeys]).toEqual([emailKeyFor(failed)]);
+ expect(state.selectedEmail).toBe(failed);
+ expect([...state.threadEmailsCache]).toEqual([[threadKeyFor(failed), [failed]]]);
+ expect([...state.expandedThreadIds]).toEqual([threadKeyFor(failed)]);
+ expect(state.error).toMatch(/archive mailbox not found/i);
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+
it.each(['single', 'year', 'month'] as const)('creates each missing owner’s Archive independently in %s mode', async mode => {
folders.primary = [mailbox('primary', 'inbox')];
folders.shared = [mailbox('shared', 'inbox', true)];
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 9a07087bc..3ae02e603 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -911,6 +911,11 @@ export async function ensureArchiveMailbox(opts: {
const viewMailbox = opts.mailboxes.find(m => m.id === opts.selectedMailboxId);
const scopeId = opts.accountId ?? (viewMailbox?.isShared ? viewMailbox.accountId : undefined);
+ const mailCapability = opts.client.getAccountCapability?.('urn:ietf:params:jmap:mail', scopeId) as
+ { mayCreateTopLevelMailbox?: boolean } | undefined;
+ // Only an explicit account-level denial prevents creating the root Archive.
+ // Existing Archives (and their year/month child folders) are unaffected.
+ if (mailCapability?.mayCreateTopLevelMailbox === false) throw new ArchiveMailboxNotFoundError();
// null: a plugin refused the new folder (and said why).
if (!(await createMailboxWithHooks(opts.client, 'Archive', undefined, scopeId, { role: 'archive' }))) return null;
From db38ad320ef727f8a74ef08fe59c28005497c247 Mon Sep 17 00:00:00 2001
From: lucamzanon
Date: Fri, 16 Oct 2026 05:46:46 +0200
Subject: [PATCH 008/238] feat: optionally keep mailbox passwords in the
account list backup
Part 2 of 3 of the account archive agreed in #1198. When the admin turns on
"Account List Backup: Passwords" (off by default), saving the list offers to
include the passwords of the accounts signed in here with a plain password.
They are encrypted in the browser together with the list. On restore, those
accounts are signed in to in the background, one by one, without leaving the
active account; a refused password leaves the account waiting for a sign-in by
hand, as before. OAuth and TOTP accounts are saved without a password.
---
app/(main)/admin/_tabs/policy.tsx | 1 +
.../settings/account-archive-settings.tsx | 37 ++++++++--
lib/__tests__/account-archive-client.test.ts | 73 +++++++++++++++++++
lib/__tests__/account-archive.test.ts | 16 +++-
lib/account-archive-client.ts | 40 ++++++++--
lib/account-archive.ts | 17 +++--
lib/admin/types.ts | 3 +
lib/jmap/client.ts | 9 +++
lib/lite-config.ts | 1 +
locales/ar/common.json | 7 ++
locales/ca/common.json | 7 ++
locales/cs/common.json | 7 ++
locales/da/common.json | 7 ++
locales/de/common.json | 7 ++
locales/en/common.json | 7 ++
locales/es/common.json | 7 ++
locales/fa/common.json | 7 ++
locales/fr/common.json | 7 ++
locales/he/common.json | 7 ++
locales/hu/common.json | 7 ++
locales/it/common.json | 7 ++
locales/ja/common.json | 7 ++
locales/ko/common.json | 7 ++
locales/lv/common.json | 7 ++
locales/mn/common.json | 7 ++
locales/nb/common.json | 7 ++
locales/nl/common.json | 7 ++
locales/pl/common.json | 7 ++
locales/pt/common.json | 7 ++
locales/ro/common.json | 7 ++
locales/ru/common.json | 7 ++
locales/sk/common.json | 7 ++
locales/tr/common.json | 7 ++
locales/uk/common.json | 7 ++
locales/zh-TW/common.json | 7 ++
locales/zh/common.json | 7 ++
.../auth-store-archive-restore.test.ts | 24 +++++-
stores/auth-store.ts | 37 ++++++++++
38 files changed, 424 insertions(+), 23 deletions(-)
create mode 100644 lib/__tests__/account-archive-client.test.ts
diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx
index e42478bac..244bad7e9 100644
--- a/app/(main)/admin/_tabs/policy.tsx
+++ b/app/(main)/admin/_tabs/policy.tsx
@@ -39,6 +39,7 @@ const FEATURE_GATE_LABELS: Partial s.username);
const serverUrl = useAuthStore(s => s.serverUrl);
+ const passwordsAllowed = usePolicyStore(s => s.isFeatureEnabled('accountArchivePasswordsEnabled'));
+ const { rememberMeEnabled } = useConfig();
const [saved, setSaved] = useState(null);
const [mode, setMode] = useState<'save' | 'restore' | null>(null);
const [password, setPassword] = useState('');
const [confirmation, setConfirmation] = useState('');
+ const [includePasswords, setIncludePasswords] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ error: boolean; text: string } | null>(null);
const owner: ArchiveOwner | null = username && serverUrl ? { username, serverUrl } : null;
@@ -39,7 +44,7 @@ export function AccountArchiveSettings() {
if (!owner) return null;
const open = (next: 'save' | 'restore' | null) => {
- setMode(next); setPassword(''); setConfirmation(''); setMessage(null);
+ setMode(next); setPassword(''); setConfirmation(''); setIncludePasswords(false); setMessage(null);
};
const run = async (action: () => Promise) => {
setBusy(true); setMessage(null);
@@ -56,16 +61,22 @@ export function AccountArchiveSettings() {
const save = () => run(async () => {
if (password !== confirmation) throw new Error('mismatch');
- const accounts = collectAccounts();
+ const accounts = collectAccounts(passwordsAllowed && includePasswords);
await putArchive(owner, await encryptArchive(owner, accounts, password));
setSaved(true);
- return t('saved', { count: accounts.length });
+ const withPassword = accounts.filter(a => a.password).length;
+ return [t('saved', { count: accounts.length }), withPassword ? t('saved_passwords', { count: withPassword }) : ''].join(' ').trim();
});
const restore = () => run(async () => {
const envelope = await fetchArchive(owner);
if (!envelope) { setSaved(false); throw new Error('storage_failed'); }
- const result = restoreAccounts(await decryptArchive(owner, envelope, password));
- return [t('restored', result), result.skipped ? t('skipped', { count: result.skipped }) : ''].join(' ').trim();
+ const result = await restoreArchive(await decryptArchive(owner, envelope, password), rememberMeEnabled);
+ return [
+ t(result.waiting ? 'restored' : 'restored_signed_in', result),
+ result.signedIn ? t('signed_in', { count: result.signedIn }) : '',
+ result.signInFailed ? t('sign_in_failed', { count: result.signInFailed }) : '',
+ result.skipped ? t('skipped', { count: result.skipped }) : '',
+ ].join(' ').trim();
});
const remove = () => {
if (!window.confirm(t('delete_confirm'))) return;
@@ -73,7 +84,7 @@ export function AccountArchiveSettings() {
};
return (
-
+
{saved === null ? '…' : t(saved ? 'status_saved' : 'status_none', { account: toUnicodeEmail(owner.username) })}
@@ -105,6 +116,18 @@ export function AccountArchiveSettings() {
id={`${id}-confirm`} type="password" value={confirmation} required autoComplete="new-password"
onChange={e => setConfirmation(e.target.value)}
/>
+ {passwordsAllowed && (
+
+
+ setIncludePasswords(e.target.checked)}
+ />
+ {t('include_passwords')}
+
+
{t('include_passwords_hint')}
+
+ )}
>
)}
diff --git a/lib/__tests__/account-archive-client.test.ts b/lib/__tests__/account-archive-client.test.ts
new file mode 100644
index 000000000..f0f0d87dd
--- /dev/null
+++ b/lib/__tests__/account-archive-client.test.ts
@@ -0,0 +1,73 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const auth = vi.hoisted(() => ({
+ clients: new Map
string | null }>(),
+ signIn: vi.fn(),
+}));
+
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: {
+ getState: () => ({
+ getClientForAccount: (id: string) => auth.clients.get(id),
+ signInRestoredAccount: auth.signIn,
+ }),
+ },
+}));
+
+import { collectAccounts, restoreArchive } from '@/lib/account-archive-client';
+import { generateAccountId } from '@/lib/account-utils';
+import { useAccountStore } from '@/stores/account-store';
+
+const server = 'https://mail.example.com';
+const add = (username: string, authMode: 'basic' | 'oauth') => useAccountStore.getState().addAccount({
+ username, serverUrl: server, authMode, rememberMe: true, label: username, displayName: username, email: username,
+ lastLoginAt: 1, isConnected: true, hasError: false, isDefault: false,
+});
+
+beforeEach(() => {
+ useAccountStore.setState({ accounts: [], defaultAccountId: null });
+ auth.clients.clear();
+ auth.signIn.mockReset();
+});
+
+describe('collectAccounts', () => {
+ it('leaves passwords out unless asked', () => {
+ const id = add('a@example.com', 'basic');
+ auth.clients.set(id, { getBasicPassword: () => 'secret-a' });
+ expect(collectAccounts()[0]).not.toHaveProperty('password');
+ expect(collectAccounts(true)[0].password).toBe('secret-a');
+ });
+
+ it('takes a password only from a signed-in Basic account', () => {
+ const basic = add('a@example.com', 'basic');
+ const oauth = add('b@example.com', 'oauth');
+ add('c@example.com', 'basic'); // not signed in here
+ auth.clients.set(basic, { getBasicPassword: () => null }); // TOTP or bearer
+ auth.clients.set(oauth, { getBasicPassword: () => 'never-read' });
+ expect(collectAccounts(true).map(a => a.password)).toEqual([undefined, undefined, undefined]);
+ });
+});
+
+describe('restoreArchive', () => {
+ const entry = (username: string, password?: string) => ({ username, serverUrl: server, label: username, avatarColor: '#2563eb', password });
+
+ it('signs in to the accounts saved with a password and leaves the others waiting', async () => {
+ auth.signIn.mockImplementation(async (id: string, password: string) => {
+ if (password !== 'right') return false;
+ useAccountStore.getState().updateAccount(id, { awaitingSignIn: false });
+ return true;
+ });
+ const result = await restoreArchive([entry('a@example.com', 'right'), entry('b@example.com', 'stale'), entry('c@example.com')], true);
+ expect(auth.signIn).toHaveBeenCalledTimes(2);
+ expect(auth.signIn).toHaveBeenCalledWith(generateAccountId('a@example.com', server), 'right', true);
+ expect(result).toMatchObject({ added: 3, signedIn: 1, signInFailed: 1, waiting: 2 });
+ });
+
+ it('does not sign in again to an account already connected here', async () => {
+ const id = add('a@example.com', 'basic');
+ auth.clients.set(id, { getBasicPassword: () => 'right' });
+ const result = await restoreArchive([entry('a@example.com', 'right')], true);
+ expect(auth.signIn).not.toHaveBeenCalled();
+ expect(result).toMatchObject({ updated: 1, signedIn: 0, waiting: 0 });
+ });
+});
diff --git a/lib/__tests__/account-archive.test.ts b/lib/__tests__/account-archive.test.ts
index 2c5bc6048..c7dba9e78 100644
--- a/lib/__tests__/account-archive.test.ts
+++ b/lib/__tests__/account-archive.test.ts
@@ -38,12 +38,20 @@ describe('account archive encryption', () => {
await expect(encryptArchive(owner, accounts, 'short')).rejects.toThrow('password_length');
});
- it('never carries a password or any other extra field', async () => {
- const withSecrets = accounts.map(a => ({ ...a, password: 'mailbox-secret', token: 't' }));
+ it('carries a mailbox password when given one, and no other extra field', async () => {
+ const withSecrets = [{ ...accounts[0], password: 'mailbox-secret', token: 't' }, { ...accounts[1], token: 't' }];
const envelope = await encryptArchive(owner, withSecrets, password);
+ expect(JSON.stringify(envelope)).not.toContain('mailbox-secret');
const restored = await decryptArchive(owner, envelope, password);
- expect(JSON.stringify(restored)).not.toContain('mailbox-secret');
- expect(Object.keys(restored[0]).sort()).toEqual(['avatarColor', 'label', 'serverUrl', 'username']);
+ expect(restored[0].password).toBe('mailbox-secret');
+ expect(Object.keys(restored[0]).sort()).toEqual(['avatarColor', 'label', 'password', 'serverUrl', 'username']);
+ expect(Object.keys(restored[1]).sort()).toEqual(['avatarColor', 'label', 'serverUrl', 'username']);
+ });
+
+ it('rejects a password that is empty, too long or not a string', () => {
+ for (const bad of ['', 'x'.repeat(1025), 42]) {
+ expect(() => parseArchivedAccounts([{ ...accounts[0], password: bad as string }])).toThrow('invalid_archive');
+ }
});
it('rejects malformed lists and envelopes', async () => {
diff --git a/lib/account-archive-client.ts b/lib/account-archive-client.ts
index 2ca00f455..3eb83c826 100644
--- a/lib/account-archive-client.ts
+++ b/lib/account-archive-client.ts
@@ -2,6 +2,7 @@ import { apiFetch } from '@/lib/browser-navigation';
import { IS_LITE } from '@/lib/lite';
import { generateAccountId, getMaxAccounts } from '@/lib/account-utils';
import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
import type { ArchiveEnvelope, ArchiveOwner, ArchivedAccount } from './account-archive';
async function request(owner: ArchiveOwner, init: RequestInit = {}): Promise<{ envelope?: ArchiveEnvelope | null }> {
@@ -29,11 +30,19 @@ export async function removeArchive(owner: ArchiveOwner): Promise {
await request(owner, { method: 'DELETE' });
}
-/** This browser's accounts as the archive keeps them: no credentials of any kind. */
-export function collectAccounts(): ArchivedAccount[] {
- return useAccountStore.getState().accounts.map(a => ({
- username: a.username, serverUrl: a.serverUrl, label: a.label, avatarColor: a.avatarColor,
- }));
+/**
+ * This browser's accounts as the archive keeps them. With `includePasswords`,
+ * an account signed in here with a plain password carries it; tokens and
+ * accounts not signed in carry nothing.
+ */
+export function collectAccounts(includePasswords = false): ArchivedAccount[] {
+ const auth = useAuthStore.getState();
+ return useAccountStore.getState().accounts.map(a => {
+ const entry: ArchivedAccount = { username: a.username, serverUrl: a.serverUrl, label: a.label, avatarColor: a.avatarColor };
+ const password = includePasswords && a.authMode === 'basic' ? auth.getClientForAccount(a.id)?.getBasicPassword() : null;
+ if (password) entry.password = password;
+ return entry;
+ });
}
/**
@@ -61,3 +70,24 @@ export function restoreAccounts(archived: ArchivedAccount[]): { added: number; u
}
return { added, updated, skipped };
}
+
+/**
+ * Restores the list, then signs in one by one to the accounts saved with a
+ * password that are not signed in here yet. An account whose password no
+ * longer works keeps waiting for a sign-in by hand.
+ */
+export async function restoreArchive(archived: ArchivedAccount[], rememberMe: boolean) {
+ const result = restoreAccounts(archived);
+ let signedIn = 0, signInFailed = 0;
+ for (const entry of archived) {
+ if (!entry.password) continue;
+ const id = generateAccountId(entry.username, entry.serverUrl);
+ const account = useAccountStore.getState().getAccountById(id);
+ if (!account || account.authMode !== 'basic' || useAuthStore.getState().getClientForAccount(id)) continue;
+ if (await useAuthStore.getState().signInRestoredAccount(id, entry.password, rememberMe)) signedIn++;
+ else signInFailed++;
+ }
+ const waiting = archived.filter(e => useAccountStore.getState()
+ .getAccountById(generateAccountId(e.username, e.serverUrl))?.awaitingSignIn).length;
+ return { ...result, signedIn, signInFailed, waiting };
+}
diff --git a/lib/account-archive.ts b/lib/account-archive.ts
index fa64a505f..7f7fa50c1 100644
--- a/lib/account-archive.ts
+++ b/lib/account-archive.ts
@@ -3,12 +3,12 @@
* can bring the same accounts back. Encryption happens in the browser with a
* password the user chooses; the server only ever sees the envelope.
*
- * The list holds no mailbox passwords: each restored account is signed in to
- * by hand.
+ * Mailbox passwords are optional: an account saved with its password is
+ * signed in to on restore, one saved without asks for its own sign-in.
*/
export interface ArchiveOwner { username: string; serverUrl: string }
-export interface ArchivedAccount extends ArchiveOwner { label: string; avatarColor: string }
+export interface ArchivedAccount extends ArchiveOwner { label: string; avatarColor: string; password?: string }
export interface ArchiveEnvelope {
version: 1;
iterations: typeof ARCHIVE_ITERATIONS;
@@ -55,9 +55,14 @@ export function parseArchivedAccounts(value: unknown): ArchivedAccount[] {
return value.map((a: ArchivedAccount) => {
archiveIdentity(a);
if (typeof a.label !== 'string' || a.label.length > 320
- || typeof a.avatarColor !== 'string' || !/^#[0-9a-f]{6}$/i.test(a.avatarColor)) throw new Error('invalid_archive');
- // Rebuilt field by field, so nothing else (a password, say) survives a round trip.
- return { username: a.username.trim(), serverUrl: trimUrl(a.serverUrl), label: a.label, avatarColor: a.avatarColor };
+ || typeof a.avatarColor !== 'string' || !/^#[0-9a-f]{6}$/i.test(a.avatarColor)
+ || (a.password !== undefined && (typeof a.password !== 'string' || !a.password || a.password.length > 1024))) {
+ throw new Error('invalid_archive');
+ }
+ // Rebuilt field by field, so nothing else (a token, say) survives a round trip.
+ const entry: ArchivedAccount = { username: a.username.trim(), serverUrl: trimUrl(a.serverUrl), label: a.label, avatarColor: a.avatarColor };
+ if (a.password !== undefined) entry.password = a.password;
+ return entry;
});
}
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 8e17ce6f0..f7f563567 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -63,6 +63,8 @@ export interface FeatureGates {
unifiedCrossAccountEnabled: boolean;
/** Encrypted account list kept on the server, restorable on another device. */
accountArchiveEnabled: boolean;
+ /** Lets that list carry mailbox passwords, so a restore signs in by itself. */
+ accountArchivePasswordsEnabled: boolean;
/** Composer "Custom" From: free-text sender address instead of an identity. */
customFromEnabled: boolean;
}
@@ -93,6 +95,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
crossAllViewEnabled: false,
unifiedCrossAccountEnabled: false,
accountArchiveEnabled: false,
+ accountArchivePasswordsEnabled: false,
customFromEnabled: true,
};
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 0ed5c8f82..de12f7707 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -1176,6 +1176,15 @@ export class JMAPClient implements IJMAPClient {
return this.authHeader;
}
+ /**
+ * The password of a plain Basic session, or null for a bearer session and
+ * for one that appends a TOTP code (that code expires; the password alone
+ * would not sign in).
+ */
+ getBasicPassword(): string | null {
+ return this.authHeader.startsWith('Basic ') && !this.onTotpRequired ? this.password : null;
+ }
+
getServerUrl(): string {
return this.serverUrl;
}
diff --git a/lib/lite-config.ts b/lib/lite-config.ts
index ac1a4ea1b..b3f6ed201 100644
--- a/lib/lite-config.ts
+++ b/lib/lite-config.ts
@@ -125,6 +125,7 @@ export function applyLitePolicy(raw: unknown): SettingsPolicy {
sidebarAppsEnabled: false,
// Kept by this app's server, which Lite does not have.
accountArchiveEnabled: false,
+ accountArchivePasswordsEnabled: false,
},
forceEnabledPlugins: [],
approvedPlugins: [],
diff --git a/locales/ar/common.json b/locales/ar/common.json
index c4f30806f..fe0cd9dae 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -1735,6 +1735,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1746,9 +1747,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 9a41a17f5..1cb207ca8 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -1735,6 +1735,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1746,9 +1747,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 98e481e8d..64187cbd1 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1743,9 +1744,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/da/common.json b/locales/da/common.json
index 2279fdd31..65616b4e8 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -1735,6 +1735,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1746,9 +1747,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/de/common.json b/locales/de/common.json
index ce73a9998..8940eed14 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Sicherung der Kontoliste",
"description": "Speichert die Liste der Konten in diesem Browser auf dem Server, verschlüsselt mit einem Passwort, das nur Sie kennen, und stellt sie auf einem anderen Gerät wieder her. Postfachpasswörter werden nicht gespeichert: Sie melden sich bei jedem wiederhergestellten Konto selbst an.",
+ "description_passwords": "Speichert die Liste der Konten in diesem Browser auf dem Server, verschlüsselt mit einem Passwort, das nur Sie kennen, und stellt sie auf einem anderen Gerät wieder her. Sie können die Postfachpasswörter einschließen, dann melden sich die wiederhergestellten Konten selbst an.",
"status_none": "Für {account} ist keine Liste gespeichert.",
"status_saved": "Für {account} ist eine Liste gespeichert.",
"save": "Liste speichern",
@@ -1743,9 +1744,15 @@
"password_hint": "Mindestens 10 Zeichen. Es kann nicht wiederhergestellt werden: Wenn Sie es vergessen, speichern Sie die Liste erneut mit einem neuen.",
"restore_hint": "Das Passwort, das beim Speichern der Liste gewählt wurde.",
"confirm": "Passwort bestätigen",
+ "include_passwords": "Postfachpasswörter einschließen",
+ "include_passwords_hint": "Konten, die hier mit einem Passwort angemeldet sind, melden sich nach einer Wiederherstellung selbst an. Wer das Sicherungspasswort kennt, kommt in jedes Postfach der Liste.",
"saved": "Liste gespeichert ({count, plural, one {# Konto} other {# Konten}}).",
"restored": "Hinzugefügte Konten: {added}. Bereits vorhandene, aktualisierte Konten: {updated}. Öffnen Sie jedes neue Konto in der Kontoliste, um sich anzumelden.",
+ "restored_signed_in": "Hinzugefügte Konten: {added}. Bereits vorhandene, aktualisierte Konten: {updated}.",
"skipped": "{count, plural, one {# Konto wurde} other {# Konten wurden}} nicht hinzugefügt: Die Höchstzahl an Konten ist erreicht.",
+ "saved_passwords": "Passwörter für {count, plural, one {# Konto} other {# Konten}} eingeschlossen.",
+ "signed_in": "Bei {count, plural, one {# Konto} other {# Konten}} mit dem gespeicherten Passwort angemeldet.",
+ "sign_in_failed": "{count, plural, one {# gespeichertes Passwort wurde} other {# gespeicherte Passwörter wurden}} abgelehnt: Melden Sie sich bei diesen Konten über die Kontoliste an.",
"deleted": "Gespeicherte Liste gelöscht.",
"errors": {
"mismatch": "Die Passwörter stimmen nicht überein.",
diff --git a/locales/en/common.json b/locales/en/common.json
index fb0d9fd56..acea20da8 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -1735,6 +1735,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1746,9 +1747,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/es/common.json b/locales/es/common.json
index 37daa1e88..b76bd7229 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Copia de la lista de cuentas",
"description": "Guarda en el servidor la lista de cuentas de este navegador, cifrada con una contraseña que solo tú conoces, y recupérala en otro dispositivo. Las contraseñas de los buzones no se guardan: inicias sesión tú en cada cuenta restaurada.",
+ "description_passwords": "Guarda en el servidor la lista de cuentas de este navegador, cifrada con una contraseña que solo tú conoces, y recupérala en otro dispositivo. Puedes incluir las contraseñas de los buzones, así las cuentas restauradas inician sesión solas.",
"status_none": "No hay ninguna lista guardada para {account}.",
"status_saved": "Hay una lista guardada para {account}.",
"save": "Guardar lista",
@@ -1743,9 +1744,15 @@
"password_hint": "Al menos 10 caracteres. No se puede recuperar: si la olvidas, vuelve a guardar la lista con una nueva.",
"restore_hint": "La contraseña elegida al guardar la lista.",
"confirm": "Confirmar contraseña",
+ "include_passwords": "Incluir las contraseñas de los buzones",
+ "include_passwords_hint": "Las cuentas iniciadas aquí con contraseña iniciarán sesión solas tras restaurar. Quien conozca la contraseña de la copia entra en todos los buzones de la lista.",
"saved": "Lista guardada ({count, plural, one {# cuenta} other {# cuentas}}).",
"restored": "Cuentas añadidas: {added}. Cuentas ya presentes y actualizadas: {updated}. Abre cada cuenta nueva desde la lista de cuentas para iniciar sesión.",
+ "restored_signed_in": "Cuentas añadidas: {added}. Cuentas ya presentes y actualizadas: {updated}.",
"skipped": "{count, plural, one {# cuenta no se ha añadido} other {# cuentas no se han añadido}}: se ha alcanzado el límite de cuentas.",
+ "saved_passwords": "Contraseñas incluidas para {count, plural, one {# cuenta} other {# cuentas}}.",
+ "signed_in": "Sesión iniciada en {count, plural, one {# cuenta} other {# cuentas}} con la contraseña guardada.",
+ "sign_in_failed": "{count, plural, one {# contraseña guardada fue rechazada} other {# contraseñas guardadas fueron rechazadas}}: inicia sesión en esas cuentas desde la lista de cuentas.",
"deleted": "Lista guardada eliminada.",
"errors": {
"mismatch": "Las contraseñas no coinciden.",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 1dd5982d3..b1e779643 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -1735,6 +1735,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1746,9 +1747,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 630b4d3a8..50134eac2 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Sauvegarde de la liste des comptes",
"description": "Conservez sur le serveur la liste des comptes de ce navigateur, chiffrée avec un mot de passe que vous seul connaissez, et retrouvez-la sur un autre appareil. Les mots de passe des boîtes ne sont pas enregistrés : vous vous connectez vous-même à chaque compte restauré.",
+ "description_passwords": "Conservez sur le serveur la liste des comptes de ce navigateur, chiffrée avec un mot de passe que vous seul connaissez, et retrouvez-la sur un autre appareil. Vous pouvez inclure les mots de passe des boîtes : les comptes restaurés se connectent alors tout seuls.",
"status_none": "Aucune liste enregistrée pour {account}.",
"status_saved": "Une liste est enregistrée pour {account}.",
"save": "Enregistrer la liste",
@@ -1743,9 +1744,15 @@
"password_hint": "Au moins 10 caractères. Il ne peut pas être récupéré : si vous l'oubliez, enregistrez à nouveau la liste avec un nouveau mot de passe.",
"restore_hint": "Le mot de passe choisi lors de l'enregistrement de la liste.",
"confirm": "Confirmer le mot de passe",
+ "include_passwords": "Inclure les mots de passe des boîtes",
+ "include_passwords_hint": "Les comptes connectés ici avec un mot de passe se connecteront tout seuls après une restauration. Quiconque connaît le mot de passe de la sauvegarde accède à toutes les boîtes de la liste.",
"saved": "Liste enregistrée ({count, plural, one {# compte} other {# comptes}}).",
"restored": "Comptes ajoutés : {added}. Comptes déjà présents et mis à jour : {updated}. Ouvrez chaque nouveau compte depuis la liste des comptes pour vous connecter.",
+ "restored_signed_in": "Comptes ajoutés : {added}. Comptes déjà présents et mis à jour : {updated}.",
"skipped": "{count, plural, one {# compte n'a pas été ajouté} other {# comptes n'ont pas été ajoutés}} : la limite de comptes est atteinte.",
+ "saved_passwords": "Mots de passe inclus pour {count, plural, one {# compte} other {# comptes}}.",
+ "signed_in": "Connecté à {count, plural, one {# compte} other {# comptes}} avec le mot de passe enregistré.",
+ "sign_in_failed": "{count, plural, one {# mot de passe enregistré a été refusé} other {# mots de passe enregistrés ont été refusés}} : connectez-vous à ces comptes depuis la liste des comptes.",
"deleted": "Liste enregistrée supprimée.",
"errors": {
"mismatch": "Les mots de passe ne correspondent pas.",
diff --git a/locales/he/common.json b/locales/he/common.json
index 208910020..cb155a5ca 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -1697,6 +1697,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1708,9 +1709,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 4dacbd4bc..21f24a033 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -1735,6 +1735,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1746,9 +1747,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/it/common.json b/locales/it/common.json
index f37082347..829dc344d 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Backup dell'elenco account",
"description": "Conserva sul server l'elenco degli account di questo browser, cifrato con una password che conosci solo tu, e ripristinalo su un altro dispositivo. Le password delle caselle non vengono salvate: accedi tu a ogni account ripristinato.",
+ "description_passwords": "Conserva sul server l'elenco degli account di questo browser, cifrato con una password che conosci solo tu, e ripristinalo su un altro dispositivo. Puoi includere le password delle caselle, così gli account ripristinati accedono da soli.",
"status_none": "Nessun elenco salvato per {account}.",
"status_saved": "C'è un elenco salvato per {account}.",
"save": "Salva elenco",
@@ -1743,9 +1744,15 @@
"password_hint": "Almeno 10 caratteri. Non si può recuperare: se la dimentichi, salva di nuovo l'elenco con una nuova password.",
"restore_hint": "La password scelta quando l'elenco è stato salvato.",
"confirm": "Conferma password",
+ "include_passwords": "Includi le password delle caselle",
+ "include_passwords_hint": "Gli account collegati qui con una password accederanno da soli dopo il ripristino. Chi conosce la password del backup entra in tutte le caselle dell'elenco.",
"saved": "Elenco salvato ({count, plural, one {# account} other {# account}}).",
"restored": "Account aggiunti: {added}. Account già presenti e aggiornati: {updated}. Apri ogni nuovo account dall'elenco account per accedere.",
+ "restored_signed_in": "Account aggiunti: {added}. Account già presenti e aggiornati: {updated}.",
"skipped": "{count, plural, one {# account non è stato aggiunto} other {# account non sono stati aggiunti}}: è stato raggiunto il limite di account.",
+ "saved_passwords": "Password incluse per {count, plural, one {# account} other {# account}}.",
+ "signed_in": "Accesso fatto a {count, plural, one {# account} other {# account}} con la password salvata.",
+ "sign_in_failed": "{count, plural, one {# password salvata è stata rifiutata} other {# password salvate sono state rifiutate}}: accedi a quegli account dall'elenco account.",
"deleted": "Elenco salvato eliminato.",
"errors": {
"mismatch": "Le password non coincidono.",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 0c636cc1a..7e688d18e 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1743,9 +1744,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index bb6db3845..a12733bb9 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1743,9 +1744,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index b608b9e41..f3837e59b 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1743,9 +1744,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index ba5ed7f09..27430db5f 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -1735,6 +1735,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1746,9 +1747,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 5cf593737..30bd04381 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -1735,6 +1735,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1746,9 +1747,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index e5c51f7ea..e2cd055a5 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1743,9 +1744,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 3f49754eb..0120c8b18 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1743,9 +1744,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 3ab8ec837..a802a14e7 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1743,9 +1744,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 2ce53a3bf..bfe14556f 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -1735,6 +1735,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1746,9 +1747,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 78d322d13..ae5f22bba 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1743,9 +1744,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 2ac4a6ef2..a79226869 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -1735,6 +1735,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1746,9 +1747,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 95b31efd0..91988428a 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1743,9 +1744,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 59c8facba..ec6176e29 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1743,9 +1744,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 87b7185aa..e0ed91300 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -1735,6 +1735,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1746,9 +1747,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 7ba954f26..7be9845dc 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -1732,6 +1732,7 @@
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
+ "description_passwords": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. You can include the mailbox passwords, so the restored accounts sign in by themselves.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
@@ -1743,9 +1744,15 @@
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
+ "include_passwords": "Include mailbox passwords",
+ "include_passwords_hint": "Accounts signed in here with a password will sign in by themselves after a restore. Anyone who knows the backup password gets into every mailbox in the list.",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
+ "restored_signed_in": "Accounts added: {added}. Accounts already here and updated: {updated}.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
+ "saved_passwords": "Passwords included for {count, plural, one {# account} other {# accounts}}.",
+ "signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
+ "sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
diff --git a/stores/__tests__/auth-store-archive-restore.test.ts b/stores/__tests__/auth-store-archive-restore.test.ts
index 47f51b689..5c9511b43 100644
--- a/stores/__tests__/auth-store-archive-restore.test.ts
+++ b/stores/__tests__/auth-store-archive-restore.test.ts
@@ -8,7 +8,7 @@ vi.mock('@/lib/jmap/client', () => {
class JMAPClient {
constructor(public serverUrl: string, public username: string, public password: string) {}
static withBearer() { throw new Error('not used'); }
- async connect() {}
+ async connect() { if (this.password === 'wrong') throw new Error('Authentication failed'); }
getSessionUsername() { return this.username; }
getUsername() { return this.username; }
async getIdentities() { return [{ id: 'a', email: this.username, name: this.username, mayDelete: false }]; }
@@ -119,3 +119,25 @@ it('treats a browser left with only accounts awaiting sign-in as signed out, not
expect(useAuthStore.getState().error).not.toBe('connection_failed');
expect(useAccountStore.getState().accounts.map(a => a.id)).toEqual(['alice@x.test@mail.x.test']);
});
+
+it('signs in to a restored account with its saved password without leaving the active one', async () => {
+ restoreAccounts(archived);
+ await useAuthStore.getState().checkAuth();
+ const alice = 'alice@x.test@mail.x.test';
+ const fetchMock = vi.mocked(fetch);
+ fetchMock.mockClear();
+ const active = useAuthStore.getState().activeAccountId;
+
+ expect(await useAuthStore.getState().signInRestoredAccount(alice, 'wrong', true)).toBe(false);
+ expect(useAccountStore.getState().getAccountById(alice)?.awaitingSignIn).toBe(true);
+
+ expect(await useAuthStore.getState().signInRestoredAccount(alice, 'alice-pw', true)).toBe(true);
+ expect(useAuthStore.getState().activeAccountId).toBe(active);
+ expect(useAuthStore.getState().getClientForAccount(alice)).toBeDefined();
+ expect(useAccountStore.getState().getAccountById(alice)).toMatchObject({ isConnected: true, rememberMe: true, awaitingSignIn: false });
+ const sessionWrite = fetchMock.mock.calls.find(([url, init]) => String(url).includes('/api/auth/session') && init?.method === 'POST');
+ expect(JSON.parse(String(sessionWrite?.[1]?.body))).toMatchObject({ username: 'alice@x.test', password: 'alice-pw' });
+
+ // Already connected: nothing to do.
+ expect(await useAuthStore.getState().signInRestoredAccount(alice, 'alice-pw', true)).toBe(false);
+});
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index e3a78b048..9d6723174 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -120,6 +120,12 @@ interface AuthState {
* session still hold the old one, which the server now refuses.
*/
updateBasicPassword: (newPassword: string) => Promise;
+ /**
+ * Sign in, in the background, to a Basic account restored from the account
+ * list backup with its password. The active account stays as it is.
+ * Resolves false when the server refuses the password or cannot be reached.
+ */
+ signInRestoredAccount: (accountId: string, password: string, rememberMe: boolean) => Promise;
refreshIdentities: () => Promise;
getClientForAccount: (accountId: string) => JMAPClient | undefined;
getAllConnectedClients: () => Map;
@@ -3031,6 +3037,37 @@ export const useAuthStore = create()(
}
},
+ signInRestoredAccount: async (accountId, password, rememberMe) => {
+ const accountStore = useAccountStore.getState();
+ const account = accountStore.getAccountById(accountId);
+ if (!account || account.authMode !== 'basic' || clients.has(accountId)) return false;
+ const { serverUrl, username, cookieSlot } = account;
+ if (await precheckCredentials(serverUrl, { username, password })) return false;
+ const client = new JMAPClient(serverUrl, username, password);
+ try {
+ await client.connect();
+ } catch (err) {
+ debug.error(`Failed to sign in to restored account ${accountId}:`, err);
+ client.disconnect();
+ return false;
+ }
+ // Without a remembered session the next reload would drop the account
+ // again, like any sign-in made without "Remember me".
+ await Promise.all([
+ rememberMe || IS_LITE ? persistSession(cookieSlot, { serverUrl, username, password }) : null,
+ syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot),
+ ]);
+ clients.set(accountId, client);
+ bindClientStatusHandlers(client, set, get, accountId);
+ accountStore.updateAccount(accountId, {
+ awaitingSignIn: false, isConnected: true, hasError: false, errorMessage: undefined,
+ rememberMe, lastLoginAt: Date.now(),
+ });
+ void syncAccountDisplayName(accountId, client);
+ set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 }));
+ return true;
+ },
+
syncIdentities: () => {
const identityState = useIdentityStore.getState();
const identities = identityState.identities;
From d6d3683b6501af823725201edf5f3507c25d209f Mon Sep 17 00:00:00 2001
From: Tobias Genannt
Date: Fri, 16 Oct 2026 10:08:27 +0200
Subject: [PATCH 009/238] feat(plugins): add Combobox component to UI kit with
host floating dropdown portal
- Add Combobox component supporting single and multiselect modes, search filtering, and clear button
- Implement floating dropdown bridge and host slot portal so dropdown renders cleanly outside plugin iframe
- Fix clear button and chip removals to not unexpectedly open the dropdown
- Add comprehensive unit tests covering Combobox options, clear behavior, multiselect, and bridge communication
---
components/plugins/plugin-iframe-slot.tsx | 219 +++++++-
lib/__tests__/plugin-ui-kit.test.tsx | 179 ++++++-
lib/plugin-sandbox/host-bridge.ts | 21 +-
lib/plugin-sandbox/protocol.ts | 69 ++-
lib/plugin-sandbox/runtime.tsx | 17 +
lib/plugin-sandbox/ui-kit/components.tsx | 495 +++++++++++++++++-
.../ui-kit/floating-dropdown-bridge.ts | 82 +++
lib/plugin-sandbox/ui-kit/index.ts | 2 +-
8 files changed, 1072 insertions(+), 12 deletions(-)
create mode 100644 lib/plugin-sandbox/ui-kit/floating-dropdown-bridge.ts
diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx
index 0eda9b2ec..c571f9a93 100644
--- a/components/plugins/plugin-iframe-slot.tsx
+++ b/components/plugins/plugin-iframe-slot.tsx
@@ -7,13 +7,17 @@
// to a wrapper .
import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
import type { SlotName } from '@/lib/plugin-types';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
+import type { FloatingDropdownOption } from '@/lib/plugin-sandbox/protocol';
import { slotPropsFor } from '@/lib/plugin-sandbox/permissions';
import { isInlineSlot } from '@/lib/plugin-sandbox/slot-layout';
import { snapshotHostTheme } from '@/lib/plugin-sandbox/host-theme';
import { useThemeStore } from '@/stores/theme-store';
+import { Check } from '@/components/icons';
+import { cn } from '@/lib/utils';
interface Props {
pluginId: string;
@@ -21,11 +25,23 @@ interface Props {
extraProps?: Record
;
}
+interface FloatingDropdownState {
+ id: string;
+ rect: { left: number; top: number; bottom: number; width: number; height: number };
+ options: FloatingDropdownOption[];
+ multiple?: boolean;
+ selectedValues: string[];
+ singleValue?: string;
+ highlightedIndex: number;
+ ariaLabel?: string;
+}
+
export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
const wrapperRef = useRef(null);
const instanceRef = useRef(null);
const [height, setHeight] = useState(0);
const [width, setWidth] = useState(undefined);
+ const [floatingDropdown, setFloatingDropdown] = useState(null);
const inline = isInlineSlot(slot);
// null = pending, true/false = decided
const [show, setShow] = useState(null);
@@ -71,11 +87,41 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
setHeight(h);
if (w !== undefined) setWidth(w);
},
+ onFloatingDropdown: (msg) => {
+ if (msg.type === 'floating-dropdown-open') {
+ setFloatingDropdown({
+ id: msg.id,
+ rect: msg.rect,
+ options: msg.options,
+ multiple: msg.multiple,
+ selectedValues: msg.selectedValues,
+ singleValue: msg.singleValue,
+ highlightedIndex: msg.highlightedIndex,
+ ariaLabel: msg.ariaLabel,
+ });
+ } else if (msg.type === 'floating-dropdown-update') {
+ setFloatingDropdown((prev) => {
+ if (!prev || prev.id !== msg.id) return prev;
+ return {
+ ...prev,
+ ...(msg.rect ? { rect: msg.rect } : {}),
+ ...(msg.options ? { options: msg.options } : {}),
+ ...(msg.multiple !== undefined ? { multiple: msg.multiple } : {}),
+ ...(msg.selectedValues ? { selectedValues: msg.selectedValues } : {}),
+ ...(msg.singleValue !== undefined ? { singleValue: msg.singleValue } : {}),
+ ...(msg.highlightedIndex !== undefined ? { highlightedIndex: msg.highlightedIndex } : {}),
+ };
+ });
+ } else if (msg.type === 'floating-dropdown-close') {
+ setFloatingDropdown((prev) => (prev?.id === msg.id ? null : prev));
+ }
+ },
});
instanceRef.current = inst;
return () => {
try { inst.destroy(); } catch { /* ignore */ }
instanceRef.current = null;
+ setFloatingDropdown(null);
};
// We intentionally don't depend on props here - propagating prop
// changes happens via postMessage below to avoid iframe churn.
@@ -103,12 +149,173 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
if (show !== true) return null;
return (
+ <>
+
+ {floatingDropdown && (
+ {
+ instanceRef.current?.sendFloatingDropdownEvent({
+ type: 'floating-dropdown-select',
+ id: floatingDropdown.id,
+ option,
+ });
+ }}
+ onHighlight={(index) => {
+ instanceRef.current?.sendFloatingDropdownEvent({
+ type: 'floating-dropdown-highlight',
+ id: floatingDropdown.id,
+ index,
+ });
+ }}
+ onDismiss={() => {
+ instanceRef.current?.sendFloatingDropdownEvent({
+ type: 'floating-dropdown-dismiss',
+ id: floatingDropdown.id,
+ });
+ setFloatingDropdown(null);
+ }}
+ />
+ )}
+ >
+ );
+}
+
+function FloatingDropdownPortal({
+ dropdown,
+ wrapperEl,
+ onSelect,
+ onHighlight,
+ onDismiss,
+}: {
+ dropdown: FloatingDropdownState;
+ wrapperEl: HTMLElement | null;
+ onSelect: (option: FloatingDropdownOption) => void;
+ onHighlight: (index: number) => void;
+ onDismiss: () => void;
+}) {
+ const dropdownRef = useRef(null);
+ const optionRefs = useRef<(HTMLDivElement | null)[]>([]);
+
+ // Compute screen coordinates relative to host viewport
+ const iframe = wrapperEl?.querySelector('iframe') ?? wrapperEl;
+ const iframeRect = iframe ? iframe.getBoundingClientRect() : { left: 0, top: 0 };
+ const hostLeft = iframeRect.left + dropdown.rect.left;
+ const hostTop = iframeRect.top + dropdown.rect.top;
+ const hostBottom = iframeRect.top + dropdown.rect.bottom;
+ const hostWidth = dropdown.rect.width;
+
+ const spaceBelow = typeof window !== 'undefined' ? window.innerHeight - hostBottom - 8 : 300;
+ const spaceAbove = hostTop - 8;
+ const openUpward = spaceBelow < 200 && spaceAbove > spaceBelow;
+ const maxHeight = Math.max(120, Math.min(240, openUpward ? spaceAbove : spaceBelow));
+ const left = typeof window !== 'undefined' ? Math.max(8, Math.min(hostLeft, window.innerWidth - hostWidth - 8)) : hostLeft;
+ const top = openUpward ? undefined : hostBottom + 4;
+ const bottom = openUpward && typeof window !== 'undefined' ? window.innerHeight - hostTop + 4 : undefined;
+
+ // Auto-scroll highlighted option into view
+ useEffect(() => {
+ const i = dropdown.highlightedIndex;
+ if (i >= 0 && optionRefs.current[i]) {
+ optionRefs.current[i]?.scrollIntoView({ block: 'nearest' });
+ }
+ }, [dropdown.highlightedIndex]);
+
+ // Outside click and scroll handling
+ useEffect(() => {
+ const onMouseDown = (e: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
+ onDismiss();
+ }
+ };
+ const onScroll = (e: Event) => {
+ if (dropdownRef.current && dropdownRef.current.contains(e.target as Node)) {
+ return; // Allow scroll inside the dropdown listbox itself
+ }
+ onDismiss();
+ };
+ const onResize = () => onDismiss();
+
+ document.addEventListener('mousedown', onMouseDown, true);
+ window.addEventListener('scroll', onScroll, true);
+ window.addEventListener('resize', onResize);
+ return () => {
+ document.removeEventListener('mousedown', onMouseDown, true);
+ window.removeEventListener('scroll', onScroll, true);
+ window.removeEventListener('resize', onResize);
+ };
+ }, [onDismiss]);
+
+ if (typeof document === 'undefined') return null;
+
+ return createPortal(
+ ref={dropdownRef}
+ id={dropdown.id}
+ role="listbox"
+ aria-multiselectable={dropdown.multiple || undefined}
+ aria-label={dropdown.ariaLabel}
+ style={{
+ position: 'fixed',
+ left: `${left}px`,
+ top: top !== undefined ? `${top}px` : undefined,
+ bottom: bottom !== undefined ? `${bottom}px` : undefined,
+ width: `${hostWidth}px`,
+ maxHeight: `${maxHeight}px`,
+ zIndex: 100010,
+ }}
+ className="overflow-auto rounded-md border border-border bg-popover text-popover-foreground shadow-lg p-1 focus:outline-none animate-in fade-in-50 zoom-in-95 duration-100"
+ >
+ {dropdown.options.length === 0 ? (
+
+ No results
+
+ ) : (
+ dropdown.options.map((opt, i) => {
+ const isSelected = dropdown.multiple
+ ? dropdown.selectedValues.includes(opt.value)
+ : opt.value === dropdown.singleValue;
+ const isHighlighted = dropdown.highlightedIndex === i;
+ return (
+ { optionRefs.current[i] = el; }}
+ id={`${dropdown.id}-opt-${i}`}
+ role="option"
+ aria-selected={isSelected}
+ aria-disabled={opt.disabled}
+ onMouseDown={(e) => {
+ // Prevent stealing focus from the input in the iframe
+ e.preventDefault();
+ }}
+ onClick={() => {
+ if (opt.disabled) return;
+ onSelect(opt);
+ }}
+ onMouseEnter={() => {
+ onHighlight(i);
+ }}
+ className={cn(
+ 'relative flex cursor-pointer select-none items-center justify-between rounded-sm px-2 py-1.5 text-sm transition-colors',
+ isHighlighted ? 'bg-accent text-accent-foreground' : 'text-foreground',
+ isSelected && 'font-medium',
+ opt.disabled && 'pointer-events-none opacity-50',
+ )}
+ >
+ {opt.label}
+ {isSelected && }
+
+ );
+ })
+ )}
+ ,
+ document.body,
);
}
diff --git a/lib/__tests__/plugin-ui-kit.test.tsx b/lib/__tests__/plugin-ui-kit.test.tsx
index 0bd576e43..d8fffdfcc 100644
--- a/lib/__tests__/plugin-ui-kit.test.tsx
+++ b/lib/__tests__/plugin-ui-kit.test.tsx
@@ -152,7 +152,7 @@ describe('plugin UI kit components', () => {
const { makePluginRequire } = await import('../plugin-sandbox/runtime');
const req = makePluginRequire(null);
expect(req('@plugin-host/ui')).toBe(kit.pluginUiKit);
- expect(kit.KIT_EXPORTS).toEqual(expect.arrayContaining(['Button', 'Input', 'Select', 'Checkbox', 'Field', 'Tabs', 'List', 'ListRow', 'Badge', 'EmptyState', 'PageHeader', 'Spinner', 'DialogButtons', 'InfoBar', 'Chip']));
+ expect(kit.KIT_EXPORTS).toEqual(expect.arrayContaining(['Button', 'Input', 'Select', 'Combobox', 'Checkbox', 'Field', 'Tabs', 'List', 'ListRow', 'Badge', 'EmptyState', 'PageHeader', 'Spinner', 'DialogButtons', 'InfoBar', 'Chip']));
expect(() => req('left-pad')).toThrow(/not available/);
});
});
@@ -224,6 +224,183 @@ describe('plugin dialog footer', () => {
});
});
+describe('Combobox', () => {
+ const options = [
+ { value: 'apple', label: 'Apple' },
+ { value: 'banana', label: 'Banana' },
+ { value: 'cherry', label: 'Cherry' },
+ ];
+
+ it('renders with placeholder and closed dropdown', () => {
+ render( );
+ const input = screen.getByRole('combobox');
+ expect(input).toHaveAttribute('placeholder', 'Choose fruit');
+ expect(input).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.queryByRole('listbox')).toBeNull();
+ });
+
+ it('opens and filters options on input', () => {
+ render( );
+ const input = screen.getByRole('combobox');
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'ban' } });
+ expect(input).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByRole('listbox')).toBeTruthy();
+ expect(screen.getByText('Banana')).toBeTruthy();
+ expect(screen.queryByText('Apple')).toBeNull();
+ });
+
+ it('selects option on click', () => {
+ const onChange = vi.fn();
+ render( );
+ const input = screen.getByRole('combobox');
+ fireEvent.click(input);
+ fireEvent.click(screen.getByText('Cherry'));
+ expect(onChange).toHaveBeenCalledWith('cherry');
+ expect(screen.queryByRole('listbox')).toBeNull();
+ });
+
+ it('navigates with keyboard and selects on Enter', () => {
+ const onChange = vi.fn();
+ render( );
+ const input = screen.getByRole('combobox');
+ fireEvent.keyDown(input, { key: 'ArrowDown' }); // opens dropdown
+ expect(screen.getByRole('listbox')).toBeTruthy();
+ fireEvent.keyDown(input, { key: 'ArrowDown' }); // highlight next
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onChange).toHaveBeenCalled();
+ });
+
+ it('clears selection when clear button is clicked without opening dropdown', () => {
+ const onChange = vi.fn();
+ render( );
+ const clearBtn = screen.getByLabelText('Clear');
+ fireEvent.click(clearBtn);
+ expect(onChange).toHaveBeenCalledWith('');
+ expect(screen.queryByRole('listbox')).toBeNull();
+ });
+
+ it('closes dropdown and clears selection if clear button is clicked while open', () => {
+ const onChange = vi.fn();
+ render( );
+ const input = screen.getByRole('combobox');
+ fireEvent.focus(input);
+ expect(screen.getByRole('listbox')).toBeTruthy();
+
+ const clearBtn = screen.getByLabelText('Clear');
+ fireEvent.click(clearBtn);
+ expect(onChange).toHaveBeenCalledWith('');
+ expect(screen.queryByRole('listbox')).toBeNull();
+ });
+
+ it('supports multiselect mode with chip rendering, toggling and backspace removal', () => {
+ const onChange = vi.fn();
+ const { rerender } = render(
+
+ );
+ expect(screen.getByRole('checkbox', { name: 'Apple' })).toBeTruthy();
+ expect(screen.getByLabelText('Remove Apple')).toBeTruthy();
+
+ // Clicking remove chip does not open dropdown
+ fireEvent.click(screen.getByLabelText('Remove Apple'));
+ expect(onChange).toHaveBeenCalledWith([]);
+ expect(screen.queryByRole('listbox')).toBeNull();
+
+ // Open dropdown and toggle cherry
+ const input = screen.getByRole('combobox');
+ fireEvent.focus(input);
+ fireEvent.click(screen.getByText('Cherry'));
+ expect(onChange).toHaveBeenCalledWith(['apple', 'cherry']);
+
+ // Backspace on empty input removes last value
+ rerender( );
+ fireEvent.keyDown(input, { key: 'Backspace' });
+ expect(onChange).toHaveBeenCalledWith(['apple']);
+ });
+
+ it('associates label when wrapped in Field', () => {
+ render( );
+ expect(screen.getByLabelText('Fruit')).toBeInstanceOf(HTMLInputElement);
+ });
+});
+
+describe('Combobox floating dropdown bridge', () => {
+ const options = [
+ { value: 'apple', label: 'Apple' },
+ { value: 'banana', label: 'Banana' },
+ ];
+
+ it('delegates dropdown rendering to the host when supported and handles host selection', async () => {
+ const bridge = await import('../plugin-sandbox/ui-kit/floating-dropdown-bridge');
+ const sentMessages: any[] = [];
+ bridge.initFloatingDropdownBridge(
+ (msg) => { sentMessages.push(msg); },
+ () => true,
+ );
+
+ const onChange = vi.fn();
+ render( );
+ const input = screen.getByRole('combobox');
+ fireEvent.focus(input);
+
+ // Dropdown listbox is NOT rendered inside the component root
+ expect(screen.queryByRole('listbox')).toBeNull();
+
+ // Verify open message was sent to host
+ expect(sentMessages).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: 'floating-dropdown-open',
+ id: 'fruit-picker-listbox',
+ options,
+ }),
+ ]),
+ );
+
+ // Host selects banana
+ act(() => {
+ bridge.dispatchFloatingDropdownEvent('fruit-picker-listbox', {
+ type: 'select',
+ option: options[1],
+ });
+ });
+
+ expect(onChange).toHaveBeenCalledWith('banana');
+
+ // Tear down bridge for following tests
+ bridge.initFloatingDropdownBridge(null as any, () => false);
+ });
+
+ it('supports multiselect toggling and updates host with selected values', async () => {
+ const bridge = await import('../plugin-sandbox/ui-kit/floating-dropdown-bridge');
+ const sentMessages: any[] = [];
+ bridge.initFloatingDropdownBridge(
+ (msg) => { sentMessages.push(msg); },
+ () => true,
+ );
+
+ const onChange = vi.fn();
+ render( );
+ const input = screen.getByRole('combobox');
+ fireEvent.focus(input);
+
+ expect(screen.queryByRole('listbox')).toBeNull();
+
+ // Host selects banana
+ act(() => {
+ bridge.dispatchFloatingDropdownEvent('tag-picker-listbox', {
+ type: 'select',
+ option: options[1],
+ });
+ });
+
+ expect(onChange).toHaveBeenCalledWith(['apple', 'banana']);
+
+ // Tear down bridge
+ bridge.initFloatingDropdownBridge(null as any, () => false);
+ });
+});
+
describe('plugin-kit.css', () => {
it('builds the kit\'s utilities without the global Tailwind reset', async () => {
const postcss = (await import('postcss')).default;
diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts
index af22c0832..d75d2286b 100644
--- a/lib/plugin-sandbox/host-bridge.ts
+++ b/lib/plugin-sandbox/host-bridge.ts
@@ -16,8 +16,12 @@ import { effectiveSettings } from '../plugin-settings';
import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload, StreamChunkMsg, StreamEndMsg, StreamProgressMsg,
+ FloatingDropdownOpenMsg, FloatingDropdownUpdateMsg, FloatingDropdownCloseMsg,
+ FloatingDropdownSelectMsg, FloatingDropdownHighlightMsg, FloatingDropdownDismissMsg,
} from './protocol';
+export type FloatingDropdownHostMsg = FloatingDropdownOpenMsg | FloatingDropdownUpdateMsg | FloatingDropdownCloseMsg;
+
// ─── Callback marshalling ────────────────────────────────────
/**
@@ -75,6 +79,8 @@ export interface SlotOptions {
hostContainer: HTMLElement;
/** Called whenever the sandbox reports a new content size (width only for inline slots). */
onResize: (height: number, width?: number) => void;
+ /** Called whenever the sandbox requests to open/update/close a floating dropdown. */
+ onFloatingDropdown?: (msg: FloatingDropdownHostMsg) => void;
}
export interface InitDoneInfo {
@@ -170,6 +176,7 @@ export class SandboxInstance {
initPayload: InitPayload,
hostContainer: HTMLElement,
private slotResizeCb: ((height: number, width?: number) => void) | null,
+ private slotFloatingDropdownCb?: ((msg: FloatingDropdownHostMsg) => void) | null,
) {
this.pluginId = plugin.id;
this.mode = initPayload.mode;
@@ -360,6 +367,13 @@ export class SandboxInstance {
this.slotResizeCb?.(height, width ?? undefined);
return;
}
+
+ case 'floating-dropdown-open':
+ case 'floating-dropdown-update':
+ case 'floating-dropdown-close': {
+ this.slotFloatingDropdownCb?.(msg);
+ return;
+ }
}
}
@@ -445,6 +459,11 @@ export class SandboxInstance {
this.send({ type: 'props-update', props: encoded });
}
+ sendFloatingDropdownEvent(msg: FloatingDropdownSelectMsg | FloatingDropdownHighlightMsg | FloatingDropdownDismissMsg): void {
+ if (this.destroyed) return;
+ this.send(msg);
+ }
+
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
@@ -533,5 +552,5 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
locale: opts.locale,
theme: snapshotHostTheme(),
};
- return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
+ return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown);
}
diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts
index fce78e31c..cd93a04f5 100644
--- a/lib/plugin-sandbox/protocol.ts
+++ b/lib/plugin-sandbox/protocol.ts
@@ -145,6 +145,48 @@ export interface SlotShouldShowResultMsg {
show: boolean;
}
+export interface FloatingDropdownRect {
+ left: number;
+ top: number;
+ bottom: number;
+ width: number;
+ height: number;
+}
+
+export interface FloatingDropdownOption {
+ value: string;
+ label: string;
+ disabled?: boolean;
+}
+
+export interface FloatingDropdownOpenMsg {
+ type: 'floating-dropdown-open';
+ id: string;
+ rect: FloatingDropdownRect;
+ options: FloatingDropdownOption[];
+ multiple?: boolean;
+ selectedValues: string[];
+ singleValue?: string;
+ highlightedIndex: number;
+ ariaLabel?: string;
+}
+
+export interface FloatingDropdownUpdateMsg {
+ type: 'floating-dropdown-update';
+ id: string;
+ rect?: FloatingDropdownRect;
+ options?: FloatingDropdownOption[];
+ multiple?: boolean;
+ selectedValues?: string[];
+ singleValue?: string;
+ highlightedIndex?: number;
+}
+
+export interface FloatingDropdownCloseMsg {
+ type: 'floating-dropdown-close';
+ id: string;
+}
+
export type SandboxToHost =
| ReadyMsg
| InitDoneMsg
@@ -153,7 +195,10 @@ export type SandboxToHost =
| CallbackInvokeMsg
| HookResultMsg
| SlotResizeMsg
- | SlotShouldShowResultMsg;
+ | SlotShouldShowResultMsg
+ | FloatingDropdownOpenMsg
+ | FloatingDropdownUpdateMsg
+ | FloatingDropdownCloseMsg;
// ─── Host → Sandbox messages ─────────────────────────────────
@@ -198,6 +243,23 @@ export interface StreamEndMsg { type: 'stream-end'; streamId: string; error?: st
/** Host → sandbox: upload progress of an api.proxy.fetch request. */
export interface StreamProgressMsg { type: 'stream-progress'; streamId: string; loaded: number; total: number; }
+export interface FloatingDropdownSelectMsg {
+ type: 'floating-dropdown-select';
+ id: string;
+ option: FloatingDropdownOption;
+}
+
+export interface FloatingDropdownHighlightMsg {
+ type: 'floating-dropdown-highlight';
+ id: string;
+ index: number;
+}
+
+export interface FloatingDropdownDismissMsg {
+ type: 'floating-dropdown-dismiss';
+ id: string;
+}
+
export type HostToSandbox =
| InitMsg
| StreamChunkMsg
@@ -210,7 +272,10 @@ export type HostToSandbox =
| ThemeChangeMsg
| SettingsChangeMsg
| PropsUpdateMsg
- | SlotShouldShowMsg;
+ | SlotShouldShowMsg
+ | FloatingDropdownSelectMsg
+ | FloatingDropdownHighlightMsg
+ | FloatingDropdownDismissMsg;
// ─── Constants ───────────────────────────────────────────────
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
index 80d1641d6..f0a4db389 100644
--- a/lib/plugin-sandbox/runtime.tsx
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -29,6 +29,7 @@ import type {
} from './protocol';
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
import { pluginUiKit, SlotPropsContext } from './ui-kit';
+import { initFloatingDropdownBridge, dispatchFloatingDropdownEvent } from './ui-kit/floating-dropdown-bridge';
import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n';
import { isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import type {
@@ -1145,6 +1146,18 @@ function handleHostMessage(ev: MessageEvent): void {
case 'stream-progress':
try { proxyStreams.get(msg.streamId)?.onProgress?.(msg.loaded, msg.total); } catch { /* plugin's own error */ }
break;
+
+ case 'floating-dropdown-select':
+ dispatchFloatingDropdownEvent(msg.id, { type: 'select', option: msg.option });
+ break;
+
+ case 'floating-dropdown-highlight':
+ dispatchFloatingDropdownEvent(msg.id, { type: 'highlight', index: msg.index });
+ break;
+
+ case 'floating-dropdown-dismiss':
+ dispatchFloatingDropdownEvent(msg.id, { type: 'dismiss' });
+ break;
}
}
@@ -1158,6 +1171,10 @@ export function SandboxRuntime(): React.JSX.Element {
if (!host) return;
parentWindow = host.window;
parentOrigin = host.origin;
+ initFloatingDropdownBridge(
+ (msg) => sendToHost(msg),
+ () => !!parentWindow,
+ );
window.addEventListener('message', handleHostMessage);
// Initial ping, targeted at the host origin so it can only land on the
// page that framed us. Guard at module scope so React strict mode's
diff --git a/lib/plugin-sandbox/ui-kit/components.tsx b/lib/plugin-sandbox/ui-kit/components.tsx
index 7fa9f5ce4..dc9b20a8f 100644
--- a/lib/plugin-sandbox/ui-kit/components.tsx
+++ b/lib/plugin-sandbox/ui-kit/components.tsx
@@ -20,7 +20,14 @@ import { Button as AppButton } from '@/components/ui/button';
import { Input as AppInput } from '@/components/ui/input';
import { getFileIconByName, getFolderIcon } from '@/components/files/file-icons';
import { iconForName } from '@/components/icons/named';
-import { Check, ChevronRight, Loader2, Puzzle, Search, X } from '@/components/icons';
+import { Check, ChevronDown, ChevronRight, ChevronUp, Loader2, Puzzle, Search, X } from '@/components/icons';
+import {
+ isFloatingDropdownSupported,
+ openFloatingDropdown,
+ updateFloatingDropdown,
+ closeFloatingDropdown,
+ registerFloatingDropdownListener,
+} from './floating-dropdown-bridge';
type Children = { children?: React.ReactNode };
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'primary';
@@ -160,6 +167,492 @@ export function Select({ options, children, onChange, disabled, block = false, c
);
}
+export interface KitComboboxOption {
+ value: string;
+ label: string;
+ disabled?: boolean;
+}
+
+export interface KitComboboxProps {
+ options?: KitComboboxOption[];
+ /** Currently selected value (string for single, string[] for multiple). */
+ value?: string | string[] | null;
+ /** Called with the selected value (string for single, string[] for multiple). */
+ onChange?: (value: any) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ /** Stretch to the width of its container (forms); settings rows keep it narrow. */
+ block?: boolean;
+ /** Allow selecting multiple options (e.g. for tags). */
+ multiple?: boolean;
+ /** Whether the user can clear the selected value (default: true). */
+ clearable?: boolean;
+ id?: string;
+ /** Accessible label when not wrapped by Field. */
+ ariaLabel?: string;
+ className?: string;
+}
+
+/** A searchable combobox with keyboard navigation, clear button and multiselect support. */
+export function Combobox({
+ options = [],
+ value,
+ onChange,
+ placeholder,
+ disabled = false,
+ block = false,
+ multiple = false,
+ clearable = true,
+ id,
+ ariaLabel,
+ className,
+}: KitComboboxProps) {
+ const autoId = React.useId();
+ const inputId = id ?? autoId;
+ const listboxId = `${inputId}-listbox`;
+
+ const containerRef = React.useRef(null);
+ const inputRef = React.useRef(null);
+ const listRef = React.useRef(null);
+ const optionRefs = React.useRef<(HTMLDivElement | null)[]>([]);
+
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [query, setQuery] = React.useState('');
+ const [isTyping, setIsTyping] = React.useState(false);
+ const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
+
+ const selectedValues = React.useMemo(() => {
+ if (multiple) {
+ return Array.isArray(value) ? value : value ? [value] : [];
+ }
+ return [];
+ }, [multiple, value]);
+
+ const singleValue = React.useMemo(() => {
+ if (multiple) return '';
+ return typeof value === 'string' ? value : '';
+ }, [multiple, value]);
+
+ const selectedOption = React.useMemo(() => {
+ if (multiple || !singleValue) return null;
+ return options.find((o) => o.value === singleValue) || null;
+ }, [multiple, singleValue, options]);
+
+ const selectedOptions = React.useMemo(() => {
+ if (!multiple) return [];
+ return options.filter((o) => selectedValues.includes(o.value));
+ }, [multiple, options, selectedValues]);
+
+ // Synchronize input query with selected option label when not typing in single-select mode
+ React.useEffect(() => {
+ if (!isTyping) {
+ if (multiple) {
+ setQuery('');
+ } else {
+ setQuery(selectedOption ? selectedOption.label : '');
+ }
+ }
+ }, [selectedOption, isTyping, multiple]);
+
+ const filteredOptions = React.useMemo(() => {
+ if (!isTyping || !query.trim()) return options;
+ const q = query.trim().toLowerCase();
+ return options.filter((o) => o.label.toLowerCase().includes(q));
+ }, [options, isTyping, query]);
+
+ // When opening dropdown, pre-highlight the currently selected item or first item
+ React.useEffect(() => {
+ if (isOpen) {
+ if (multiple) {
+ setHighlightedIndex(filteredOptions.length > 0 ? 0 : -1);
+ } else {
+ const idx = filteredOptions.findIndex((o) => o.value === singleValue);
+ setHighlightedIndex(idx >= 0 ? idx : filteredOptions.length > 0 ? 0 : -1);
+ }
+ } else {
+ setHighlightedIndex(-1);
+ }
+ }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Auto-scroll highlighted option into view
+ React.useEffect(() => {
+ if (isOpen && highlightedIndex >= 0 && optionRefs.current[highlightedIndex]) {
+ const el = optionRefs.current[highlightedIndex];
+ if (typeof el?.scrollIntoView === 'function') {
+ el.scrollIntoView({ block: 'nearest' });
+ }
+ }
+ }, [isOpen, highlightedIndex]);
+
+ // Close when clicking outside
+ React.useEffect(() => {
+ if (!isOpen) return;
+ const onDocMouseDown = (e: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ setIsOpen(false);
+ setIsTyping(false);
+ if (multiple) {
+ setQuery('');
+ } else {
+ setQuery(selectedOption ? selectedOption.label : '');
+ }
+ }
+ };
+ document.addEventListener('mousedown', onDocMouseDown);
+ return () => document.removeEventListener('mousedown', onDocMouseDown);
+ }, [isOpen, selectedOption, multiple]);
+
+ const toggleMultipleOption = (opt: KitComboboxOption) => {
+ if (opt.disabled) return;
+ const exists = selectedValues.includes(opt.value);
+ const next = exists ? selectedValues.filter((v) => v !== opt.value) : [...selectedValues, opt.value];
+ onChange?.(next);
+ setQuery('');
+ setIsTyping(false);
+ if (isOpen) {
+ inputRef.current?.focus();
+ }
+ };
+
+ const selectOption = (opt: KitComboboxOption) => {
+ if (opt.disabled) return;
+ if (multiple) {
+ toggleMultipleOption(opt);
+ } else {
+ onChange?.(opt.value);
+ setIsOpen(false);
+ setIsTyping(false);
+ setQuery(opt.value ? opt.label : '');
+ inputRef.current?.focus();
+ }
+ };
+
+ const isHostFloating = isFloatingDropdownSupported();
+
+ const getContainerRect = () => {
+ if (!containerRef.current) return null;
+ const r = containerRef.current.getBoundingClientRect();
+ return {
+ left: Math.round(r.left),
+ top: Math.round(r.top),
+ bottom: Math.round(r.bottom),
+ width: Math.round(r.width),
+ height: Math.round(r.height),
+ };
+ };
+
+ const selectOptionRef = React.useRef(selectOption);
+ selectOptionRef.current = selectOption;
+ const selectedOptionRef = React.useRef(selectedOption);
+ selectedOptionRef.current = selectedOption;
+
+ // When isOpen changes: if host floating dropdown is active, open it on host and register listener
+ React.useEffect(() => {
+ if (!isHostFloating) return;
+ if (isOpen) {
+ const rect = getContainerRect();
+ if (!rect) return;
+ openFloatingDropdown({
+ id: listboxId,
+ rect,
+ options: filteredOptions,
+ multiple,
+ selectedValues,
+ singleValue,
+ highlightedIndex,
+ ariaLabel: ariaLabel ?? placeholder,
+ });
+
+ const unregister = registerFloatingDropdownListener(listboxId, (ev) => {
+ if (ev.type === 'select') {
+ selectOptionRef.current(ev.option);
+ } else if (ev.type === 'highlight') {
+ setHighlightedIndex(ev.index);
+ } else if (ev.type === 'dismiss') {
+ setIsOpen(false);
+ setIsTyping(false);
+ if (multiple) {
+ setQuery('');
+ } else {
+ setQuery(selectedOptionRef.current ? selectedOptionRef.current.label : '');
+ }
+ }
+ });
+
+ return () => {
+ unregister();
+ closeFloatingDropdown(listboxId);
+ };
+ }
+ }, [isOpen, isHostFloating, listboxId]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Push updates to host dropdown when options, selection, or highlight changes while open
+ React.useEffect(() => {
+ if (!isHostFloating || !isOpen) return;
+ const rect = getContainerRect();
+ updateFloatingDropdown({
+ id: listboxId,
+ ...(rect ? { rect } : {}),
+ options: filteredOptions,
+ multiple,
+ selectedValues,
+ singleValue,
+ highlightedIndex,
+ });
+ }, [isHostFloating, isOpen, filteredOptions, selectedValues, singleValue, highlightedIndex, multiple, listboxId]);
+
+ const handleClear = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (multiple) {
+ onChange?.([]);
+ } else {
+ onChange?.('');
+ }
+ setQuery('');
+ setIsTyping(false);
+ setIsOpen(false);
+ };
+
+ const handleToggle = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (disabled) return;
+ if (isOpen) {
+ setIsOpen(false);
+ setIsTyping(false);
+ if (multiple) {
+ setQuery('');
+ } else {
+ setQuery(selectedOption ? selectedOption.label : '');
+ }
+ } else {
+ setIsOpen(true);
+ setIsTyping(false);
+ inputRef.current?.focus();
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (disabled) return;
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (!isOpen) {
+ setIsOpen(true);
+ setIsTyping(false);
+ } else if (filteredOptions.length > 0) {
+ setHighlightedIndex((prev) => (prev + 1 < filteredOptions.length ? prev + 1 : 0));
+ }
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (!isOpen) {
+ setIsOpen(true);
+ setIsTyping(false);
+ } else if (filteredOptions.length > 0) {
+ setHighlightedIndex((prev) => (prev - 1 >= 0 ? prev - 1 : filteredOptions.length - 1));
+ }
+ } else if (e.key === 'Enter') {
+ if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
+ e.preventDefault();
+ selectOption(filteredOptions[highlightedIndex]);
+ }
+ } else if (e.key === 'Escape') {
+ if (isOpen) {
+ e.preventDefault();
+ setIsOpen(false);
+ setIsTyping(false);
+ if (multiple) {
+ setQuery('');
+ } else {
+ setQuery(selectedOption ? selectedOption.label : '');
+ }
+ }
+ } else if (e.key === 'Tab') {
+ if (isOpen) {
+ setIsOpen(false);
+ setIsTyping(false);
+ if (multiple) {
+ setQuery('');
+ } else {
+ setQuery(selectedOption ? selectedOption.label : '');
+ }
+ }
+ } else if (e.key === 'Backspace' && multiple && query === '' && selectedValues.length > 0) {
+ const next = selectedValues.slice(0, -1);
+ onChange?.(next);
+ }
+ };
+
+ const hasValue = multiple ? selectedValues.length > 0 : Boolean(singleValue);
+
+ return (
+
+
{
+ if (!disabled) inputRef.current?.focus();
+ }}
+ className={cn(
+ 'relative w-full rounded-md border border-input bg-background transition-colors duration-150 focus-within:ring-2 focus-within:ring-ring hover:border-muted-foreground',
+ disabled && 'cursor-not-allowed opacity-50',
+ multiple ? 'flex flex-wrap items-center gap-1.5 p-1.5 min-h-[38px] pe-14' : 'flex items-center',
+ )}
+ >
+ {multiple &&
+ selectedOptions.map((opt) => (
+
+ {opt.label}
+ {!disabled && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleMultipleOption(opt);
+ }}
+ aria-label={`Remove ${opt.label}`}
+ className="text-muted-foreground hover:text-foreground p-0.5 rounded focus:outline-none"
+ >
+
+
+ )}
+
+ ))}
+
+
= 0 ? `${listboxId}-opt-${highlightedIndex}` : undefined}
+ value={query}
+ placeholder={multiple && selectedValues.length > 0 ? '' : placeholder}
+ onChange={(e) => {
+ setQuery(e.target.value);
+ setIsTyping(true);
+ if (!isOpen) setIsOpen(true);
+ }}
+ onFocus={() => {
+ if (!disabled && !isOpen) {
+ setIsOpen(true);
+ setIsTyping(false);
+ }
+ }}
+ onClick={() => {
+ if (!disabled && !isOpen) {
+ setIsOpen(true);
+ setIsTyping(false);
+ }
+ }}
+ onKeyDown={handleKeyDown}
+ className={cn(
+ multiple
+ ? 'flex-1 min-w-[80px] bg-transparent border-0 p-0 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-0'
+ : cn(
+ 'w-full rounded-md border-0 bg-transparent ps-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground outline-none',
+ clearable && hasValue ? 'pe-14' : 'pe-8',
+ ),
+ )}
+ />
+
+ {clearable && hasValue && !disabled && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={handleClear}
+ aria-label="Clear"
+ className="p-1 rounded hover:text-foreground focus:outline-none"
+ >
+
+
+ )}
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={handleToggle}
+ aria-label={isOpen ? 'Close' : 'Open'}
+ className="p-1 rounded hover:text-foreground focus:outline-none disabled:opacity-50"
+ >
+ {isOpen ? : }
+
+
+
+
+ {isOpen && !isHostFloating && (
+
+ {filteredOptions.length === 0 ? (
+
+ No results
+
+ ) : (
+ filteredOptions.map((opt, i) => {
+ const isSelected = multiple ? selectedValues.includes(opt.value) : opt.value === singleValue;
+ const isHighlighted = highlightedIndex === i;
+ return (
+
{ optionRefs.current[i] = el; }}
+ id={`${listboxId}-opt-${i}`}
+ role="option"
+ aria-selected={isSelected}
+ aria-disabled={opt.disabled}
+ onMouseDown={(e) => e.preventDefault()}
+ onClick={() => selectOption(opt)}
+ onMouseEnter={() => setHighlightedIndex(i)}
+ className={cn(
+ 'relative flex cursor-pointer select-none items-center justify-between rounded-sm px-2 py-1.5 text-sm transition-colors',
+ isHighlighted ? 'bg-accent text-accent-foreground' : 'text-foreground',
+ isSelected && 'font-medium',
+ opt.disabled && 'pointer-events-none opacity-50',
+ )}
+ >
+ {opt.label}
+ {isSelected && }
+
+ );
+ })
+ )}
+
+ )}
+
+ );
+}
+
export interface KitCheckboxProps {
checked: boolean;
onChange?: (checked: boolean) => void;
diff --git a/lib/plugin-sandbox/ui-kit/floating-dropdown-bridge.ts b/lib/plugin-sandbox/ui-kit/floating-dropdown-bridge.ts
new file mode 100644
index 000000000..ec93d1d76
--- /dev/null
+++ b/lib/plugin-sandbox/ui-kit/floating-dropdown-bridge.ts
@@ -0,0 +1,82 @@
+// Bridge between Combobox in `@plugin-host/ui` and the sandbox runtime.
+// When running inside a host-framed sandbox iframe, Combobox delegates its
+// floating dropdown rendering to the host so the dropdown menu floats
+// outside the popup modal boundary without causing the popup to scroll.
+
+import type {
+ FloatingDropdownOpenMsg,
+ FloatingDropdownUpdateMsg,
+ FloatingDropdownOption,
+ FloatingDropdownRect,
+} from '../protocol';
+
+export type { FloatingDropdownOption, FloatingDropdownRect };
+
+export type FloatingDropdownHostEvent =
+ | { type: 'select'; option: FloatingDropdownOption }
+ | { type: 'highlight'; index: number }
+ | { type: 'dismiss' };
+
+type DropdownListener = (event: FloatingDropdownHostEvent) => void;
+
+let hostSender: ((msg: any) => void) | null = null;
+let hostAvailableCheck: (() => boolean) | null = null;
+const listeners = new Map>();
+
+export function initFloatingDropdownBridge(
+ sender: (msg: any) => void,
+ isAvailable: () => boolean,
+): void {
+ hostSender = sender;
+ hostAvailableCheck = isAvailable;
+}
+
+export function isFloatingDropdownSupported(): boolean {
+ return hostAvailableCheck ? hostAvailableCheck() : false;
+}
+
+export function openFloatingDropdown(spec: Omit): void {
+ hostSender?.({ type: 'floating-dropdown-open', ...spec });
+}
+
+export function updateFloatingDropdown(spec: Omit): void {
+ hostSender?.({ type: 'floating-dropdown-update', ...spec });
+}
+
+export function closeFloatingDropdown(id: string): void {
+ hostSender?.({ type: 'floating-dropdown-close', id });
+}
+
+export function registerFloatingDropdownListener(
+ id: string,
+ listener: DropdownListener,
+): () => void {
+ let set = listeners.get(id);
+ if (!set) {
+ set = new Set();
+ listeners.set(id, set);
+ }
+ set.add(listener);
+ return () => {
+ set?.delete(listener);
+ if (set && set.size === 0) {
+ listeners.delete(id);
+ }
+ };
+}
+
+export function dispatchFloatingDropdownEvent(
+ id: string,
+ event: FloatingDropdownHostEvent,
+): void {
+ const set = listeners.get(id);
+ if (set) {
+ for (const listener of set) {
+ try {
+ listener(event);
+ } catch (err) {
+ console.error('[floating-dropdown] listener error', err);
+ }
+ }
+ }
+}
diff --git a/lib/plugin-sandbox/ui-kit/index.ts b/lib/plugin-sandbox/ui-kit/index.ts
index 7f27e58b8..18dc6492a 100644
--- a/lib/plugin-sandbox/ui-kit/index.ts
+++ b/lib/plugin-sandbox/ui-kit/index.ts
@@ -10,7 +10,7 @@ export { DialogButtons, SlotPropsContext, useSlotProps } from './dialog-buttons'
export type { DialogButtonSpec } from './dialog-buttons';
/** Bumped when a component is added or its props change in a way plugins can see. */
-export const KIT_VERSION = 1;
+export const KIT_VERSION = 2;
export const pluginUiKit = Object.freeze({
...components,
From ae7f794d2f6f3c6edd14a73b7496f90e8f4e2a12 Mon Sep 17 00:00:00 2001
From: raudraido
Date: Fri, 16 Oct 2026 18:19:29 +0300
Subject: [PATCH 010/238] feat: add Estonian (et) locale
Complete Estonian translation (every key in en), wired into the locale list, the message loaders, the language switcher (with an Estonian flag drawn in the Twemoji style, since Twemoji's set is not used for it) and the locale-count test.
Co-Authored-By: Claude Sonnet 5.5
---
components/providers/intl-provider.tsx | 1 +
components/ui/flag-icons.tsx | 6 +
components/ui/language-switcher.tsx | 1 +
i18n/request.ts | 3 +
i18n/routing.ts | 2 +-
lib/__tests__/plugin-permission-text.test.ts | 4 +-
locales/et/common.json | 4401 ++++++++++++++++++
7 files changed, 4415 insertions(+), 3 deletions(-)
create mode 100644 locales/et/common.json
diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx
index e02248037..c63250ce1 100644
--- a/components/providers/intl-provider.tsx
+++ b/components/providers/intl-provider.tsx
@@ -27,6 +27,7 @@ export const LOCALE_LOADERS: Record Promise> = {
en: () => Promise.resolve(enMessages as Messages),
es: () => import('@/locales/es/common.json').then((m) => m.default),
he: () => import('@/locales/he/common.json').then((m) => m.default),
+ et: () => import('@/locales/et/common.json').then((m) => m.default),
fa: () => import('@/locales/fa/common.json').then((m) => m.default),
fr: () => import('@/locales/fr/common.json').then((m) => m.default),
hu: () => import('@/locales/hu/common.json').then((m) => m.default),
diff --git a/components/ui/flag-icons.tsx b/components/ui/flag-icons.tsx
index 06c871f32..7c956a1ff 100644
--- a/components/ui/flag-icons.tsx
+++ b/components/ui/flag-icons.tsx
@@ -126,6 +126,11 @@ function FlagTW(props: FlagProps) {
return ;
}
+// Estonia is not in Twemoji's set used above; drawn here in the same style.
+function FlagEE(props: FlagProps) {
+ return ;
+}
+
export const flagComponents: Record ReactElement> = {
ar: FlagAE,
ca: FlagCAT,
@@ -136,6 +141,7 @@ export const flagComponents: Record ReactElement>
en: FlagGB,
fa: FlagIR,
es: FlagES,
+ et: FlagEE,
fr: FlagFR,
he: FlagIL,
it: FlagIT,
diff --git a/components/ui/language-switcher.tsx b/components/ui/language-switcher.tsx
index 5126cb226..4228d9a77 100644
--- a/components/ui/language-switcher.tsx
+++ b/components/ui/language-switcher.tsx
@@ -17,6 +17,7 @@ const languages = [
{ value: 'da', label: 'Dansk' },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
+ { value: 'et', label: 'Eesti' },
{ value: 'fa', label: 'فارسی' },
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
diff --git a/i18n/request.ts b/i18n/request.ts
index 1e78baeb2..ad4dfd081 100644
--- a/i18n/request.ts
+++ b/i18n/request.ts
@@ -43,6 +43,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'he':
messages = (await import('../locales/he/common.json')).default;
break;
+ case 'et':
+ messages = (await import('../locales/et/common.json')).default;
+ break;
case 'fa':
messages = (await import('../locales/fa/common.json')).default;
break;
diff --git a/i18n/routing.ts b/i18n/routing.ts
index 72a034721..54b8ef177 100644
--- a/i18n/routing.ts
+++ b/i18n/routing.ts
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'always'
| 'as-needed';
-const SUPPORTED_LOCALES = ['ar', 'ca', 'cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'mn', 'nb', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh', 'zh-TW'] as const;
+const SUPPORTED_LOCALES = ['ar', 'ca', 'cs', 'da', 'de', 'en', 'es', 'et', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'mn', 'nb', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh', 'zh-TW'] as const;
// Fallback locale used when the visitor's Accept-Language header does not
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
diff --git a/lib/__tests__/plugin-permission-text.test.ts b/lib/__tests__/plugin-permission-text.test.ts
index 64aa1ed4c..8e27fdc93 100644
--- a/lib/__tests__/plugin-permission-text.test.ts
+++ b/lib/__tests__/plugin-permission-text.test.ts
@@ -14,8 +14,8 @@ describe('permission texts', () => {
const shown = ALL_PERMISSIONS.filter((p) => !implicit.has(p));
const langs = readdirSync(LOCALES).filter((l) => !l.includes('.'));
- it('covers all 27 locales', () => {
- expect(langs).toHaveLength(27);
+ it('covers all 28 locales', () => {
+ expect(langs).toHaveLength(28);
});
it.each(langs)('has a title and body for every permission in %s', (lang) => {
diff --git a/locales/et/common.json b/locales/et/common.json
new file mode 100644
index 000000000..c0e06d1b8
--- /dev/null
+++ b/locales/et/common.json
@@ -0,0 +1,4401 @@
+{
+ "meta_description": "Minimalistlik JMAP-protokolli kasutav veebipostiklient",
+ "login": {
+ "title": "Veebipost",
+ "username_label": "E-post",
+ "username_placeholder": "sinu@email.ee",
+ "password_label": "Parool",
+ "password_placeholder": "Sisesta oma parool",
+ "jmap_endpoint_label": "JMAP server",
+ "jmap_endpoint_placeholder": "https://mail.example.com",
+ "jmap_endpoint_cors_hint": "Server peab lubama CORS-päringuid sellelt domeenilt.",
+ "jmap_server_label": "Server",
+ "jmap_server_auto_picked": "Server valiti automaatselt sinu e-posti domeeni järgi.",
+ "sign_in": "Logi sisse",
+ "signing_in": "Sisselogimine...",
+ "loading": "Laadimine...",
+ "reconnecting": "Ühendus katkes. Proovin uuesti ühenduda…",
+ "error": {
+ "invalid_credentials": "Vale e-posti aadress või parool. Palun kontrolli andmeid ja proovi uuesti.",
+ "invalid_token": "Server ei võtnud seda juurdepääsuluba vastu. Kontrolli, et see on täielik ning pole aegunud ega tühistatud.",
+ "connection_failed": "Serveriga ei saanud ühendust. Kontrolli internetiühendust ja proovi uuesti.",
+ "cors_blocked": "Server on saadaval, kuid blokeerib ristpäritolu (cross-origin) päringuid. Kontrolli oma JMAP serveri CORS-seadeid ja luba see domeen.",
+ "server_error": "Server on ajutiselt kättesaamatu. Palun proovi hiljem uuesti.",
+ "generic": "Ilmnes ootamatu viga. Kui probleem püsib, võta ühendust administraatoriga.",
+ "totp_required": "Vajalik on kaheastmelise autentimise kood. Palun sisesta kood allpool.",
+ "totp_invalid": "Vale autentimiskood. Palun kontrolli autentimisrakendust ja proovi uuesti.",
+ "oauth_discovery_failed": "SSO on lubatud, kuid identiteedipakkujaga ei saadud ühendust. Kontrolli OAuth seadistust.",
+ "token_exchange_failed": "Sinu parool ja kood võeti vastu, aga meiliserver keeldus sellele rakendusele seansi algatamisest. Palu administraatoril kontrollida selle OAuth-kliendi seadeid."
+ },
+ "show_password": "Näita parooli",
+ "hide_password": "Peida parool",
+ "totp_toggle": "Mul on kaheastmelise autentimise kood",
+ "token_toggle": "Logi sisse juurdepääsuloaga",
+ "password_toggle": "Logi sisse parooliga",
+ "token_label": "Juurdepääsuluba",
+ "token_placeholder": "Kleebi oma API-luba",
+ "token_hint": "Loo oma meiliteenuse seadetes API-luba ja kleebi see siia.",
+ "remember_me": "Jäta mind meelde",
+ "config_error": {
+ "title": "Seadistuse viga",
+ "fetch_failed": "Rakenduse seadistust ei õnnestunud laadida. Palun proovi hiljem uuesti.",
+ "server_not_configured": "Postiserverit ei ole seadistatud. Palun võta ühendust administraatoriga."
+ },
+ "remove_from_history": "Eemalda ajaloost",
+ "totp_label": "Autentimiskood",
+ "totp_placeholder": "000000",
+ "session_expired": "Sinu sessioon aegus. Palun logi uuesti sisse.",
+ "dismiss": "Sulge",
+ "or": "või",
+ "sign_in_sso": "Logi sisse SSO-ga",
+ "add_account_title": "Lisa konto",
+ "add_account_subtitle": "Logi sisse teise kontoga",
+ "cancel": "Tühista",
+ "website": "Veebileht",
+ "imprint": "Väljaandja andmed",
+ "privacy_policy": "Privaatsuspoliitika",
+ "try_demo": "Proovi demot",
+ "demo_description": "Tutvu näidisandmetega - kontot ei ole vaja",
+ "demo_launching": "Demo käivitamine...",
+ "demo_login_button": "Käivita demo",
+ "demo_tagline": "Proovi täisfunktsionaalset e-posti klienti. Kontot pole vaja.",
+ "demo_no_signup": "Registreerimist pole vaja - tutvu vabalt näidisandmetega",
+ "oauth_completing": "Sisselogimise lõpetamine...",
+ "oauth_error": {
+ "title": "Autentimine ebaõnnestus",
+ "invalid_state": "Turvakontroll ebaõnnestus. Palun proovi uuesti sisse logida.",
+ "missing_params": "Autoriseerimisandmed puuduvad. Palun proovi uuesti sisse logida.",
+ "token_exchange_failed": "Autentimist ei õnnestunud lõpetada. Palun proovi uuesti.",
+ "access_denied": "Juurdepääs keelati. Palun võta ühendust administraatoriga.",
+ "back_to_login": "Tagasi"
+ }
+ },
+ "sidebar": {
+ "close": "Sulge",
+ "compose": "Koosta",
+ "compose_hint": "Koosta (c)",
+ "search_placeholder": "Otsi kirju...",
+ "search_placeholder_hint": "Otsi kirju... (vajuta /)",
+ "storage": "Talletusruum",
+ "storage_used": "Kasutatud",
+ "storage_free": "Vaba",
+ "storage_total": "Kokku",
+ "sign_out": "Logi välja",
+ "sign_out_of": "Logi välja kontolt {account}",
+ "sign_out_all": "Logi välja kõigist kontodest",
+ "add_account": "Lisa konto",
+ "set_as_default": "Määra vaikimisi",
+ "switch_account": "Vaheta kontot",
+ "contacts": "Kontaktid",
+ "calendar": "Kalender",
+ "settings": "Seaded",
+ "admin": "Haldus",
+ "files": "Failid",
+ "loading_mailboxes": "Postkastide laadimine...",
+ "push_connected": "Reaalajas uuendused aktiivsed",
+ "push_disconnected": "Reaalajas uuendused mitteaktiivsed",
+ "keyboard_shortcuts": "Kiirklahvid",
+ "theme": {
+ "light": "Hele režiim",
+ "dark": "Tume režiim",
+ "system": "Süsteemi teema"
+ },
+ "language": {
+ "title": "Keel"
+ },
+ "mailboxes": {
+ "inbox": "Saabunud",
+ "sent": "Saadetud",
+ "drafts": "Mustandid",
+ "trash": "Prügikast",
+ "archive": "Arhiiv",
+ "starred": "Tähega tähistatud",
+ "all_mail": "Kõik kirjad",
+ "spam": "Rämpspost",
+ "important": "Oluline"
+ },
+ "unified_inbox": "Ühine saabunud",
+ "unified_sent": "Kõik saadetud",
+ "unified_drafts": "Kõik mustandid",
+ "unified_trash": "Kõik prügikastid",
+ "unified_archive": "Kõik arhiivid",
+ "unified_junk": "Kõik rämpspost",
+ "all_accounts": "Kõik kontod",
+ "all_accounts_loading": "Kontode laadimine: {loaded} / {total}",
+ "unified_mailbox": "Ühine postkast",
+ "expand": "Laienda",
+ "collapse": "Ahenda",
+ "expand_tooltip": "Laienda",
+ "collapse_tooltip": "Ahenda",
+ "mobile": {
+ "search": "Otsi",
+ "compose": "Koosta",
+ "go_back": "Mine tagasi",
+ "toggle_menu": "Ava/sulge menüü"
+ },
+ "clear_search": "Tühjenda otsing",
+ "vacation_active": "Puhkuse vastaja on aktiivne",
+ "demo_banner": "Demorežiim",
+ "demo_reset": "Lähtesta",
+ "demo_tour": "Tutvustus",
+ "tags": "Sildid",
+ "show_all_tags": "Näita kõiki ({count})",
+ "show_fewer_tags": "Näita vähem",
+ "folders": "Kaustad",
+ "show_more": "Rohkem",
+ "show_fewer": "Vähem",
+ "shared": "Jagatud",
+ "shared_account_hidden": "Külgribal peidetud. Lülita see tagasi sisse menüüs Seaded › Paigutus.",
+ "notifications_muted": "Märguanded vaigistatud",
+ "mail": "Kirjad",
+ "nav_label": "Navigeerimine",
+ "add_app": "Rakendused",
+ "scheduled": "Ajastatud",
+ "unified_all_unread": "Kõik lugemata",
+ "unified_all_starred": "Kõik tähega tähistatud",
+ "unified_all_mail": "Kõik kirjad",
+ "remove_account": "Eemalda konto",
+ "remove_account_confirm": "Eemalda {account} sellest seadmest? Saad selle hiljem uuesti lisada.",
+ "labels": "Sildid",
+ "create_label": "Loo uus silt",
+ "mv_tasks": "Ülesanded",
+ "mv_side_panel": "Külgpaneel",
+ "mv_open_full": "Ava täisvaates",
+ "mv_previous_day": "Eelmine päev",
+ "mv_next_day": "Järgmine päev",
+ "mv_add_another_account": "Lisa veel üks konto",
+ "mv_manage_account": "Halda oma kontot",
+ "mv_storage_used": "{percent}% mahust {total} kasutatud"
+ },
+ "protocol_handlers": {
+ "title": "Vaikerakendused",
+ "description": "Vali, kas e-posti ja kalendri lingid avanevad Bulwarkis. Tehniliselt registreerib Bulwark end mailto: ja webcal: linkide protokollikäsitlejana.",
+ "unsupported": "See brauser või ühendus ei toeta protokollikäsitleja käsitsi registreerimist. Paigaldatud PWA-d saad siiski kasutada brauseri või operatsioonisüsteemi seadete kaudu.",
+ "mailto_label": "E-posti lingid",
+ "mailto_description": "Ava mailto: lingid Bulwarkis koos eeltäidetud kirja koostamise aknaga.",
+ "protocol_open_mode_label": "Protokollilinkide avamisel",
+ "protocol_open_mode_description": "Vali, kas Bulwark avab mailto: ja webcal: lingid uuel vahekaardil või kasutab olemasolevat sessiooni. Aktiivse sessiooni valik vajab teavituste õigust, et saaksid vajadusel klõpsata varuteavitusel, mis toob Bulwarki esiplaanile, kui brauser blokeerib fookuse liikumise.",
+ "protocol_open_mode_active_session": "Ava võimalusel aktiivses sessioonis",
+ "protocol_open_mode_new_tab": "Ava alati uuel vahekaardil",
+ "focus_notification_title": "Ava Bulwark",
+ "focus_notification_body": "Link avati Bulwarkis. Klõpsa, et tuua aken esiplaanile.",
+ "webcal_label": "Kalendri lingid",
+ "webcal_description": "Ava webcal: lingid Bulwarkis koos eeltäidetud kalendri tellimise dialoogiga.",
+ "register_mailto": "Registreeri e-posti rakendus",
+ "register_webcal": "Registreeri kalendrirakendus",
+ "mailto_registered": "E-posti käsitleja registreerimist taotleti",
+ "webcal_registered": "Kalendri käsitleja registreerimist taotleti",
+ "registration_failed": "Protokollikäsitleja registreerimine ebaõnnestus",
+ "opening_mailto": "Kirja koostamise akna avamine...",
+ "opening_webcal": "Kalendri avamine...",
+ "browser_note": "Sinu brauser või operatsioonisüsteem võib paluda seda kinnitada ning enne vaikerakenduseks valimist võib nõuda Bulwarki paigaldamist.",
+ "select_account_title": "Vali konto",
+ "select_mailto_account": "Vali, milline konto peaks selle e-posti lingi avama.",
+ "select_webcal_account": "Vali, milline konto peaks selle kalendrilingi avama.",
+ "select_account_note": "See kehtib ainult selle protokollilingi kohta.",
+ "detail_to": "Kellele",
+ "detail_subject": "Teema",
+ "detail_no_subject": "Teema puudub",
+ "detail_calendar": "Kalender",
+ "detail_source": "Allikas",
+ "active_account": "Aktiivne",
+ "switching_account": "Konto vahetamine..."
+ },
+ "sidebar_apps": {
+ "modal_title": "Külgriba rakendused",
+ "add_new": "Lisa rakendus",
+ "edit_app": "Muuda rakendust",
+ "name_label": "Nimi",
+ "name_placeholder": "Minu rakendus",
+ "name_required": "Nimi on kohustuslik",
+ "url_label": "URL",
+ "url_required": "URL on kohustuslik",
+ "url_invalid": "Sisesta kehtiv http või https URL",
+ "icon_label": "Ikoon",
+ "icon_required": "Ikoon on kohustuslik",
+ "open_mode_label": "Avamisrežiim",
+ "open_new_tab": "Uus vahekaart",
+ "open_inline": "Samas aknas",
+ "cancel": "Tühista",
+ "add": "Lisa",
+ "update": "Uuenda",
+ "delete": "Kustuta",
+ "delete_confirm_title": "Kustuta rakendus",
+ "delete_confirm": "Kas oled kindel, et soovid kustutada \"{name}\"?",
+ "no_apps": "Rakendusi pole veel lisatud",
+ "no_apps_hint": "Lisa oma külgribale kohandatud rakendusi ja linke",
+ "managed_title": "Pakutud sinu administraatori poolt",
+ "managed_description": "Need rakendused on seadistatud kõigile ja neid ei saa muuta.",
+ "managed_badge": "Hallatud",
+ "search_icons": "Otsi ikoone...",
+ "show_popular": "Populaarsed",
+ "show_all": "Kõik",
+ "no_icons_found": "Ikoone ei leitud",
+ "inline_badge": "Samas aknas",
+ "tab_badge": "Vahekaart",
+ "show_on_mobile": "Näita mobiilis"
+ },
+ "email_list": {
+ "no_emails": "Sõnumeid ei leitud",
+ "no_emails_description": "See postkast on tühi",
+ "no_search_results": "Tulemusi ei leitud",
+ "no_search_results_description": "Proovi otsingut või filtreid muuta",
+ "loading": "Kirjade laadimine...",
+ "unread": "lugemata",
+ "to_me": "Minule",
+ "to_recipients": "{count} saajale",
+ "and_others": "ja veel {count}",
+ "draft": "Mustand",
+ "starred": "Tähega tähistatud",
+ "pinned": "Kinnitatud",
+ "replied": "Vastatud",
+ "forwarded": "Edastatud",
+ "has_attachment": "Sisaldab manust",
+ "conversations_range": "{from}–{to, number} / {total, number}",
+ "conversations_count": "{count} vestlust {total}-st",
+ "conversations_count_plus": "{count}+ vestlust",
+ "conversations_count_simple": "{count} vestlust",
+ "no_conversations": "Vestlusi pole",
+ "loading_more": "Laen rohkem kirju...",
+ "no_more_emails": "Rohkem kirju pole laadida",
+ "batch_actions": {
+ "select": "Vali kirjad",
+ "select_all": "Vali kõik",
+ "selected_messages": "{count, plural, one {1 kiri} other {# kirja}} valitud",
+ "mark_read": "Märgi loetuks",
+ "mark_unread": "Märgi lugemata",
+ "delete": "Kustuta",
+ "delete_confirm_title": "Kirjade kustutamine",
+ "delete_confirm_message": "Kas oled kindel, et soovid kustutada {count, plural, one {1 kirja} other {# kirja}}?",
+ "clear_selection": "Tühista valik"
+ },
+ "permanent_delete": "Kustuta jäädavalt",
+ "permanent_delete_confirm_title": "Jäädav kustutamine",
+ "permanent_delete_confirm_message": "See kiri kustutatakse jäädavalt. Seda toimingut ei saa tagasi võtta.",
+ "permanent_delete_confirm_batch_message": "{count, plural, one {See 1 kiri kustutatakse jäädavalt.} other {Need # kirja kustutatakse jäädavalt.}} Seda toimingut ei saa tagasi võtta.",
+ "empty_folder": {
+ "button": "Tühjenda kaust",
+ "confirm_title": "Kausta tühjendamine",
+ "confirm_message": "Kõik selle kausta kirjad kustutatakse jäädavalt. Seda toimingut ei saa tagasi võtta.",
+ "confirm_message_trash": "Kõik selle kausta kirjad teisaldatakse prügikasti.",
+ "confirm_button": "Tühjenda kaust",
+ "junk_hint": "Rämpsposti kausta tühjendades saad kõik kirjad jäädavalt eemaldada.",
+ "trash_hint": "Prügikasti tühjendades saad kõik kirjad jäädavalt eemaldada."
+ },
+ "no_scheduled_emails": "Ajastatud kirju pole",
+ "no_scheduled_emails_description": "Hiljem saatmiseks ajastatud kirjad kuvatakse siin.",
+ "scheduled_count": "{count, plural, one {1 ajastatud kiri} other {# ajastatud kirja}}",
+ "scheduled_actions_hint": "Ajastatud kirju saab ajastatud toimingute alt tühistada, ümber ajastada või muuta.",
+ "cancel_scheduled_send": "Tühista saatmine",
+ "reschedule_send": "Ajasta uuesti",
+ "cancel_and_edit": "Tühista ja muuda",
+ "cancel_and_compose_again": "Tühista ja koosta uuesti",
+ "reschedule_prompt": "Sisesta uus kuupäev/kellaaeg, nt 2026-05-04T15:30"
+ },
+ "email_viewer": {
+ "read_receipt": {
+ "prompt": "Saatja soovib teadet, kui avad selle kirja.",
+ "send": "Saada kinnitus",
+ "ignore": "Eira",
+ "sent": "Lugemiskinnitus saadetud.",
+ "send_failed": "Lugemiskinnitust ei õnnestunud saata",
+ "mdn_subject": "Loetud: {subject}",
+ "mdn_body": "See on kinnitus kirjale, mille saatsid saajale {recipient}.\n\nMärkus: see kinnitus tõendab vaid, et kiri kuvati saaja arvutis. Ei ole tagatud, et saaja on kirja sisu lugenud või mõistnud."
+ },
+ "no_email_selected": "Kirja pole valitud",
+ "no_email_description": "Kirja vaatamiseks vali see loetelust",
+ "no_conversation_selected": "Vestlust pole valitud",
+ "no_conversation_description": "Vestluse lugemiseks vali see loetelust",
+ "compose": "Koosta",
+ "compose_hint": "Koosta uus kiri",
+ "no_subject": "(Teema puudub)",
+ "no_body_content": "(Sisu pole saadaval)",
+ "no_preview_available": "Eelvaadet pole saadaval",
+ "show_quoted_text": "Näita tsiteeritud teksti",
+ "hide_quoted_text": "Peida tsiteeritud tekst",
+ "loading_email": "Kirja laadimine...",
+ "loading": "Laadimine...",
+ "reply": "Vasta",
+ "reply_all": "Vasta kõigile",
+ "forward": "Saada edasi",
+ "delete": "Kustuta",
+ "archive": "Arhiveeri",
+ "archive_mailbox_not_found": "Arhiivikausta ei leitud - kirja ei saa arhiveerida",
+ "star": "Tähista tähega",
+ "unstar": "Eemalda täht",
+ "mark_unread": "Märgi lugemata",
+ "mark_read": "Märgi loetuks",
+ "unread": "Lugemata",
+ "read": "Loetud",
+ "spam_short": "Rämpspost",
+ "not_spam_short": "Pole rämpspost",
+ "move": "Teisalda",
+ "print": "Prindi",
+ "view_source": "Vaata lähtekoodi",
+ "fullscreen": "Täisekraan",
+ "exit_fullscreen": "Välju täisekraanist",
+ "view_in_dark_mode": "Vaata tumedas režiimis",
+ "view_in_light_mode": "Vaata heledas režiimis",
+ "export_email": "Ekspordi .eml failina",
+ "forward_as_attachment": "Saada edasi manusena",
+ "import_email": "Impordi .eml või .zip",
+ "keyboard_shortcuts": "Kiirklahvid (?)",
+ "email_source": "Kirja lähtekood",
+ "draft_banner": "See kiri on mustand",
+ "edit_draft": "Muuda",
+ "copy_source": "Kopeeri lõikelauale",
+ "source_copied": "Lähtekood kopeeritud lõikelauale",
+ "attachments": "Manused",
+ "important": "Oluline",
+ "download": "Laadi alla",
+ "download_all": "Laadi kõik alla",
+ "from": "Saatja",
+ "to": "Saaja",
+ "cc": "Koopia",
+ "bcc": "Pimekoopia",
+ "date": "Kuupäev",
+ "subject": "Teema",
+ "show_details": "Näita üksikasju",
+ "hide_details": "Peida üksikasjad",
+ "external_content_warning": "Pildid ja väline sisu on blokeeritud",
+ "load_external_content": "Laadi pildid",
+ "trust_sender": "Usalda seda saatjat alati",
+ "sender_check": {
+ "unverified_label": "Kontrollimata saatja",
+ "failed_label": "Saatja kontroll ebaõnnestus",
+ "unverified": "See kiri väidab, et on saadetud domeenilt {domain}, kuid seda ei kinnita miski. Sellel puudub domeeni {domain} kehtiv allkiri ja see ei tulnud serverist, mille {domain} on volitanud.",
+ "unverified_sent_from": "See kiri väidab, et on saadetud domeenilt {domain}, kuid see saadeti hostilt {host} ja sellel puudub domeeni {domain} kehtiv allkiri.",
+ "failed": "See kiri väidab, et on saadetud domeenilt {domain}, kuid see ei läbinud selle domeeni saatjakontrolle. See võib olla võltsitud.",
+ "failed_sent_from": "See kiri väidab, et on saadetud domeenilt {domain}, kuid see saadeti hostilt {host} ega läbinud domeeni {domain} saatjakontrolle. See võib olla võltsitud.",
+ "caution": "Ole ettevaatlik linkide, manuste ning paroolide või makseandmete küsimistega."
+ },
+ "back_to_list": "Tagasi loetellu",
+ "view_contact": "Vaata kontakti",
+ "message_details": "Kirja üksikasjad",
+ "more_reply_options": "Rohkem vastamisvõimalusi",
+ "set_tag": "Määra silt",
+ "tag": "Silt",
+ "more_actions": "Rohkem toiminguid",
+ "previous": "Eelmine",
+ "next": "Järgmine",
+ "move_to": "Teisalda kausta...",
+ "remove_tag": "Eemalda silt",
+ "tag_filter_placeholder": "Filtreeri silte",
+ "tag_no_matches": "Sobivaid silte ei leitud",
+ "more_count": "+{count} veel",
+ "characters_count": "{count} tähemärki",
+ "quick_reply_placeholder": "Kirjuta kiirvastus...",
+ "more_options": "Rohkem valikuid",
+ "sending": "Saatmine...",
+ "security_authentication": "Turvalisus ja autentimine",
+ "technical_details": "Tehnilised üksikasjad",
+ "message_id_label": "Message-ID:",
+ "reply_to_label": "Reply-To:",
+ "delivery_time_label": "Kohaletoimetamise aeg:",
+ "conversation_part_label": "Osa vestlusest:",
+ "previous_messages": "{count} eelmine kiri",
+ "previous_messages_plural": "{count} eelmist kirja",
+ "time": {
+ "day": "päev",
+ "days": "päeva",
+ "hour": "tund",
+ "hours": "tundi",
+ "minute": "minut",
+ "minutes": "minutit"
+ },
+ "unknown_sender": "Tundmatu",
+ "recipient_me": "mina",
+ "recipient_and_others": "{name} ja veel {count}",
+ "recipient_to_prefix": "Saaja:",
+ "authentication": {
+ "title": "Autentimine",
+ "status": {
+ "verified": "Kinnitatud",
+ "warning": "Hoiatus",
+ "none": "Autentimata"
+ },
+ "spf": {
+ "pass": "SPF läbitud",
+ "fail": "SPF ebaõnnestus",
+ "none": "SPF puudub"
+ },
+ "dkim": {
+ "pass": "DKIM kehtiv",
+ "fail": "DKIM kehtetu",
+ "none": "DKIM puudub"
+ },
+ "dmarc": {
+ "pass": "DMARC läbitud",
+ "fail": "DMARC ebaõnnestus",
+ "none": "DMARC puudub"
+ },
+ "spam_score": "Rämpsposti skoor",
+ "tooltip_spf": "Sender Policy Framework: kontrollib, kas saatev server on volitatud selle domeeni nimel kirju saatma",
+ "tooltip_dkim": "DomainKeys Identified Mail: kinnitab krüptograafilise allkirja abil, et kirja ei muudetud edastamise ajal",
+ "tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: tagab, et SPF ja DKIM vastavad saatja domeenile, ning määrab reeglid ebaõnnestumiste jaoks",
+ "policy": "Reegel",
+ "result": {
+ "pass": "Läbitud",
+ "fail": "Ebaõnnestus",
+ "softfail": "Nõrk ebaõnnestumine",
+ "neutral": "Neutraalne",
+ "permerror": "Püsiv viga",
+ "temperror": "Ajutine viga",
+ "none": "Puudub"
+ }
+ },
+ "details": {
+ "recipients_routing": "Saajad ja marsruutimine",
+ "authentication_security": "Autentimine ja turvalisus",
+ "identifiers_threading": "Identifikaatorid ja vestlusahel",
+ "mailing_list": "Meililist",
+ "message_properties": "Kirja omadused",
+ "sent": "Saadetud",
+ "received": "Vastu võetud",
+ "delivery_time": "Kohaletoimetamise aeg",
+ "in_reply_to": "In-Reply-To",
+ "references": "References",
+ "thread_id": "Vestluse ID",
+ "size": "Suurus",
+ "mime_type": "MIME tüüp",
+ "attachments_summary": "{count} faili · {size}",
+ "list_id": "Listi ID",
+ "list_help": "Listi abi",
+ "list_post": "Listi postitus",
+ "list_unsubscribe": "Tellimuse tühistamine",
+ "iprev": "Pöörd-DNS",
+ "spam_status": "Rämpsposti olek",
+ "ai_verdict": "AI hinnang",
+ "account": "Konto",
+ "no_subject": "(teema puudub)"
+ },
+ "headers": {
+ "routing": "Marsruutimine",
+ "received": "Vastu võetud",
+ "message_id": "Kirja ID",
+ "list_info": "Listi teave"
+ },
+ "tooltips": {
+ "reply": "Vasta (r)",
+ "reply_all": "Vasta kõigile (a)",
+ "forward": "Saada edasi (f)",
+ "archive": "Arhiveeri (e)",
+ "delete": "Kustuta (# või Del)",
+ "star": "Tähista tähega (s)",
+ "unstar": "Eemalda täht (s)",
+ "compose": "Koosta (c)",
+ "previous": "Eelmine kiri",
+ "next": "Järgmine kiri",
+ "edit_draft": "Muuda mustandit"
+ },
+ "spam": {
+ "button_title": "Märgi rämpspostiks",
+ "not_spam_title": "Märgi tavaliseks kirjaks",
+ "toast_success": "Teisaldatud rämpsposti",
+ "toast_batch": "{count} kirja teisaldatud rämpsposti",
+ "toast_undo": "Võta tagasi",
+ "toast_not_spam_success": "Teisaldatud saabunud kirjadesse",
+ "toast_not_spam_batch": "{count} kirja teisaldatud saabunud kirjadesse",
+ "error": "Rämpspostiks märkimine ebaõnnestus",
+ "error_not_spam": "Kirja taastamine ebaõnnestus"
+ },
+ "unsubscribe_banner": {
+ "label": "Uudiskiri",
+ "button": "Tühista tellimus",
+ "confirm_title": "Kas tühistada selle saatja tellimus?",
+ "confirm_button": "Kinnita",
+ "cancel": "Tühista",
+ "success_http": "Tellimuse tühistamise leht avati uues vahekaardis",
+ "success_mailto": "Tellimuse tühistamise kiri saadetud",
+ "confirm_message_http": "Tellimuse tühistamise leht avaneb uues vahekaardis.",
+ "confirm_message_mailto": "Saatjale saadetakse tellimuse tühistamise kiri.",
+ "error": "Tellimust ei õnnestunud tühistada",
+ "dismiss": "Sulge"
+ },
+ "calendar_invitation": {
+ "loading": "Sündmuse üksikasjade laadimine…",
+ "title": "Kalendrikutse",
+ "published_title": "Avaldatud sündmus",
+ "response_title": "Vastus sündmusele",
+ "update_title": "Sündmuse uuendus",
+ "counter_title": "Vastuettepanek",
+ "refresh_title": "Värskenduse taotlus",
+ "declined_counter_title": "Vastuettepanek tagasi lükatud",
+ "cancelled_title": "Sündmus tühistatud",
+ "organizer": "Korraldaja: {name}",
+ "organizer_label": "Korraldaja",
+ "attendees": "{count, plural, one {# osaleja} other {# osalejat}}",
+ "accept": "Nõustu",
+ "maybe": "Võib-olla",
+ "decline": "Keeldu",
+ "add_to_calendar": "Lisa kalendrisse",
+ "added": "Lisatud kalendrisse",
+ "rsvp_sent": "Vastus saadetud",
+ "parse_error": "Kutset ei õnnestunud lugeda",
+ "action_failed": "Seda kalenditoimingut ei õnnestunud lõpetada.",
+ "no_calendar": "Kalender pole saadaval",
+ "published_info": "See sündmus jagati info edastamiseks.",
+ "response_info": "See kiri sisaldab osaleja vastust.",
+ "response_info_organizer": "See osaleja vastus uuendab sinu sündmust.",
+ "update_info": "See kiri uuendab olemasolevat sündmust.",
+ "counter_info": "See kiri teeb ettepaneku sündmust muuta.",
+ "counter_info_organizer": "See osaleja tegi ettepaneku sinu sündmust muuta.",
+ "refresh_info": "See kiri küsib sündmuse uusimaid andmeid.",
+ "refresh_info_organizer": "Osaleja küsis sündmuse uusimaid andmeid.",
+ "declined_counter_info": "Korraldaja lükkas vastuettepaneku tagasi.",
+ "authentication_failed_info": "Selle kutse e-posti autentimiskontrollid ebaõnnestusid. Suhtu kalenditoimingutesse ettevaatlikult.",
+ "authentication_missing_info": "Sellel kutsel puudub kinnitatud e-posti autentimine. Kui midagi tundub kahtlane, kontrolli üksikasju korraldajaga.",
+ "sender_mismatch_info": "See kutse saadeti aadressilt {sender}, kuid kalendriandmetes märgitud korraldaja on {organizer}.",
+ "sender_mismatch_unverified_info": "See kutse saadeti aadressilt {sender}, kuid kalendriandmetes märgitud korraldaja on {organizer}, ning kirja ei õnnestunud kinnitada.",
+ "organizer_role": "Sina oled selle sündmuse korraldaja",
+ "your_response": "Sinu vastus: {status}",
+ "response_needed": "Vajab vastust",
+ "response_accepted": "Nõustutud",
+ "response_tentative": "Esialgne",
+ "response_declined": "Tagasi lükatud",
+ "response_delegated": "Delegeeritud",
+ "actor_sent_info": "Saatis {name}.",
+ "actor_response_info": "{name} vastas: {status}.",
+ "actor_counter_info": "{name} tegi ettepaneku seda sündmust muuta.",
+ "actor_refresh_info": "{name} küsis sündmuse uusimaid andmeid.",
+ "actor_declined_counter_info": "{name} lükkas vastuettepaneku tagasi.",
+ "actor_note": "Märkus: {comment}",
+ "actor_unknown": "Keegi",
+ "proposed_changes": "Pakutud muudatused",
+ "change_title": "Pealkiri",
+ "change_time": "Aeg",
+ "change_location": "Asukoht",
+ "change_description": "Kirjeldus",
+ "change_empty": "Puudub",
+ "change_from_to": "{before} -> {after}",
+ "apply_proposal": "Rakenda pakutud muudatused",
+ "proposal_applied": "Pakutud muudatused rakendatud.",
+ "review_proposal": "Vaata ettepanekut üle",
+ "review_request": "Vaata taotlust üle",
+ "view_in_calendar": "Vaata kalendris",
+ "select_calendar": "Vali kalender",
+ "already_in_calendar": "Juba sinu kalendris",
+ "request_info": "Sind on sellele sündmusele kutsutud. Vasta, et anda korraldajale teada oma saadavusest.",
+ "cancel_info": "Korraldaja on selle sündmuse tühistanud.",
+ "event_updated": "Uuendus #{sequence}",
+ "event_status_tentative": "Esialgne",
+ "event_status_cancelled": "Tühistatud",
+ "expand": "Näita üksikasju",
+ "collapse": "Peida üksikasjad"
+ },
+ "send": "Saada",
+ "more": "veel",
+ "scheduled_banner": "Ajastatud saatmiseks kell {date}",
+ "scheduled_send_created": "Kiri ajastatud saatmiseks",
+ "sending_in": "Saatmine {seconds, plural, one {# sekundi} other {# sekundi}} pärast",
+ "undo_send_failed": "Tagasivõtmine ei õnnestunud. Kiri võib juba olla saadetud.",
+ "cancel_scheduled_send": "Tühista saatmine",
+ "reschedule_send": "Ajasta uuesti",
+ "cancel_and_edit": "Tühista ja muuda",
+ "cancel_and_compose_again": "Tühista ja koosta uuesti",
+ "reschedule_prompt": "Sisesta uus kuupäev/kellaaeg, nt 2026-05-04T15:30",
+ "scheduled_actions_only": "Ajastatud vaade toetab ainult ajastatud toiminguid",
+ "undo_send_scheduled": "Kiri ajastatud saatmiseks",
+ "undo_send": "Võta saatmine tagasi",
+ "contact_sidebar": {
+ "title": "Kontakt",
+ "close": "Sulge külgriba",
+ "action_email": "E-post",
+ "action_email_title": "Saada e-kiri",
+ "action_copy": "Kopeeri",
+ "action_copy_title": "Kopeeri e-posti aadress",
+ "action_edit_title": "Muuda kontakti",
+ "section_emails": "E-posti aadressid",
+ "section_phones": "Telefonid",
+ "section_organizations": "Organisatsioonid",
+ "section_addresses": "Aadressid",
+ "section_notes": "Märkused",
+ "not_in_contacts": "Ei ole sinu kontaktides",
+ "add_to_contacts": "Lisa kontaktidesse",
+ "copied": "Kopeeritud!",
+ "copy_failed": "Kopeerimine ebaõnnestus"
+ },
+ "send_now": "Saada kohe",
+ "verification_code": {
+ "copy": "Kopeeri kood {code}",
+ "copied": "Kood kopeeritud",
+ "copy_failed": "Koodi kopeerimine ebaõnnestus"
+ },
+ "mv_position": "{index} / {total}",
+ "plugin_panel_show": "Näita pistikprogrammi paneeli",
+ "plugin_panel_hide": "Peida pistikprogrammi paneel"
+ },
+ "email_composer": {
+ "minimize": "Minimeeri",
+ "maximize": "Täisekraan",
+ "restore": "Taasta",
+ "new_message": "Uus kiri",
+ "signature_edit_hint": "Allkirja muutmiseks tee topeltklõps",
+ "reply": "Vasta",
+ "reply_all": "Vasta kõigile",
+ "forward": "Saada edasi",
+ "reply_to": "Vasta",
+ "reply_all_to": "Vasta kõigile",
+ "forward_message": "Saada edasi",
+ "from": "Saatja",
+ "to": "Saajale",
+ "cc": "CC",
+ "bcc": "BCC",
+ "subject": "Teema",
+ "body_placeholder": "Kirjuta oma sõnum...",
+ "formatting": "Vormindus",
+ "send": "Saada",
+ "cancel": "Tühista",
+ "attach": "Lisa manus",
+ "attach_photos": "Fotod ja videod",
+ "attach_files": "Failid",
+ "attach_from_files": "Lisa failidest",
+ "files_picker": {
+ "attach": "{count, plural, =0 {Lisa manus} one {Lisa 1 fail} other {Lisa # faili}}",
+ "empty_folder": "See kaust on tühi",
+ "load_failed": "Sinu faile ei õnnestunud laadida"
+ },
+ "discard": "Kustuta",
+ "discard_draft_title": "Kustutada mustand?",
+ "discard_draft_confirm": "Sul on salvestamata muudatusi. Kas soovid selle mustandi kustutada?",
+ "discard_draft_message": "Mustand kustutatakse.",
+ "saving": "Salvestamine...",
+ "sending": "Saatmine...",
+ "add_link": "Lisa link",
+ "link_url_prompt": "Sisesta URL",
+ "draft_saved": "Mustand salvestatud",
+ "save_failed": "Salvestamine ebaõnnestus",
+ "to_placeholder": "Saaja e-posti aadressid",
+ "cc_placeholder": "CC saajad",
+ "bcc_placeholder": "BCC saajad",
+ "subject_placeholder": "Teema",
+ "cc_label": "CC:",
+ "bcc_label": "BCC:",
+ "subject_label": "Teema:",
+ "file_size_kb": "KB",
+ "prefix": {
+ "forward": "Fwd:",
+ "reply": "Vs:"
+ },
+ "forward_batch_count": "{count, plural, one {1 kiri} other {# kirja}}",
+ "no_subject": "(Teemata)",
+ "unknown_sender": "Tundmatu",
+ "quote": {
+ "reply_header": "{date} kirjutas {sender}:",
+ "forward_header": "---------- Edastatud sõnum ----------",
+ "from": "Saatja: {sender}",
+ "date": "Kuupäev: {date}",
+ "subject": "Teema: {subject}",
+ "to": "Saajad: {recipients}"
+ },
+ "remove_sub_address": "Eemalda alamaadress",
+ "from_override": {
+ "toggle_off": "Muuda saatjat",
+ "toggle_on": "Tühista saatja muutmine",
+ "toggle_tooltip": "Muuda saatja nime ja aadressi vabalt. Kiri saadetakse endiselt sinu identiteedi kaudu - muutub ainult nähtav saatja päis.",
+ "name_label": "Saatja nimi",
+ "name_placeholder": "Nimi",
+ "email_label": "Saatja e-posti aadress",
+ "email_placeholder": "alias@example.com",
+ "envelope_notice": "Kui sinu server ei võta seda aadressi ümbriku saatjaks vastu, kasutatakse selle asemel {identity} ja adressaadid näevad seda päisest Return-Path."
+ },
+ "save_as_template": "Salvesta mallina",
+ "attach_from_device": "Laadi üles sellest seadmest",
+ "templates_menu": "Mallid",
+ "insert_template": "Lisa mall",
+ "options": "Valikud",
+ "option_plain_text": "Lihttekst",
+ "option_plain_text_hint": "Saada ilma vormindusteta",
+ "option_read_receipt": "Küsi lugemiskinnitust",
+ "option_read_receipt_hint": "Palu adressaadil kinnitada, et ta kirja avas",
+ "option_dsn": "Kohaletoimetamise teade",
+ "option_dsn_hint": "Saa aruanne, kui server kirja kohale toimetab",
+ "option_require_tls": "Nõua krüpteeritud kohaletoimetamist",
+ "option_require_tls_hint": "Toimeta kohale ainult TLS-iga, muidu lükka tagasi",
+ "validation": {
+ "recipient_required": "Saatmiseks lisa saaja",
+ "body_required": "Kirjuta sõnum või lisa fail",
+ "invalid_recipients": "Ei saadetud - kontrolli neid aadresse: {addresses}",
+ "attachments_uploading": "Manuseid laaditakse veel üles — saadetakse kohe, kui need valmis on",
+ "attachment_upload_failed": "Ei saadetud — manuse üleslaadimine ebaõnnestus. Eemalda see ja proovi uuesti."
+ },
+ "upload_progress": "Laadimine üles: {uploaded} / {total}",
+ "upload_cancel": "Tühista üleslaadimine",
+ "upload_failed": "Faili {filename} üleslaadimine ebaõnnestus",
+ "attachment_too_large": "„{name}” on suurem, kui server lubab ({max} faili kohta)",
+ "attachments_total_too_large": "Manused ületaksid serveri piiri {max} kirja kohta",
+ "drop_files": "Manustamiseks lohista failid siia",
+ "show_less": "Näita vähem",
+ "send_failed": "Kirja saatmine ebaõnnestus",
+ "send_offline": "Paistab, et oled võrgust väljas. Kiri on alles, saada see uuesti, kui oled taas ühenduses.",
+ "send_timeout": "Server lakkas vastamast. Enne uuesti saatmist kontrolli oma saadetud kirjade kausta — sõnum võis siiski välja minna.",
+ "send_recipients_rejected": "Ei saadetud - server lükkas kõik adressaadid tagasi.",
+ "send_plugin_failed": "Ei saadetud - saatmist käsitlev pistikprogramm ebaõnnestus või võttis liiga kaua aega. Kontrolli enne uuesti saatmist kausta Saadetud.",
+ "send_some_recipients_rejected": "Saadetud, kuid mitte neile adressaatidele - server lükkas nad tagasi.",
+ "continue_draft": "Jätka mustandiga",
+ "close_draft_title": "Salvestada või kustutada mustand?",
+ "close_draft_message": "Sul on salvestamata muudatusi. Kas soovid selle mustandina salvestada või kustutada?",
+ "smime_sign_on": "S/MIME allkirjastamine lubatud",
+ "smime_sign_off": "Luba S/MIME allkirjastamine",
+ "smime_encrypt_on": "S/MIME krüpteerimine lubatud",
+ "smime_encrypt_off": "Luba S/MIME krüpteerimine",
+ "smime_encrypt_unavailable": "S/MIME krüpteerimine pole saadaval – puuduvad saaja sertifikaadid",
+ "smime_unlock_title": "Ava S/MIME võti",
+ "smime_unlock_message": "Sisesta parool, et avada oma S/MIME allkirjastamise võti.",
+ "smime_unlock_button": "Ava",
+ "smime_passphrase_placeholder": "Parool",
+ "forgot_attachment": {
+ "title": "Kas unustasid manuse lisada?",
+ "message": "Sinu sõnumis mainitakse \"{keyword}\", kuid ühtegi faili pole lisatud. Kas saata ikkagi?",
+ "send_anyway": "Saada ikkagi",
+ "back": "Tagasi muutmise juurde"
+ },
+ "empty_subject": {
+ "title": "Saata ilma teemata?",
+ "message": "Sellel sõnumil pole teemat. Kas saata see ikkagi?",
+ "dont_ask_again": "Ära küsi uuesti",
+ "send_anyway": "Saada ikkagi",
+ "back": "Tagasi muutmise juurde"
+ },
+ "schedule_send": "Ajasta saatmine",
+ "schedule_send_description": "Vali, millal server peaks selle sõnumi välja saatma.",
+ "schedule_preset": {
+ "tomorrow_morning": "Homme hommikul",
+ "tomorrow_afternoon": "Homme pärastlõunal",
+ "monday_morning": "Esmaspäeva hommikul"
+ },
+ "schedule_send_required": "Vali kuupäev ja kellaaeg.",
+ "schedule_send_invalid": "Sisesta kehtiv kuupäev ja kellaaeg.",
+ "schedule_send_future": "Vali tulevikus olev kuupäev ja kellaaeg.",
+ "schedule_send_too_late": "See aeg on hilisem kui server lubab.",
+ "schedule_send_unsupported": "Ajastatud saatmine ei ole selle konto jaoks toetatud.",
+ "schedule_send_cleanup_warning": "Ajastatud saatmine loodi, kuid mustandi koristamine ebaõnnestus.",
+ "send_delay_unsupported": "Saatmise viivitus ei ole selle konto jaoks toetatud.",
+ "send_delay_unsupported_confirm": "See konto ei toeta saatmise viivitust. Kas saata kohe?",
+ "recipient_edit_email": "Muuda e-posti aadressi",
+ "recipient_edit_name": "Muuda kuvatavat nime",
+ "recipient_email_placeholder": "E-posti aadress",
+ "recipient_name_placeholder": "Kuvatav nimi",
+ "invalid_recipient": "Pole kehtiv e-posti aadress",
+ "autocomplete_search_server": "Otsi serverist",
+ "autocomplete_searching": "Otsimine...",
+ "mention_recipients": "Adressaadid",
+ "toolbar": {
+ "bold": "Rasvane",
+ "italic": "Kaldkiri",
+ "underline": "Allajoonitud",
+ "strikethrough": "Läbijoonitud",
+ "text_color": "Teksti värv",
+ "remove_color": "Eemalda värv",
+ "heading_1": "Pealkiri 1",
+ "heading_2": "Pealkiri 2",
+ "bullet_list": "Täpploend",
+ "ordered_list": "Numberloend",
+ "quote": "Tsitaat",
+ "code_block": "Koodiplokk",
+ "align_left": "Joonda vasakule",
+ "align_center": "Joonda keskele",
+ "align_right": "Joonda paremale",
+ "text_direction": "Teksti suund (RTL/LTR)",
+ "link": "Link",
+ "table": "Tabel",
+ "clear_formatting": "Eemalda vormindus",
+ "undo": "Võta tagasi",
+ "redo": "Tee uuesti",
+ "add_row_above": "Lisa rida ülalt",
+ "add_row_below": "Lisa rida alt",
+ "add_column_before": "Lisa veerg enne",
+ "add_column_after": "Lisa veerg pärast",
+ "delete_row": "Kustuta rida",
+ "delete_column": "Kustuta veerg",
+ "toggle_header_row": "Päiserida sisse/välja",
+ "delete_table": "Kustuta tabel",
+ "pick_size": "Vali suurus",
+ "font_size": "Fondi suurus",
+ "font_size_default": "Vaikimisi",
+ "background_color": "Taustavärv",
+ "remove_background_color": "Eemalda taustavärv"
+ },
+ "send_filing_warning": "Saadetud - kuid saatmisjärgne koristus ebaõnnestus, mustand võib alles jääda.",
+ "pop_out": "Ava vastus eraldi aknas",
+ "send_guard_failed": "Ei saadetud - kirju enne saatmist kontrolliv pistikprogramm ebaõnnestus või ei vastanud."
+ },
+ "confirm_dialog": {
+ "confirm": "Kinnita",
+ "cancel": "Tühista"
+ },
+ "common": {
+ "loading": "Laadimine...",
+ "error": "Viga",
+ "success": "Õnnestus",
+ "cancel": "Tühista",
+ "save": "Salvesta",
+ "delete": "Kustuta",
+ "edit": "Muuda",
+ "close": "Sulge",
+ "search": "Otsi",
+ "refresh": "Värskenda",
+ "settings": "Seaded",
+ "help": "Abi",
+ "logout": "Logi välja",
+ "yes": "Jah",
+ "no": "Ei",
+ "unknown": "Tundmatu",
+ "app_title": "Webmail",
+ "reconnecting": "Ühendus katkes. Proovime uuesti ühenduda…",
+ "rate_limited_title": "Serveri autentimine on ajutiselt piiratud.",
+ "rate_limited_detail": "Bulwark on taustapäringud lukustumise vältimiseks peatanud. Proovime uuesti {seconds}s pärast.",
+ "rate_limited_action_title": "Päring peatati lukustumise vältimiseks.",
+ "rate_limited_action_detail": "Bulwark ootab serveri jahtumisaja lõppu, enne kui saadab rohkem autenditud päringuid. Proovi uuesti {seconds}s pärast.",
+ "pull_to_refresh": "Värskendamiseks tõmba alla",
+ "release_to_refresh": "Värskendamiseks vabasta",
+ "refreshing": "Värskendamine…"
+ },
+ "notifications": {
+ "email_sent": "E-kiri saadeti edukalt",
+ "email_deleted": "E-kiri kustutati",
+ "email_archived": "E-kiri arhiveeriti",
+ "emails_deleted": "{count, plural, one {# kiri kustutatud} other {# kirja kustutatud}}",
+ "emails_archived": "{count, plural, one {# kiri arhiveeritud} other {# kirja arhiveeritud}}",
+ "error_archiving": "Kirja arhiveerimine ebaõnnestus",
+ "error_updating": "Kirja uuendamine ebaõnnestus",
+ "email_starred": "E-kiri tähistati",
+ "email_unstarred": "E-kirja tähistus eemaldati",
+ "email_marked_read": "E-kiri märgiti loetuks",
+ "email_marked_unread": "E-kiri märgiti lugemata",
+ "copied_to_clipboard": "Kopeeritud lõikelauale",
+ "source_copied": "Lähtekood kopeeriti lõikelauale",
+ "error_sending": "E-kirja saatmine ebaõnnestus",
+ "error_deleting": "E-kirja kustutamine ebaõnnestus",
+ "error_loading": "E-kirjade laadimine ebaõnnestus",
+ "new_email": "Uus e-kiri",
+ "new_email_from": "Saatja: {sender}",
+ "click_to_view": "Vaatamiseks klõpsa",
+ "email_moved": "E-kiri liigutati",
+ "emails_moved": "{count} e-kirja liigutati",
+ "moved_to_mailbox": "Liigutatud kausta {mailbox}",
+ "move_failed": "Liigutamine ebaõnnestus",
+ "emails_copied": "{count} kirja kopeeritud",
+ "copy_failed": "Kopeerimine ebaõnnestus",
+ "move_error": "E-kirju ei õnnestunud valitud kausta liigutada",
+ "email_tagged": "E-kiri märgistati",
+ "emails_tagged": "{count} e-kirja märgistati",
+ "tag_failed": "Märgistamine ebaõnnestus",
+ "identity_created": "Identiteet loodi edukalt",
+ "identity_updated": "Identiteet uuendati edukalt",
+ "identity_deleted": "Identiteet kustutati",
+ "identity_set_primary": "Peamine identiteet uuendati",
+ "identity_create_failed": "Identiteedi loomine ebaõnnestus: {error}",
+ "identity_update_failed": "Identiteedi uuendamine ebaõnnestus: {error}",
+ "identity_delete_failed": "Identiteedi kustutamine ebaõnnestus: {error}",
+ "identity_unauthorized": "Sul ei ole õigust sellelt e-posti aadressilt saata",
+ "identity_not_found": "Identiteeti ei leitud",
+ "vacation_saved": "Puhkusevastaja seaded salvestati",
+ "vacation_save_failed": "Puhkusevastaja seadete salvestamine ebaõnnestus",
+ "filters_saved": "Filtrid salvestati edukalt",
+ "filters_save_failed": "Filtrite salvestamine ebaõnnestus",
+ "filters_deleted": "Filtrireegel kustutati",
+ "templates_exported": "Mallid eksporditi edukalt",
+ "templates_imported": "{count, plural, one {# mall} other {# malli}} imporditi",
+ "templates_import_errors": "Mõnda malli ei õnnestunud importida",
+ "templates_import_empty": "Failist ei leitud ühtegi malli",
+ "export_email_error": "E-kirja eksportimine ebaõnnestus",
+ "import_email_success": "E-kiri imporditi edukalt",
+ "import_email_error": "E-kirja importimine ebaõnnestus",
+ "rule_created": "Reegel loodud",
+ "rule_merged": "Lisatud reeglisse „{name}”",
+ "rule_already_covered": "Juba kaetud reegliga „{name}”",
+ "rule_apply_existing": "{count, plural, one {Rakenda # olemasolevale kirjale} other {Rakenda # olemasolevale kirjale}}",
+ "rule_applied": "{count, plural, =0 {Ühtegi kirja polnud vaja muuta} one {Reegel rakendati # kirjale} other {Reegel rakendati # kirjale}}",
+ "rule_apply_failed": "Reeglit ei õnnestunud olemasolevatele kirjadele rakendada",
+ "rule_edit": "Muuda reeglit",
+ "rule_undo": "Võta tagasi",
+ "rule_undone": "Reegli muudatus võeti tagasi",
+ "rule_undo_failed": "Reegli muudatust ei õnnestunud tagasi võtta",
+ "rule_undo_conflict": "Sinu filtreid on vahepeal muudetud. Võta muudatus tagasi filtriseadetes.",
+ "rule_folder_failed": "Kausta loomine ebaõnnestus",
+ "vacation_filters_save_failed": "Äraolekuteate seaded salvestati, kuid edastus- ja vastuse adressaate ei õnnestunud salvestada"
+ },
+ "date": {
+ "today": "Täna",
+ "yesterday": "Eile",
+ "this_week": "Sel nädalal",
+ "last_week": "Eelmisel nädalal",
+ "this_month": "Sel kuul",
+ "older": "Vanemad",
+ "just_now": "Just praegu",
+ "minutes_ago": "{count} minut tagasi",
+ "minutes_ago_plural": "{count} minutit tagasi",
+ "hours_ago": "{count} tund tagasi",
+ "hours_ago_plural": "{count} tundi tagasi",
+ "days_ago": "{count} päev tagasi",
+ "days_ago_plural": "{count} päeva tagasi"
+ },
+ "language": {
+ "title": "Keel",
+ "english": "English",
+ "french": "Français",
+ "japanese": "日本語",
+ "spanish": "Español",
+ "italian": "Italiano",
+ "german": "Deutsch",
+ "dutch": "Nederlands",
+ "portuguese": "Português",
+ "russian": "Русский",
+ "select_language": "Vali keel",
+ "switch_to_english": "Vaheta keeleks English",
+ "switch_to_french": "Vaheta keeleks Français",
+ "switch_to_japanese": "Vaheta keeleks 日本語",
+ "switch_to_spanish": "Vaheta keeleks Español",
+ "switch_to_italian": "Vaheta keeleks Italiano",
+ "switch_to_german": "Vaheta keeleks Deutsch",
+ "switch_to_dutch": "Vaheta keeleks Nederlands",
+ "switch_to_portuguese": "Vaheta keeleks Português",
+ "switch_to_russian": "Vaheta keeleks Русский",
+ "switching": "Keele vahetamine...",
+ "switch": "Vaheta keelt",
+ "current": "Praegune keel",
+ "polish": "Polski",
+ "switch_to_polish": "Vaheta keeleks Polski",
+ "en": "English",
+ "fr": "Français",
+ "de": "Deutsch",
+ "es": "Español",
+ "it": "Italiano",
+ "ja": "日本語",
+ "ko": "한국어",
+ "lv": "Latviešu",
+ "nl": "Nederlands",
+ "pl": "Polski",
+ "pt": "Português",
+ "he": "עברית",
+ "ru": "Русский",
+ "sk": "Slovenčina",
+ "uk": "Українська",
+ "zh": "简体中文",
+ "fa": "فارسی",
+ "farsi": "فارسی",
+ "switch_to_farsi": "Vaheta keeleks فارسی",
+ "mn": "Монгол",
+ "mongolian": "Монгол",
+ "switch_to_mongolian": "Vaheta keeleks Монгол"
+ },
+ "settings": {
+ "title": "Seaded",
+ "back_to_mail": "Tagasi postkasti",
+ "save_success": "Seaded salvestatud",
+ "import_success": "Seaded imporditud",
+ "import_error": "Seadete importimine ebaõnnestus",
+ "reset_confirm": "Kas soovid kindlasti lähtestada kõik seaded vaikeväärtustele?",
+ "unsaved_changes": "Sul on salvestamata muudatusi",
+ "discard_changes": "Kas loobuda salvestamata muudatustest?",
+ "discard": "Loobu",
+ "keep_editing": "Jätka muutmist",
+ "search_placeholder": "Otsi seadetest",
+ "search_clear": "Tühjenda otsing",
+ "search_no_results": "Vastavaid seadeid ei leitud",
+ "tabs": {
+ "appearance": "Välimus",
+ "language": "Keel, piirkond ja kellaaeg",
+ "email": "E-kirjade käitumine",
+ "composer": "Kirja koostamine",
+ "privacy": "Privaatsus ja turvalisus",
+ "account": "Konto",
+ "identities": "Identiteedid",
+ "vacation": "Puhkusevastaja",
+ "advanced": "Täpsemalt",
+ "calendar": "Kalender",
+ "filters": "Filtrid",
+ "templates": "Mallid",
+ "folders": "Kaustad",
+ "keywords": "Sildid",
+ "security": "Turvalisus",
+ "files": "Failid",
+ "contacts": "Kontaktid",
+ "encryption": "Krüpteerimine",
+ "protocol_handlers": "Vaikerakendused",
+ "sidebar_apps": "Külgriba rakendused",
+ "notifications": "Teavitused",
+ "layout": "Paigutus",
+ "themes": "Teemad",
+ "reading": "Lugemine",
+ "composing": "Kirjutamine",
+ "downloads": "Allalaadimised",
+ "content_senders": "Sisu ja saatjad",
+ "about_data": "Teave ja andmed",
+ "debug": "Silumine"
+ },
+ "themes": {
+ "title": "Teemad",
+ "description": "Vali administraatori paigaldatud teemade ja sisseehitatud eelseadistuste seast.",
+ "locked": "Teema valiku on administraator lukustanud.",
+ "forced": "Teema „{name}” on administraatori määratud ja seda ei saa muuta",
+ "admin_theme": "Administraatori teema",
+ "activated": "Teema aktiveeritud",
+ "default_restored": "Vaiketeema taastatud",
+ "default_name": "Vaikimisi",
+ "default_badge": "Vaikimisi",
+ "admin_enforced": "Administraatori määratud"
+ },
+ "tab_groups": {
+ "general": "Üldine",
+ "account": "Konto ja identiteet",
+ "organization": "Kirjade korraldamine",
+ "apps": "Rakendused",
+ "system": "Süsteem",
+ "appearance": "Välimus",
+ "mail": "Post",
+ "privacy": "Privaatsus ja turvalisus",
+ "advanced": "Täpsemalt"
+ },
+ "appearance": {
+ "message_list_order": {
+ "label": "Kirjade loendi järjestus",
+ "description": "Kuidas kirju loendis järjestatakse. Järjestamise teeb server, seega hõlmab see kogu kausta, mitte ainult laaditud kirju.",
+ "preset": {
+ "chronological": "Kronoloogiline (uuemad enne)",
+ "unread_first": "Lugemata enne",
+ "starred_first": "Tärniga enne",
+ "tagged_first": "Siltidega enne",
+ "custom": "Kohandatud"
+ },
+ "tag_label": "Silt",
+ "advanced_show": "Täpsemalt: muuda järjestustasemeid",
+ "advanced_hide": "Peida täpsem vaade",
+ "criterion_label": "{n}. taseme kriteerium",
+ "direction_label": "{n}. taseme suund",
+ "criteria": {
+ "unread": "Lugemata",
+ "starred": "Tärniga",
+ "tag": "Silt",
+ "receivedAt": "Saabumiskuupäev",
+ "sentAt": "Saatmiskuupäev",
+ "from": "Saatja",
+ "subject": "Teema",
+ "size": "Suurus"
+ },
+ "direction": {
+ "unread_first": "Lugemata enne",
+ "read_first": "Loetud enne",
+ "starred_first": "Tärniga enne",
+ "unstarred_first": "Tärnita enne",
+ "tagged_first": "Siltidega enne",
+ "untagged_first": "Siltideta enne",
+ "newest_first": "Uuemad enne",
+ "oldest_first": "Vanemad enne",
+ "a_to_z": "A → Z",
+ "z_to_a": "Z → A",
+ "largest_first": "Suuremad enne",
+ "smallest_first": "Väiksemad enne"
+ },
+ "add_level": "Lisa tase",
+ "remove_level": "Eemalda tase",
+ "move_up": "Liiguta üles",
+ "move_down": "Liiguta alla",
+ "tiebreak_note": "Kuni kolm taset; hilisemad tasemed lahendavad viigid. Uuemad enne rakendatakse alati viimasena.",
+ "scope": {
+ "label": "Rakenda kõigile kaustadele",
+ "description": "Kui väljas, kehtib järjestus ainult Saabunud kirjade kaustale; teised kaustad jäävad kronoloogiliseks."
+ },
+ "unsupported_note": "Sinu meiliserver ei toeta järjestamist loetud oleku, tärnide ega siltide järgi. Need tasemed jäetakse arvestamata ja loend jääb kronoloogiliseks."
+ },
+ "title": "Välimus",
+ "description": "Kohanda oma veebiposti välimust ja tunnetust",
+ "theme": {
+ "label": "Teema",
+ "description": "Vali eelistatud värviskeem",
+ "light": "Hele",
+ "dark": "Tume",
+ "system": "Süsteem"
+ },
+ "language": {
+ "label": "Keel",
+ "description": "Vali eelistatud keel"
+ },
+ "font_size": {
+ "label": "Kirja suurus",
+ "description": "Kohanda teksti suurust parema loetavuse jaoks",
+ "small": "Väike",
+ "medium": "Keskmine",
+ "large": "Suur"
+ },
+ "list_density": {
+ "label": "Tihedus",
+ "description": "Reguleeri vahesid ja polsterdust kogu kasutajaliideses",
+ "extra_compact": "Eriti kompaktne",
+ "compact": "Kompaktne",
+ "regular": "Tavaline",
+ "comfortable": "Mugav"
+ },
+ "interface_layout": {
+ "label": "Liidese paigutus",
+ "description": "Kus asuvad otsing, navigeerimine ja tööriistaribad",
+ "default": "Vaikimisi",
+ "mountain_view": "Mountain View"
+ },
+ "animations": {
+ "label": "Luba animatsioonid",
+ "description": "Kuva sujuvaid üleminekuid ja efekte"
+ },
+ "toolbar_position": {
+ "label": "Tööriistariba asukoht",
+ "description": "Kuhu kuvada kirjatoimingute nupud (Vasta, Arhiveeri, Kustuta jne)",
+ "top": "Üleval",
+ "below_subject": "Teema all"
+ },
+ "toolbar_labels": {
+ "label": "Näita tööriistariba nimetusi",
+ "description": "Kuva tekstisildid tööriistariba ikoonide kõrval. Lülita välja ruumi säästmiseks, kui ikoonid on juba tuttavad."
+ },
+ "hide_account_switcher": {
+ "label": "Peida külgriba kontovahetaja",
+ "description": "Peida kontovaliku menüü kaustade külgriba ülaosast. Kontosid saab endiselt vahetada alumiselt navigeerimisribalt."
+ },
+ "show_rail_account_list": {
+ "label": "Näita kontode avatare navigeerimisribal",
+ "description": "Kuva navigeerimisriba allosas eraldi kontoringid kiireks vahetamiseks ning nende all väljalogimise nupp."
+ },
+ "unified_mailbox": {
+ "label": "Ühendatud postkast",
+ "description": "Kuva aktiivse konto ja selle jagatud kaustade ühendatud kaustad (Saabunud, Saadetud jne).",
+ "cross_account": {
+ "label": "Kõigi kontode ulatuses",
+ "description": "Ühenda ühendatud postkast kõigi lisatud kontode ulatuses, mitte ainult aktiivse konto piires."
+ },
+ "include_group": {
+ "label": "Kaasa rühma postkastid",
+ "description": "Kaasa ühendatud vaatesse ka jagatud/rühma postkastid."
+ }
+ },
+ "all_mail": {
+ "folders_label": "Kaustad ühendatud loendites",
+ "folders_description": "Vali, millised selle konto kaustad kaasatakse loenditesse Kõik kirjad / Lugemata / Tärniga.",
+ "account_hint": "Kehtib konto {account} kohta.",
+ "no_folders": "Kaustad puuduvad."
+ },
+ "colorful_sidebar_icons": {
+ "label": "Värvilised külgriba ikoonid",
+ "description": "Värvi kausta- ja sildiikoonid tüübi järgi (sinine Saabunud, punane Rämpspost, roheline Saadetud jne). Lülita välja ühevärvilise külgriba jaoks."
+ },
+ "tint_list_rows": {
+ "label": "Värvi loendirida sildi värviga",
+ "description": "Tooni iga kirja rida selle esimese sildi värviga. Lülita välja, et read jääksid tavalised; sildipunktid ja -märgised näitavad värvi ikkagi."
+ },
+ "tint_list_rows_by_account": {
+ "label": "Toonita nimekirja read konto värviga",
+ "description": "Ühendatud vaates varjuta iga rida konto värviga konto punkti näitamise asemel. Sellel on eelis sildi värvi ees; sildid säilitavad oma värvi oma märgistel."
+ },
+ "show_folder_total_count": {
+ "label": "Näita kirjade koguarvu",
+ "description": "Näita kaustade ja siltide kõrval kirjade koguarvu koos lugemata arvuga. Lülita välja, et näidata ainult lugemata arvu."
+ },
+ "favicon_unread_badge": {
+ "label": "Lugemata arv vahekaardi ja rakenduse ikoonil",
+ "description": "Näita saabunud kirjade lugemata arvu märgisena brauseri vahekaardi ikoonil ja toetuse korral ka paigaldatud rakenduse ikoonil, et uued kirjad oleksid näha ka siis, kui vahekaart pole fookuses. Lülita välja, et mõlemad ikoonid jääksid märgiseta."
+ },
+ "show_shared_mail_accounts": {
+ "label": "Näita jagatud meilikontosid",
+ "description": "Näita sinuga jagatud postkastide kaustu külgribal jaotises Jagatud. Lülita välja, et peita kõik, või peida allpool üksikud kontod. Nende kalendrid ja kontaktid jäävad alles."
+ },
+ "pro_interface": {
+ "label": "Pro liides (eksperimentaalne)",
+ "description": "Ainult lauaarvutile mõeldud kogenud kasutaja paigutus mitme vahekaardiga kirjade sirvimiseks ja kontodeüleseks töövooks. Standardliidest see ei mõjuta ning saad igal ajal tagasi lülituda.",
+ "open_label": "Ava Pro liides",
+ "back_to_standard": "Tagasi standardliidesesse"
+ },
+ "cross_unread": {
+ "label": "Lugemata",
+ "description": "Kuva ühendatud postkastis kirje Lugemata, mis loetleb valitud kaustade lugemata kirjad."
+ },
+ "cross_starred": {
+ "label": "Tärniga",
+ "description": "Kuva ühendatud postkastis kirje Tärniga, mis loetleb valitud kaustade märgistatud/tärniga kirjad."
+ },
+ "cross_all": {
+ "label": "Kõik kirjad",
+ "description": "Kuva ühendatud postkastis kirje Kõik kirjad, mis loetleb kõik valitud kaustade kirjad."
+ }
+ },
+ "keywords": {
+ "title": "E-kirjade sildid",
+ "description": "Määra sildid, et oma e-kirju korrastada. Need salvestatakse serveris JMAP võtmesõnadena.",
+ "add_keyword": "Lisa silt",
+ "label_field": "Kuvatav nimi",
+ "label_placeholder": "nt Töö, Isiklik, Kiireloomuline",
+ "id_field": "Sildi ID",
+ "id_placeholder": "nt work, personal",
+ "color_field": "Värv",
+ "id_exists": "See sildi ID on juba kasutusel",
+ "edit": "Muuda silti",
+ "delete": "Kustuta silt",
+ "save": "Salvesta",
+ "add": "Lisa",
+ "cancel": "Tühista",
+ "migrating": "Sildi uuendamine olemasolevatel kirjadel…",
+ "migration_error": "Sildi uuendamine olemasolevatel kirjadel ebaõnnestus",
+ "nesting": {
+ "label": "Pesastatud sildid",
+ "description": "Pesasta silte teiste siltide alla ja kuva neid külgribal puuna."
+ },
+ "parent_field": "Ülemsilt",
+ "no_parent": "Ülemsilt puudub",
+ "too_long": "See sildi tee on liiga pikk (kuni {max} tähemärki)",
+ "has_children_locked": "Selle sildi all on pesastatud teisi silte, seega on selle nimi ja ülemsilt lukustatud. Teisalda või eemalda need kõigepealt.",
+ "has_children_delete": "Eemalda kõigepealt selle sildi alla pesastatud sildid",
+ "visibility_field": "Nähtavus külgribal",
+ "visibility": {
+ "show": "Näita",
+ "unread": "Näita, kui lugemata",
+ "hide": "Peida"
+ },
+ "unrecognized": {
+ "title": "Taasta sildid serverist",
+ "description": "Siltide nimed ja värvid salvestatakse selles brauseris, kuid sildid ise elavad su kirjadel JMAP võtmesõnadena. Skanni oma posti võtmesõnade suhtes, mida ükski siinne silt ei seleta, ja lisa need tagasi.",
+ "scan": "Skanni silte",
+ "rescan": "Skanni uuesti",
+ "scanning": "Skannimine… {scanned} / {total} kirja",
+ "none": "Kõik su kirjade sildid on siin juba määratud.",
+ "messages": "{count, plural, one {# kiri} other {# kirja}}",
+ "add_all": "Lisa kõik {count}",
+ "partial": "Skannitud on ainult {scanned} kõige uuemat {total} kirjast. Ainult vanematel kirjadel kasutatud sildid võivad puududa.",
+ "error": "Kirjade siltide skannimine ebaõnnestus."
+ }
+ },
+ "notifications": {
+ "test_sound": "Testi teavitusheli",
+ "sounds": {
+ "default": "Vaikimisi (piiks)",
+ "cheerful": "Rõõmus",
+ "involved": "Elav",
+ "swift": "Kiire liigutus",
+ "relax": "Rahulik"
+ },
+ "push": {
+ "title": "Taustateavitused",
+ "description": "Saa süsteemseid teavitusi uutest kirjadest ka siis, kui see sait on suletud. Edastatakse Bulwarki push-releele; relee ei näe kirjade sisu kunagi.",
+ "relay_label": "Push-relee",
+ "relay_desc": "Server, mis edastab su teavitusi. Vali administraatori pakutavate releede hulgast.",
+ "relay_locked": "Määratud administraatori poolt",
+ "relay_locked_desc": "Push-relee on määranud administraator ja seda ei saa muuta.",
+ "status_active": "Aktiivne selles seadmes",
+ "status_inactive": "Selles seadmes pole lubatud",
+ "status_unsupported": "See brauser ei toeta Web Pushi",
+ "status_lite": "Veebi-tõukemärguanded pole Bulwark Lite'is saadaval; need vajavad Bulwarki serverit.",
+ "status_busy": "Töötleb…",
+ "enable": "Luba",
+ "reenable": "Registreeri uuesti",
+ "disable": "Keela",
+ "confirm_disable_title": "Kas keelata taustateavitused?",
+ "confirm_disable_message": "See seade ei saa enam teavitusi, kui sait on suletud.",
+ "dismiss_aria": "Sulge teavituse viip",
+ "ios_hint": "iOS-is paigalda sait esmalt avakuvale - Safari edastab Web Pushi ainult paigaldatud PWA-dele.",
+ "devices_title": "Registreeritud seadmed",
+ "devices_desc": "Kõik seadmed, mis on registreeritud selle konto taustateavitusi saama. Tühistamine peatab seadme teavitused koheselt.",
+ "devices_loading": "Registreeritud seadmete laadimine…",
+ "devices_empty": "Ühtegi seadet pole taustateavituste jaoks registreeritud.",
+ "devices_error": "Registreeritud seadmete laadimine ebaõnnestus.",
+ "devices_refresh": "Värskenda",
+ "device_this": "See seade",
+ "device_other": "Muu seade",
+ "device_status_active": "Edastab teavitusi",
+ "device_status_inactive": "Pole kunagi teavitust edastanud",
+ "device_status_unknown": "Sellele releele pole tuntud",
+ "device_expires": "Aegub {date}",
+ "revoke": "Tühista",
+ "confirm_revoke_title": "Kas tühistada see seade?",
+ "confirm_revoke_message": "See lakkab taustateavitusi koheselt saamast. Pushi uuesti lubamine sellel seadmel registreerib selle uuesti.",
+ "confirm_revoke_message_this": "See seade lakkab taustateavitusi koheselt saamast."
+ },
+ "push_all": {
+ "title": "Kõik kontod",
+ "description": "Lülita tõukemärguanded korraga sisse või välja mitmele kontole. Üks brauseri tellimus teenindab neid kõiki.",
+ "select_all": "Vali iga ühendatud konto",
+ "state_on": "märguanded sees",
+ "state_off": "märguanded väljas",
+ "enable_selected": "Luba valitutele",
+ "disable_selected": "Keela valitutele",
+ "result_enabled": "Märguanded lubatud {count} kontole.",
+ "result_partial": "Lubatud {enabled} kontole; {failed} kontol ei õnnestunud lubada.",
+ "result_disabled": "Märguanded keelatud {count} kontole."
+ },
+ "sound_selection": {
+ "title": "Teavitusheli",
+ "description": "Vali, milline heli teavituste puhul mängitakse",
+ "choose": "Heli",
+ "choose_desc": "Vali teavitusheli ja kliki eelvaate kuulamiseks kõlariikoonil"
+ },
+ "email": {
+ "title": "E-kirja teavitused",
+ "description": "Seadista saabuvate kirjade teavitused",
+ "enabled": "E-kirja teavitused",
+ "enabled_desc": "Kuva teavitus uue kirja saabumisel",
+ "sound": "Teavitusheli",
+ "sound_desc": "Mängi helimärguanne uue kirja saabumisel",
+ "inbox_only": "Ainult saabunud kirjad",
+ "inbox_only_desc": "Teavita ainult kirjadest, mis jõuavad saabunud kirjade kausta; jäta vahele kirjad, mille filter liigitab mujale"
+ },
+ "calendar": {
+ "title": "Kalendri teavitused",
+ "description": "Seadista kalendrisündmuste teavitused",
+ "enabled": "Sündmuste teavitused",
+ "enabled_desc": "Kuva märguanded eelseisvate kalendrisündmuste kohta",
+ "sound": "Teavitusheli",
+ "sound_desc": "Mängi helimärguanne kalendri meeldetuletuste puhul",
+ "invitation_parsing": "Tuvasta e-kirjade kutsed",
+ "invitation_parsing_desc": "Tuvasta kalendrikutsed e-kirjade manustest ja kuva kalenditoimingud"
+ }
+ },
+ "language_region": {
+ "title": "Keel, piirkond ja kellaaeg",
+ "description": "Keel, kuupäevavorming, kellaajavorming ja muud piirkondlikud eelistused",
+ "language": {
+ "label": "Keel",
+ "description": "Vali eelistatud keel",
+ "english": "English",
+ "french": "Français"
+ },
+ "date_format": {
+ "label": "Kuupäevavorming",
+ "description": "Kuidas kuupäevi kirjade loendis kuvatakse",
+ "smart": "Nutikas (piirkonnateadlik)",
+ "relative": "Suhteline (1 t tagasi, 2 p tagasi)",
+ "full": "Alati täiskuupäev",
+ "preview_today": "Täna:",
+ "preview_this_week": "Sel nädalal:",
+ "preview_older": "Vanem:"
+ },
+ "date_locale": {
+ "label": "Kuupäevavormingu piirkond",
+ "description": "Kuidas numbrilisi kuupäevi järjestatakse (päev, kuu, aasta)",
+ "auto": "Automaatne (vastavalt keelele)",
+ "iso": "ISO 8601 (YYYY-MM-DD)",
+ "dmy": "Päev/Kuu/Aasta",
+ "mdy": "Kuu/Päev/Aasta"
+ },
+ "time_format": {
+ "label": "Kellaajavorming",
+ "description": "Vali 12- või 24-tunnise kella vahel",
+ "12h": "12-tunnine",
+ "24h": "24-tunnine"
+ },
+ "first_day": {
+ "label": "Nädala esimene päev",
+ "description": "Alusta nädalat pühapäeval või esmaspäeval",
+ "sunday": "Pühapäev",
+ "monday": "Esmaspäev"
+ },
+ "time_zone": {
+ "label": "Ajavöönd",
+ "description": "Kuva kirjade ja kalendri ajad selles ajavööndis, mitte brauseri teatatud ajavööndis",
+ "auto": "Automaatne ({zone})",
+ "preview_now": "Praegu:"
+ }
+ },
+ "email_behavior": {
+ "clear_search_on_folder_change": {
+ "label": "Tühjenda otsing kausta vahetamisel",
+ "description": "Kaustale (Saabunud, Saadetud, …) klõpsamine tühjendab aktiivse otsingu ja avab selle kausta. Kui see on välja lülitatud, jääb otsing kehtima ja käivitub uuesti kaustas, millele lülitud."
+ },
+ "request_read_receipt": {
+ "label": "Küsi vaikimisi lugemiskinnitust",
+ "description": "Luba uue kirja koostamisel vaikimisi lugemiskinnituse küsimine."
+ },
+ "read_receipt_response": {
+ "label": "Vastamine lugemiskinnituse päringutele",
+ "description": "Mida teha, kui saabuv kiri küsib lugemiskinnitust.",
+ "ask": "Küsi iga kord",
+ "always": "Saada alati",
+ "never": "Ära kunagi saada"
+ },
+ "title": "E-kirjade käitumine",
+ "description": "Seadista, kuidas e-kirju käsitletakse",
+ "mark_read": {
+ "label": "Märgi loetuks",
+ "description": "Millal märkida avatud e-kirjad loetuks",
+ "instant": "Koheselt",
+ "delay_3s": "3 sekundi pärast",
+ "delay_5s": "5 sekundi pärast",
+ "never": "Mitte kunagi"
+ },
+ "delete_action": {
+ "label": "Kustutamistoiming",
+ "description": "Mis juhtub e-kirja kustutamisel",
+ "trash": "Liiguta prügikasti",
+ "trash_and_read": "Liiguta prügikasti ja märgi loetuks",
+ "permanent": "Kustuta jäädavalt",
+ "warning": "E-kirjad kustutatakse jäädavalt ja neid ei saa taastada. Seda toimingut ei saa tagasi võtta."
+ },
+ "message_spacing": {
+ "label": "Kirja vahed",
+ "description": "Kirja sisu ümbritsev ruum lugemisvaates",
+ "auto": "Automaatne (soovitatud)",
+ "always": "Lisa alati ruumi",
+ "edge": "Servast servani"
+ },
+ "plain_text_font": {
+ "label": "Lihtteksti kirjatüüp",
+ "description": "Lihttekstina saadetud kirjade kirjatüüp",
+ "mono": "Fikseeritud laiusega",
+ "sans": "Tavaline (sama, mis rakenduses)"
+ },
+ "archive_mode": {
+ "label": "Arhiveeri kausta",
+ "description": "Kuidas korraldada e-kirju arhiveerimisel",
+ "single": "Ühte kausta",
+ "year": "Kaust aasta kohta",
+ "month": "Kaust kuu kohta",
+ "reorganize": "Korrasta olemasolev arhiiv",
+ "reorganize_success": "{count, plural, =0 {Ühtegi kirja ei olnud vaja korrastada} =1 {1 kiri korrastatud} other {# kirja korrastatud}}",
+ "reorganize_error": "Arhiivi korrastamine ebaõnnestus"
+ },
+ "permanently_delete_junk": {
+ "label": "Kustuta rämpspost jäädavalt",
+ "description": "Kustuta e-kirjad rämpsposti kaustast jäädavalt, selle asemel et liigutada need prügikasti"
+ },
+ "confirm_bulk_delete": {
+ "label": "Küsi kinnitust enne mitme kirja kustutamist",
+ "description": "Küsi enne mitme valitud kirja prügikasti teisaldamist. Lõplik kustutamine küsib alati."
+ },
+ "mail_layout": {
+ "label": "Kirjade paigutus",
+ "description": "Vali klassikalise jagatud lugemispaani, Gmaili-laadse fokuseeritud lugemisvoo või Zimbra-laadse alumise lugemispaani vahel.",
+ "split": "Jagatud paan",
+ "split_description": "Kuva kirjade loend ja lugemispaan kõrvuti.",
+ "focus": "Fokuseeritud loend",
+ "focus_description": "Kuva iga kirja kohta üks rida ja ava kiri täislaiuses, hoides kaustade külgriba nähtaval.",
+ "horizontal": "Lugemispaan all",
+ "horizontal_description": "Kuva kirjade loend üleval ja ava valitud kiri selle all olevas lugemispaanis."
+ },
+ "show_preview": {
+ "label": "Kuva eelvaate tekst",
+ "description": "Kuva e-kirja eelvaade loendis",
+ "focus_description": "Kuva eelvaate tekst fokuseeritud ühe rea kirjaloendi sees"
+ },
+ "show_verification_codes": {
+ "label": "Näita kinnituskoode",
+ "description": "Paku sisselogimis- ja kinnituskirjade koodi ühe klõpsuga kopeerimiseks, nimekirjas ühe päeva jooksul ja kirjas endas"
+ },
+ "disable_threading": {
+ "label": "Keela vestluste rühmitamine",
+ "description": "Kuva e-kirjad üksikute kirjadena vestluste kaupa rühmitamise asemel"
+ },
+ "plain_text_mode": {
+ "label": "Ainult lihttekst",
+ "description": "Keela vormindatud teksti redaktor ja saada kõik kirjad ainult lihttekstina, sealhulgas vastused ja edastused"
+ },
+ "auto_select_reply_identity": {
+ "label": "Vasta kirja saanud aadressilt",
+ "description": "Vastamisel saada kiri aadressilt, millele algne kiri saadeti. Sinu seadistatud identiteedid vastavad alati; kas ka teised sinu domeenide aadressid loetakse sobivaks (catch-all), määrab allpool olev „Vastuvõetud aadressi sobitamine“."
+ },
+ "reply_identity_match": {
+ "label": "Vastuvõetud aadressi sobitamine",
+ "description": "Millised vastuvõetud aadressid loetakse sinu omaks. „Ainult täpne aadress“ valib ainult ühe sinu seadistatud identiteetidest. „Iga aadress minu domeenidel“ käsitleb ka iga muud sinu identiteedi domeenil olevat aadressi catch-all aliasena ja kirjutab From-päise selle järgi ümber. Vali täpne aadress, kui need aadressid on pigem meililistid, mitte aliased.",
+ "exact": "Ainult täpne aadress",
+ "domain": "Iga aadress minu domeenidel"
+ },
+ "signature_position": {
+ "label": "Allkirja asukoht",
+ "description": "Kuhu lisada allkiri vastustes ja edastustes. Tsiteeritud teksti kohal mõjub loomuliku vastuse lõpetusena; all hoiab algse kirja katkematuna.",
+ "above_quote": "Enne tsiteeritud teksti",
+ "below_quote": "Pärast tsiteeritud teksti"
+ },
+ "signature_separator": {
+ "label": "Allkirja eraldaja",
+ "description": "Lisa allkirja ette standardne „-- “ eraldusrida (RFC 3676). Lülita välja, kui eelistad, et kiri sulandub otse allkirjaga."
+ },
+ "sub_address_delimiter": {
+ "label": "Alamaadressi eraldaja",
+ "description": "Märk, mis eraldab kasutajanime alamaadressi sildist. Kasuta sama eraldajat, mida kasutab sinu meiliserver (nt user{delimiter}tag@domain.com).",
+ "option": "{delimiter} (user{delimiter}tag@domain.com)",
+ "custom": "Kohandatud…",
+ "custom_input_label": "Kohandatud eraldusmärk"
+ },
+ "attachment_click_action": {
+ "label": "Manusel klõpsamise toiming",
+ "description": "Vali, kas manusel klõpsamine kuvab eelvaate või laadib selle kohe alla",
+ "preview": "Kuva eelvaade, kui võimalik",
+ "download": "Laadi kohe alla"
+ },
+ "attachment_position": {
+ "label": "Manuse asukoht",
+ "description": "Kuhu kuvada manused kirja päises",
+ "beside-sender": "Saatja kõrval",
+ "below-header": "Päise all"
+ },
+ "emails_per_page": {
+ "10": "10 kirja",
+ "25": "25 kirja",
+ "50": "50 kirja",
+ "100": "100 kirja",
+ "label": "Kirju lehe kohta",
+ "description": "Korraga laaditavate kirjade arv"
+ },
+ "always_light_mode": {
+ "label": "Kuva kirjad alati heledas režiimis",
+ "description": "Kuva kirja sisu heledas režiimis ka siis, kui rakendus on tumedas režiimis, vältimaks tumeda režiimi teisendusprobleeme"
+ },
+ "external_content": {
+ "label": "Väline sisu",
+ "description": "Kuidas käsitleda pilte ja välist sisu",
+ "ask": "Küsi alati",
+ "block": "Blokeeri alati",
+ "allow": "Luba alati"
+ },
+ "trusted_senders": {
+ "label": "Usaldusväärsed saatjad",
+ "description": "Halda saatjaid, kelle pildid laaditakse automaatselt",
+ "count_zero": "Puuduvad",
+ "count_one": "1 saatja",
+ "count_other": "{count} saatjat",
+ "modal_title": "Usaldusväärsed saatjad",
+ "empty_title": "Usaldusväärseid saatjaid pole veel lisatud",
+ "empty_description": "Blokeeritud piltidega kirja vaadates klõpsa „Usalda seda saatjat alati“, et lisada see siia.",
+ "add_manually": "Lisa saatja käsitsi",
+ "add_button": "Lisa",
+ "add_placeholder": "Sisesta e-posti aadress",
+ "search_placeholder": "Otsi saatjaid...",
+ "no_results": "Otsingule vastavaid saatjaid ei leitud",
+ "remove": "Eemalda",
+ "close": "Sulge",
+ "invalid_email": "Palun sisesta kehtiv e-posti aadress",
+ "already_added": "See saatja on juba usaldusväärne",
+ "save_error": "Salvestamine ebaõnnestus - üksikasjade jaoks kontrolli kontaktide silumislogi",
+ "use_address_book_label": "Sünkrooni aadressiraamatuga",
+ "use_address_book_description": "Salvesta usaldusväärsed saatjad eraldi „Usaldusväärsed saatjad“ aadressiraamatusse, et need sünkroniseeruksid kõigis su seadmetes"
+ },
+ "hover_actions": {
+ "label": "Kiirtoimingud hiirega üle liikudes",
+ "description": "Vali, millised kiirtoimingud kuvatakse, kui viid hiire kirja kohale loendis",
+ "delete": "Kustuta",
+ "star": "Lisa/eemalda täht",
+ "mark_read": "Märgi loetuks/lugemata",
+ "archive": "Arhiveeri",
+ "tag": "Silt",
+ "spam": "Märgi rämpspostiks",
+ "not_spam": "Pole rämpspost",
+ "none_selected": "Ühtegi toimingut pole valitud",
+ "mode_label": "Kuvamisrežiim",
+ "mode_inline": "Reasisene",
+ "mode_floating": "Hõljuv",
+ "corner_label": "Hõljuva asukoht",
+ "corner_top-left": "Üleval vasakul",
+ "corner_top-right": "Üleval paremal",
+ "corner_bottom-left": "All vasakul",
+ "corner_bottom-right": "All paremal"
+ },
+ "default_mail_program": {
+ "label": "Vaikimisi meiliprogramm",
+ "description": "Registreeri {appName} mailto: linkide vaikimisi meiliprogrammiks",
+ "button": "Määra vaikimisi",
+ "success": "Brauserilt küsiti kinnitust vaikimisi määramiseks",
+ "error": "Sinu brauser ei toeta seda funktsiooni"
+ },
+ "attachment_reminder": {
+ "label": "Manuse meeldetuletus",
+ "description": "Hoiata enne saatmist, kui kiri mainib manuseid, kuid ühtegi pole lisatud",
+ "keywords_label": "Käivitavad märksõnad",
+ "keywords_description": "Sõnad või fraasid, mis käivitavad meeldetuletuse, kui neid kirjas leidub",
+ "add_placeholder": "Lisa märksõna...",
+ "add": "Lisa",
+ "remove": "Eemalda"
+ },
+ "empty_subject_warning": {
+ "label": "Tühja teemarea hoiatus",
+ "description": "Küsi kinnitust enne teemarealta kirja saatmist."
+ },
+ "recipient_mentions": {
+ "label": "Maini adressaate @-ga",
+ "description": "Kirjas @ trükkimine pakub selle adressaate ja lisab valitu eesnime. Enter või Tab valib esiletõstetud nime."
+ },
+ "hide_inline_image_attachments": {
+ "label": "Peida reasisesed pildid manuste hulgast",
+ "description": "Kirja sisusse põimitud pilte ei kuvata eraldi manustena"
+ },
+ "attachment_image_previews": {
+ "label": "Kuva manustes piltide eelvaated",
+ "description": "Kuva pildimanused pisipildikaartidena üldiste failiikoonide asemel"
+ },
+ "send_delay": {
+ "label": "Saatmise tühistamine / saatmise viivitus",
+ "description": "Viivita tavalisi saatmisi lühikese serveripoolse ajaakna võrra.",
+ "off": "Väljas",
+ "seconds": "{seconds} sekundit",
+ "unsupported": "Praegune konto ei toeta viivitatud saatmist. Seade salvestatakse siiski teiste kontode jaoks."
+ },
+ "return_to_list_after_action": {
+ "label": "Naase loendisse pärast kustutamist või lugemata märkimist",
+ "description": "Pärast avatud kirja kustutamist või lugemata märkimist naase kirjade loendisse, selle asemel et avada järgmine kiri."
+ },
+ "swipe_right_action": {
+ "label": "Paremale pühkimise toiming (mobiil)",
+ "description": "Puuteseadmetel toiming, mis käivitub, kui pühid kirja loendis paremale."
+ },
+ "swipe_left_action": {
+ "label": "Vasakule pühkimise toiming (mobiil)",
+ "description": "Puuteseadmetel toiming, mis käivitub, kui pühid kirja loendis vasakule."
+ },
+ "swipe_actions": {
+ "none": "Puudub",
+ "archive": "Arhiveeri",
+ "delete": "Kustuta",
+ "mark_read": "Lülita loetud olekut",
+ "star": "Lülita täht",
+ "spam": "Märgi rämpspostiks"
+ },
+ "rtl_editing": {
+ "label": "Paremalt vasakule kirjutamise tugi",
+ "description": "Lisab koostamise tööriistaribale suunanupu, millega saad määrata lõigud vasakult paremale või paremalt vasakule"
+ }
+ },
+ "composer": {
+ "title": "Koostamine",
+ "description": "Seadista kirja koostamise sätteid",
+ "autosave": {
+ "label": "Automaatsalvestuse intervall",
+ "description": "Kui sageli mustandeid automaatselt salvestatakse",
+ "30s": "Iga 30 sekundi järel",
+ "1m": "Iga minuti järel",
+ "2m": "Iga 2 minuti järel",
+ "5m": "Iga 5 minuti järel"
+ },
+ "send_confirmation": {
+ "label": "Saatmise kinnitus",
+ "description": "Küsi kinnitust enne kirjade saatmist"
+ },
+ "default_reply": {
+ "label": "Vaikimisi vastamisrežiim",
+ "description": "Vaikimisi toiming vastamisel klõpsates",
+ "reply": "Vasta",
+ "reply_all": "Vasta kõigile"
+ }
+ },
+ "privacy": {
+ "title": "Privaatsus ja turvalisus",
+ "description": "Halda oma privaatsuse ja turvalisuse sätteid",
+ "external_images": {
+ "label": "Blokeeri välised pildid",
+ "description": "Väldi jälgimist väliste piltide kaudu"
+ },
+ "session_timeout": {
+ "label": "Seansi aegumine",
+ "description": "Logi automaatselt välja pärast tegevusetust",
+ "never": "Mitte kunagi",
+ "30m": "30 minutit",
+ "1h": "1 tund",
+ "4h": "4 tundi"
+ },
+ "clear_cache": {
+ "label": "Tühjenda vahemälu",
+ "description": "Eemalda vahemällu salvestatud andmed ja ajutised failid",
+ "button": "Tühjenda vahemälu",
+ "confirm": "Kas oled kindel, et soovid vahemälu tühjendada?",
+ "success": "Vahemälu tühjendati edukalt"
+ }
+ },
+ "account": {
+ "title": "Konto",
+ "description": "Vaata oma konto teavet",
+ "name_label": "Kuvatav nimi",
+ "username_label": "Kasutajanimi",
+ "account_type_label": "Konto tüüp",
+ "auth_method_label": "Autentimine",
+ "auth_method_oauth": "Ühekordne sisselogimine (OAuth/OIDC)",
+ "auth_method_basic": "Parool",
+ "auth_method_token": "Juurdepääsuluba",
+ "demo_account": "Demokonto",
+ "email": {
+ "label": "E-posti aadress",
+ "value": "{email}"
+ },
+ "server": {
+ "label": "JMAP server",
+ "value": "{server}"
+ },
+ "storage": {
+ "label": "Mahu kasutus",
+ "used": "{used} / {total} kasutatud",
+ "percentage": "{percent}% kasutatud"
+ },
+ "last_sync": {
+ "label": "Viimane sünkroonimine",
+ "value": "{time}"
+ },
+ "accounts": {
+ "title": "Sisselogitud kontod",
+ "description": "Lohista, et muuta kontode järjekorda konto rippmenüüs",
+ "active": "Praegu aktiivne konto",
+ "default_badge": "Vaikimisi konto",
+ "set_default": "Määra vaikimisi kontoks",
+ "switch_to": "Lülitu sellele kontole",
+ "move_up": "Liiguta üles",
+ "move_down": "Liiguta alla",
+ "drag_handle": "Lohista järjekorra muutmiseks",
+ "add": "Lisa konto"
+ },
+ "backup": {
+ "title": "Kontode nimekirja varukoopia",
+ "description": "Hoia selle brauseri kontode nimekirja serveris, krüpteeritult parooliga, mida tead ainult sina, ja too see tagasi teises seadmes. Postkastide paroole ei salvestata: igasse taastatud kontosse logid ise sisse.",
+ "status_none": "Kontole {account} pole nimekirja salvestatud.",
+ "status_saved": "Kontole {account} on nimekiri salvestatud.",
+ "save": "Salvesta nimekiri",
+ "restore": "Taasta nimekiri",
+ "delete": "Kustuta salvestatud nimekiri",
+ "cancel": "Tühista",
+ "delete_confirm": "Kas kustutada salvestatud kontode nimekiri serverist?",
+ "password": "Varukoopia parool",
+ "password_hint": "Vähemalt 10 märki. Seda ei saa taastada: kui unustad, salvesta nimekiri uuesti uue parooliga.",
+ "restore_hint": "Parool, mille valisid nimekirja salvestamisel.",
+ "confirm": "Kinnita parool",
+ "saved": "Nimekiri salvestatud ({count, plural, one {# konto} other {# kontot}}).",
+ "restored": "Lisatud kontosid: {added}. Juba olemasolevaid ja uuendatud kontosid: {updated}. Sisselogimiseks ava iga uus konto kontode nimekirjast.",
+ "skipped": "{count, plural, one {# kontot} other {# kontot}} ei lisatud: kontode piir on täis.",
+ "deleted": "Salvestatud nimekiri kustutatud.",
+ "errors": {
+ "mismatch": "Paroolid ei kattu.",
+ "password_length": "Kasuta vähemalt 10 märki.",
+ "unlock_failed": "Vale parool või nimekiri salvestati teisele kontole.",
+ "owner_signin_required": "Logi sellesse kontosse uuesti sisse ja proovi siis uuesti.",
+ "https_required": "See vajab turvalist (HTTPS) ühendust.",
+ "storage_failed": "Server ei saanud nimekirja lugeda ega salvestada. Proovi hiljem uuesti."
+ }
+ },
+ "shared_accounts": {
+ "title": "Minuga jagatud",
+ "description": "Grupi- ja jagatud kontod, mida saad hallata. Vali üks, et muuta selle filtreid, puhkusevastust, kalendreid ja kontakte.",
+ "shared_label": "Jagatud konto"
+ }
+ },
+ "scoped": {
+ "back": "Tagasi minu konto juurde",
+ "managing": "Haldan: {name}"
+ },
+ "security": {
+ "title": "Konto turvalisus",
+ "description": "Halda oma parooli, kaheastmelist autentimist ja turvasätteid",
+ "detecting": "Serveri võimekuste tuvastamine...",
+ "not_available": "Konto turvahaldus ei ole selle meiliserveri jaoks saadaval. Vajalikud õigused võivad olla keelatud. Üksikasjade jaoks vaata dokumentatsiooni .",
+ "password": {
+ "title": "Muuda parooli",
+ "current": "Praegune parool",
+ "new": "Uus parool",
+ "confirm": "Kinnita uus parool",
+ "submit": "Muuda parooli",
+ "success": "Parool muudeti edukalt",
+ "error_title": "Parooli muutmine ebaõnnestus",
+ "error_mismatch": "Uued paroolid ei kattu",
+ "error_min_length": "Parool peab olema vähemalt 8 tähemärki pikk",
+ "error_generic": "Parooli muutmine ebaõnnestus"
+ },
+ "display_name": {
+ "label": "Kuvatav nimi",
+ "description": "Sinu nimi, nagu see serveris kuvatakse",
+ "placeholder": "Sisesta oma kuvatav nimi",
+ "save": "Salvesta",
+ "success": "Kuvatav nimi uuendatud",
+ "error": "Kuvatava nime uuendamine ebaõnnestus"
+ },
+ "totp": {
+ "section_title": "Kaheastmeline autentimine",
+ "label": "TOTP autentimine",
+ "description": "Lisa täiendav turvakiht ajapõhise ühekordse parooliga",
+ "active": "Lubatud",
+ "inactive": "Keelatud",
+ "enabled": "Kaheastmeline autentimine lubatud",
+ "disabled": "Kaheastmeline autentimine keelatud",
+ "enable_error": "2FA lubamine ebaõnnestus",
+ "disable_error": "2FA keelamine ebaõnnestus",
+ "setup_instructions": "Kopeeri see URL oma autentimisrakendusse (Google Authenticator, Authy jne):",
+ "verification_code": "Kinnituskood",
+ "confirm": "Kinnita",
+ "disable": "Keela",
+ "disable_confirm_prompt": "Sisesta oma parool, et keelata kaheastmeline autentimine.",
+ "password_required": "Parool on kohustuslik",
+ "code_required": "Kinnituskood on kohustuslik",
+ "code_invalid": "Vale kinnituskood. Kontrolli autentimisrakendust ja proovi uuesti."
+ },
+ "app_passwords": {
+ "title": "Rakenduste paroolid",
+ "description": "Loo paroolid rakendustele, mis ei toeta kaheastmelist autentimist",
+ "add": "Lisa",
+ "create": "Loo",
+ "cancel": "Tühista",
+ "done": "Valmis",
+ "generate": "Genereeri",
+ "name_label": "Rakenduse nimi",
+ "name_placeholder": "nt Thunderbird, iPhone Mail",
+ "expires_label": "Aegub (valikuline)",
+ "allowed_ips_label": "Lubatud IP-d (valikuline)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Eraldatud koma või tühikuga. Jäta tühjaks, et lubada kõik IP-d.",
+ "password_label": "Parool (jäta tühjaks automaatseks genereerimiseks)",
+ "password_placeholder": "Genereeritakse automaatselt, kui tühi",
+ "copy_now_warning": "Kopeeri see parool kohe - seda ei kuvata enam uuesti.",
+ "added": "Rakenduse parool loodud",
+ "removed": "Rakenduse parool eemaldatud",
+ "add_error": "Rakenduse parooli loomine ebaõnnestus",
+ "remove_error": "Rakenduse parooli eemaldamine ebaõnnestus",
+ "none": "Ühtegi rakenduse parooli pole seadistatud"
+ },
+ "api_keys": {
+ "title": "API võtmed",
+ "description": "Loo API võtmeid skriptidele ja integratsioonidele, mis suhtlevad serveriga otse",
+ "name_label": "Võtme nimi",
+ "name_placeholder": "nt Varunduse skript, CI runner",
+ "copy_now_warning": "Kopeeri see API võti kohe - seda ei kuvata enam uuesti.",
+ "added": "API võti loodud",
+ "removed": "API võti eemaldatud",
+ "add_error": "API võtme loomine ebaõnnestus",
+ "remove_error": "API võtme eemaldamine ebaõnnestus",
+ "none": "Ühtegi API võtit pole seadistatud"
+ },
+ "encryption": {
+ "section_title": "Salvestatud andmete krüpteerimine",
+ "label": "E-kirjade krüpteerimine",
+ "description": "Krüpteeri serveris salvestatud kirjad täiendava privaatsuse tagamiseks",
+ "active": "{type} krüpteerimine lubatud",
+ "inactive": "Keelatud",
+ "enabled": "Salvestatud andmete krüpteerimine lubatud",
+ "disabled_success": "Salvestatud andmete krüpteerimine keelatud",
+ "error": "Krüpteerimissätete uuendamine ebaõnnestus",
+ "algorithm_label": "Krüpteerimisalgoritm",
+ "encrypt_on_append": "Krüpteeri uued kirjad üleslaadimisel",
+ "allow_spam_training": "Luba rämpsposti tuvastuse treenimine enne kirjade krüpteerimist",
+ "enable_encryption": "Luba krüpteerimine",
+ "enable_tooltip": "Luba krüpteerimine selle võtmega",
+ "configure_title": "Luba krüpteerimine selle võtmega",
+ "enable_button": "Luba",
+ "disable_tooltip": "Keela krüpteerimine",
+ "enabled_success": "Salvestatud andmete krüpteerimine lubatud"
+ },
+ "public_keys": {
+ "name_label": "Avaliku võtme nimi",
+ "name_placeholder": "nt Minu võti",
+ "key_label": "Avalik võti (S/MIME või PGP ASCII-armored)",
+ "key_placeholder": "Kleebi oma avalik võti siia",
+ "none": "Ühtegi avalikku võtit pole seadistatud"
+ },
+ "email_client": {
+ "title": "E-posti kliendi seadistus",
+ "description": "Kasuta neid andmeid oma lauaarvuti või mobiili e-posti kliendi seadistamiseks (Thunderbird, Apple Mail, Outlook jne)",
+ "jmap_username_label": "JMAP kasutajanimi",
+ "copy": "Kopeeri",
+ "copied": "Kopeeritud",
+ "password_instructions": "Kasuta oma e-posti kliendisse sisselogimiseks ülaltoodud JMAP kasutajanime koos rakenduse parooliga. Kui sul veel pole rakenduse parooli, loo see ülaltoodud jaotises."
+ },
+ "link_device": {
+ "title": "Ühenda mobiilirakendus",
+ "description": "Logi Bulwark Mail mobiilirakendusse sisse ilma midagi sisestamata. Genereeri siin QR-kood ja skaneeri see rakenduse sisselogimisekraanilt.",
+ "generate": "Kuva QR-kood",
+ "regenerate": "Kuva uus kood",
+ "instructions": "Ava Bulwark Mail rakendus, puuduta sisselogimisekraanil „Skaneeri QR-kood“ ja suuna kaamera siia.",
+ "expires_in": "See kood aegub {seconds} sekundi pärast. Seda saab kasutada ainult üks kord.",
+ "expired": "See kood on aegunud.",
+ "error": "Sidumiskoodi loomine ebaõnnestus. Palun proovi uuesti.",
+ "password_prompt": "Sisesta kinnituseks oma parool. Sinu telefon saab oma sisselogimise; parooli telefonile ei saadeta.",
+ "show_password": "Näita parooli",
+ "hide_password": "Peida parool",
+ "continue": "Jätka",
+ "error_wrong_password": "Vale parool. Proovi uuesti.",
+ "totp_prompt": "Sisesta kood oma autentimisrakendusest.",
+ "error_password_or_code": "Parool või kood on vale.",
+ "error_not_signed_in": "Sinu seanss on aegunud. Seadme sidumiseks logi uuesti sisse.",
+ "error_impersonation": "Seadmeid ei saa siduda, kui vaatad teise kasutaja postkasti.",
+ "error_too_many_attempts": "Liiga palju katseid. Oota hetk ja proovi uuesti.",
+ "error_session_secret": "Seadme sidumine pole sellel serveril seadistatud (puudub SESSION_SECRET). Küsi administraatorilt.",
+ "error_server_unreachable": "Meiliserveriga ei saanud ühendust. Proovi uuesti.",
+ "error_pairing_unavailable": "Sinu meiliserver ei luba seadet sel viisil siduda. Küsi administraatorilt.",
+ "qr_alt": "Sisselogimise QR-kood",
+ "link_label": "Sisselogimise link",
+ "copy_link": "Kopeeri sisselogimise link",
+ "copied": "Kopeeritud",
+ "copy_hint": "Kaamerat pole? Kopeeri link ja kleebi see rakenduses valikuga „Kleebi sisselogimise link”.",
+ "success_title": "Telefonis sisse logitud",
+ "success_body": "Võid selle lehe sulgeda. Teise seadme sidumiseks näita uut koodi.",
+ "link_another": "Seo teine seade",
+ "use_sso": "Kasuta hoopis ühekordset sisselogimist",
+ "error_insecure_server": "Mobiilirakendus ühendub meiliserveritega ainult üle https-i ja see server kasutab http-d."
+ }
+ },
+ "identities": {
+ "title": "Saatja identiteedid",
+ "description": "Halda e-posti aadresse, millelt saad kirju saata",
+ "identities_count": {
+ "label": "Sinu identiteedid",
+ "description": "Saatmiseks seadistatud e-posti aadressid",
+ "count_zero": "Identiteete pole",
+ "count_one": "1 identiteet",
+ "count_other": "{count} identiteeti"
+ },
+ "manage": "Halda identiteete",
+ "sub_addressing": {
+ "label": "Alamaadressimine",
+ "description": "Kasuta silte, näiteks user+tag@domain.com, sisenevate kirjade korraldamiseks",
+ "learn_more": "Loe lähemalt"
+ }
+ },
+ "vacation": {
+ "title": "Äraolekuteade",
+ "description": "Vasta automaatselt sisenevatele kirjadele, kui oled eemal",
+ "loading": "Äraolekuteate seadete laadimine...",
+ "not_supported": "Sinu meiliserver ei toeta äraolekuteateid.",
+ "fetch_error": "Äraolekuteate seadete laadimine ebaõnnestus. Palun proovi uuesti.",
+ "status": {
+ "label": "Äraolekuteade",
+ "description": "Saada automaatvastus inimestele, kes sulle kirjutavad",
+ "active": "Aktiivne",
+ "inactive": "Mitteaktiivne"
+ },
+ "date_range": {
+ "start": "Alguskuupäev",
+ "start_description": "Jäta tühjaks, kui alguspiirangut pole vaja",
+ "end": "Lõppkuupäev",
+ "end_description": "Jäta tühjaks, kui lõpupiirangut pole vaja"
+ },
+ "message": {
+ "subject_label": "Teema",
+ "subject_description": "Automaatvastuse teemarida",
+ "subject_placeholder": "Olen eemal",
+ "body_label": "Sõnumi sisu",
+ "body_description": "Lihtteksti kujul sõnumi sisu",
+ "body_placeholder": "Täname kirja eest. Olen hetkel eemal ja vastan pärast tagasi jõudmist.",
+ "html_label": "Vormindatud sõnum (HTML)",
+ "html_description": "Lisa rikkalikult vormindatud versioon linkide ja kujundusega. Saajad, kelle meiliklient seda kuvada ei suuda, näevad ülaltoodud lihtteksti.",
+ "html_placeholder": "Kirjuta vormindatud äraolekuvastus…"
+ },
+ "preview": {
+ "title": "Eelvaade",
+ "show": "Näita eelvaadet",
+ "hide": "Peida eelvaade"
+ },
+ "save": "Salvesta muudatused",
+ "saving": "Salvestamine...",
+ "warnings": {
+ "end_before_start": "Lõppkuupäev peab olema hilisem kui alguskuupäev",
+ "invalid_date": "Sisesta kehtiv algus- ja lõppkuupäev koos kellaajaga või jäta väli tühjaks",
+ "start_in_past": "Alguskuupäev on minevikus",
+ "empty_body": "Sõnumi sisu on tühi – saajad saavad tühja vastuse",
+ "subject_too_long": "Teema on serveri jaoks liiga pikk (kuni {max} baiti).",
+ "body_too_long": "Sõnum on serveri jaoks liiga pikk (kuni {max} baiti, vormindus kaasa arvatud).",
+ "forward_address": "Sisesta kehtiv aadress, kuhu edastada",
+ "not_running": "Edastus või vastuse adressaadid on salvestatud, kuid pole praegu aktiivsed. Salvesta, et need uuesti sisse lülitada."
+ },
+ "forward": {
+ "title": "Edastamine",
+ "description": "Edastab saabuvad kirjad teisele aadressile, automaatvastusega või ilma, ülaltoodud ajavahemiku jooksul (ilma lõppkuupäevata kuni väljalülitamiseni). Rämpsposti ei edastata.",
+ "enabled_label": "Edasta kirjad",
+ "to_label": "Edasta aadressile",
+ "to_placeholder": "email@example.com",
+ "keep_label": "Hoia saabunud kirjade kaustas",
+ "keep_description": "Muidu kiri ainult edastatakse: seda ei hoita selles postkastis ja ühtegi filtrireeglit sellele ei rakendata."
+ },
+ "audience": {
+ "label": "Vasta",
+ "description": "Sisesaatjad: {domains}",
+ "all": "Kõigile saatjatele",
+ "internal": "Ainult sisesaatjatele",
+ "external": "Ainult välissaatjatele"
+ },
+ "auto_reply": {
+ "title": "Automaatvastus"
+ }
+ },
+ "folders": {
+ "role_memos": "Märkmed",
+ "title": "Kaustad",
+ "description": "Halda oma e-posti kaustu ja määra neile standardrollid",
+ "folder_list": "Sinu kaustad",
+ "folder_list_description": "Kaustaikooni kohandamiseks klõpsa sellel",
+ "standard_roles": "Standardsed kaustarollid",
+ "standard_roles_description": "Määra, milliseid kaustu kasutatakse postkasti standardrollideks, nagu Sisendkast, Saadetud, Prügikast jne.",
+ "role_inbox": "Sisendkast",
+ "role_drafts": "Mustandid",
+ "role_sent": "Saadetud",
+ "role_trash": "Prügikast",
+ "role_junk": "Rämpspost",
+ "role_archive": "Arhiiv",
+ "role_scheduled": "Ajastatud",
+ "role_snoozed": "Edasi lükatud",
+ "role_none": "Puudub",
+ "create_folder": "Loo kaust",
+ "create_subfolder": "Loo alamkaust",
+ "reorder": "Lohista üles-alla ümberjärjestamiseks, vasakule-paremale ülemkausta muutmiseks",
+ "reorder_error": "Kaustade ümberjärjestamine ebaõnnestus",
+ "move_error": "Kausta liigutamine ebaõnnestus",
+ "select_folder": "Vali {name}",
+ "select_hint": "Vali. Shift+klõps valib kõik kaustad kuni viimati klõpsatuni.",
+ "selected_count": "{count, plural, one {# kaust} other {# kausta}} valitud",
+ "move_to": "Liiguta...",
+ "move_to_top_level": "Ülemine tase",
+ "move": "Liiguta",
+ "clear_selection": "Tühista valik",
+ "folders_moved": "{count, plural, one {# kaust liigutatud} other {# kausta liigutatud}}",
+ "move_partial_error": "{count, plural, one {# kausta} other {# kausta}} ei õnnestunud liigutada",
+ "subfolder_of": "Kausta {name} sees",
+ "subfolder_name": "Alamkausta nimi",
+ "new_folder_name": "Kausta nimi",
+ "rename": "Nimeta ümber",
+ "change_icon": "Muuda ikooni",
+ "delete": "Kustuta",
+ "confirm_delete": "Kas oled kindel, et soovid kustutada kausta \"{name}\"? Selles kaustas olevad kirjad liigutatakse prügikasti.",
+ "create": "Loo",
+ "cancel": "Tühista",
+ "no_folders": "Kohandatud kaustu pole",
+ "cannot_delete_role": "Standardrolliga kausta ei saa kustutada. Eemalda enne roll.",
+ "folder_created": "Kaust loodud",
+ "folder_renamed": "Kaust nimetatud ümber",
+ "folder_deleted": "Kaust kustutatud",
+ "role_updated": "Kausta roll uuendatud",
+ "error_create": "Kausta loomine ebaõnnestus",
+ "error_rename": "Kausta ümbernimetamine ebaõnnestus",
+ "error_delete": "Kausta kustutamine ebaõnnestus",
+ "error_delete_has_children": "Kausta ei saa kustutada: selles on veel alamkaustu. Kustuta või liiguta need enne.",
+ "error_delete_has_email": "Kausta ei saa kustutada: selles on veel kirju. Liiguta või kustuta need enne.",
+ "error_role": "Kausta rolli uuendamine ebaõnnestus"
+ },
+ "advanced": {
+ "title": "Täpsemalt",
+ "description": "Täpsemad valikud ja arendaja seaded",
+ "debug_mode": {
+ "label": "Silumisrežiim",
+ "description": "Luba tõrkeotsinguks üksikasjalik logimine"
+ },
+ "debug_categories": {
+ "description": "Vali, milliseid kategooriaid logida. Lülita välja need, mida ei vaja, et vähendada konsooli müra.",
+ "jmap": "JMAP klient",
+ "jmap_description": "Postkasti toimingud, kirjade laadimine ja JMAP protokolli päringud",
+ "calendar": "Kalender",
+ "calendar_description": "Kalendrisündmused, import ja ajastussõnumid",
+ "tasks": "Ülesanded",
+ "tasks_description": "Kalendriülesannete loomine, laadimine ja uuendamine",
+ "auth": "Autentimine",
+ "auth_description": "Sisselogimine, TOTP, tokenite vahetus ja seansihaldus",
+ "filters": "Filtrid",
+ "filters_description": "Sieve filtrireeglid ja äraolekuskriptid",
+ "email": "Kirjade vaatamine",
+ "email_description": "Kirjade renderdamine, TNEF-i töötlus ja loetuks märkimine",
+ "push": "Push-teavitused",
+ "push_description": "Push-teavituste seadistus ja edastamine",
+ "contacts": "Kontaktid ja aadressiraamatud",
+ "contacts_description": "Kontaktide sünkroonimine, aadressiraamatu toimingud ja usaldusväärsed saatjad"
+ },
+ "settings_sync": {
+ "label": "Seadete sünkroonimine",
+ "description": "Sünkroni oma seaded brauserite ja seadmete vahel"
+ },
+ "sender_favicons": {
+ "label": "Saatja favicon'id",
+ "description": "Näita äriliste saatjate profiilipiltidena veebisaitide ikoone"
+ },
+ "show_avatars_in_junk": {
+ "label": "Näita avatare rämpsposti kaustas",
+ "description": "Näita rämpsposti kaustas saatjate profiilipilte ja favicon'e. Vaikimisi välja lülitatud, et mitte anda õngitsuskatsetele visuaalset usaldusväärsust."
+ },
+ "keyboard_shortcuts": {
+ "label": "Klaviatuuri otseteed",
+ "description": "Vaata saadaolevaid klaviatuuri otseteid",
+ "button": "Vaata otseteid"
+ },
+ "refresh_cache": {
+ "label": "Värskenda vahemällu salvestatud andmeid",
+ "description": "Laadi kontaktid, kalendrid ja kaustad serverist uuesti. Kontod ja seansid jäävad alles — parandab aegunud või vale kuva ilma välja logimata.",
+ "button": "Värskenda"
+ },
+ "reset_settings": {
+ "label": "Lähtesta seaded",
+ "description": "Taasta kõik seaded vaikeväärtustele",
+ "button": "Lähtesta vaikeväärtustele"
+ },
+ "export_settings": {
+ "label": "Ekspordi seaded",
+ "description": "Laadi oma seaded alla JSON-ina",
+ "button": "Ekspordi"
+ },
+ "import_settings": {
+ "label": "Impordi seaded",
+ "description": "Lae seaded üles JSON-failist",
+ "button": "Impordi"
+ },
+ "about": {
+ "title": "Bulwark Webmail",
+ "source_code": "Lähtekood"
+ },
+ "spam_siege": {
+ "title": "Rämpsposti piiramine",
+ "intro": "Klõpsa rämpsposti ja õngitsuse peal enne, kui need sinu postkasti jõuavad. Lase päris kirjad läbi. Kui kaotad kõik kolm kilpi, langeb postkast.",
+ "kinds": {
+ "spam": "Rämpspost",
+ "phishing": "Õngitsus",
+ "legit": "Päris kiri"
+ },
+ "click_to_block": "Klõpsa blokeerimiseks",
+ "let_through": "Lase läbi",
+ "start": "Alusta",
+ "play_again": "Mängi uuesti",
+ "score": "Skoor",
+ "combo": "Seeriabonus",
+ "shields_left": "{count} / {total} kilpi alles",
+ "seconds": "{seconds}s",
+ "best": "Parim: {score}",
+ "new_best": "Uus rekord",
+ "held": "Postkast peetud",
+ "overrun": "Postkast vallutatud",
+ "stats": {
+ "caught": "Blokeeritud",
+ "delivered": "Kohale jõudnud",
+ "mistakes": "Vead"
+ }
+ }
+ },
+ "sidebar_apps": {
+ "title": "Külgriba rakendused",
+ "description": "Halda kohandatud rakendusi ja linke oma külgribal",
+ "keep_loaded": "Hoia rakendused laaditud",
+ "keep_loaded_description": "Hoia lisatud rakendused vahetamisel taustal töös, et vältida uuesti laadimist",
+ "managed_title": "Pakub sinu administraator",
+ "managed_description": "Need rakendused on seadistatud kõigile ja neid ei saa muuta.",
+ "manage_title": "Kohandatud rakendused",
+ "manage_description": "Lisa, muuda või eemalda oma külgriba kohandatud rakendusi"
+ },
+ "contacts": {
+ "title": "Kontaktid",
+ "description": "Impordi ja ekspordi oma kontaktid",
+ "group_by_letter_label": "Rühmita esitähe järgi",
+ "group_by_letter_description": "Näita kontaktide loendis tähestikulisi jaotisepealkirju",
+ "sort_by_last_name_label": "Sordi perekonnanime järgi",
+ "sort_by_last_name_description": "Järjesta kontaktide loend perekonnanime järgi, et pereliikmed oleksid koos",
+ "import_label": "Impordi kontaktid",
+ "import_description": "Impordi kontaktid vCard (.vcf) failist",
+ "export_label": "Ekspordi kontaktid",
+ "export_description": "Ekspordi kõik kontaktid vCard (.vcf) failina",
+ "manage_title": "Aadressiraamatud",
+ "manage_description": "Nimeta oma aadressiraamatud ümber",
+ "no_address_books": "Aadressiraamatuid ei leitud",
+ "categories_title": "Kategooriad",
+ "categories_description": "Nimeta kontaktikategooriad ümber",
+ "no_categories": "Kategooriaid ei leitud"
+ },
+ "downloads": {
+ "title": "Allalaadimised",
+ "description": "Kohanda, kuidas allalaaditud kirjad ja manused nimetatakse.",
+ "reset": "Taasta vaikeväärtus",
+ "preview": "Eelvaade:",
+ "email_template": {
+ "label": "Kirja (.eml) failinimi",
+ "description": "Mall, mida kasutatakse kirja eksportimisel või failisüsteemi lohistamisel. Laiend .eml lisatakse automaatselt."
+ },
+ "attachment_template": {
+ "label": "Manuse failinimi",
+ "description": "Mall, mida kasutatakse manuse allalaadimisel või lohistamisel. Kui jätad välja '{filename}' ja '{ext}', säilib algne laiend."
+ },
+ "bundle_template": {
+ "label": "Mitme kirja .zip-faili nimi",
+ "description": "Mall, mida kasutatakse mitme valitud kirja lohistamisel või allalaadimisel ühe .zip-arhiivina. Laiend .zip lisatakse automaatselt."
+ },
+ "spaces": {
+ "label": "Tühikud",
+ "description": "Asenda tulemuseks oleva failinime tühikud muu märgiga.",
+ "keep": "Säilita tühikud",
+ "underscore": "Asenda märgiga _",
+ "dash": "Asenda märgiga -"
+ },
+ "lowercase": {
+ "label": "Väiketähed",
+ "description": "Muuda kogu failinimi väiketähtedeks."
+ },
+ "strip_diacritics": {
+ "label": "Eemalda diakriitikud",
+ "description": "Teisenda diakriitikutega tähed nende ASCII vastavusteks (ä → a, é → e). Kasulik tööriistadele, mis rikuvad Unicode failinimesid."
+ },
+ "collapse_separators": {
+ "label": "Ahenda korduvad eraldajad",
+ "description": "Ahenda järjestikused tühikud, alakriipsud või sidekriipsud üheks märgiks."
+ },
+ "after_export": {
+ "label": "Pärast eksportimist",
+ "description": "Soovi korral liiguta kiri pärast .eml-ina eksportimist.",
+ "keep": "Jäta postkasti",
+ "archive": "Liiguta arhiivi",
+ "trash": "Liiguta prügikasti"
+ }
+ },
+ "filters": {
+ "title": "E-posti filtrid",
+ "description": "Loo reegleid sisenevate kirjade automaatseks sortimiseks, sildistamiseks ja haldamiseks",
+ "add_rule": "Lisa reegel",
+ "no_rules": "Filtrireegleid pole",
+ "no_rules_description": "Loo reegleid sisenevate kirjade automaatseks korraldamiseks",
+ "vacation_active": "Äraolekuteade on aktiivne",
+ "vacation_active_description": "Automaatvastus on sisenevatele sõnumitele lubatud",
+ "vacation_configure": "Seadista",
+ "edit_rule": "Muuda reeglit",
+ "new_rule": "Uus reegel",
+ "delete_rule": "Kustuta reegel",
+ "delete_confirm": "Kas oled kindel, et soovid selle reegli kustutada?",
+ "enable": "Luba",
+ "disable": "Keela",
+ "raw_editor": "Sieve lähtekoodi redaktor",
+ "raw_editor_warning": "Sieve lähtekoodi muutmine võib visuaalse reeglite redigeerimise katki teha. Siin tehtud muudatused tühistavad visuaalse koostaja.",
+ "validate": "Valideeri",
+ "validation_success": "Skript on kehtiv",
+ "validation_error": "Skriptis on vigu",
+ "save": "Salvesta reeglid",
+ "saving": "Salvestamine...",
+ "saved": "Filtrid salvestatud edukalt",
+ "save_failed": "Filtrite salvestamine ebaõnnestus",
+ "loading": "Filtrite laadimine...",
+ "not_supported": "Sinu meiliserver ei toeta e-posti filtreid.",
+ "rule_name": "Reegli nimi",
+ "rule_name_placeholder": "nt uudiskirjade sortimine",
+ "match_all": "Vasta KÕIGILE tingimustele",
+ "match_any": "Vasta MISTAHES tingimusele",
+ "conditions": "Tingimused",
+ "add_condition": "Lisa tingimus",
+ "actions": "Tegevused",
+ "add_action": "Lisa tegevus",
+ "stop_processing": "Peata järgmiste reeglite töötlemine",
+ "include_spam": "Liiguta ka rämpspostiks märgitud kirjad",
+ "attachment_type_placeholder": "nt pdf, doc, jpg",
+ "value_placeholder_multi": "Väärtus (mitu, komadega eraldatud)",
+ "condition_fields": {
+ "attachment": "Manus",
+ "from": "Saatja",
+ "to": "Saaja",
+ "cc": "Koopia",
+ "subject": "Teema",
+ "header": "Kohandatud päis",
+ "size": "Suurus",
+ "body": "Sisu",
+ "all": "Kõik kirjad"
+ },
+ "comparators": {
+ "has_any": "on olemas",
+ "has_type": "tüübiga",
+ "contains": "sisaldab",
+ "not_contains": "ei sisalda",
+ "is": "on täpselt",
+ "not_is": "ei ole",
+ "starts_with": "algab",
+ "ends_with": "lõpeb",
+ "matches": "vastab mustrile",
+ "greater_than": "on suurem kui",
+ "less_than": "on väiksem kui",
+ "address_is": "on aadress",
+ "domain_is": "on domeen"
+ },
+ "action_types": {
+ "move": "Liiguta kausta",
+ "copy": "Kopeeri kausta",
+ "forward": "Suuna edasi",
+ "mark_read": "Märgi loetuks",
+ "star": "Märgi tärniga",
+ "add_label": "Lisa silt",
+ "discard": "Hülga (kustuta vaikselt)",
+ "reject": "Lükka tagasi sõnumiga",
+ "keep": "Jäta sisendkasti",
+ "stop": "Peata töötlemine"
+ },
+ "move_to_folder": "Vali kaust",
+ "copy_to_folder": "Vali kaust",
+ "forward_to": "Suuna edasi e-posti aadressile",
+ "forward_placeholder": "email@example.com",
+ "forward_keep_copy": "Hoia koopia",
+ "forward_limit": "Edastuspiir ühe kirja kohta sellel serveril: {count}. Lisaedastused jäetakse vahele.",
+ "forward_unavailable": "See server ei luba edastamist. Filtrireeglite edastustoimingutel pole mõju.",
+ "reject_message": "Tagasilükkamise sõnum",
+ "reject_placeholder": "Sinu kiri on tagasi lükatud",
+ "label_name": "Sildi nimi",
+ "label_placeholder": "Vali silt",
+ "header_name": "Päise nimi",
+ "header_placeholder": "nt X-Mailing-List",
+ "size_bytes": "Suurus baitides",
+ "size_placeholder": "nt 1000000",
+ "system_managed": "Süsteemi hallatav reegel",
+ "opaque_warning": "See skript on muudetud väljaspool visuaalset koostajat. Saadaval on ainult Sieve lähtekoodi redigeerimine.",
+ "open_sieve_editor": "Ava Sieve lähtekoodi redaktor",
+ "fetch_error": "Filtrite laadimine ebaõnnestus",
+ "expanded_view": "Laiendatud vaade",
+ "expanded_view_description": "Näita filtrireegleid koos üksikasjalike tingimuste ja tegevuste plokkidega",
+ "if": "Kui",
+ "then": "Siis",
+ "match_all_conditions": "kõik vastavad",
+ "match_any_condition": "mistahes vastab",
+ "and": "ja",
+ "or": "või",
+ "cancel": "Tühista",
+ "confirm_delete": "Kustuta",
+ "rule_list": "Filtrireeglid",
+ "drag_to_reorder": "Lohista ümberjärjestamiseks",
+ "match_type": "Vastavuse tüüp",
+ "reset_to_visual": "Lähtesta visuaalsele koostajale",
+ "reset_warning": "See hülgab praeguse skripti ja alustab uuesti.",
+ "confirm_reset": "Lähtesta",
+ "validation_empty_name": "Reegli nimi on kohustuslik",
+ "validation_empty_conditions": "Vajalik on vähemalt üks väärtusega tingimus",
+ "validation_empty_actions": "Vajalik on vähemalt üks tegevus",
+ "templates_section": "Alusta mallist",
+ "template_newsletters": "Liiguta uudiskirjad kausta",
+ "template_receipts": "Arhiveeri kviitungid automaatselt",
+ "template_important": "Märgi olulised kirjad",
+ "template_notifications": "Filtreeri teavitused",
+ "sieve_editor": {
+ "title": "Sieve skripti redaktor",
+ "warning": "Sieve lähtekoodi muutmine võib visuaalse reeglite redigeerimise katki teha. Siin tehtud muudatused tühistavad visuaalse koostaja.",
+ "script_content": "Sieve skript",
+ "valid": "Skript on kehtiv",
+ "invalid": "Skriptis on vigu",
+ "save_warning": "Salvestamine kirjutab kõik visuaalsed reeglid üle. Seda ei saa tagasi võtta. Kinnitamiseks klõpsa uuesti nuppu Salvesta.",
+ "validating": "Valideerimine...",
+ "validate": "Valideeri",
+ "cancel": "Tühista",
+ "save": "Salvesta",
+ "confirm_save": "Kinnita salvestamine",
+ "validation_failed": "Valideerimispäring ebaõnnestus"
+ },
+ "rule_summary": {
+ "conditions_count": "{count, plural, one {# tingimus} other {# tingimust}}",
+ "actions_count": "{count, plural, one {# tegevus} other {# tegevust}}"
+ },
+ "origin_external": "Väline",
+ "managed_by_tooltip": "Hallatud {source} poolt. Muuda seda vastavas rakenduses või kasuta Sieve lähtekoodi redaktorit.",
+ "quick_rule_names": {
+ "move_sender": "Liiguta saatjalt {sender} kausta {folder}",
+ "move_domain": "Liiguta domeenilt @{domain} kausta {folder}",
+ "move_list": "Liiguta list {list} kausta {folder}",
+ "mark_read": "Märgi loetuks saatjalt {sender}",
+ "tag": "Sildista kui {tag} saatjalt {sender}",
+ "block": "Blokeeri {sender}",
+ "custom": "Kirjad saatjalt {sender}",
+ "custom_plain": "Uus reegel",
+ "senders": "{count, plural, one {# saatja} other {# saatjat}}"
+ },
+ "suggestions": "Soovitused",
+ "suggest_subject": "Teema „{subject}”",
+ "suggest_to": "Kellele: {address}",
+ "suggest_cc": "Koopia: {address}",
+ "suggest_list": "List-Id",
+ "suggest_domain": "@{domain}",
+ "apply_existing": "Rakenda ka selle kausta olemasolevatele kirjadele",
+ "apply_existing_unsupported": "Olemasolevatele kirjadele saab rakendada ainult reegleid, mis kontrollivad saatjat, adressaate, teemat või päiseid ning mis liigutavad, kopeerivad, märgivad loetuks, tähistavad või sildistavad.",
+ "period_title": "Ajavahemik",
+ "period_start": "Alguskuupäev",
+ "period_end": "Lõppkuupäev",
+ "validation_period_order": "Lõppkuupäev peab olema pärast alguskuupäeva",
+ "period_toggle": "Aktiivne ainult ajavahemikus",
+ "period_hint": "Kehtib kirjadele, mis saabuvad sel ajavahemikul. Jäta väli tühjaks, et see ots jääks lahtiseks.",
+ "validation_period_empty": "Sisesta ajavahemiku algus- või lõppkuupäev",
+ "validation_period_invalid": "Sisesta ajavahemiku jaoks kehtiv kuupäev koos kellaajaga või jäta väli tühjaks",
+ "apply_existing_period": "Ajavahemikuga reegel toimib ainult kirjadele, mis saabuvad selle jooksul, seega ei saa seda olemasolevatele kirjadele rakendada.",
+ "period_status_scheduled": "ajastatud",
+ "period_status_active": "aktiivne",
+ "period_status_expired": "aegunud"
+ },
+ "templates": {
+ "title": "E-kirjade mallid",
+ "description": "Loo korduvkasutatavaid e-kirja malle kohatäitja muutujatega",
+ "add": "Uus mall",
+ "edit": "Muuda malli",
+ "name": "Malli nimi",
+ "name_placeholder": "nt jätkukiri",
+ "category": "Kategooria",
+ "category_placeholder": "nt Töö, Isiklik",
+ "subject": "Teema",
+ "subject_placeholder": "Kirja teemarida",
+ "body": "Sisu",
+ "body_placeholder": "Kirja sisu...",
+ "recipients_placeholder": "email@example.com",
+ "identity": "Saada kui",
+ "default_identity": "Vaikimisi identiteet",
+ "favorite": "Lemmik",
+ "cancel": "Tühista",
+ "create": "Loo",
+ "update": "Uuenda",
+ "confirm_delete": "Kustuta",
+ "no_templates": "Malle pole veel",
+ "manage": "Halda malle",
+ "count": "{count, plural, one {# mall} other {# malli}}",
+ "export_import": "Eksport ja import",
+ "export_import_description": "Varunda oma mallid või kanna need üle teise seadmesse",
+ "export": "Ekspordi",
+ "import": "Impordi",
+ "validation": {
+ "empty": "Malli nimi on kohustuslik",
+ "too_long": "Malli nimi tohib olla kuni 200 tähemärki"
+ }
+ },
+ "files": {
+ "display": {
+ "title": "Kuvamine",
+ "description": "Seadista, kuidas faile ja kaustu kuvatakse"
+ },
+ "default_view": {
+ "label": "Vaikevaade",
+ "description": "Vali ruudustiku- ja loendipaigutuse vahel",
+ "list": "Loend",
+ "grid": "Ruudustik"
+ },
+ "default_sort": {
+ "label": "Vaikimisi sortimine",
+ "description": "Vali failide vaikimisi sortimisviis",
+ "name": "Nimi",
+ "size": "Suurus",
+ "modified": "Muudetud"
+ },
+ "sort_direction": {
+ "label": "Sortimissuund",
+ "description": "Vali kasvav või kahanev järjestus",
+ "ascending": "Kasvav",
+ "descending": "Kahanev"
+ },
+ "icons": {
+ "title": "Ikoonid",
+ "description": "Seadista failiikoonide välimust"
+ },
+ "show_icons": {
+ "label": "Näita failiikoone",
+ "description": "Kuva ikoonid failide ja kaustade kõrval"
+ },
+ "colored_icons": {
+ "label": "Värvilised ikoonid",
+ "description": "Kasuta ühevärviliste asemel värvilisi ikoone"
+ },
+ "show_thumbnails": {
+ "label": "Näita pisipilte",
+ "description": "Kuva pildifailide puhul ikoonide asemel eelvaated"
+ },
+ "behavior": {
+ "title": "Käitumine",
+ "description": "Seadista failihalduri käitumist"
+ },
+ "show_hidden": {
+ "label": "Näita peidetud faile",
+ "description": "Kuva failid ja kaustad, mis algavad punktiga"
+ },
+ "preview": {
+ "label": "Eelvaade"
+ }
+ }
+ },
+ "errors": {
+ "page_error_title": "Midagi läks valesti",
+ "page_error_description": "Ilmnes ootamatu viga. Proovi uuesti või mine tagasi avalehele.",
+ "sidebar_error": "Postkaste ei õnnestunud laadida",
+ "email_list_error": "E-kirju ei õnnestunud laadida",
+ "viewer_error_title": "E-kirja ei saa kuvada",
+ "viewer_error_description": "Selle e-kirja kuvamisel tekkis probleem. See võib sisaldada toetamata sisu.",
+ "composer_error": "Kirjutamisakent ei õnnestunud laadida",
+ "settings_error_title": "Seaded pole saadaval",
+ "settings_error_description": "Seadeid ei õnnestunud laadida. Sinu eelistusi ei pruugita salvestada.",
+ "try_again": "Proovi uuesti",
+ "reload": "Laadi uuesti",
+ "reload_emails": "Laadi e-kirjad uuesti",
+ "reload_settings": "Laadi seaded uuesti",
+ "retry": "Proovi uuesti",
+ "go_home": "Mine postkasti"
+ },
+ "context_menu": {
+ "reply": "Vasta",
+ "reply_all": "Vasta kõigile",
+ "forward": "Edasta",
+ "mark_read": "Märgi loetuks",
+ "mark_unread": "Märgi lugemata",
+ "star": "Tähista",
+ "unstar": "Eemalda tähistus",
+ "pin": "Kinnita",
+ "unpin": "Eemalda kinnitus",
+ "move_to": "Liiguta kausta...",
+ "copy_to_account": "Kopeeri kontole...",
+ "archive": "Arhiveeri",
+ "delete": "Kustuta",
+ "mark_as_spam": "Märgi rämpspostiks",
+ "not_spam": "Pole rämpspost",
+ "tag": "Märgista",
+ "items_selected": "{count} e-kirja valitud",
+ "forward_as_attachments": "Edasta manustena",
+ "edit_draft": "Muuda mustandit",
+ "cancel_scheduled_send": "Tühista saatmine",
+ "reschedule_send": "Muuda ajastust",
+ "cancel_and_edit": "Tühista ja muuda",
+ "cancel_and_compose_again": "Tühista ja kirjuta uuesti",
+ "rules": {
+ "title": "Reeglid",
+ "move_from": "Liiguta alati saatja {sender} kirjad",
+ "move_from_domain": "Liiguta alati domeeni @{domain} kirjad",
+ "move_from_list": "Liiguta alati selle listi kirjad",
+ "mark_read_from": "Märgi alati loetuks saatjalt {sender}",
+ "tag_with": "Sildista alati sildiga",
+ "block": "{count, plural, one {Blokeeri saatja} other {Blokeeri # saatjat}}",
+ "no_junk": "Sellel kontol pole rämpsposti kausta",
+ "create": "Loo reegel…",
+ "manage": "Halda reegleid",
+ "senders_count": "{count, plural, one {# saatja} other {# saatjat}}",
+ "cross_account": "Vali kirjad ühelt kontolt",
+ "opaque_hint": "Sinu filtreid on käsitsi muudetud. Ava filtriseaded",
+ "loading": "Reeglite laadimine…",
+ "load_failed": "Reegleid ei õnnestunud laadida",
+ "none": "Reegleid pole veel",
+ "new_mail_only": "Ainult uutele kirjadele",
+ "running": "Käivitan „{name}” kaustas {folder}…"
+ },
+ "find_emails_from": "Leia kirjad saatjalt {name}"
+ },
+ "mailbox_context_menu": {
+ "mark_folder_read": "Märgi kaust loetuks",
+ "mark_folder_tree_read": "Märgi kaust ja alamkaustad loetuks",
+ "mark_all_folders_read": "Märgi kõik kaustad loetuks",
+ "new_subfolder": "Uus alamkaust...",
+ "new_folder": "Uus kaust...",
+ "rename": "Nimeta ümber...",
+ "share": "Jaga...",
+ "import_email": "Impordi .eml või .zip...",
+ "empty_folder": "Tühjenda kaust",
+ "empty_folder_generic": "Tühjenda kaust",
+ "delete_folder": "Kustuta kaust",
+ "refresh": "Värskenda",
+ "hide_shared_account": "Peida külgribalt",
+ "mute_notifications": "Vaigista märguanded",
+ "unmute_notifications": "Lõpeta märguannete vaigistus",
+ "mute_account_notifications": "Vaigista selle konto märguanded",
+ "unmute_account_notifications": "Lõpeta selle konto märguannete vaigistus",
+ "reset_folder_notifications": "Lähtesta kausta märguandeseaded",
+ "mark_all_confirm_title": "Märgi kõik kaustad loetuks",
+ "mark_all_confirm_message": "Kas märkida kõik lugemata sõnumid sinu isiklikus kontos loetuks?",
+ "delete_confirm_title": "Kustuta kaust",
+ "delete_confirm_message": "Kas kustutada kaust \"{name}\" jäädavalt? Seda toimingut ei saa tagasi võtta.",
+ "prompt_new_subfolder": "Sisesta uue alamkausta nimi.",
+ "prompt_new_folder": "Sisesta uue kausta nimi.",
+ "prompt_rename": "Sisesta selle kausta uus nimi.",
+ "placeholder_folder_name": "Kausta nimi",
+ "create": "Loo",
+ "rename_confirm": "Nimeta ümber",
+ "toast_marked_read": "Kaust märgiti loetuks",
+ "toast_marked_read_count": "{count, plural, one {1 sõnum} other {# sõnumit}} märgiti loetuks",
+ "toast_already_read": "Lugemata sõnumeid pole",
+ "toast_marked_all_read": "Kõik kaustad märgiti loetuks",
+ "toast_emptied": "Kaust tühjendati",
+ "toast_folder_created": "Kaust loodi",
+ "toast_folder_renamed": "Kaust nimetati ümber",
+ "toast_folder_deleted": "Kaust kustutati",
+ "toast_error_mark_read": "Loetuks märkimine ebaõnnestus",
+ "toast_error_empty": "Kausta tühjendamine ebaõnnestus",
+ "toast_error_create": "Kausta loomine ebaõnnestus",
+ "toast_error_rename": "Kausta ümbernimetamine ebaõnnestus",
+ "toast_error_delete": "Kausta kustutamine ebaõnnestus",
+ "toast_error_delete_has_children": "Kaustal on alamkaustad. Eemalda need enne.",
+ "toast_error_delete_has_email": "Kaust ei ole tühi. Tühjenda see enne.",
+ "delete_with_contents_title": "Kaust ei ole tühi",
+ "delete_with_contents_message": "Kaust \"{name}\" sisaldab veel {count, plural, one {1 sõnum} other {# sõnumit}}. Kas kustutada kaust ja kõik selle sõnumid? Seda ei saa tagasi võtta.",
+ "delete_with_contents_confirm": "Kustuta kaust ja sõnumid"
+ },
+ "shortcuts": {
+ "title": "Kiirklahvid",
+ "tip": "Selle abi nägemiseks vajuta igal ajal ?",
+ "sections": {
+ "navigation": "Navigeerimine",
+ "actions": "E-kirja toimingud",
+ "global": "Üldine",
+ "threads": "Vestlused",
+ "composer": "Kirjutamine"
+ },
+ "navigation": {
+ "next_email": "Järgmine e-kiri",
+ "previous_email": "Eelmine e-kiri",
+ "open_email": "Ava e-kiri",
+ "close_email": "Sulge / tühista valik",
+ "go_to_folder": "Mine kausta"
+ },
+ "actions": {
+ "reply": "Vasta",
+ "reply_all": "Vasta kõigile",
+ "forward": "Edasta",
+ "star": "Lülita tähistus",
+ "archive": "Arhiveeri",
+ "delete": "Kustuta",
+ "mark_unread": "Märgi lugemata",
+ "mark_read": "Märgi loetuks",
+ "toggle_spam": "Märgi rämpspostiks / Pole rämpspost"
+ },
+ "global": {
+ "compose": "Kirjuta uus e-kiri",
+ "search": "Fookus otsingusse",
+ "help": "Näita kiirklahve",
+ "refresh": "Värskenda e-kirjad",
+ "select_all": "Vali kõik",
+ "global_search": "Otsi kõike"
+ },
+ "threads": {
+ "expand_collapse": "Ava/sulge vestlus",
+ "select_mountain_view": "Vali vestlus"
+ },
+ "composer": {
+ "send": "Saada e-kiri",
+ "schedule_send": "Ajasta saatmine",
+ "template_picker": "Ava mallivalija"
+ }
+ },
+ "threads": {
+ "messages_one": "{count} sõnum",
+ "messages_other": "{count} sõnumit",
+ "messages_tooltip": "{count, plural, one {# sõnum selles vestluses} other {# sõnumit selles vestluses}}",
+ "expand": "Ava vestlus",
+ "collapse": "Sulge vestlus",
+ "loading": "Vestluse laadimine...",
+ "mark_read": "Märgi vestlus loetuks",
+ "mark_unread": "Märgi vestlus lugemata",
+ "archive": "Arhiveeri vestlus",
+ "delete": "Kustuta vestlus",
+ "star": "Tähista vestlus",
+ "unstar": "Eemalda vestluse tähistus",
+ "toggle_thread": "Lülita vestlus"
+ },
+ "identities": {
+ "modal_title": "Halda saatja identiteete",
+ "create_new": "Loo uus identiteet",
+ "edit_identity": "Muuda identiteeti",
+ "delete_confirm": "Kustutada see identiteet? Seda ei saa tagasi võtta.",
+ "cannot_delete": "Seda identiteeti ei saa kustutada",
+ "primary_identity": "Peamine",
+ "set_as_primary": "Määra peamiseks",
+ "no_identities": "Identiteete ei leitud",
+ "display": {
+ "reply_to": "Vastamisaadress:",
+ "bcc": "BCC:",
+ "signature": "Allkiri:",
+ "preview": "Eelvaade:"
+ },
+ "validation_errors": {
+ "invalid_emails": "Vigased e-posti aadressid: {emails}",
+ "unknown_error": "Tundmatu viga"
+ },
+ "form": {
+ "name_label": "Kuvatav nimi",
+ "name_placeholder": "nt Töö e-post, Isiklik",
+ "name_required": "Nimi on kohustuslik",
+ "email_label": "E-posti aadress",
+ "email_placeholder": "your.email@example.com",
+ "email_required": "E-posti aadress on kohustuslik",
+ "email_invalid": "Palun sisesta kehtiv e-posti aadress",
+ "email_immutable": "E-posti aadressi ei saa pärast loomist muuta",
+ "reply_to_label": "Vastamisaadress (valikuline)",
+ "reply_to_placeholder": "different@example.com",
+ "bcc_label": "Automaatne BCC (valikuline)",
+ "bcc_placeholder": "archive@example.com",
+ "text_signature_label": "Tekstiallkiri",
+ "html_signature_label": "HTML-allkiri",
+ "signature_byte_counter": "{bytes} / {max} baiti",
+ "signature_byte_limit_reached": "Serveri piirmäär on täis",
+ "save": "Salvesta identiteet",
+ "cancel": "Tühista",
+ "creating": "Loomine...",
+ "updating": "Uuendamine..."
+ },
+ "sub_address": {
+ "button_tooltip": "Kasuta alamaadressi",
+ "popover_title": "Lisa alamaadressi silt",
+ "tag_input_placeholder": "Sisesta silt (nt ostlemine)",
+ "preview_label": "Eelvaade:",
+ "recent_tags": "Hiljutised sildid",
+ "suggested_tags": "Soovitatud",
+ "use_address": "Kasuta seda aadressi",
+ "invalid_tag": "Silt tohib sisaldada ainult tähti, numbreid ja sidekriipse",
+ "tag_too_long": "Silt tohib olla kuni 30 tähemärki",
+ "help_text": "Aadressile user{delimiter}tag@domain.com saadetud kirjad jõuavad sinu postkasti",
+ "validation": {
+ "empty": "Silt ei tohi olla tühi",
+ "too_long": "Silt tohib olla kuni {max} tähemärki",
+ "invalid_chars": "Silt tohib sisaldada ainult tähti, numbreid ja sidekriipse"
+ }
+ },
+ "badge": {
+ "sent_via": "kaudu",
+ "sub_address_tag": "Saadetud alamaadressiga: {tag}",
+ "identity_name": "Saadetud identiteediga: {name}",
+ "identity_short": "{name} kaudu",
+ "subaddress_tag": "+{tag}"
+ },
+ "delete_button": "Kustuta",
+ "delete_confirm_title": "Kustuta identiteet"
+ },
+ "templates": {
+ "picker_title": "Vali mall",
+ "search_placeholder": "Otsi malle...",
+ "section_favorites": "Lemmikud",
+ "section_recent": "Hiljutised",
+ "section_uncategorized": "Muud",
+ "no_templates": "Malle veel pole",
+ "no_results": "Malle ei leitud",
+ "fill_placeholders": "Täida muutujate väärtused",
+ "enter_value": "Sisesta väärtus...",
+ "preview": "Eelvaade",
+ "insert_with_values": "Lisa väärtustega",
+ "insert_raw": "Lisa täitmata kujul",
+ "copy_suffix": "(koopia)",
+ "placeholder": "Muutuja",
+ "placeholders": {
+ "recipient_name": "Saaja nimi",
+ "company": "Ettevõtte nimi",
+ "date": "Tänane kuupäev",
+ "day_of_week": "Nädalapäev",
+ "sender_name": "Sinu nimi"
+ }
+ },
+ "contacts": {
+ "title": "Kontaktid",
+ "search_placeholder": "Otsi kontakte...",
+ "create_new": "Uus kontakt",
+ "no_category": "Kategooria puudub",
+ "rename_category": "Nimeta kategooria ümber",
+ "category_name_label": "Kategooria nimi",
+ "category_renamed": "Kategooria nimetati ümber",
+ "category_rename_failed": "Kategooria ümbernimetamine ebaõnnestus",
+ "category_added": "Kontakt lisati kategooriasse {name}",
+ "category_added_plural": "{count} kontakti lisati kategooriasse {name}",
+ "empty_state": "Kontakte veel pole",
+ "empty_state_title": "Kontakte veel pole",
+ "empty_state_subtitle": "Loo oma esimene kontakt või impordi vCard-failist",
+ "empty_search": "Otsingule ei vastanud ükski kontakt",
+ "empty_search_hint": "Proovi teistsugust otsisõna",
+ "empty_filtered": "Filtritele ei vastanud ükski kontakt",
+ "empty_filtered_hint": "Proovi filtreid muuta või need tühjendada",
+ "clear_search": "Tühjenda otsing",
+ "import_vcard": "Impordi vCard",
+ "delete_confirm_title": "Kustuta kontakt",
+ "delete_confirm": "Kas oled kindel, et soovid selle kontakti kustutada?",
+ "local_mode": "Kontaktid salvestatakse kohalikult (server ei toeta JMAP Contacts funktsiooni)",
+ "back_to_contacts": "Tagasi kontaktide juurde",
+ "back_to_email": "Tagasi e-posti juurde",
+ "open_categories": "Ava kategooriad",
+ "tabs": {
+ "all": "Kõik",
+ "groups": "Grupid"
+ },
+ "shared": {
+ "title": "Jagatud"
+ },
+ "address_books": {
+ "create": "Uus aadressiraamat",
+ "created": "Aadressiraamat loodud",
+ "create_failed": "Aadressiraamatu loomine ebaõnnestus",
+ "title": "Minu aadressiraamatud",
+ "shared_prefix": "Jagatud: {name}",
+ "moved": "Kontakt liigutati aadressiraamatusse {name}",
+ "moved_plural": "{count} kontakti liigutati aadressiraamatusse {name}",
+ "move_failed": "Kontakti liigutamine ebaõnnestus",
+ "address_book": "Aadressiraamat",
+ "rename": "Nimeta aadressiraamat ümber",
+ "name_label": "Aadressiraamatu nimi",
+ "renamed": "Aadressiraamat nimetati ümber",
+ "rename_failed": "Aadressiraamatu ümbernimetamine ebaõnnestus",
+ "default": "Vaikimisi",
+ "set_default": "Määra vaikimisi valikuks",
+ "default_updated": "Vaikimisi aadressiraamat uuendatud",
+ "set_default_failed": "Vaikimisi aadressiraamatu määramine ebaõnnestus",
+ "manage": "Halda aadressiraamatuid",
+ "share": "Jaga aadressiraamatut",
+ "new_contact_in_book": "Uus kontakt selles aadressiraamatus",
+ "delete": "Kustuta aadressiraamat",
+ "confirm_delete": "Kustutada \"{name}\"? Kõik selle aadressiraamatu kontaktid eemaldatakse.",
+ "deleted": "Aadressiraamat kustutatud",
+ "delete_failed": "Aadressiraamatu kustutamine ebaõnnestus"
+ },
+ "detail": {
+ "emails": "E-posti aadressid",
+ "phones": "Telefoninumbrid",
+ "organizations": "Organisatsioonid",
+ "addresses": "Aadressid",
+ "notes": "Märkmed",
+ "titles": "Ametinimetused ja rollid",
+ "online_services": "Veebiteenused",
+ "anniversaries": "Tähtpäevad",
+ "personal_info": "Isiklik info",
+ "languages": "Keeled",
+ "categories": "Kategooriad",
+ "related_contacts": "Seotud kontaktid",
+ "crypto_keys": "Krüptovõtmed",
+ "cert_issuer": "Väljastaja",
+ "cert_expires": "Aegub",
+ "cert_expired": "Aegunud",
+ "cert_fingerprint": "Sõrmejälg",
+ "cert_algorithm": "Algoritm",
+ "import_to_smime": "Impordi S/MIME-sse",
+ "cert_already_imported": "Juba imporditud S/MIME-sse",
+ "cert_imported": "Sertifikaat imporditud S/MIME hoidlasse",
+ "cert_import_failed": "Sertifikaadi importimine ebaõnnestus",
+ "no_contact_selected": "Vali kontakt, et vaadata üksikasju",
+ "compose_email": "Koosta kiri",
+ "copy_email": "Kopeeri e-posti aadress",
+ "copy_phone": "Kopeeri telefoninumber",
+ "copy_url": "Kopeeri URL",
+ "copied": "Kopeeritud lõikelauale",
+ "copy_failed": "Lõikelauale kopeerimine ebaõnnestus",
+ "created": "Loodud",
+ "updated": "Viimati uuendatud",
+ "timezone": "Ajavöönd",
+ "anniversary_birth": "Sünnipäev",
+ "anniversary_death": "Surmapäev",
+ "anniversary_wedding": "Aastapäev",
+ "anniversary_other": "Muu",
+ "personal_expertise": "Kompetents",
+ "personal_hobby": "Hobi",
+ "personal_interest": "Huvi",
+ "personal_other": "Muu",
+ "gender": "Sugu",
+ "gender_masculine": "Mees",
+ "gender_feminine": "Naine",
+ "gender_other": "Muu",
+ "gender_none": "Ei kohaldu",
+ "gender_unknown": "Teadmata",
+ "calendar": "Kalender",
+ "calendar_uri": "Kalendri URL",
+ "scheduling_uri": "Planeerimise URL",
+ "freebusy_uri": "Vaba/hõivatud URL",
+ "section_contact": "Kontaktandmed",
+ "section_work": "Töö",
+ "section_personal": "Isiklik",
+ "email_default_label": "E-post",
+ "phone_default_label": "Telefon",
+ "address_default_label": "Aadress",
+ "online_service_default_label": "Veebis",
+ "organization_label": "Organisatsioon",
+ "title_label": "Ametinimetus",
+ "role_label": "Roll",
+ "language_label": "Keel",
+ "related_default_label": "Seotud",
+ "more_actions": "Rohkem toiminguid",
+ "age_years": "{count, plural, one {1-aastane} other {#-aastane}}",
+ "years_since": "{count, plural, one {1 aasta} other {# aastat}}"
+ },
+ "activity": {
+ "recent_emails": "Hiljutised kirjad",
+ "upcoming_events": "Eelseisvad sündmused",
+ "no_emails": "Hiljutisi kirju pole",
+ "no_events": "Eelseisvaid sündmusi pole",
+ "no_subject": "(Teema puudub)",
+ "no_title": "(Pealkiri puudub)",
+ "load_failed": "Laadimine ebaõnnestus",
+ "unknown_sender": "Tundmatu saatja",
+ "all_day": "Kogu päev"
+ },
+ "form": {
+ "create_title": "Uus kontakt",
+ "edit_title": "Muuda kontakti",
+ "section_address_book": "Kataloog",
+ "select_address_book": "Vali kataloog...",
+ "section_identity": "Nimi ja identiteet",
+ "contact_type": "Kontakti tüüp",
+ "type_person": "Isik",
+ "type_organization": "Organisatsioon",
+ "section_work": "Töö ja organisatsioon",
+ "prefix": "Tiitel",
+ "prefix_placeholder": "dr., hr., pr.",
+ "given_name": "Eesnimi",
+ "middle_name": "Keskmine nimi",
+ "surname": "Perekonnanimi",
+ "suffix": "Liide",
+ "suffix_placeholder": "Jr., Sr., III",
+ "nickname": "Hüüdnimi",
+ "nickname_placeholder": "Hüüdnimi",
+ "email": "E-post",
+ "email_placeholder": "email@example.com",
+ "phone": "Telefon",
+ "phone_placeholder": "+1 234 567 890",
+ "phone_type": "Tüüp",
+ "phone_voice": "Kõne",
+ "phone_cell": "Mobiil",
+ "phone_fax": "Faks",
+ "phone_pager": "Pager",
+ "phone_video": "Video",
+ "phone_text": "Tekstisõnum",
+ "organization": "Organisatsioon",
+ "organization_placeholder": "Ettevõtte nimi",
+ "department": "Osakond",
+ "department_placeholder": "Osakond",
+ "job_title": "Ametinimetus",
+ "job_title_placeholder": "nt tarkvarainsener",
+ "role": "Roll",
+ "role_placeholder": "nt meeskonnajuht",
+ "addresses": "Aadressid",
+ "add_address": "Lisa aadress",
+ "street": "Tänav",
+ "city": "Linn",
+ "region": "Maakond / piirkond",
+ "postcode": "Sihtnumber",
+ "country": "Riik",
+ "online_services": "Veebiteenused",
+ "add_online_service": "Lisa veebiteenus",
+ "url_placeholder": "https://...",
+ "service_placeholder": "Teenus",
+ "anniversaries": "Tähtpäevad",
+ "add_anniversary": "Lisa kuupäev",
+ "anniversary_birth": "Sünnipäev",
+ "anniversary_wedding": "Aastapäev",
+ "anniversary_death": "Mälestuspäev",
+ "anniversary_other": "Muu",
+ "personal_info": "Isiklik info",
+ "add_personal_info": "Lisa kirje",
+ "personal_info_placeholder": "nt fotograafia",
+ "personal_expertise": "Kompetents",
+ "personal_hobby": "Hobi",
+ "personal_interest": "Huvi",
+ "personal_other": "Muu",
+ "level": "Tase",
+ "level_high": "Kõrge",
+ "level_medium": "Keskmine",
+ "level_low": "Madal",
+ "categories": "Kategooriad",
+ "categories_placeholder": "nt pere, sõbrad, kolleegid",
+ "categories_hint": "Sisesta, et otsida või lisada kategooriaid",
+ "category_add": "Lisa",
+ "note": "Märkmed",
+ "note_placeholder": "Lisa märkus...",
+ "gender": "Sugu",
+ "gender_sex": "Sugu",
+ "gender_male": "Mees",
+ "gender_female": "Naine",
+ "gender_other": "Muu",
+ "gender_none": "Ei kohaldu",
+ "gender_unknown": "Teadmata",
+ "gender_identity": "Sooidentiteet",
+ "gender_identity_placeholder": "Sooidentiteet...",
+ "calendar": "Kalender",
+ "calendar_uri": "Kalendri URL",
+ "scheduling_uri": "Planeerimise URL",
+ "freebusy_uri": "Vaba/hõivatud URL",
+ "context_work": "Töö",
+ "context_private": "Isiklik",
+ "add_email": "Lisa e-post",
+ "add_phone": "Lisa telefon",
+ "save": "Salvesta",
+ "cancel": "Tühista",
+ "creating": "Loomine...",
+ "updating": "Uuendamine...",
+ "name_required": "Sisesta eesnimi, perekonnanimi või organisatsioon",
+ "email_invalid": "Sisesta kehtiv e-posti aadress",
+ "email_error_inline": "Vigane e-posti aadressi vorming",
+ "save_failed": "Kontakti salvestamine ebaõnnestus",
+ "delete": "Kustuta",
+ "upload_photo": "Laadi foto üles",
+ "remove_photo": "Eemalda foto",
+ "photo_hint": "JPG või PNG, kuni 10 MB. Suurust muudetakse vajadusel.",
+ "photo_too_large": "Pilt on liiga suur (maksimaalselt 10 MB)",
+ "photo_invalid": "Vigane pildifail",
+ "change_photo": "Muuda"
+ },
+ "groups": {
+ "create": "Uus grupp",
+ "edit": "Muuda gruppi",
+ "empty": "Gruppe veel pole",
+ "delete_confirm_title": "Kustuta grupp",
+ "delete_confirm": "Kas oled kindel, et soovid selle grupi kustutada?",
+ "name_label": "Grupi nimi",
+ "name_placeholder": "nt meeskond, pere",
+ "name_required": "Grupi nimi on kohustuslik",
+ "save_failed": "Grupi salvestamine ebaõnnestus",
+ "members_label": "Liikmed",
+ "search_members": "Otsi lisatavaid kontakte...",
+ "no_members": "Selles grupis pole liikmeid",
+ "member_count": "{count, plural, =0 {Liikmeid pole} one {1 liige} other {# liiget}}",
+ "send_email": "Saada kiri grupile",
+ "send_email_to": "Kellele",
+ "send_email_cc": "Cc",
+ "send_email_bcc": "Bcc",
+ "no_member_emails": "Selles grupis pole ühtegi e-posti aadressiga liiget."
+ },
+ "import": {
+ "title": "Impordi kontaktid",
+ "drop_hint": "Kliki, et valida vCard-fail",
+ "file_types": ".vcf või .vcard failid",
+ "no_contacts": "Failist ei leitud ühtegi kontakti",
+ "parse_error": "vCard-faili töötlemine ebaõnnestus",
+ "found": "{count, plural, one {Leiti 1 kontakt} other {Leiti # kontakti}}",
+ "duplicate": "Duplikaat",
+ "select_all": "Vali kõik",
+ "deselect_all": "Tühista kõigi valik",
+ "selected": "{count, plural, one {1 valitud} other {# valitud}}",
+ "import_button": "Impordi",
+ "importing": "Importimine...",
+ "success": "{count, plural, one {Imporditi 1 kontakt} other {Imporditi # kontakti}}",
+ "failed": "Import ebaõnnestus",
+ "close": "Sulge",
+ "file_too_large": "Fail on liiga suur (maksimaalselt 5 MB)"
+ },
+ "export": {
+ "title": "Ekspordi kontaktid",
+ "success": "{count, plural, one {Eksporditi 1 kontakt} other {Eksporditi # kontakti}}"
+ },
+ "bulk": {
+ "selected": "{count, plural, one {1 valitud} other {# valitud}}",
+ "select_all": "Vali kõik",
+ "delete": "Kustuta",
+ "delete_confirm_title": "Kustuta kontaktid",
+ "delete_confirm": "Kustutada {count, plural, one {1 kontakt} other {# kontakti}}?",
+ "deleted": "{count, plural, one {Kustutati 1 kontakt} other {Kustutati # kontakti}}",
+ "add_to_group": "Lisa gruppi",
+ "choose_group": "Vali grupp",
+ "adding_contacts": "Lisamine: {count, plural, one {1 kontakt} other {# kontakti}}",
+ "added_to_group": "Kontaktid lisati gruppi",
+ "export": "Eksport",
+ "clear": "Tühista valik"
+ },
+ "toast": {
+ "created": "Kontakt loodud",
+ "updated": "Kontakt uuendatud",
+ "deleted": "Kontakt kustutatud",
+ "error_create": "Kontakti loomine ebaõnnestus",
+ "error_update": "Kontakti uuendamine ebaõnnestus",
+ "error_delete": "Kontakti kustutamine ebaõnnestus"
+ },
+ "context_menu": {
+ "open": "Ava",
+ "edit": "Muuda",
+ "send_email": "Saada kiri",
+ "add_to_group": "Lisa gruppi",
+ "export_vcard": "Ekspordi vCardina",
+ "delete": "Kustuta",
+ "call": "Helista",
+ "duplicate": "Dubleeri",
+ "print": "Prindi"
+ },
+ "filters": {
+ "toggle": "Filtrid",
+ "select": "Vali",
+ "clear": "Tühjenda",
+ "close": "Sulge",
+ "title": "Täpsem filtreerimine",
+ "organization": "Ettevõte",
+ "organization_placeholder": "nt Acme Corp",
+ "job_title": "Ametinimetus",
+ "job_title_placeholder": "nt disainer",
+ "location": "Asukoht",
+ "location_placeholder": "Linn või riik",
+ "email_domain": "E-posti domeen",
+ "email_domain_placeholder": "example.com",
+ "birthday_month": "Sünnipäev kuul",
+ "any_month": "Iga kuu",
+ "has_email": "E-posti aadressiga",
+ "has_phone": "Telefoninumbriga",
+ "has_photo": "Fotoga",
+ "sort_by": "Sordi",
+ "sort_first_name": "Eesnimi",
+ "sort_last_name": "Perekonnanimi"
+ }
+ },
+ "calendar": {
+ "title": "Kalender",
+ "back_to_email": "Tagasi e-posti juurde",
+ "back_to_month": "Tagasi kuu juurde",
+ "my_calendars": "Kalendrid",
+ "birthday_calendar": "Sünnipäevad",
+ "mini_calendar_change": "Kuu vahetamiseks klõpsa",
+ "views": {
+ "month": "Kuu",
+ "week": "Nädal",
+ "day": "Päev",
+ "agenda": "Päevakava",
+ "today": "Täna",
+ "month_hint": "Kuu (m)",
+ "week_hint": "Nädal (w)",
+ "day_hint": "Päev (d)",
+ "agenda_hint": "Päevakava (a)",
+ "tasks": "Ülesanded",
+ "tasks_hint": "Ülesanded (k)"
+ },
+ "events": {
+ "hidden_before": "{count, plural, one {# varasem sündmus} other {# varasemat sündmust}}",
+ "hidden_after": "{count, plural, one {# hilisem sündmus} other {# hilisemat sündmust}}",
+ "show_all_hours": "Näita kõiki tunde",
+ "show_display_hours": "Näita ainult {start} – {end}",
+ "create": "Loo sündmus",
+ "edit": "Muuda sündmust",
+ "delete": "Kustuta sündmus",
+ "details": "Sündmuse üksikasjad",
+ "no_events": "Sündmusi pole",
+ "all_day": "Kogu päev",
+ "more": "+{count} veel",
+ "show_more": "Näita rohkem",
+ "show_less": "Näita vähem",
+ "no_title": "(Pealkirjata)",
+ "resize": "Muuda sündmuse suurust",
+ "duplicate": "Dubleeri",
+ "today_header": "Täna",
+ "tomorrow_header": "Homme",
+ "agenda_show_earlier": "Näita varasemaid sündmusi",
+ "agenda_loading": "Laadimine…",
+ "agenda_range_start": "Kuvatakse sündmusi alates {date}",
+ "agenda_range_end": "Kuvatakse sündmusi kuni {date}",
+ "export_ics": "Ekspordi .ics-failina",
+ "copy_title": "Kopeeri pealkiri",
+ "copy_link": "Kopeeri koosoleku link",
+ "new_event": "Uus sündmus",
+ "new_all_day_event": "Uus kogu päeva sündmus",
+ "new_task": "Uus ülesanne",
+ "go_to_today": "Mine tänasesse päeva"
+ },
+ "detail": {
+ "add_note": "Lisa märkus...",
+ "save_note": "Salvesta",
+ "note_saved": "Märkus lisatud",
+ "open_link": "Ava link",
+ "copy_location": "Kopeeri asukoht",
+ "meeting_link": "Koosoleku link",
+ "tentative": "Esialgne",
+ "cancelled": "Tühistatud",
+ "delete_confirm": "Kustutada see sündmus?"
+ },
+ "form": {
+ "title": "Pealkiri",
+ "description": "Kirjeldus",
+ "location": "Asukoht",
+ "meeting_link": "Koosoleku link",
+ "start_date": "Alguskuupäev",
+ "end_date": "Lõpukuupäev",
+ "start_time": "Algusaeg",
+ "end_time": "Lõppaeg",
+ "all_day_event": "Kogu päeva sündmus",
+ "calendar_select": "Kalender",
+ "save": "Salvesta",
+ "cancel": "Tühista",
+ "delete_confirm": "Kas oled kindel, et soovid selle sündmuse kustutada?",
+ "color": "Värv"
+ },
+ "participants": {
+ "availability": {
+ "title": "Saadavus",
+ "free": "Saadaval",
+ "busy": "Hõivatud",
+ "tentative": "Esialgne",
+ "unknown": "Ei ole selle serveri kasutaja",
+ "checking": "Kontrollimine…"
+ },
+ "title": "Osalejad",
+ "add": "Lisa osaleja",
+ "organizer": "Korraldaja",
+ "attendee": "Osaleja",
+ "accepted": "Nõustunud",
+ "declined": "Keeldunud",
+ "tentative": "Esialgne",
+ "needs_action": "Vajab vastust",
+ "remove": "Eemalda",
+ "edit": "Muuda",
+ "email_placeholder": "Lisa e-posti aadress või otsi kontaktidest",
+ "send_invitations": "Saada osalejatele kutsed",
+ "status_summary": "{accepted} nõustunud, {pending} ootel",
+ "invited_by": "Kutse saatjalt {name}",
+ "respond_below": "Vasta allolevate nuppudega",
+ "rsvp_label": "Sinu vastus",
+ "cancel_notification": "Osalejaid teavitatakse tühistamisest",
+ "you_organizer": "Sina oled korraldaja",
+ "you_attendee": "Sina oled osaleja",
+ "no_participants": "Osalejaid pole",
+ "count": "{count, plural, one {# osaleja} other {# osalejat}}"
+ },
+ "recurrence": {
+ "title": "Kordumine",
+ "none": "Ei kordu",
+ "daily": "Iga päev",
+ "weekly": "Iga nädal",
+ "monthly": "Iga kuu",
+ "yearly": "Iga aasta",
+ "every_n_days": "Iga {count} päeva järel",
+ "every_n_weeks": "Iga {count} nädala järel",
+ "every_n_months": "Iga {count} kuu järel",
+ "until": "Kuni",
+ "occurrences": "{count} kordust",
+ "custom": "Kohandatud…",
+ "edit_custom": "Muuda kohandatud kordumist",
+ "every_n_years": "Iga {count} aasta järel",
+ "on_days": "päevadel {days}",
+ "on_day_n": "kuupäeval {day}",
+ "on_the_nth": "{nth} {day}",
+ "in_month": "kuus {month}",
+ "nth_1": "esimene",
+ "nth_2": "teine",
+ "nth_3": "kolmas",
+ "nth_4": "neljas",
+ "nth_last": "viimane",
+ "editor_freq_day": "Päev",
+ "editor_freq_week": "Nädal",
+ "editor_freq_month": "Kuu",
+ "editor_freq_year": "Aasta",
+ "editor_repeats_on": "Kordub",
+ "editor_every": "Iga",
+ "editor_unit_days": "päev(a)",
+ "editor_unit_weeks": "nädal(at)",
+ "editor_unit_months": "kuu(d)",
+ "editor_unit_years": "aasta(t)",
+ "editor_on_day": "kuupäeval",
+ "editor_on_the": "kuu",
+ "editor_in": "kuus",
+ "editor_ends": "Lõpeb",
+ "editor_never": "Mitte kunagi",
+ "editor_ends_on": "Kuupäeval",
+ "editor_ends_after": "Pärast",
+ "editor_occurrences": "kordust"
+ },
+ "recurrence_scope": {
+ "edit_title": "Korduva sündmuse muutmine",
+ "delete_title": "Korduva sündmuse kustutamine",
+ "description": "See on korduv sündmus. Milliseid sündmusi soovid muuta?",
+ "this_event": "Ainult see sündmus",
+ "this_and_future": "See ja järgnevad sündmused",
+ "all_events": "Kõik sündmused",
+ "cancel": "Tühista",
+ "save": "Salvesta",
+ "delete": "Kustuta",
+ "rsvp_title": "Vasta korduvale sündmusele",
+ "rsvp_description": "See on korduv sündmus. Millistele sündmustele sinu vastus kehtib?",
+ "respond": "Vasta"
+ },
+ "alerts": {
+ "title": "Meeldetuletused",
+ "none": "Meeldetuletusi pole",
+ "at_time": "Sündmuse toimumise ajal",
+ "minutes_before": "{count, plural, one {# minut enne} other {# minutit enne}}",
+ "hours_before": "{count, plural, one {# tund enne} other {# tundi enne}}",
+ "days_before": "{count, plural, one {# päev enne} other {# päeva enne}}",
+ "weeks_before": "{count, plural, one {# nädal enne} other {# nädalat enne}}",
+ "unit_minutes_before": "minutit enne",
+ "unit_hours_before": "tundi enne",
+ "unit_days_before": "päeva enne",
+ "unit_weeks_before": "nädalat enne",
+ "add": "Lisa meeldetuletus",
+ "remove": "Eemalda meeldetuletus",
+ "amount": "Meeldetuletuse kogus",
+ "unit": "Meeldetuletuse ühik",
+ "action": "Meeldetuletuse tüüp",
+ "action_display": "Märguanne",
+ "action_email": "E-kiri",
+ "by_email": "{label} e-kirjaga",
+ "kept": "{count, plural, one {Säilitatakse veel # meeldetuletus, mida siin muuta ei saa} other {Säilitatakse veel # meeldetuletust, mida siin muuta ei saa}}",
+ "other": "{count, plural, one {# muu meeldetuletus} other {# muud meeldetuletust}}",
+ "calendar_defaults": "Kalendri vaikeseaded",
+ "customize": "Kohanda",
+ "use_defaults": "Kasuta kalendri vaikeseadeid"
+ },
+ "settings": {
+ "limit_hours": "Piira nähtavaid tunde",
+ "limit_hours_desc": "Näita päeva- ja nädalavaates ainult neid tunde. Väljaspool neid olevad sündmused on kättesaadavad iga päeva üla- ja allservas olevate nooltega.",
+ "visible_hours": "Nähtavad tunnid",
+ "visible_hours_start": "Nähtavate tundide algus",
+ "visible_hours_end": "Nähtavate tundide lõpp",
+ "hide_non_working_days": "Peida puhkepäevad",
+ "hide_non_working_days_desc": "Jäta päevad, mil ei tööta, nädalavaatest välja. Nende sündmused on endiselt näha kuu-, päeva- ja päevakavavaates.",
+ "working_days": "Tööpäevad",
+ "title": "Kalendri seaded",
+ "default_view": "Vaikevaade",
+ "week_starts_on": "Nädal algab",
+ "time_format": "Ajavorming",
+ "default_calendar": "Vaikekalender",
+ "default_reminder": "Vaikimisi meeldetuletus",
+ "time_format_12h": "12-tunnine",
+ "time_format_24h": "24-tunnine",
+ "notifications_enabled": "Sündmuste teavitused",
+ "notifications_enabled_desc": "Kuva teavitusi eelseisvate kalendrisündmuste kohta",
+ "notification_sound": "Teavituse heli",
+ "notification_sound_desc": "Esita kalendriteavituste puhul heli",
+ "invitation_parsing": "Tuvasta e-kirjade kutsed",
+ "invitation_parsing_desc": "Tuvasta kalendrikutsed e-kirjade manustes ja kuva kalenditoimingud",
+ "show_time_in_month_view": "Kuva kellaaeg kuuvaates",
+ "show_time_in_month_view_desc": "Kuva sündmuste kellaajad kuu kalendrivaates. Väikestel ekraanidel kuvatakse punktide asemel täielikud sündmuste kirjed.",
+ "show_week_numbers": "Kuva nädalanumbrid",
+ "show_week_numbers_desc": "Kuva nädalanumbrid minikalendris",
+ "calendar_free_scroll": "Vaba kerimine",
+ "calendar_free_scroll_desc": "Keri kuude, nädalate ja päevade vahel pidevalt, mitte ühe perioodi kaupa",
+ "enable_tasks": "Luba ülesanded",
+ "enable_tasks_desc": "Kuva kalendris ülesannete vaade tegemiste haldamiseks",
+ "show_tasks_on_calendar": "Kuva ülesanded kalendris",
+ "show_tasks_on_calendar_desc": "Kuva ülesannete sildid päeva- ja nädalavaates",
+ "hover_preview": "Sündmuse hõljutusega eelvaade",
+ "hover_preview_desc": "Kuva kalendrisündmuse kohal hõljutades üksikasjade hüpikaken",
+ "hover_preview_instant": "Koheselt",
+ "hover_preview_delay_500ms": "0,5-sekundiline viivitus",
+ "hover_preview_delay_1s": "1-sekundiline viivitus",
+ "hover_preview_delay_2s": "2-sekundiline viivitus",
+ "hover_preview_off": "Keelatud",
+ "organizer_identity": "Korralda kutseid kui",
+ "organizer_identity_desc": "Kalendriaadress, mida kasutatakse korraldajana, kui kutsud inimesi sündmustele.",
+ "organizer_identity_failed": "Korraldaja aadressi muutmine ebaõnnestus",
+ "show_birthday_calendar": "Kontaktide sünnipäevakalender",
+ "show_birthday_calendar_desc": "Kuva virtuaalne kalender sinu kontaktide sünnipäevadega"
+ },
+ "days": {
+ "monday": "Esmaspäev",
+ "tuesday": "Teisipäev",
+ "wednesday": "Kolmapäev",
+ "thursday": "Neljapäev",
+ "friday": "Reede",
+ "saturday": "Laupäev",
+ "sunday": "Pühapäev",
+ "mon": "E",
+ "tue": "T",
+ "wed": "K",
+ "thu": "N",
+ "fri": "R",
+ "sat": "L",
+ "sun": "P"
+ },
+ "months": {
+ "jan": "Jaan",
+ "feb": "Veebr",
+ "mar": "Märts",
+ "apr": "Apr",
+ "may": "Mai",
+ "jun": "Juuni",
+ "jul": "Juuli",
+ "aug": "Aug",
+ "sep": "Sept",
+ "oct": "Okt",
+ "nov": "Nov",
+ "dec": "Dets",
+ "far": "Farvardin",
+ "ord": "Ordibehesht",
+ "kho": "Khordad",
+ "tir": "Tir",
+ "mor": "Mordad",
+ "sha": "Shahrivar",
+ "meh": "Mehr",
+ "aba": "Aban",
+ "aza": "Azar",
+ "dey": "Dey",
+ "bah": "Bahman",
+ "esf": "Esfand"
+ },
+ "notifications": {
+ "event_created": "Sündmus loodud",
+ "event_updated": "Sündmus uuendatud",
+ "event_deleted": "Sündmus kustutatud",
+ "calendar_created": "Kalender loodud",
+ "calendar_deleted": "Kalender kustutatud",
+ "event_move_error": "Sündmuse liigutamine ebaõnnestus",
+ "event_resize_error": "Sündmuse suuruse muutmine ebaõnnestus",
+ "alert_title": "Eelseisev sündmus",
+ "alert_now": "Algab kohe",
+ "alert_in_minutes": "{count} min pärast",
+ "invitation_sent": "Kutsed saadetud",
+ "invitations_denied_title": "Kutseid ei saa saata",
+ "invitations_denied": "Server keeldus kutseid saatmast: {reason}. Kas salvestada sündmus kutseid saatmata?",
+ "save_without_invitations": "Salvesta kutseteta",
+ "rsvp_updated": "Vastus uuendatud",
+ "rsvp_error": "Vastuse uuendamine ebaõnnestus",
+ "event_duplicated": "Sündmus dubleeritud",
+ "event_error": "Sündmuse salvestamine ebaõnnestus",
+ "task_due": "Ülesande tähtaeg",
+ "event_exported": "Sündmus eksporditud",
+ "title_copied": "Pealkiri kopeeritud",
+ "link_copied": "Link kopeeritud"
+ },
+ "status": {
+ "loading_calendars": "Kalendrite laadimine...",
+ "loading_events": "Sündmuste laadimine..."
+ },
+ "quick_create": {
+ "placeholder": "Uue sündmuse pealkiri",
+ "aria_label": "Kiire sündmuse loomine",
+ "add_title": "Lisa pealkiri",
+ "more_options": "Rohkem valikuid"
+ },
+ "nav_prev": "Eelmine",
+ "nav_next": "Järgmine",
+ "nav_open_menu": "Ava menüü",
+ "import": {
+ "title": "Kalendri importimine",
+ "tab_file": "Fail",
+ "tab_url": "URL",
+ "select_file": "Vali .ics fail",
+ "drop_file": "või lohista fail siia",
+ "supported_formats": "Toetab iCalendari (.ics) faile",
+ "url_description": "Sündmuste importimiseks sisesta välise iCalendari (.ics) voo URL.",
+ "url_placeholder": "https://example.com/calendar.ics",
+ "url_hint": "Toetab CalDAV ja iCalendari (.ics) URL-e",
+ "fetch": "Laadi",
+ "invalid_url": "Palun sisesta kehtiv URL",
+ "url_fetch_failed": "Kalendri laadimine URL-ilt ebaõnnestus",
+ "parsing": "Kalendrifaili töötlemine...",
+ "parsed_events": "Leiti {count} sündmust",
+ "no_events": "Failist ei leitud sündmusi",
+ "select_all": "Vali kõik",
+ "deselect_all": "Tühista kõigi valik",
+ "target_calendar": "Impordi kalendrisse",
+ "import_button": "Impordi valitud",
+ "importing": "Sündmuste importimine...",
+ "success": "{count} sündmust imporditi edukalt",
+ "error": "Kalendri importimine ebaõnnestus",
+ "file_too_large": "Fail ületab 10 MB piirangu",
+ "invalid_format": "Vale kalendrifaili vorming"
+ },
+ "webcal_action": {
+ "title": "Ava kalendrilink",
+ "description": "Kuidas soovid kalendrit \"{name}\" kasutada?",
+ "import_title": "Impordi ühekordselt",
+ "import_description": "Laadi sündmused kohe alla ja kopeeri need ühte oma kalendritest.",
+ "subscribe_title": "Telli",
+ "subscribe_description": "Hoia see kalender automaatselt sünkroonituna eraldi kalendrina.",
+ "cancel": "Tühista"
+ },
+ "management": {
+ "title": "Kalendrite haldamine",
+ "description": "Loo, nimeta ümber ja kohanda oma kalendreid. Kalendri värvi kiireks muutmiseks tee küljeribal kalendril paremklõps.",
+ "name": "Nimi",
+ "name_placeholder": "Kalendri nimi",
+ "color": "Värv",
+ "change_color": "Muuda värvi",
+ "random_color": "Uus juhuslik värv",
+ "add_calendar": "Lisa kalender",
+ "edit": "Muuda",
+ "delete": "Kustuta",
+ "save": "Salvesta",
+ "create": "Loo",
+ "cancel": "Tühista",
+ "default": "Vaikimisi",
+ "confirm_delete": "Kustutada \"{name}\"? Kõik selle kalendri sündmused eemaldatakse.",
+ "confirm_clear": "Tühjendada kõik sündmused kalendrist \"{name}\"? Seda ei saa tagasi võtta.",
+ "set_default": "Määra vaikimisi kalendriks",
+ "default_updated": "Vaikekalender uuendatud",
+ "error_default": "Vaikekalendri määramine ebaõnnestus",
+ "clear_events": "Tühjenda sündmused",
+ "events_cleared": "{count} sündmust tühjendatud",
+ "error_clear": "Kalendrisündmuste tühjendamine ebaõnnestus",
+ "calendar_created": "Kalender loodud",
+ "calendar_updated": "Kalender uuendatud",
+ "calendar_deleted": "Kalender kustutatud",
+ "color_updated": "Kalendri värv uuendatud",
+ "error_create": "Kalendri loomine ebaõnnestus",
+ "error_update": "Kalendri uuendamine ebaõnnestus",
+ "error_delete": "Kalendri kustutamine ebaõnnestus",
+ "caldav_url": "CalDAV URL",
+ "copy_url": "Kopeeri CalDAV URL",
+ "url_copied": "CalDAV URL kopeeriti lõikelauale",
+ "share": "Jaga kalendrit",
+ "new_event_in_calendar": "Uus sündmus selles kalendris",
+ "shared_with": "Jagatud: {names}",
+ "shared_with_count": "{count, plural, one {Jagatud 1 inimesega} other {Jagatud # inimesega}}",
+ "move_up": "Liiguta üles",
+ "move_down": "Liiguta alla",
+ "kind": "Sisu",
+ "kind_events": "Sündmused",
+ "kind_tasks": "Ülesanded",
+ "kind_both": "Sündmused ja ülesanded",
+ "kind_hint": "Määrab, millised rakendused seda kalendrit CalDAV kaudu sünkroonides näevad. Seda ei saa hiljem muuta.",
+ "default_alerts": "Vaikemeeldetuletused",
+ "default_alerts_title": "Kalendri „{name}” vaikemeeldetuletused",
+ "default_alerts_hint": "Selle kalendri uued sündmused saavad need meeldetuletused, kui sa neid sündmuses ei muuda. Siin muutmine ei muuda olemasolevaid sündmusi. E-posti meeldetuletused saadab meiliserver.",
+ "default_alerts_timed": "Kellaajaga sündmused",
+ "default_alerts_all_day": "Terve päeva sündmused",
+ "default_alerts_updated": "Vaikemeeldetuletused uuendatud"
+ },
+ "subscription": {
+ "title": "iCal tellimus",
+ "section_title": "iCal tellimused",
+ "description": "Telli väline iCalendari voog. Sündmused sünkroonitakse automaatselt omaette kalendrisse. Toetab https:// ja webcal:// URL-e.",
+ "url_label": "Kalendri URL",
+ "url_placeholder": "https://example.com/calendar.ics või webcal://...",
+ "name_label": "Kalendri nimi",
+ "name_placeholder": "nt riigipühad",
+ "color_label": "Värv",
+ "refresh_interval": "Värskendamise intervall",
+ "interval_15": "Iga 15 minuti järel",
+ "interval_30": "Iga 30 minuti järel",
+ "interval_60": "Iga tunni järel",
+ "interval_360": "Iga 6 tunni järel",
+ "interval_1440": "Iga päev",
+ "subscribe": "Telli",
+ "subscribing": "Tellimine...",
+ "save": "Salvesta muudatused",
+ "saving": "Salvestamine...",
+ "edit": "Muuda",
+ "edit_title": "Tellimuse muutmine",
+ "updated": "\"{name}\" uuendatud",
+ "update_error": "Tellimuse uuendamine ebaõnnestus",
+ "invalid_url": "Palun sisesta kehtiv URL",
+ "success": "Tellitud: \"{name}\"",
+ "error": "Tellimuse lisamine ebaõnnestus",
+ "refresh": "Värskenda kohe",
+ "refresh_success": "Tellimus värskendatud",
+ "refresh_error": "Tellimuse värskendamine ebaõnnestus",
+ "unsubscribe": "Lõpeta tellimus",
+ "confirm_delete": "Lõpetada tellimus \"{name}\"? Kalender ja kõik selle sündmused eemaldatakse.",
+ "deleted": "Tellimus eemaldatud",
+ "delete_error": "Tellimuse eemaldamine ebaõnnestus",
+ "last_refreshed": "Viimati uuendatud: {time}"
+ },
+ "tasks": {
+ "label": "Ülesanded",
+ "no_tasks": "Ülesandeid pole",
+ "no_title": "(Pealkirjata)",
+ "mark_complete": "Märgi täidetuks",
+ "mark_incomplete": "Märgi täitmata",
+ "filter_all": "Kõik",
+ "filter_pending": "Ootel",
+ "filter_completed": "Täidetud",
+ "filter_overdue": "Tähtaeg möödas",
+ "show_completed": "Kuva täidetud",
+ "create": "Uus ülesanne",
+ "edit": "Muuda ülesannet",
+ "title_placeholder": "Ülesande pealkiri",
+ "description_placeholder": "Lisa kirjeldus...",
+ "due_date": "Tähtaeg",
+ "include_time": "Kaasa kellaaeg",
+ "priority": "Prioriteet",
+ "priority_none": "Puudub",
+ "priority_high": "Kõrge",
+ "priority_medium": "Keskmine",
+ "priority_low": "Madal",
+ "progress": "Olek",
+ "progress_needs_action": "Vajab vastust",
+ "progress_in_process": "Pooleli",
+ "progress_completed": "Täidetud",
+ "progress_cancelled": "Tühistatud",
+ "calendar": "Kalender",
+ "alert": "Meeldetuletus",
+ "alert_none": "Puudub",
+ "alert_at_time": "Tähtaja saabudes",
+ "alert_5min": "5 minutit enne",
+ "alert_15min": "15 minutit enne",
+ "alert_30min": "30 minutit enne",
+ "alert_1hr": "1 tund enne",
+ "alert_1day": "1 päev enne",
+ "delete": "Kustuta",
+ "cancel": "Tühista",
+ "save": "Salvesta",
+ "quick_add_placeholder": "Lisa ülesanne...",
+ "due_today": "Täna",
+ "due_tomorrow": "Homme",
+ "overdue": "Tähtaeg möödas"
+ },
+ "toolbar": {
+ "more": "Rohkem toiminguid",
+ "select_view": "Muuda vaadet, praegu {view}"
+ },
+ "other_calendars": "Muud kalendrid",
+ "calendar_options": "Kalendri {name} valikud"
+ },
+ "calendar_event_notifications": {
+ "someone": "Keegi",
+ "invited": "{name} kutsus sind sündmusele \"{title}\"",
+ "updated": "{name} uuendas sündmust \"{title}\"",
+ "cancelled": "{name} tühistas sündmuse \"{title}\"",
+ "untitled": "Nimetu sündmus"
+ },
+ "share_notifications": {
+ "someone": "Keegi",
+ "shared": "{name} jagas sinuga {kind} \"{object}\"",
+ "changed": "{name} muutis sinu juurdepääsu {kind}-le \"{object}\"",
+ "revoked": "{name} eemaldas sinu juurdepääsu {kind}-le \"{object}\"",
+ "object": {
+ "folder": "kaust",
+ "calendar": "kalender",
+ "address_book": "aadressiraamat",
+ "files": "failikaust"
+ }
+ },
+ "sharing": {
+ "title": "Jaga \"{name}\"",
+ "description": "Anna juurdepääs selle serveri teistele kasutajatele või gruppidele. Muudatused rakenduvad koheselt.",
+ "no_shares": "Pole veel kellegagi jagatud.",
+ "add_person": "Lisa isik või grupp",
+ "search_placeholder": "Otsi nime või e-posti järgi…",
+ "loading_principals": "Kasutajate laadimine…",
+ "no_principals": "Teisi kasutajaid ega gruppe ei leitud.",
+ "no_match": "Vasteid ei leitud.",
+ "remove": "Eemalda juurdepääs",
+ "group": "Grupp",
+ "share_added": "Juurdepääs antud",
+ "share_updated": "Juurdepääs uuendatud",
+ "share_removed": "Juurdepääs eemaldatud",
+ "share_failed": "Jagamise uuendamine ebaõnnestus",
+ "preset": {
+ "freeBusy": "Ainult vaba/hõivatud",
+ "read": "Ainult lugemine",
+ "readWrite": "Lugemine ja kirjutamine",
+ "manager": "Haldur",
+ "custom": "Kohandatud"
+ }
+ },
+ "advanced_search": {
+ "title": "Täpsem otsing",
+ "from": "Saatja",
+ "from_placeholder": "Saatja e-post või nimi",
+ "to": "Saaja",
+ "to_placeholder": "Saaja e-post või nimi",
+ "subject": "Teema",
+ "subject_placeholder": "Teema sisaldab...",
+ "body": "Sisu",
+ "body_placeholder": "Sisu sisaldab...",
+ "folder": "Kaust",
+ "all_folders": "Kõik kaustad",
+ "all_folders_except_spam_trash": "Kõik kaustad peale rämpsposti ja prügikasti",
+ "has_attachment": "Manused",
+ "date_after": "Pärast",
+ "date_before": "Enne",
+ "size_min": "Suurem kui (KB)",
+ "size_max": "Väiksem kui (KB)",
+ "starred": "Tähistatud",
+ "unread": "Lugemata",
+ "read": "Loetud",
+ "yes": "Jah",
+ "no": "Ei",
+ "clear": "Tühjenda",
+ "clear_all": "Tühjenda kõik",
+ "filters_active": "{count} filter",
+ "filters_active_plural": "{count} filtrit",
+ "toggle_filters": "Rohkem",
+ "search_hint": "Kasuta täpseks otsinguks täiendavaid filtreid",
+ "advanced_filters_tooltip": "Täpsema otsingu filtrid",
+ "results_found": "{count, plural, =0 {Tulemusi ei leitud} one {# tulemus leitud} other {# tulemust leitud}}",
+ "results_found_more": "{count}+ tulemust leitud",
+ "suggestions_recent": "Hiljutised otsingud",
+ "suggestions_people": "Inimesed",
+ "suggestions_from": "Otsi e-kirju saatjalt {address}",
+ "suggestions_to": "Otsi e-kirju saajale {address}",
+ "suggestions_remove_recent": "Eemalda hiljutistest otsingutest",
+ "has_words": "Sisaldab sõnu",
+ "size": "Suurus",
+ "date_within": "Kuupäev",
+ "search_in": "Otsi kaustast",
+ "has_attachment_check": "Sisaldab manust",
+ "search": "Otsi"
+ },
+ "welcome": {
+ "title": "Tere tulemast sinu postkasti",
+ "tip_compose": "Vajuta c uue e-kirja kirjutamiseks",
+ "tip_shortcuts": "Vajuta ? kõigi kiirklahvide nägemiseks",
+ "tip_sidebar": "Kalender, Kontaktid ja Seaded leiad külgmenüüst",
+ "tip_settings": "Kohanda oma kogemust seadetes",
+ "tip_compose_touch": "Uue kirja kirjutamiseks puuduta pliiatsinuppu",
+ "tip_sidebar_touch": "Kalender, kontaktid ja seaded asuvad alumisel ribal",
+ "got_it": "Selge",
+ "settings": "Seaded",
+ "dismiss": "Sulge",
+ "start_tour": "Alusta tutvustust"
+ },
+ "demo_welcome": {
+ "title": "Tere tulemast Bulwark Maili juurde",
+ "description": "Tutvu täisfunktsionaalse veebipostiga - otse oma brauseris. Kõik andmed jäävad sinu seadmesse, seega võid vabalt kõike proovida.",
+ "feature_email": "Loe ja kirjuta e-kirju",
+ "feature_organize": "Märgid, tähed ja kaustad",
+ "feature_shortcuts": "Kiirklahvid",
+ "feature_privacy": "100% privaatne demo",
+ "hint": "Klõpsa alustamiseks vasakul mõnel e-kirjal või tee allpool tutvustusring."
+ },
+ "files": {
+ "office_edit": "Muuda dokumenti",
+ "office_loading": "Dokumendiredaktori avamine…",
+ "office_error": "Dokumendiredaktorit ei õnnestunud avada.",
+ "office_close": "Sulge redaktor",
+ "open_in_new_tab": "Ava uues vahelehes",
+ "title": "Failid",
+ "search_placeholder": "Otsi faile...",
+ "empty_state_title": "Faile pole veel",
+ "upload_files": "Laadi failid üles",
+ "new_folder": "Uus kaust",
+ "new_folder_name": "Kausta nimi",
+ "rename": "Nimeta ümber",
+ "rename_title": "Nimeta ümber",
+ "new_name": "Uus nimi",
+ "name_forbidden_chars": "Server ei luba nimedes neid märke: {chars}",
+ "name_reserved": "See nimi on serveris reserveeritud.",
+ "delete": "Kustuta",
+ "delete_confirm_title": "Kustuta ressurss",
+ "delete_confirm_message": "Kas oled kindel, et soovid kustutada \"{name}\"? Seda ei saa tagasi võtta.",
+ "download": "Laadi alla",
+ "name": "Nimi",
+ "size": "Suurus",
+ "modified": "Muudetud",
+ "type": "Tüüp",
+ "folder": "Kaust",
+ "file": "Fail",
+ "no_accounts": "Ühendatud kontosid pole.",
+ "uploading": "Üleslaadimine...",
+ "upload_success": "{count, plural, one {1 fail üles laaditud} other {# faili üles laaditud}}",
+ "upload_canceled": "Üleslaadimine tühistatud",
+ "upload_canceled_partial": "{count, plural, one {1 fail} other {# faili}} laaditi üles enne tühistamist",
+ "upload_error": "Faili üleslaadimine ebaõnnestus",
+ "create_folder_success": "Kaust loodud",
+ "create_folder_error": "Kausta loomine ebaõnnestus",
+ "delete_success": "Edukalt kustutatud",
+ "delete_error": "Kustutamine ebaõnnestus",
+ "rename_success": "Edukalt ümber nimetatud",
+ "rename_error": "Ümbernimetamine ebaõnnestus",
+ "download_error": "Allalaadimine ebaõnnestus",
+ "not_available": "Failihoidla ei ole selles serveris saadaval",
+ "cancel": "Tühista",
+ "create": "Loo",
+ "save": "Salvesta",
+ "no_results": "Otsingule vastavaid faile ei leitud",
+ "batch_delete_confirm_message": "Kas oled kindel, et soovid kustutada {count, plural, one {1 üksuse} other {# üksust}}? Seda ei saa tagasi võtta.",
+ "batch_delete_success": "{count, plural, one {1 üksus kustutatud} other {# üksust kustutatud}}",
+ "grid_view": "Ruudustikvaade",
+ "list_view": "Loendivaade",
+ "details": "Üksikasjad",
+ "path": "Asukoht",
+ "preview": "Eelvaade",
+ "preview_error": "Eelvaate laadimine ebaõnnestus",
+ "cut": "Lõika",
+ "copy": "Kopeeri",
+ "paste": "Kleebi",
+ "move_success": "{count, plural, one {1 üksus liigutatud} other {# üksust liigutatud}}",
+ "move_error": "Liigutamine ebaõnnestus",
+ "paste_success": "Edukalt kleebitud",
+ "paste_error": "Kleepimine ebaõnnestus",
+ "new_text_file": "Uus tekstifail",
+ "file_name": "Faili nimi",
+ "retry": "Proovi uuesti",
+ "refresh": "Värskenda",
+ "duplicate": "Dubleeri",
+ "duplicate_success": "Edukalt dubleeritud",
+ "duplicate_error": "Dubleerimine ebaõnnestus",
+ "share": "Jaga",
+ "shared": "Jagatud",
+ "shared_with_me": "Minuga jagatud",
+ "shared_by": "Jagas {name}",
+ "create_file_success": "Fail loodud",
+ "create_file_error": "Faili loomine ebaõnnestus",
+ "favorites": "Lemmikud",
+ "recent": "Hiljutised",
+ "show_in_folder": "Näita kaustas",
+ "remove_from_recent": "Eemalda hiljutistest",
+ "upload_folder": "Laadi kaust üles",
+ "file_too_large": "\"{name}\" ületab maksimaalset failisuurust ({max})",
+ "undo": "Võta tagasi",
+ "undo_success": "Tegevus tagasi võetud",
+ "undo_error": "Tagasivõtmine ebaõnnestus",
+ "toolbar": "Failitoimingud",
+ "open_folder_tree": "Ava kaustapuu",
+ "file_list": "Failid ja kaustad",
+ "context_menu": "Toimingud",
+ "settings_ascending": "Kasvav",
+ "settings_descending": "Kahanev",
+ "disabled_title": "Administraator on failide funktsiooni välja lülitanud",
+ "disabled_description": "Suurte failide üleslaadimine WebDAV kaudu võib põhjustada Stalwart/RocksDB ebastabiilsust, sealhulgas mälupuudusest tingitud krahhe ja taastamatut kettaruumi kasutust. Kustutatud failid ei pruugi objektihoidlast kohe kaduda. Seda funktsiooni ei soovitata kasutada tootmiskeskkonnas.",
+ "migration_title": "Failide uuendamine…",
+ "migration_description": "Kaustade ja failide korrastamine õigesse struktuuri. See toimub ainult üks kord.",
+ "new": "Uus",
+ "my_files": "Minu failid",
+ "folders": "Kaustad",
+ "files_heading": "Failid",
+ "open": "Ava",
+ "star": "Tähista",
+ "unstar": "Eemalda tärn",
+ "expand_folder": "Laienda {name}",
+ "collapse_folder": "Ahenda {name}",
+ "sidebar_hint": "Tähista kaustu ja ava faile, et neid siit leida.",
+ "breadcrumbs": "Kausta tee",
+ "show_path": "Näita teel peidetud kaustu",
+ "selection_actions": "Valitud üksuste toimingud",
+ "selected_count": "{count, plural, one {1 valitud} other {# valitud}}",
+ "clear_selection": "Tühista valik",
+ "select_all": "Vali kõik",
+ "select_item": "Vali {name}",
+ "more_actions": "Rohkem toiminguid: {name}",
+ "paste_here": "Kleebi sellesse kausta",
+ "paste_count": "{count, plural, one {Kleebi 1 üksus} other {Kleebi # üksust}}",
+ "sort_by": "Sorteeri",
+ "view_mode": "Paigutus",
+ "clear_search": "Tühjenda otsing",
+ "no_results_for": "Selles kaustas pole midagi, mis vastaks päringule „{query}”",
+ "download_count": "{count, plural, one {Laadi alla 1 fail} other {Laadi alla # faili}}",
+ "delete_count": "{count, plural, one {Kustuta 1 üksus} other {Kustuta # üksust}}",
+ "close_details": "Sulge üksikasjad",
+ "file_kind": "{ext}-fail",
+ "folder_summary": "{folders, plural, =0 {Kaustu pole} one {1 kaust} other {# kausta}}, {files, plural, =0 {faile pole} one {1 fail} other {# faili}}",
+ "details_hint": "Üksikasjade nägemiseks vali fail või kaust.",
+ "sharing": "Jagamine",
+ "shared_with_count": "{count, plural, one {Jagatud 1 inimesega} other {Jagatud # inimesega}}",
+ "only_you": "Ainult sina",
+ "empty_folder_title": "See kaust on tühi",
+ "empty_folder_description": "Lohista failid siia või laadi need oma seadmest üles.",
+ "empty_folder_read_only": "Sellesse kausta ei saa faile lisada.",
+ "choose_account": "Vali konto",
+ "drop_to_upload": "Lase lahti, et laadida kausta {folder}",
+ "uploading_count": "Üleslaadimine {current} / {total}",
+ "dismiss": "Sulge",
+ "zoom_in": "Suumi sisse",
+ "zoom_out": "Suumi välja",
+ "zoom_reset": "Sobita ekraanile",
+ "rotate": "Pööra",
+ "close_preview": "Sulge"
+ },
+ "smime": {
+ "your_certificates": "Sinu sertifikaadid",
+ "your_certificates_desc": "Impordi ja halda oma S/MIME sertifikaate e-kirjade allkirjastamiseks ja krüpteerimiseks",
+ "recipient_certificates": "Saajate sertifikaadid",
+ "recipient_certificates_desc": "Avalikud sertifikaadid saajatele saadetavate e-kirjade krüpteerimiseks",
+ "identity_bindings": "Identiteedi võtmeseosed",
+ "identity_bindings_desc": "Seo S/MIME sertifikaadid oma e-posti identiteetidega",
+ "defaults_title": "Vaikeseaded",
+ "defaults_desc": "Seadista allkirjastamise ja krüpteerimise vaikekäitumine",
+ "import_pkcs12": "Impordi PKCS#12 (.p12/.pfx)",
+ "import_public_cert": "Impordi sertifikaat",
+ "no_certificates": "Sertifikaate pole veel imporditud",
+ "no_recipient_certs": "Saajate sertifikaate pole",
+ "expires": "Aegub",
+ "expired": "Aegunud",
+ "bound_to": "Seotud",
+ "no_key_bound": "Puudub",
+ "lock": "Lukusta võti",
+ "unlock": "Ava võti",
+ "details": "Vaata üksikasju",
+ "delete": "Kustuta",
+ "encrypt_by_default": "Krüpteeri vaikimisi",
+ "encrypt_by_default_desc": "Krüpteeri e-kirjad automaatselt, kui kõigil saajatel on sertifikaat",
+ "remember_unlocked": "Jäta avatud võtmed meelde",
+ "remember_unlocked_desc": "Hoia võtmed avatuna kogu selle brauserisessiooni jooksul",
+ "sign_default_for": "Allkirjasta vaikimisi",
+ "enter_p12_passphrase": "Sisesta PKCS#12 paroolfraas",
+ "p12_passphrase_desc": "Sisesta paroolfraas, mis kaitseb seda sertifikaadifaili",
+ "enter_storage_passphrase": "Määra hoidla paroolfraas",
+ "storage_passphrase_desc": "Vali paroolfraas, mis kaitseb seda võtit brauseris puhkeolekus",
+ "next": "Järgmine",
+ "import": "Impordi",
+ "unlock_key": "Ava võti",
+ "unlock_key_desc": "Sisesta hoidla paroolfraas, et avada see võti allkirjastamiseks või dekrüpteerimiseks",
+ "passphrase_placeholder": "Sisesta paroolfraas",
+ "confirm_passphrase_placeholder": "Kinnita paroolfraas",
+ "passphrase_mismatch": "Paroolfraasid ei kattu",
+ "cancel": "Tühista",
+ "processing": "Töötlemine…",
+ "close": "Sulge",
+ "certificate_details": "Sertifikaadi üksikasjad",
+ "cert_subject": "Subjekt",
+ "cert_issuer": "Väljastaja",
+ "cert_email": "E-post",
+ "cert_serial": "Seerianumber",
+ "cert_validity": "Kehtivus",
+ "cert_fingerprint": "Sõrmejälg (SHA-256)",
+ "cert_algorithm": "Algoritm",
+ "cert_capabilities": "Võimalused",
+ "cert_source": "Allikas",
+ "cert_expired": "See sertifikaat on aegunud",
+ "cert_not_yet_valid": "See sertifikaat ei kehti veel",
+ "cap_sign": "Allkirjastamine",
+ "cap_encrypt": "Krüpteerimine",
+ "cap_none": "Puudub",
+ "show_passphrase": "Näita paroolfraasi",
+ "hide_passphrase": "Peida paroolfraas",
+ "sign_toggle": "Allkirjasta",
+ "encrypt_toggle": "Krüpteeri",
+ "missing_recipient_certs": "Sertifikaadid puuduvad: {emails}",
+ "missing_sender_cert": "Selle identiteediga pole sertifikaati seotud",
+ "status_encrypted_ok": "See sõnum oli krüpteeritud",
+ "status_encrypted_no_key": "See sõnum on krüpteeritud, kuid sobivat võtit ei leitud",
+ "status_encrypted_failed": "Selle sõnumi dekrüpteerimine ebaõnnestus",
+ "status_signed_valid": "Allkiri on kontrollitud",
+ "status_signed_invalid": "Allkirja kontrollimine ebaõnnestus",
+ "status_signed_expired_cert": "Allkirjastatud aegunud sertifikaadiga",
+ "status_signed_self_signed": "Allkiri on kehtiv, kuid sertifikaat on ise-allkirjastatud (ei ole usaldusväärne)",
+ "status_signed_mismatch": "Allkiri on kehtiv, kuid allkirjastaja ei ühti saatjaga",
+ "status_unsupported": "Toetamata S/MIME vorming",
+ "auto_import_signer_certs": "Impordi allkirjastajate sertifikaadid automaatselt",
+ "auto_import_signer_certs_desc": "Salvesta kontrollitud allkirjastatud e-kirjade sertifikaadid automaatselt hilisemaks krüpteerimiseks",
+ "export": "Ekspordi",
+ "enter_export_passphrase": "Määra ekspordi paroolfraas",
+ "export_passphrase_desc": "Vali paroolfraas eksporditud PKCS#12 faili kaitsmiseks",
+ "export_storage_desc": "Sisesta hoidla paroolfraas, et dekrüpteerida võti eksportimiseks",
+ "incorrect_passphrase": "Vale paroolfraas"
+ },
+ "tour": {
+ "step_counter": "Samm {current}/{total}",
+ "skip": "Jäta tutvustus vahele",
+ "back": "Tagasi",
+ "next": "Järgmine",
+ "finish": "Lõpeta",
+ "take_a_tour": "Tutvu liidesega",
+ "restart_title": "Sissejuhatav tutvustus",
+ "restart_desc": "Vaata liidese juhendatud tutvustust uuesti",
+ "restart_button": "Alusta tutvustust uuesti",
+ "show_on_new_devices_title": "Näita uutes seadmetes",
+ "show_on_new_devices_desc": "Näita tervitusteadet ja tutvustust uuesti, kui logid esimest korda sisse uues seadmes, isegi kui oled need mujal juba läbinud",
+ "sidebar_title": "Sinu postkastid",
+ "sidebar_desc": "See on sinu kaustade külgriba. Kirjade vaatamiseks klõpsa mistahes postkastil. Saad luua kaustu, lohistada kirju nende vahel ja näha korraga lugemata kirjade arvu.",
+ "compose_title": "Koosta e-kiri",
+ "compose_desc": "Klõpsa siia, et kirjutada uus e-kiri. Saad lisada saajaid, manuseid ja kasutada rikastatud tekstivormingut.",
+ "search_title": "Otsi oma kirjade seast",
+ "search_desc": "Otsi saatja, teema või sisu järgi. Täpsemate valikute (kuupäevavahemik, manused, tärniga tähistatud kirjad) jaoks klõpsa filtriikoonil.",
+ "email_list_title": "Sinu kirjade loend",
+ "email_list_desc": "Kirjad kuvatakse siin. Lugemiseks klõpsa kirjal, see avaneb paremal. Märkeruutude abil saad valida mitu kirja ja need korraga liigutada, kustutada või sildistada.",
+ "email_viewer_title": "Lugemispaan",
+ "email_viewer_desc": "Valitud kiri avaneb siin. Tööriistariba nuppudega saad vastata, edastada, arhiveerida või kustutada. Samuti saad kirju tärniga märkida või lisada värvisilte.",
+ "keywords_title": "Värvisildid",
+ "keywords_desc": "Korrasta oma e-kirju värvikoodiga siltide abil. Sildistamiseks lohista kiri sildile või tee kirjal paremklõps ja määra silt.",
+ "calendar_title": "Kalender",
+ "calendar_desc": "Lülitu kalendrile, et hallata oma sündmusi. Loo sündmusi, sea meeldetuletusi ning vaata päeva-, nädala- või kuuvaadet.",
+ "contacts_title": "Kontaktid",
+ "contacts_desc": "Siin asub sinu aadressiraamat. Impordi kontakte, loo gruppe ja klõpsa kontaktil, et näha kõiki üksikasju.",
+ "settings_title": "Seaded",
+ "settings_desc": "Kohanda kõike: teema, tihedus, allkirjad, filtrid, kiirklahvid, kalendri vaikeseaded ja palju muud.",
+ "shortcuts_title": "Kiirklahvid",
+ "shortcuts_desc": "Kogenud kasutajate lemmik. Vajuta igal ajal ?, et näha kõiki saadaolevaid kiirklahve. Saad liikuda, kirjutada ja hallata kirju ilma hiirt puudutamata.",
+ "compose_open_title": "Kirjakoostaja",
+ "compose_open_desc": "See on kirjakoostaja. Lisa saajaid, kirjuta oma sõnum, lisa manuseid ja kasuta rikastatud tekstivormingut. Samuti saad salvestada mustandeid ja kasutada malle.",
+ "calendar_view_title": "Sinu kalender",
+ "calendar_view_desc": "Siin on sinu kalender koos näidissündmustega. Tööriistariba abil saad vahetada päeva-, nädala-, kuu- ja päevakorravaadete vahel.",
+ "create_event_title": "Loo sündmus",
+ "create_event_desc": "Klõpsa sellel nupul uue kalendrisündmuse loomiseks. Saad määrata pealkirja, kuupäeva, kellaaja ja lisada osalejaid.",
+ "event_modal_title": "Sündmuse üksikasjad",
+ "event_modal_desc": "Siin on sündmuse vorm. Täida pealkiri, vali kuupäev ja kellaaeg, lisa asukoht või osalejad. Kui oled valmis, vajuta salvesta - või sulge vorm ja liigu edasi.",
+ "contacts_list_title": "Sinu kontaktid",
+ "contacts_list_desc": "Siin on sinu kontaktid. Klõpsa kontaktil, et näha selle täielikke andmeid paremal. Samuti saad luua uusi kontakte, importida vCarde või korraldada kontakte gruppidesse.",
+ "settings_tabs_title": "Seadete menüü",
+ "settings_tabs_desc": "Siin on kõik seadete kategooriad. Kohanda välimust, halda identiteete, seadista e-posti filtreid, kalendrit ja palju muud.",
+ "files_title": "Failihoidla",
+ "files_desc": "Failihalduriga saad faile üles laadida, korrastada ja jagada - nagu isiklik pilvekettas, mis on sinu e-postiga sisse ehitatud.",
+ "demo_banner_title": "Demorežiimi juhtnupud",
+ "demo_banner_desc": "Oled demorežiimis - kõik jääb sinu brauserisse. Vajuta igal ajal 'Lähtesta demo', et alustada puhta näidisandmestikuga otsast peale.",
+ "quota_title": "Mahu kasutus",
+ "quota_desc": "Jälgi siin oma postkasti suurust. Ring täitub, mida rohkem ruumi kasutad."
+ },
+ "unified_mailbox": {
+ "search_unavailable": "Otsing pole ühendatud vaates saadaval"
+ },
+ "quote_header": {
+ "reply_line": "{date}, {from} kirjutas:",
+ "forwarded_separator": "---------- Edastatud sõnum ----------",
+ "from_label": "Saatja",
+ "date_label": "Kuupäev",
+ "subject_label": "Teema"
+ },
+ "pwa_install": {
+ "title": "Paigalda {appName}",
+ "description": "Paigalda meie rakendus kiireks ligipääsuks ja võrguühenduseta kasutamiseks.",
+ "not_now": "Mitte praegu",
+ "install": "Paigalda",
+ "dont_remind": "Ära tuleta enam meelde",
+ "dismiss_aria": "Sulge paigaldusteade"
+ },
+ "deep_link": {
+ "message_not_found": "See sõnum ei ole enam saadaval.",
+ "thread_not_found": "See vestlus ei ole enam saadaval.",
+ "folder_not_found": "See kaust ei ole enam saadaval.",
+ "event_not_found": "See sündmus ei ole enam saadaval.",
+ "file_not_found": "See fail ei ole enam saadaval.",
+ "account_unavailable": "Selle lingi avamiseks logi sisse vastavasse kontosse.",
+ "copy_message": "Kopeeri link sõnumile",
+ "copy_conversation": "Kopeeri link vestlusele",
+ "copy_event": "Kopeeri link sündmusele",
+ "copied": "Link kopeeritud",
+ "copy_failed": "Linki ei õnnestunud kopeerida"
+ },
+ "plugins": {
+ "oauth_callback": {
+ "completing": "Autoriseerimise lõpetamine…",
+ "completing_message": "Palun oota, kuni ühendus luuakse.",
+ "received_title": "Autoriseerimine õnnestus",
+ "received_message": "Võid naasta vahelehele, kust ühenduse alustasid.",
+ "failed_title": "Autoriseerimine ebaõnnestus",
+ "denied": "Autoriseerimisest keelduti.",
+ "failed_detail": "Autoriseerimine ebaõnnestus: {error}",
+ "missing_params": "Autoriseerimiskood või olek puudub.",
+ "delivery_failed": "Autoriseerimise tulemust ei õnnestunud rakenduse vahelehele edastada. Palun sulge see aken ja proovi uuesti."
+ },
+ "settings": {
+ "title": "Pistikprogrammid",
+ "description": "Administraatori lisatud pistikprogrammid. Lülita need oma konto jaoks sisse või välja.",
+ "description_uploads": "Pistikprogrammid lisavad veebimeilile funktsioone. Lülita need oma konto jaoks sisse või välja või paigalda oma.",
+ "description_demo": "Pistikprogrammid pole demos saadaval.",
+ "install_button": "Paigalda pistikprogramm",
+ "install_hint": "Vali pistikprogrammi ZIP-fail.",
+ "install_hint_approval": "Vali pistikprogrammi ZIP-fail. Administraator peab selle heaks kiitma, enne kui saad selle sisse lülitada.",
+ "install_uploads_off": "Sinu administraator ei luba pistikprogramme paigaldada.",
+ "install_managed_id": "Sinu administraator pakub juba selle nimega pistikprogrammi, seega ei saa seda asendada.",
+ "install_invalid": "See fail ei ole paigaldatav pistikprogramm: {details}",
+ "toast_installed": "Pistikprogramm paigaldatud. Lülita see allpool sisse.",
+ "toast_installed_pending": "Pistikprogramm paigaldatud ja saadetud administraatorile heakskiitmiseks.",
+ "toast_enabled": "{name} on sees",
+ "toast_disabled": "{name} on väljas",
+ "toast_removed": "{name} eemaldati",
+ "toast_forced": "Sinu administraator hoiab {name} sisse lülitatuna",
+ "toast_approval_requested": "{name} ootab administraatori heakskiitu",
+ "empty_title": "Pistikprogramme pole veel",
+ "empty_admin": "Sinu administraator pole ühtegi pistikprogrammi lisanud.",
+ "empty_uploads": "Alustamiseks paigalda pistikprogramm ZIP-failist.",
+ "empty_demo": "Pistikprogrammid vajavad päris kontot, seega demos neid pole.",
+ "syncing": "Kontrollin pistikprogramme serveriga…",
+ "status_installed": "Väljas",
+ "status_enabled": "Käivitub",
+ "status_running": "Sees",
+ "status_disabled": "Väljas",
+ "status_error": "Probleem",
+ "badge_forced": "Kohustuslik",
+ "badge_managed": "Sinu administraatorilt",
+ "badge_yours": "Sinu paigaldatud",
+ "badge_awaiting": "Ootab heakskiitu",
+ "toggle_label": "Lülita {name} sisse või välja",
+ "forced_note": "Sinu administraator nõuab seda pistikprogrammi, seega ei saa seda välja lülitada.",
+ "permissions": "Mida see teha saab",
+ "permissions_managed": "Mida see teha saab (administraatori heaks kiidetud)",
+ "settings": "Seaded",
+ "remove": "Eemalda",
+ "remove_title": "Kas eemaldada {name}?",
+ "remove_message": "Pistikprogramm ja kõik, mida see selles brauseris salvestas, kustutatakse. Võid selle hiljem uuesti paigaldada.",
+ "remove_confirm": "Eemalda",
+ "error_user_plugins_off": "Sinu administraator lülitas kasutajate paigaldatud pistikprogrammid välja, seega see pistikprogramm ei tööta.",
+ "error_approval_unavailable": "See pistikprogramm paigaldati enne heakskiidu nõuet. Eemalda see ja paigalda uuesti, et saata see heakskiitmiseks.",
+ "error_approval_denied": "Sinu administraator ei kiitnud seda pistikprogrammi heaks.",
+ "error_approval_pending": "Ootan, et administraator selle pistikprogrammi heaks kiidaks.",
+ "error_auto_disabled": "Lülitati välja, kuna see jäi ebaõnnestuma. Lülita uuesti sisse, et proovida.",
+ "accounts_heading": "Ühendatud kontod",
+ "account_on": "{label} serveris {host}",
+ "account_waiting": "{label} serveris {host} (sisselogimine pole lõpetatud)",
+ "disconnect": "Katkesta ühendus",
+ "disconnect_failed": "Ühenduse katkestamine ebaõnnestus: {label}"
+ },
+ "consent": {
+ "title": "Kas lubada pistikprogrammil {name} seda teha?",
+ "intro": "Pistikprogramm küsib allolevaid õigusi. See saab need ainult siis, kui lubad.",
+ "high_risk_note": "Märgitud üksused annavad pistikprogrammile juurdepääsu sinu kirjade sisule või võimaldavad tal andmeid mujale saata. Luba need ainult pistikprogrammidele, mida usaldad.",
+ "revoke_hint": "Võid pistikprogrammi igal ajal seadetes välja lülitada või eemaldada.",
+ "deny": "Ära luba",
+ "allow": "Luba"
+ },
+ "permissions": {
+ "none": "See ei vaja erilisi õigusi.",
+ "high_risk": "Vajab ettevaatust",
+ "email_read": {
+ "title": "Loe sinu kirju",
+ "body": "Näha saatjaid, adressaate, teemasid ja iga sinu konto kirja täisteksti ning sinu kaustu."
+ },
+ "email_write": {
+ "title": "Muuda ja korrasta sinu kirju",
+ "body": "Lisada või eemaldada silte, lippe ja muid märksõnu sinu kirjadelt, liigutada neid sinu kaustade vahel ja arhiivi ning salvestada mustandeid."
+ },
+ "email_send": {
+ "title": "Muuda kirja enne saatmist",
+ "body": "Lugeda ja muuta sinu kirjutatavate kirjade adressaate, teemat, teksti ja manuseid ning takistada kirja saatmist."
+ },
+ "crypto_full": {
+ "title": "Täielik juurdepääs sellele rakendusele",
+ "body": "Töötab samade õigustega kui veebimeil ise. See saab lugeda kogu sinu posti ja salvestatud võtmeid ning tegutseda sinu nimel. Luba see ainult pistikprogrammidele, mida täielikult usaldad."
+ },
+ "email_raw_send": {
+ "title": "Saada ise koostatud kirju",
+ "body": "Saata sinu kontolt täielikke kirju, näiteks allkirjastatud või krüpteeritud."
+ },
+ "email_blob_read": {
+ "title": "Loe kirjade toorsisu",
+ "body": "Lugeda manuseid ja kirjade täielikku lähteteksti, millega tegutsed, ning iga faili, mille lisad, enne selle saatmist."
+ },
+ "email_blob_write": {
+ "title": "Asenda manuseid enne saatmist",
+ "body": "Laadida faile sinu meiliserverisse ja asendada sinu lisatud fail teisega enne kirja saatmist."
+ },
+ "email_render_takeover": {
+ "title": "Muuda kirjade kuvamist",
+ "body": "Asendada sinu avatud kirjade ja kirjade nimekirja tekst, näiteks dekrüpteeritud sisu näitamiseks."
+ },
+ "calendar_read": {
+ "title": "Loe sinu kalendrit",
+ "body": "Näha sinu kalendreid ja nende sündmusi."
+ },
+ "calendar_write": {
+ "title": "Muuda sinu kalendrit",
+ "body": "Luua, muuta ja kustutada sündmusi."
+ },
+ "contacts_read": {
+ "title": "Loe sinu kontakte",
+ "body": "Otsida ja lugeda sinu aadressiraamatuid."
+ },
+ "contacts_write": {
+ "title": "Muuda sinu kontakte",
+ "body": "Luua, muuta ja kustutada kontakte ja aadressiraamatuid."
+ },
+ "files_read": {
+ "title": "Loe sinu faile",
+ "body": "Sirvida ja avada sinu kontol hoitavaid faile."
+ },
+ "files_write": {
+ "title": "Muuda sinu faile",
+ "body": "Laadida üles, ümber nimetada, liigutada ja kustutada faile sinu kontol."
+ },
+ "identity_read": {
+ "title": "Vaata sinu saatjaaadresse",
+ "body": "Näha aadresse, millelt saadad, ja nende allkirju."
+ },
+ "identity_write": {
+ "title": "Muuda sinu saatjaaadresse",
+ "body": "Luua, muuta ja kustutada saatjaaadresse ja allkirju."
+ },
+ "filters_read": {
+ "title": "Loe sinu meilifiltreid",
+ "body": "Näha reegleid, mis sinu saabuvat posti sorteerivad."
+ },
+ "filters_write": {
+ "title": "Muuda sinu meilifiltreid",
+ "body": "Lisada reegleid sinu serveripoolsetele filtritele. Sellised reeglid võivad saabuvat posti liigutada, edastada või kustutada."
+ },
+ "tasks_read": {
+ "title": "Loe sinu ülesandeid",
+ "body": "Näha sinu ülesandenimekirju ja ülesandeid."
+ },
+ "tasks_write": {
+ "title": "Muuda sinu ülesandeid",
+ "body": "Luua, muuta, lõpetada ja kustutada ülesandeid."
+ },
+ "templates_read": {
+ "title": "Loe sinu malle",
+ "body": "Näha sinu salvestatud kirjamalle."
+ },
+ "templates_write": {
+ "title": "Muuda sinu malle",
+ "body": "Luua, muuta ja kustutada kirjamalle."
+ },
+ "smime_read": {
+ "title": "Vaata sinu S/MIME olekut",
+ "body": "Näha, kas sul on S/MIME võtmeid ja sertifikaate, ilma võtmetele juurdepääsuta."
+ },
+ "vacation_read": {
+ "title": "Vaata sinu äraolekuteadet",
+ "body": "Näha, kas sinu automaatvastus on sees ja mida see ütleb."
+ },
+ "vacation_write": {
+ "title": "Muuda sinu äraolekuteadet",
+ "body": "Lülitada sinu automaatvastus sisse või välja ning muuta selle teksti ja kuupäevi."
+ },
+ "settings_read": {
+ "title": "Loe sinu seadeid",
+ "body": "Näha sinu eelistusi ja siltide definitsioone."
+ },
+ "settings_write": {
+ "title": "Muuda sinu seadeid",
+ "body": "Lisada ja ümber järjestada silte ning muuta sinu eelistusi."
+ },
+ "security_read": {
+ "title": "Vaata sinu konto turvalisust",
+ "body": "Näha, kas kaheastmeline sisselogimine ja krüpteerimine on sees, ilma saladustele juurdepääsuta."
+ },
+ "auth_observe": {
+ "title": "Tea, millal sisse logid",
+ "body": "Saada teada, kui logid sisse, vahetad kontot või sinu seanssi uuendatakse."
+ },
+ "auth_emit": {
+ "title": "Logi sind välja",
+ "body": "Logida sind veebimeilist välja."
+ },
+ "account_read": {
+ "title": "Vaata sinu kontosid",
+ "body": "Näha sinu lisatud kontosid koos nende aadresside ja serveritega."
+ },
+ "http_post": {
+ "title": "Kasuta veebimeili serverit sinu eest",
+ "body": "Kutsuda neid serveri lõpp-punkte sinu sisselogimisega:"
+ },
+ "http_fetch": {
+ "title": "Ühendu teiste veebisaitidega",
+ "body": "Saata päringuid neile saitidele. Kõik, mida pistikprogramm lugeda saab, võidakse sinna saata:"
+ },
+ "admin_config": {
+ "title": "Loe oma seadeid administraatorilt",
+ "body": "Lugeda seadeid, mille administraator selle pistikprogrammi jaoks salvestas."
+ },
+ "ui_observe": {
+ "title": "Jälgi, kus rakenduses oled",
+ "body": "Teada, milline leht on avatud, ja reageerida linkidele, mida klõpsad."
+ },
+ "ui_toolbar": {
+ "title": "Lisa tööriistaribadele nuppe",
+ "body": "Näidata oma nuppe kirja tööriistaribal, avatud kirjal ja ülaribal."
+ },
+ "ui_app_top_banner": {
+ "title": "Näita ülaservas bännerit",
+ "body": "Näidata rakenduse ülaservas üle kogu laiuse teadet."
+ },
+ "ui_email_banner": {
+ "title": "Näita kirjadel teateid",
+ "body": "Näidata oma teateid ja kokkuvõtteid avatud kirja või vestluse osade kohal, sees ja vahel."
+ },
+ "ui_email_footer": {
+ "title": "Lisa sisu kirjade alla",
+ "body": "Näidata oma sisu avatud kirja all."
+ },
+ "ui_contact_cryptokeys": {
+ "title": "Näita kontaktide krüptovõtmeid",
+ "body": "Näidata ja hallata kontakti krüptovõtmeid."
+ },
+ "ui_email_details": {
+ "title": "Lisa kirja üksikasjadele paneele",
+ "body": "Näidata oma paneeli avatud kirja kõrval või all."
+ },
+ "ui_download_file": {
+ "title": "Salvesta faile sinu seadmesse",
+ "body": "Pakkuda allalaadimiseks enda loodud faile."
+ },
+ "ui_message_list_tabs": {
+ "title": "Lisa saabunud kirjade kausta kaardid",
+ "body": "Sorteerida sinu saabunud kirjad kaartidesse, nagu Uuendused või Pakkumised."
+ },
+ "ui_composer_toolbar": {
+ "title": "Lisa kirjakoostajasse",
+ "body": "Näidata oma nuppe, menüükirjeid ja jaotisi kirja kirjutamise ajal."
+ },
+ "ui_composer_sidebar": {
+ "title": "Lisa kirjakoostajasse paneel",
+ "body": "Näidata oma paneeli sinu kirjutatava kirja kõrval."
+ },
+ "ui_sidebar_widget": {
+ "title": "Lisa külgribale",
+ "body": "Näidata oma kaste ja jaotisi meili külgribal."
+ },
+ "ui_settings_section": {
+ "title": "Lisa seadete leht",
+ "body": "Näidata oma lehte seadetes."
+ },
+ "ui_context_menu": {
+ "title": "Lisa kirjamenüüsse",
+ "body": "Lisada kirjeid menüüsse, mis avaneb kirjal hiireparema klõpsamisel."
+ },
+ "ui_navigation_rail": {
+ "title": "Lisa navigeerimisribale",
+ "body": "Näidata oma nuppu navigeerimisribal."
+ },
+ "ui_keyboard": {
+ "title": "Lisa kiirklahve",
+ "body": "Reageerida enda määratud kiirklahvidele."
+ },
+ "ui_calendar_action": {
+ "title": "Lisa kalendrisse",
+ "body": "Näidata oma nuppe ja jaotisi sündmustel ning lühikesi silte ja punkte kalendripäevadel."
+ },
+ "ui_admin_page": {
+ "title": "Lisa administraatori leht",
+ "body": "Näidata oma lehte administraatori seadetes."
+ },
+ "app_lifecycle": {
+ "title": "Tea, millal rakendus käivitub ja peatub",
+ "body": "Saada teada, kui rakendus käivitub, läheb võrgust välja või sa logid välja."
+ },
+ "ui_message_list": {
+ "title": "Lisa kirjade nimekirja",
+ "body": "Näidata oma sisu kirjade nimekirja kohal ning nuppe kirjadel ja valiku tööriistaribal."
+ },
+ "ui_contacts": {
+ "title": "Lisa kontaktidesse",
+ "body": "Näidata oma jaotisi kontakti üksikasjades ja nuppe kontaktide nimekirjas."
+ },
+ "ui_tasks": {
+ "title": "Lisa ülesannetesse",
+ "body": "Näidata oma jaotisi ülesande üksikasjades ja nuppe ülesannete nimekirjas."
+ },
+ "ui_files": {
+ "title": "Lisa failidesse",
+ "body": "Näidata oma jaotisi faili eelvaates ja kirjeid faili menüüs."
+ },
+ "ui_commands": {
+ "title": "Lisa käske",
+ "body": "Lisada oma kirjeid käsupaletti (Ctrl+K)."
+ },
+ "ui_app_page": {
+ "title": "Lisa lehti",
+ "body": "Lisada rakendusse oma lehti, igaühel nupp navigeerimisribal."
+ },
+ "http_proxy": {
+ "title": "Kasuta sinu ühendatud kontosid",
+ "body": "Saata päringuid läbi Bulwarki serveri kontoga või võtmega, mille sina või sinu administraator seadistas. Pistikprogramm ei näe kunagi parooli, kuid saab teha kõike, mida see konto oma serveris teha saab."
+ },
+ "compose_open": {
+ "title": "Ava kirjakoostaja sinu eest",
+ "body": "Avada uus kiri täidetud adressaatide, teksti või manustega. Sina otsustad ikka, kas saata."
+ },
+ "compose_read": {
+ "title": "Loe kirja, mida kirjutad",
+ "body": "Lugeda kirja teksti selle kirjutamise ajal, kui kasutad pistikprogrammi kirjakoostajas. Mitte allkirja ega tsiteeritud kirja."
+ },
+ "compose_write": {
+ "title": "Muuda kirja, mida kirjutad",
+ "body": "Asendada või lisada teksti kirjas selle kirjutamise ajal, kui kasutad pistikprogrammi kirjakoostajas. Iga muudatuse saad tagasi võtta ja sina otsustad ikka, kas saata."
+ },
+ "ui_notifications": {
+ "title": "Näita märguandeid",
+ "body": "Näidata märguandeid sinu seadmes või rakenduses, kui pole neid lubanud. Iga märguanne nimetab pistikprogrammi."
+ }
+ },
+ "dialog": {
+ "from_plugin": "Pistikprogrammilt {name}",
+ "ok": "OK",
+ "submit": "Saada",
+ "confirm": "Kinnita",
+ "cancel": "Tühista",
+ "close": "Sulge",
+ "turn_off": "Lülita {name} välja",
+ "plugin_menu": "Valikud: {name}",
+ "password_note": "See vorm pärineb pistikprogrammilt {name}, mitte veebimeililt. Sisesta parool ainult siis, kui usaldad seda pistikprogrammi.",
+ "toast_auto_disabled": "{name} lülitati välja, kuna see jäi ebaõnnestuma."
+ },
+ "connect": {
+ "title": "Ühenda {label}",
+ "intro_password": "{name} soovib kasutada sinu kontot {label}. Bulwark hoiab parooli oma serveris ja saadab selle ainult hostile {host}. Pistikprogramm ei näe seda kunagi.",
+ "intro_password_any": "{name} soovib kasutada sinu kontot {label}. Bulwark hoiab parooli oma serveris ja saadab selle ainult serverisse, mille siia sisestad. Pistikprogramm ei näe seda kunagi.",
+ "intro_signin": "{name} soovib sinu kontole {label} sisse logida. Sisesta siia server ja logi siis sisse selle enda lehel. Bulwark hoiab saadud juurdepääsu oma serveris; pistikprogramm ei näe seda kunagi.",
+ "server": "Serveri aadress",
+ "server_hint": "cloud.example.com",
+ "username": "Kasutajanimi",
+ "secret": "Parool või võti",
+ "connect": "Ühenda",
+ "continue": "Jätka",
+ "cancel": "Tühista",
+ "saving": "Salvestamine…",
+ "error_server": "Sisesta serveri aadress, näiteks https://cloud.example.com",
+ "error_save": "Salvestamine ebaõnnestus: {error}"
+ }
+ },
+ "global_search": {
+ "title": "Otsi kõike",
+ "placeholder": "Otsi e-kirju, kontakte, kalendrit, faile...",
+ "scope_label": "Otsi kohast",
+ "account_label": "Konto",
+ "scope_all": "Kõik",
+ "scope_mail": "E-post",
+ "scope_contacts": "Kontaktid",
+ "scope_calendar": "Kalender",
+ "scope_files": "Failid",
+ "all_accounts": "Kõik kontod",
+ "show_all": "Näita kõiki tulemusi",
+ "no_results": "Päringule „{query}“ tulemusi ei leitud",
+ "server_hint": "Täissõnad otsivad serverist; osalised sõnad vastavad juba laaditud kirjetele",
+ "searching": "Otsimine...",
+ "account_error": "Kontost {account} otsimine ebaõnnestus",
+ "recent_searches": "Hiljutised otsingud",
+ "recurring": "Korduv",
+ "no_subject": "(teema puudub)",
+ "empty_hint": "Otsi e-kirju, kontakte, sündmusi ja faile kõigist kontodest. Proovi from:, subject:, has:attachment, after:2026-01-01.",
+ "flat_by_date": "Kuupäeva järgi",
+ "grouped": "Rühmitatud",
+ "load_more": "Laadi rohkem",
+ "preview_empty": "Vali tulemus eelvaate nägemiseks",
+ "open_full": "Ava",
+ "view_label": "Vaade"
+ },
+ "top_bar": {
+ "more": "Rohkem valikuid"
+ },
+ "plugin_pages": {
+ "loading": "Lehe laadimine",
+ "not_available_title": "See leht pole saadaval",
+ "not_available_description": "Pistikprogramm, mis selle lisas, on välja lülitatud või enam paigaldamata.",
+ "back_to_mail": "Tagasi meili juurde",
+ "settings_group": "Pistikprogrammid"
+ },
+ "command_palette": {
+ "title": "Käsupalett",
+ "open": "Ava käsupalett",
+ "placeholder": "Trüki käsk",
+ "no_results": "Sobivaid käske pole",
+ "go_to": "Mine: {name}",
+ "compose": "Uus kiri",
+ "search": "Otsi kirju",
+ "theme_dark": "Lülitu tumedale teemale",
+ "theme_light": "Lülitu heledale teemale",
+ "shortcuts": "Näita kiirklahve",
+ "group_navigation": "Mine",
+ "group_actions": "Toimingud",
+ "group_plugins": "Pistikprogrammid"
+ },
+ "notification_center": {
+ "title": "Märguanded",
+ "mark_all_read": "Märgi kõik loetuks",
+ "clear": "Tühjenda",
+ "empty": "Märguandeid pole",
+ "close": "Sulge",
+ "unread": "Lugemata",
+ "unread_count": "{count} lugemata",
+ "level_error": "Viga",
+ "level_warning": "Hoiatus"
+ }
+}
From 629781ffd406a6d10d784d12927a979baa80fa8b Mon Sep 17 00:00:00 2001
From: Sebastian Sanitz
Date: Fri, 16 Oct 2026 17:29:54 +0200
Subject: [PATCH 011/238] fix(tasks): send an all-day task's due and start as a
LocalDateTime
The task modal and the plugin API send an all-day due as a bare date
("2026-10-14"). A task's due and start are LocalDateTimes (RFC 8984).
Stalwart accepts the bare date and echoes it back over JMAP, but the
stored iCalendar has no DUE, so CalDAV clients (phones, Thunderbird)
show the task without a date. "2026-10-14T00:00:00" with showWithoutTime
is stored as DUE;VALUE=DATE.
createCalendarTask and updateCalendarTask now complete a bare due or
start to midnight. Every write goes through them, so a task saved by an
older client gets a proper due when it moves to another account.
Tests: create, update and the cross-account move against round trips
recorded on Stalwart v0.16.24 (JMAP set, then CalDAV GET of the stored
iCalendar); times and a cleared due stay as they are.
Co-Authored-By: Claude Opus 5.5
---
.../fixtures/stalwart-task-due-wire.json | 25 +++++++
lib/__tests__/jmap-calendar-tasks.test.ts | 65 ++++++++++++++++++-
lib/jmap/client.ts | 24 +++++--
3 files changed, 108 insertions(+), 6 deletions(-)
create mode 100644 lib/__tests__/fixtures/stalwart-task-due-wire.json
diff --git a/lib/__tests__/fixtures/stalwart-task-due-wire.json b/lib/__tests__/fixtures/stalwart-task-due-wire.json
new file mode 100644
index 000000000..6541e93dc
--- /dev/null
+++ b/lib/__tests__/fixtures/stalwart-task-due-wire.json
@@ -0,0 +1,25 @@
+{
+ "_source": "Stalwart v0.16.24, local server, 2026-10-01. Each row: a JMAP CalendarEvent/set (or a CalDAV PUT), then CalendarEvent/get and a CalDAV GET of the stored iCalendar. icalDue is the DUE line of that iCalendar, null when it has none.",
+ "allDayTaskDue": [
+ {
+ "write": { "method": "update", "patch": { "due": "2026-10-14" } },
+ "jmapAfter": { "due": "2026-10-14", "showWithoutTime": true },
+ "icalDue": null
+ },
+ {
+ "write": { "method": "update", "patch": { "due": "2026-10-14T00:00:00" } },
+ "jmapAfter": { "due": "2026-10-14T00:00:00", "showWithoutTime": true },
+ "icalDue": "DUE;VALUE=DATE:20261014"
+ },
+ {
+ "write": { "method": "create", "patch": { "due": "2026-10-07", "showWithoutTime": true } },
+ "jmapAfter": { "due": "2026-10-07", "showWithoutTime": true },
+ "icalDue": null
+ },
+ {
+ "write": { "method": "caldav-put", "ical": "DUE;VALUE=DATE:20261020" },
+ "jmapAfter": { "due": "2026-10-20T00:00:00", "showWithoutTime": true },
+ "icalDue": "DUE;VALUE=DATE:20261020"
+ }
+ ]
+}
diff --git a/lib/__tests__/jmap-calendar-tasks.test.ts b/lib/__tests__/jmap-calendar-tasks.test.ts
index 3d83c6e00..a7cfc929c 100644
--- a/lib/__tests__/jmap-calendar-tasks.test.ts
+++ b/lib/__tests__/jmap-calendar-tasks.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
import type { CalendarTask } from '../jmap/types';
+import wire from './fixtures/stalwart-task-due-wire.json';
function makeSession() {
return {
@@ -22,6 +23,31 @@ function jsonResponse(body: unknown): Response {
type Call = { method: string; args: Record };
+const isBareDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
+
+/**
+ * Whether Stalwart keeps a task due of this shape in the stored iCalendar,
+ * read from recorded round trips: it echoes a bare date over JMAP but writes
+ * no DUE, so CalDAV clients lose the date.
+ */
+function stalwartKeepsDue(due: string): boolean {
+ const rows = wire.allDayTaskDue.filter((row) => row.write.patch && isBareDate(row.write.patch.due) === isBareDate(due));
+ if (rows.length === 0) throw new Error(`no recorded round trip for a due like ${due}`);
+ return rows.every((row) => row.icalDue !== null);
+}
+
+/** What the task modal sends on save: every field, here for an all-day task. */
+const modalPayload = (due: string | null): Partial => ({
+ '@type': 'Task',
+ title: 'Pay rent',
+ description: '',
+ due,
+ showWithoutTime: true,
+ priority: 0,
+ progress: 'needs-action',
+ calendarIds: { 'cal-1': true },
+});
+
describe('JMAPClient task operations (#958)', () => {
let client: JMAPClient;
let sentCalls: Call[];
@@ -121,6 +147,32 @@ describe('JMAPClient task operations (#958)', () => {
expect(createPayload.title).toBe('New Task');
expect(createPayload).not.toHaveProperty('progressUpdated');
});
+
+ const sentTask = (kind: 'create' | 'update', key: string) => {
+ const setCall = sentCalls.find((c) => c.method === 'CalendarEvent/set');
+ return (setCall?.args[kind] as Record>)[key];
+ };
+
+ it.each([
+ ['createCalendarTask', 'create', 'new-task', (c: JMAPClient) => c.createCalendarTask(modalPayload('2026-10-07'))],
+ ['updateCalendarTask', 'update', 'task-1', (c: JMAPClient) => c.updateCalendarTask('task-1', modalPayload('2026-10-07'))],
+ ] as const)('%s sends an all-day due as a LocalDateTime, which Stalwart keeps in the iCalendar', async (_name, kind, key, write) => {
+ await write(client);
+
+ const due = sentTask(kind, key).due as string;
+ expect(due).toBe('2026-10-07T00:00:00');
+ expect(stalwartKeepsDue(due)).toBe(true);
+ });
+
+ it('completes a bare start date as well and leaves a due with a time alone', async () => {
+ await client.updateCalendarTask('task-1', { start: '2026-10-01', due: '2026-10-14T09:30:00' });
+ expect(sentTask('update', 'task-1')).toMatchObject({ start: '2026-10-01T00:00:00', due: '2026-10-14T09:30:00' });
+ });
+
+ it('keeps a cleared due cleared', async () => {
+ await client.updateCalendarTask('task-1', modalPayload(null));
+ expect(sentTask('update', 'task-1').due).toBeNull();
+ });
});
describe('JMAPClient tasks in shared calendars (#1204)', () => {
@@ -129,7 +181,9 @@ describe('JMAPClient tasks in shared calendars (#1204)', () => {
// Tasks per account as the server stores them (bare ids).
const serverTasks: Record[]> = {
- 'acct-1': [{ id: 'own-1', '@type': 'Task', uid: 'uid-own', title: 'Mine', progress: 'needs-action', calendarIds: { 'cal-1': true }, created: '2026-10-01T00:00:00Z', isDraft: false, isOrigin: true, locations: { l1: { '@type': 'Location', name: 'Desk' } } }],
+ 'acct-1': [{ id: 'own-1', '@type': 'Task', uid: 'uid-own', title: 'Mine', progress: 'needs-action', calendarIds: { 'cal-1': true }, created: '2026-10-01T00:00:00Z', isDraft: false, isOrigin: true, locations: { l1: { '@type': 'Location', name: 'Desk' } } },
+ // Saved by a client that sent a bare date: Stalwart echoes it as such.
+ { id: 'own-2', '@type': 'Task', uid: 'uid-due', title: 'Dated', progress: 'needs-action', calendarIds: { 'cal-1': true }, due: wire.allDayTaskDue[0].jmapAfter.due, showWithoutTime: true }],
'acct-2': [{ id: 'shared-1', '@type': 'Task', uid: 'uid-shared', title: 'Theirs', progress: 'needs-action', calendarIds: { 'cal-s': true } }],
};
@@ -195,6 +249,15 @@ describe('JMAPClient tasks in shared calendars (#1204)', () => {
expect(newId).toBe('acct-2:new-id');
});
+ it('moving a task with a bare due date to another account stores the due as a LocalDateTime', async () => {
+ await client.updateCalendarTask('own-2', { calendarIds: { 'acct-2:cal-s': true } });
+
+ const create = sentCalls.find((c) => c.method === 'CalendarEvent/set' && c.args.create);
+ const due = (create?.args.create as Record>)['new-task'].due as string;
+ expect(due).toBe('2026-10-14T00:00:00');
+ expect(stalwartKeepsDue(due)).toBe(true);
+ });
+
it('updates a shared task in place in its own account with real ids', async () => {
const id = await client.updateCalendarTask('acct-2:shared-1', { progress: 'completed', calendarIds: { 'acct-2:cal-s': true } });
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 0ed5c8f82..7da41220d 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -568,6 +568,20 @@ function getUserTimeZone(): string | undefined {
}
}
+const BARE_DATE = /^\d{4}-\d{2}-\d{2}$/;
+
+/**
+ * A task's `due` and `start` are LocalDateTimes (RFC 8984). Stalwart accepts a
+ * bare date and echoes it over JMAP, but stores no DUE in the iCalendar, so
+ * CalDAV clients lose the date. Completes a bare date to midnight.
+ */
+function completeBareTaskDates>(task: T): T {
+ const result = { ...task };
+ if (typeof result.due === 'string' && BARE_DATE.test(result.due)) result.due = `${result.due}T00:00:00`;
+ if (typeof result.start === 'string' && BARE_DATE.test(result.start)) result.start = `${result.start}T00:00:00`;
+ return result;
+}
+
/**
* Stalwart's calcard crate uses singular property names ("recurrenceRule")
* instead of the RFC 8984 plural forms ("recurrenceRules").
@@ -7793,7 +7807,7 @@ export class JMAPClient implements IJMAPClient {
accountId = resolved.accountId;
taskData.calendarIds = resolved.calendarIds;
}
- const cleanTask = { ...taskData, '@type': 'Task' };
+ const cleanTask = { ...completeBareTaskDates(taskData), '@type': 'Task' };
debug.group('CalendarTask/create', 'tasks');
debug.log('tasks', 'CalendarTask/create accountId', accountId);
@@ -7876,10 +7890,10 @@ export class JMAPClient implements IJMAPClient {
*/
async updateCalendarTask(taskId: string, updates: Partial, targetAccountId?: string): Promise {
const source = targetAccountId ? { accountId: targetAccountId, id: taskId } : this.splitCalendarObjectId(taskId);
- let patch = updates;
- if (!targetAccountId && updates.calendarIds) {
- const dest = this.resolveTaskCalendarIds(updates.calendarIds);
- patch = { ...updates, calendarIds: dest.calendarIds };
+ let patch = completeBareTaskDates(updates);
+ if (!targetAccountId && patch.calendarIds) {
+ const dest = this.resolveTaskCalendarIds(patch.calendarIds);
+ patch = { ...patch, calendarIds: dest.calendarIds };
if (dest.accountId !== source.accountId) {
return this.moveCalendarTaskToAccount(source, dest.accountId, patch);
}
From 964626794bb838e9d7e1f992375a0a52bdff2810 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 16 Oct 2026 20:46:27 +0200
Subject: [PATCH 012/238] feat: browse the company directory in contacts and
the composer #1253
---
.../directory-picker-dialog.test.tsx | 101 ++++++
components/contacts/contacts-app.tsx | 72 ++++-
components/contacts/contacts-sidebar.tsx | 30 +-
components/contacts/directory-browser.tsx | 299 ++++++++++++++++++
.../contacts/directory-entry-detail.tsx | 58 ++++
.../contacts/directory-picker-dialog.tsx | 78 +++++
components/email/email-composer.tsx | 46 +++
lib/__tests__/jmap-principals-paging.test.ts | 26 ++
lib/jmap/client-interface.ts | 3 +-
lib/jmap/client.ts | 14 +-
lib/jmap/principals-error.ts | 11 +
locales/ar/common.json | 43 +++
locales/ca/common.json | 43 +++
locales/cs/common.json | 43 +++
locales/da/common.json | 43 +++
locales/de/common.json | 43 +++
locales/en/common.json | 43 +++
locales/es/common.json | 43 +++
locales/fa/common.json | 43 +++
locales/fr/common.json | 43 +++
locales/he/common.json | 43 +++
locales/hu/common.json | 43 +++
locales/it/common.json | 43 +++
locales/ja/common.json | 43 +++
locales/ko/common.json | 43 +++
locales/lv/common.json | 43 +++
locales/mn/common.json | 43 +++
locales/nb/common.json | 43 +++
locales/nl/common.json | 43 +++
locales/pl/common.json | 43 +++
locales/pt/common.json | 43 +++
locales/ro/common.json | 43 +++
locales/ru/common.json | 43 +++
locales/sk/common.json | 43 +++
locales/tr/common.json | 43 +++
locales/uk/common.json | 43 +++
locales/zh-TW/common.json | 43 +++
locales/zh/common.json | 43 +++
stores/__tests__/contact-store.test.ts | 52 +++
stores/contact-store.ts | 72 ++++-
40 files changed, 1998 insertions(+), 25 deletions(-)
create mode 100644 components/contacts/__tests__/directory-picker-dialog.test.tsx
create mode 100644 components/contacts/directory-browser.tsx
create mode 100644 components/contacts/directory-entry-detail.tsx
create mode 100644 components/contacts/directory-picker-dialog.tsx
create mode 100644 lib/jmap/principals-error.ts
diff --git a/components/contacts/__tests__/directory-picker-dialog.test.tsx b/components/contacts/__tests__/directory-picker-dialog.test.tsx
new file mode 100644
index 000000000..bb4c719f7
--- /dev/null
+++ b/components/contacts/__tests__/directory-picker-dialog.test.tsx
@@ -0,0 +1,101 @@
+import { render, screen, within } from '@testing-library/react';
+import { fireEvent } from '@testing-library/dom';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { DirectoryPickerDialog } from '../directory-picker-dialog';
+import { useContactStore, type DirectoryEntry, type DirectoryStatus } from '@/stores/contact-store';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+// next-intl is mocked globally in vitest.setup (t returns the key).
+
+const client = { supportsPrincipals: () => true, getPrincipals: vi.fn() } as unknown as IJMAPClient;
+
+function people(n: number): DirectoryEntry[] {
+ return Array.from({ length: n }, (_, i) => {
+ const num = String(i).padStart(3, '0');
+ return { id: `p${num}`, name: `Person ${num}`, email: `person${num}@example.org`, type: 'individual' as const };
+ });
+}
+
+function setDirectory(entries: DirectoryEntry[], status: DirectoryStatus) {
+ useContactStore.setState({ directoryPrincipals: entries, directoryStatus: status, directoryLoaded: status === 'ready' });
+}
+
+function rows() {
+ return within(screen.getByRole('list')).queryAllByRole('listitem');
+}
+
+describe('DirectoryPickerDialog', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('lists the directory before any search, one page at a time', () => {
+ setDirectory(people(60), 'ready');
+ render( );
+ expect(rows()).toHaveLength(50);
+ fireEvent.click(screen.getByLabelText('next_page'));
+ expect(rows()).toHaveLength(10);
+ expect(rows()[0]).toHaveTextContent('Person 050');
+ });
+
+ it('searches by name and address', () => {
+ setDirectory([
+ { name: 'Ana Silva', email: 'ana@example.org' },
+ { name: 'Finance Department', email: 'finance@example.org', type: 'group' },
+ { name: 'Marcos Souza', email: 'marcos@example.org' },
+ ], 'ready');
+ render( );
+ fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'finance@' } });
+ expect(rows()).toHaveLength(1);
+ expect(rows()[0]).toHaveTextContent('Finance Department');
+ fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'souza' } });
+ expect(rows()[0]).toHaveTextContent('Marcos Souza');
+ });
+
+ it('adds the checked entries to the chosen field and closes', () => {
+ setDirectory([
+ { name: 'Marcos Souza', email: 'marcos@example.org' },
+ { name: 'Ana Silva', email: 'ana@example.org' },
+ { name: 'Finance Department', email: 'finance@example.org' },
+ ], 'ready');
+ const onAdd = vi.fn();
+ const onClose = vi.fn();
+ render( );
+ // Rows are sorted by name: Ana, Finance, Marcos. Clicking a row toggles it.
+ fireEvent.click(rows()[0]);
+ fireEvent.click(rows()[2]);
+ expect(screen.getByText('add_cc')).not.toBeDisabled();
+ fireEvent.click(screen.getByText('add_cc'));
+ expect(onAdd).toHaveBeenCalledWith('cc', [
+ expect.objectContaining({ email: 'marcos@example.org' }),
+ expect.objectContaining({ email: 'ana@example.org' }),
+ ]);
+ expect(onAdd.mock.calls[0][1]).toHaveLength(2);
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('keeps the add buttons disabled until something is checked', () => {
+ setDirectory(people(3), 'ready');
+ render( );
+ expect(screen.getByText('add_to')).toBeDisabled();
+ });
+
+ it.each([
+ ['forbidden', 'forbidden_title'],
+ ['error', 'error_title'],
+ ['ready', 'empty_title'],
+ ['unavailable', 'unavailable_title'],
+ ] as const)('tells a %s directory apart from the others', (status, text) => {
+ setDirectory([], status);
+ render( );
+ expect(screen.getByText(text)).toBeInTheDocument();
+ for (const other of ['forbidden_title', 'error_title', 'empty_title', 'unavailable_title'].filter((t) => t !== text)) {
+ expect(screen.queryByText(other)).not.toBeInTheDocument();
+ }
+ });
+
+ it('keeps showing the last list when a refresh fails', () => {
+ setDirectory(people(2), 'error');
+ render( );
+ expect(rows()).toHaveLength(2);
+ expect(screen.getByRole('alert')).toHaveTextContent('refresh_failed');
+ });
+});
diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx
index 9ca42ffc3..646410053 100644
--- a/components/contacts/contacts-app.tsx
+++ b/components/contacts/contacts-app.tsx
@@ -15,10 +15,12 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
+import { DirectoryBrowser, directoryKey } from "@/components/contacts/directory-browser";
+import { DirectoryEntryDetail } from "@/components/contacts/directory-entry-detail";
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
-import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
+import { useContactStore, getContactDisplayName, getContactPrimaryEmail, type DirectoryEntry } from "@/stores/contact-store";
import { savePendingMailto } from "@/lib/protocol-handlers/session";
import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
@@ -55,7 +57,8 @@ type View =
| "group-detail"
| "group-create"
| "group-edit"
- | "bulk-add-to-group";
+ | "bulk-add-to-group"
+ | "directory-detail";
export interface ContactsAppProps {
/** Path segments after `/contacts` (`['', 'edit']`). */
@@ -123,6 +126,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const [returnToEmail, setReturnToEmail] = useState(false);
const [renamingKeyword, setRenamingKeyword] = useState(null);
const [selectedGroupId, setSelectedGroupId] = useState(null);
+ const [directorySelection, setDirectorySelection] = useState>(() => new Set());
+ const [openDirectoryKey, setOpenDirectoryKey] = useState(null);
+ const directoryPrincipals = useContactStore((s) => s.directoryPrincipals);
+ const showDirectory = !!client?.supportsPrincipals();
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
@@ -291,7 +298,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// Contacts to display based on active category
const displayedContacts = useMemo(() => {
- if (activeCategory === "all") return individuals;
+ if (activeCategory === "all" || activeCategory === "directory") return individuals;
if (activeCategory === "uncategorized") {
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0);
}
@@ -312,9 +319,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const handleSelectCategory = useCallback((category: ContactCategory) => {
setActiveCategory(category);
clearSelection();
+ setDirectorySelection(new Set());
if (typeof category === "object" && "groupId" in category) {
setSelectedGroupId(category.groupId);
setView("group-detail");
+ } else if (category === "directory") {
+ setSelectedGroupId(null);
+ setOpenDirectoryKey(null);
+ setView("list");
} else {
setSelectedGroupId(null);
}
@@ -599,6 +611,19 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
openComposeInApp([formatRecipientEntry(chip)], field);
}, [getGroupMembers, t, openComposeInApp]);
+ const handleComposeDirectory = useCallback((entries: DirectoryEntry[]) => {
+ if (entries.length === 0) return;
+ openComposeInApp(entries.map((e) => formatRecipient(e.name !== e.email ? e.name : "", e.email)), "to");
+ }, [openComposeInApp]);
+
+ const handleAddDirectoryEntryToContacts = useCallback((entry: DirectoryEntry) => {
+ setCreatePrefill({ email: entry.email, name: entry.name !== entry.email ? entry.name : undefined });
+ setSelectedContact(null);
+ setDefaultBookIdForCreate(undefined);
+ setActiveCategory("all");
+ setView("create");
+ }, [setSelectedContact]);
+
const handleComposeContact = useCallback((contact: ContactCard) => {
const email = getContactPrimaryEmail(contact).trim();
if (!email) return;
@@ -744,6 +769,16 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
if (!isAuthenticated) return null;
const renderRightPanel = () => {
+ // The directory is read-only: its panel shows the open entry whatever the view.
+ if (activeCategory === "directory") {
+ return (
+ directoryKey(e) === openDirectoryKey) ?? null}
+ onCompose={(entry) => handleComposeDirectory([entry])}
+ onAddToContacts={handleAddDirectoryEntryToContacts}
+ />
+ );
+ }
switch (view) {
case "create":
return ;
@@ -1002,6 +1037,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
}
} : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
+ directoryCount={showDirectory ? directoryPrincipals.length : undefined}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/>
@@ -1029,6 +1065,35 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
)}
style={!isMobile ? { width: `${listWidth}px` } : undefined}
>
+ {activeCategory === "directory" ? (
+ <>
+ { setOpenDirectoryKey(directoryKey(entry)); setView("directory-detail"); }}
+ activeKey={openDirectoryKey}
+ onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
+ className="flex-1"
+ />
+ {directorySelection.size > 0 && (
+
+
+ {t("directory.selected_count", { count: directorySelection.size })}
+
+ setDirectorySelection(new Set())}>
+ {t("bulk.clear")}
+
+ handleComposeDirectory(directoryPrincipals.filter((e) => directorySelection.has(directoryKey(e))))}
+ >
+ {t("directory.write_email")}
+
+
+ )}
+ >
+ ) : (
setNarrowSidebarOpen(true) : undefined}
isLoading={supportsSync && !contactsLoaded}
/>
+ )}
{!isMobile && (
diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx
index ccdff5024..fb5ad8511 100644
--- a/components/contacts/contacts-sidebar.tsx
+++ b/components/contacts/contacts-sidebar.tsx
@@ -2,7 +2,7 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl";
-import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star } from "@/components/icons";
+import { BookUser, Building2, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star } from "@/components/icons";
import { useRouter } from "@/i18n/navigation";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
@@ -12,7 +12,7 @@ import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
import { useAccountStore } from "@/stores/account-store";
-export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
+export type ContactCategory = "all" | "directory" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
interface ContactsSidebarProps {
groups: ContactCard[];
@@ -35,6 +35,8 @@ interface ContactsSidebarProps {
onCreateContactInBook?: (addressBook: AddressBook) => void;
onDeleteAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
+ /** Entries in the company directory; the Directory entry shows only when set. */
+ directoryCount?: number;
className?: string;
/**
* Pro shell: render one collapsible section per connected local account
@@ -106,6 +108,7 @@ export function ContactsSidebar({
onCreateContactInBook,
onDeleteAddressBook,
onRenameKeyword,
+ directoryCount,
className,
multiAccountMode,
}: ContactsSidebarProps) {
@@ -346,6 +349,29 @@ export function ContactsSidebar({
+ {/* Company directory (server principals), read-only */}
+ {directoryCount !== undefined && (
+ onSelectCategory("directory")}
+ className={cn(
+ "w-full flex items-center gap-2 px-3 text-sm transition-colors",
+ activeCategory === "directory"
+ ? "bg-accent text-accent-foreground font-medium"
+ : "text-foreground/80 hover:bg-muted"
+ )}
+ style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
+ data-testid="contacts-directory-entry"
+ >
+
+ {t("directory.title")}
+ {directoryCount > 0 && (
+
+ {directoryCount}
+
+ )}
+
+ )}
+
{/* Address Books: per-account groups in multi-account Pro mode, else the
classic "My Address Books" section. */}
{multiAccountMode && localAccountGroups.length > 0 ? (
diff --git a/components/contacts/directory-browser.tsx b/components/contacts/directory-browser.tsx
new file mode 100644
index 000000000..86d0bab2c
--- /dev/null
+++ b/components/contacts/directory-browser.tsx
@@ -0,0 +1,299 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslations, useLocale } from "next-intl";
+import { Search, X, RefreshCw, Loader2, Lock, AlertCircle, BookUser, CheckSquare, Square, ChevronLeft, ChevronRight, Menu, Users, MapPin, Box } from "@/components/icons";
+import { Input } from "@/components/ui/input";
+import { Avatar } from "@/components/ui/avatar";
+import { cn } from "@/lib/utils";
+import type { IJMAPClient } from "@/lib/jmap/client-interface";
+import { useContactStore, type DirectoryEntry } from "@/stores/contact-store";
+
+export const DIRECTORY_PAGE_SIZE = 50;
+
+/** Case-insensitive key of a directory entry, used for selection. */
+export function directoryKey(entry: DirectoryEntry): string {
+ return entry.email.toLowerCase();
+}
+
+export function filterDirectory(entries: DirectoryEntry[], query: string, locale?: string): DirectoryEntry[] {
+ const q = query.trim().toLowerCase();
+ const matched = q
+ ? entries.filter((e) =>
+ e.name.toLowerCase().includes(q) ||
+ e.email.toLowerCase().includes(q) ||
+ (e.description?.toLowerCase().includes(q) ?? false))
+ : entries;
+ const collator = new Intl.Collator(locale, { sensitivity: "base" });
+ return [...matched].sort((a, b) => collator.compare(a.name, b.name) || collator.compare(a.email, b.email));
+}
+
+function TypeIcon({ type }: { type: DirectoryEntry["type"] }) {
+ if (type === "group") return ;
+ if (type === "location") return ;
+ if (type === "resource") return ;
+ return null;
+}
+
+interface DirectoryBrowserProps {
+ client: IJMAPClient | null;
+ /** Keys (lowercased addresses) of the checked entries. */
+ selected: Set;
+ onSelectedChange: (next: Set) => void;
+ /** Contacts view: clicking a row opens it. Without this, clicking toggles it. */
+ onOpen?: (entry: DirectoryEntry) => void;
+ activeKey?: string | null;
+ onMenuClick?: () => void;
+ autoFocusSearch?: boolean;
+ className?: string;
+}
+
+/**
+ * The company directory (RFC 9670 principals the server lists for this
+ * account), read-only: search, pages, multi-select. Used by the Contacts
+ * "Directory" entry and the composer's recipient picker.
+ */
+export function DirectoryBrowser({
+ client,
+ selected,
+ onSelectedChange,
+ onOpen,
+ activeKey,
+ onMenuClick,
+ autoFocusSearch,
+ className,
+}: DirectoryBrowserProps) {
+ const t = useTranslations("contacts.directory");
+ const tContacts = useTranslations("contacts");
+ const locale = useLocale();
+ const entries = useContactStore((s) => s.directoryPrincipals);
+ const status = useContactStore((s) => s.directoryStatus);
+ const fetchDirectory = useContactStore((s) => s.fetchDirectory);
+ const [query, setQuery] = useState("");
+ const [page, setPage] = useState(0);
+ const searchRef = useRef(null);
+
+ // The directory loads at sign-in; fetch here only if that never ran.
+ useEffect(() => {
+ if (client && status === "idle") void fetchDirectory(client);
+ }, [client, status, fetchDirectory]);
+
+ useEffect(() => {
+ if (autoFocusSearch) searchRef.current?.focus();
+ }, [autoFocusSearch]);
+
+ const filtered = useMemo(() => filterDirectory(entries, query, locale), [entries, query, locale]);
+ const pageCount = Math.max(1, Math.ceil(filtered.length / DIRECTORY_PAGE_SIZE));
+ const currentPage = Math.min(page, pageCount - 1);
+ const pageEntries = filtered.slice(currentPage * DIRECTORY_PAGE_SIZE, (currentPage + 1) * DIRECTORY_PAGE_SIZE);
+
+ const loading = status === "loading" || (status === "idle" && !!client);
+ const refresh = () => { if (client) void fetchDirectory(client); };
+
+ const toggle = (entry: DirectoryEntry) => {
+ const next = new Set(selected);
+ const key = directoryKey(entry);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ onSelectedChange(next);
+ };
+
+ const pageAllSelected = pageEntries.length > 0 && pageEntries.every((e) => selected.has(directoryKey(e)));
+ const togglePage = () => {
+ const next = new Set(selected);
+ for (const e of pageEntries) {
+ if (pageAllSelected) next.delete(directoryKey(e));
+ else next.add(directoryKey(e));
+ }
+ onSelectedChange(next);
+ };
+
+ let placeholder: React.ReactNode = null;
+ if (entries.length === 0) {
+ if (loading) {
+ placeholder = (
+ } title={t("loading")} />
+ );
+ } else if (status === "unavailable" || !client) {
+ placeholder = } title={t("unavailable_title")} hint={t("unavailable_hint")} />;
+ } else if (status === "forbidden") {
+ placeholder = } title={t("forbidden_title")} hint={t("forbidden_hint")} onRetry={refresh} retryLabel={t("retry")} />;
+ } else if (status === "error") {
+ placeholder = } title={t("error_title")} hint={t("error_hint")} onRetry={refresh} retryLabel={t("retry")} />;
+ } else {
+ placeholder = } title={t("empty_title")} hint={t("empty_hint")} />;
+ }
+ } else if (filtered.length === 0) {
+ placeholder = } title={t("no_match")} />;
+ }
+
+ return (
+
+
+
+ {onMenuClick && (
+
+
+
+ )}
+
0 ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted",
+ )}
+ title={pageAllSelected ? t("deselect_page") : t("select_page")}
+ aria-label={pageAllSelected ? t("deselect_page") : t("select_page")}
+ >
+ {pageAllSelected ? : }
+
+
+
+ { setQuery(e.target.value); setPage(0); }}
+ className={cn("ps-9 h-9", query && "pe-8")}
+ aria-label={t("search_placeholder")}
+ />
+ {query && (
+ { setQuery(""); setPage(0); }}
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
+ aria-label={tContacts("clear_search")}
+ >
+
+
+ )}
+
+
+
+
+
+ {entries.length > 0 && (status === "error" || status === "forbidden") && (
+
{t("refresh_failed")}
+ )}
+
+
+
+ {placeholder ?? pageEntries.map((entry) => {
+ const key = directoryKey(entry);
+ const checked = selected.has(key);
+ const showEmail = entry.name !== entry.email;
+ return (
+
(onOpen ? onOpen(entry) : toggle(entry))}
+ className={cn(
+ "w-full flex items-center gap-3 px-4 py-2 border-b border-border cursor-pointer select-none transition-colors",
+ activeKey === key ? "bg-selection" : "hover:bg-muted",
+ checked && activeKey !== key && "bg-selection/60",
+ )}
+ >
+
{ e.stopPropagation(); toggle(entry); }}
+ className={cn("p-1 rounded flex-shrink-0 hover:bg-muted/50", checked ? "text-primary" : "text-muted-foreground")}
+ >
+ {checked ? : }
+
+
+
+
+ {entry.name}
+ {entry.type && entry.type !== "individual" && (
+
+
+
+ )}
+
+ {showEmail &&
{entry.email}
}
+
+
+ );
+ })}
+
+
+ {filtered.length > 0 && (
+
+
+ {t("range", {
+ from: currentPage * DIRECTORY_PAGE_SIZE + 1,
+ to: currentPage * DIRECTORY_PAGE_SIZE + pageEntries.length,
+ total: filtered.length,
+ })}
+
+ {pageCount > 1 && (
+
+ setPage(currentPage - 1)}
+ disabled={currentPage === 0}
+ className="p-1.5 rounded-md hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
+ aria-label={t("previous_page")}
+ >
+
+
+ {t("page", { page: currentPage + 1, count: pageCount })}
+ setPage(currentPage + 1)}
+ disabled={currentPage >= pageCount - 1}
+ className="p-1.5 rounded-md hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
+ aria-label={t("next_page")}
+ >
+
+
+
+ )}
+
+ )}
+
+ );
+}
+
+function StatePanel({ icon, title, hint, onRetry, retryLabel }: {
+ icon: React.ReactNode;
+ title: string;
+ hint?: string;
+ onRetry?: () => void;
+ retryLabel?: string;
+}) {
+ return (
+
+ {icon}
+
{title}
+ {hint &&
{hint}
}
+ {onRetry && (
+
+ {retryLabel}
+
+ )}
+
+ );
+}
diff --git a/components/contacts/directory-entry-detail.tsx b/components/contacts/directory-entry-detail.tsx
new file mode 100644
index 000000000..c1435f5e8
--- /dev/null
+++ b/components/contacts/directory-entry-detail.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Mail, UserPlus, BookUser } from "@/components/icons";
+import { Avatar } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import type { DirectoryEntry } from "@/stores/contact-store";
+
+interface DirectoryEntryDetailProps {
+ entry: DirectoryEntry | null;
+ onCompose: (entry: DirectoryEntry) => void;
+ onAddToContacts: (entry: DirectoryEntry) => void;
+}
+
+/** Read-only view of one directory entry. */
+export function DirectoryEntryDetail({ entry, onCompose, onAddToContacts }: DirectoryEntryDetailProps) {
+ const t = useTranslations("contacts.directory");
+
+ if (!entry) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
{entry.name}
+ {entry.type && (
+
{t(`type.${entry.type}`)}
+ )}
+
+
+ onCompose(entry)}>
+
+ {t("write_email")}
+
+ onAddToContacts(entry)}>
+
+ {t("add_to_contacts")}
+
+
+
+
+
+
{t("email")}
+ {entry.email}
+
+
+
{t("read_only_note")}
+
+ );
+}
diff --git a/components/contacts/directory-picker-dialog.tsx b/components/contacts/directory-picker-dialog.tsx
new file mode 100644
index 000000000..493a84e3c
--- /dev/null
+++ b/components/contacts/directory-picker-dialog.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { X } from "@/components/icons";
+import { Button } from "@/components/ui/button";
+import { useFocusTrap } from "@/hooks/use-focus-trap";
+import type { IJMAPClient } from "@/lib/jmap/client-interface";
+import { useContactStore, type DirectoryEntry } from "@/stores/contact-store";
+import { DirectoryBrowser, directoryKey } from "./directory-browser";
+
+export type RecipientField = "to" | "cc" | "bcc";
+
+interface DirectoryPickerDialogProps {
+ client: IJMAPClient | null;
+ onClose: () => void;
+ /** Called with the checked entries in directory order; the dialog closes afterwards. */
+ onAdd: (field: RecipientField, entries: DirectoryEntry[]) => void;
+}
+
+/** The composer's "Select from directory" dialog. */
+export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPickerDialogProps) {
+ const t = useTranslations("contacts.directory");
+ const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
+ const [selected, setSelected] = useState>(() => new Set());
+
+ const add = (field: RecipientField) => {
+ const entries = useContactStore.getState().directoryPrincipals.filter((e) => selected.has(directoryKey(e)));
+ if (entries.length === 0) return;
+ onAdd(field, entries);
+ onClose();
+ };
+
+ return (
+
+
+
+
+ {t("picker_title")}
+
+
+
+
+
+
+
+
+
+
+ {t("selected_count", { count: selected.size })}
+
+ {t("cancel")}
+ add("bcc")}>{t("add_bcc")}
+ add("cc")}>{t("add_cc")}
+ add("to")}>{t("add_to")}
+
+
+
+ );
+}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 3886a2fc9..a283e4b30 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -33,6 +33,7 @@ import {
ReplyAll,
Reply,
Forward,
+ BookUser,
} from "@/components/icons";
import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
import { registerComposer } from "@/lib/plugin-sandbox/composer-registry";
@@ -73,6 +74,8 @@ import { usePluginActions } from "@/components/plugins/plugin-actions";
import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { FilePickerDialog } from "@/components/files/file-picker-dialog";
+import { DirectoryPickerDialog } from "@/components/contacts/directory-picker-dialog";
+import type { DirectoryEntry } from "@/stores/contact-store";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNode } from "@/lib/jmap/types";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
@@ -819,6 +822,7 @@ export function EmailComposer({
// The Files account being browsed for attachments, fixed when the picker
// opens so the picked blobs are tagged with the login that owns them.
const [filesPicker, setFilesPicker] = useState<{ client: IJMAPClient; accountId?: string } | null>(null);
+ const [showDirectoryPicker, setShowDirectoryPicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showCloseDialog, setShowCloseDialog] = useState(false);
// The formatting toolbar is folded away on a phone until asked for.
@@ -1643,6 +1647,26 @@ export function EmailComposer({
}
}, [autoQuery, composerClient, isSearchingServer, searchRecipients]);
+ // Recipients picked in the directory dialog; addresses already in that
+ // field are skipped.
+ const addDirectoryRecipients = useCallback((field: 'to' | 'cc' | 'bcc', entries: DirectoryEntry[]) => {
+ const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
+ setter(prev => {
+ const seen = new Set(prev.filter(r => !r.group).map(r => r.email.toLowerCase()));
+ const next = [...prev];
+ for (const e of entries) {
+ const key = e.email.toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ next.push({ name: e.name !== e.email ? e.name : undefined, email: e.email });
+ }
+ return next;
+ });
+ if (field === 'to' && validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false }));
+ if (field === 'cc') setShowCc(true);
+ if (field === 'bcc') setShowBcc(true);
+ }, [validationErrors.to]);
+
const insertAutocomplete = (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) clearTimeout(autocompleteTimeoutRef.current);
setAutoQuery('');
@@ -3726,6 +3750,20 @@ export function EmailComposer({
onMoveChip={handleMoveChip}
/>
+ {activeClient?.supportsPrincipals() && (
+
setShowDirectoryPicker(true)}
+ className="h-7 w-7 p-0"
+ title={t('select_from_directory')}
+ aria-label={t('select_from_directory')}
+ data-testid="composer-directory-picker"
+ >
+
+
+ )}
)}
+ {showDirectoryPicker && (
+
setShowDirectoryPicker(false)}
+ onAdd={addDirectoryRecipients}
+ />
+ )}
+
{filesPicker && (
{
expect(principals).toHaveLength(1203);
expect(queries.map((q) => [q.position, q.limit])).toEqual([[0, 500], [500, 500], [1000, 500]]);
});
+
+ function forbiddenClient() {
+ const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
+ Object.assign(client, {
+ accountId: 'me',
+ capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 }, 'urn:ietf:params:jmap:principals': {} },
+ });
+ vi.spyOn(client as unknown as { supportsPrincipals: () => boolean }, 'supportsPrincipals').mockReturnValue(true);
+ vi.spyOn(client as unknown as { request: () => Promise }, 'request').mockResolvedValue({
+ methodResponses: [
+ ['error', { type: 'forbidden', description: 'Directory queries are disabled' }, '0'],
+ ['error', { type: 'forbidden' }, '1'],
+ ],
+ });
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ return client;
+ }
+
+ it('reports a refused directory query when asked to', async () => {
+ await expect(forbiddenClient().getPrincipals(undefined, { throwOnError: true }))
+ .rejects.toMatchObject({ name: 'PrincipalsRequestError', type: 'forbidden' });
+ });
+
+ it('still answers an empty list to callers that did not ask', async () => {
+ await expect(forbiddenClient().getPrincipals()).resolves.toEqual([]);
+ });
});
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 40fcf1b97..74496d2a7 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -507,7 +507,8 @@ export interface IJMAPClient {
// ── Sharing (RFC 9670 Principals) ─────────────────────────────
supportsPrincipals(): boolean;
- getPrincipals(targetAccountId?: string): Promise;
+ /** Errors are logged and yield [] unless `throwOnError` (PrincipalsRequestError). */
+ getPrincipals(targetAccountId?: string, options?: { throwOnError?: boolean }): Promise;
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise;
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise;
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise;
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 0ed5c8f82..02258ff37 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -15,6 +15,7 @@ import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
import { decodeFileNodeName } from "./filenode-name";
import { contactFromWire, contactToWire } from "./contact-wire";
import { SchedulingDeniedError } from "./scheduling-error";
+import { PrincipalsRequestError } from "./principals-error";
export { SchedulingDeniedError };
import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules";
@@ -5916,7 +5917,7 @@ export class JMAPClient implements IJMAPClient {
* to the chained Principal/get, which the server refuses with
* `requestTooLarge` once the directory holds more than that (500).
*/
- async getPrincipals(targetAccountId?: string): Promise {
+ async getPrincipals(targetAccountId?: string, options?: { throwOnError?: boolean }): Promise {
if (!this.supportsPrincipals()) return [];
const accountId = targetAccountId || this.accountId;
const pageSize = this.getMaxObjectsInGet();
@@ -5934,7 +5935,11 @@ export class JMAPClient implements IJMAPClient {
const ids: string[] = response.methodResponses?.find((r) => r[0] === "Principal/query")?.[1]?.ids ?? [];
const getResp = response.methodResponses?.find((r) => r[0] === "Principal/get");
- if (!getResp) break;
+ if (!getResp) {
+ if (!options?.throwOnError) break;
+ const err = response.methodResponses?.find((r) => r[0] === "error")?.[1] as { type?: string; description?: string } | undefined;
+ throw new PrincipalsRequestError(err?.type ?? null, err?.description);
+ }
const list = (getResp[1].list || []) as Principal[];
all.push(...list.map((p) => ({ ...p, accountId })));
if (ids.length < pageSize) break;
@@ -5942,6 +5947,11 @@ export class JMAPClient implements IJMAPClient {
}
return all;
} catch (error) {
+ if (options?.throwOnError) {
+ throw error instanceof PrincipalsRequestError
+ ? error
+ : new PrincipalsRequestError(null, error instanceof Error ? error.message : undefined);
+ }
console.error("Failed to fetch principals:", error);
return [];
}
diff --git a/lib/jmap/principals-error.ts b/lib/jmap/principals-error.ts
new file mode 100644
index 000000000..f88d58583
--- /dev/null
+++ b/lib/jmap/principals-error.ts
@@ -0,0 +1,11 @@
+/**
+ * Principal/query or Principal/get failed. `type` is the JMAP method error
+ * (`forbidden` when the server does not let this account query the
+ * directory), or null when the request itself failed.
+ */
+export class PrincipalsRequestError extends Error {
+ constructor(readonly type: string | null, description?: string) {
+ super(description || type || 'Failed to fetch principals');
+ this.name = 'PrincipalsRequestError';
+ }
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index c4f30806f..30174e5d3 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "إخفاء لوحة الإضافة"
},
"email_composer": {
+ "select_from_directory": "الاختيار من الدليل",
"minimize": "تصغير",
"maximize": "ملء الشاشة",
"restore": "استعادة",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "الدليل",
+ "search_placeholder": "ابحث عن اسم أو عنوان بريد",
+ "refresh": "تحديث الدليل",
+ "retry": "إعادة المحاولة",
+ "loading": "جارٍ تحميل الدليل…",
+ "unavailable_title": "لا يوجد دليل",
+ "unavailable_hint": "لا يوفّر خادم البريد دليلًا للحسابات.",
+ "forbidden_title": "الدليل غير متاح",
+ "forbidden_hint": "لا يسمح خادم البريد لهذا الحساب بتصفّح الدليل. اسأل المسؤول.",
+ "error_title": "تعذّر تحميل الدليل",
+ "error_hint": "تحقق من الاتصال وحاول مرة أخرى.",
+ "empty_title": "الدليل فارغ",
+ "empty_hint": "لا يعرض خادم البريد أي حساب له عنوان بريد.",
+ "no_match": "لا أحد في الدليل يطابق بحثك",
+ "refresh_failed": "تعذّر تحديث الدليل. تُعرض آخر قائمة تم تحميلها.",
+ "select_page": "تحديد الجميع في هذه الصفحة",
+ "deselect_page": "إلغاء تحديد الجميع في هذه الصفحة",
+ "select_entry": "تحديد {name}",
+ "range": "{from}–{to} من {total}",
+ "page": "الصفحة {page} من {count}",
+ "previous_page": "الصفحة السابقة",
+ "next_page": "الصفحة التالية",
+ "picker_title": "الاختيار من الدليل",
+ "cancel": "إلغاء",
+ "selected_count": "تم تحديد {count}",
+ "add_to": "إضافة إلى «إلى»",
+ "add_cc": "إضافة إلى «نسخة»",
+ "add_bcc": "إضافة إلى «نسخة مخفية»",
+ "select_hint": "اختر شخصًا لعرض تفاصيله",
+ "write_email": "كتابة رسالة",
+ "add_to_contacts": "إضافة إلى جهات الاتصال",
+ "email": "البريد",
+ "read_only_note": "تأتي إدخالات الدليل من خادم البريد ولا يمكن تعديلها هنا.",
+ "type": {
+ "individual": "شخص",
+ "group": "مجموعة",
+ "resource": "مورد",
+ "location": "موقع",
+ "other": "أخرى"
+ }
+ },
"title": "جهات الاتصال",
"search_placeholder": "بحث في جهات الاتصال...",
"create_new": "جهة اتصال جديدة",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 9a41a17f5..2923807c1 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Amaga el tauler del connector"
},
"email_composer": {
+ "select_from_directory": "Selecciona del directori",
"minimize": "Minimitza",
"maximize": "Pantalla completa",
"restore": "Restaura",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Directori",
+ "search_placeholder": "Cerca un nom o una adreça electrònica",
+ "refresh": "Actualitza el directori",
+ "retry": "Torna-ho a provar",
+ "loading": "S'està carregant el directori…",
+ "unavailable_title": "Sense directori",
+ "unavailable_hint": "El teu servidor de correu no ofereix un directori de comptes.",
+ "forbidden_title": "Directori no disponible",
+ "forbidden_hint": "El teu servidor de correu no permet que aquest compte consulti el directori. Pregunta a l'administrador.",
+ "error_title": "No s'ha pogut carregar el directori",
+ "error_hint": "Comprova la connexió i torna-ho a provar.",
+ "empty_title": "El directori és buit",
+ "empty_hint": "El teu servidor de correu no llista cap compte amb adreça electrònica.",
+ "no_match": "Ningú del directori coincideix amb la cerca",
+ "refresh_failed": "No s'ha pogut actualitzar el directori. Es mostra l'última llista carregada.",
+ "select_page": "Selecciona tothom d'aquesta pàgina",
+ "deselect_page": "Desselecciona tothom d'aquesta pàgina",
+ "select_entry": "Selecciona {name}",
+ "range": "{from}–{to} de {total}",
+ "page": "Pàgina {page} de {count}",
+ "previous_page": "Pàgina anterior",
+ "next_page": "Pàgina següent",
+ "picker_title": "Selecciona del directori",
+ "cancel": "Cancel·la",
+ "selected_count": "{count} seleccionats",
+ "add_to": "Afegeix a Per a",
+ "add_cc": "Afegeix a A/c",
+ "add_bcc": "Afegeix a C/o",
+ "select_hint": "Selecciona algú per veure'n els detalls",
+ "write_email": "Escriu un correu",
+ "add_to_contacts": "Afegeix als contactes",
+ "email": "Correu",
+ "read_only_note": "Les entrades del directori vénen del teu servidor de correu i no es poden editar aquí.",
+ "type": {
+ "individual": "Persona",
+ "group": "Grup",
+ "resource": "Recurs",
+ "location": "Ubicació",
+ "other": "Altre"
+ }
+ },
"title": "Contactes",
"search_placeholder": "Cerca contactes...",
"create_new": "Contacte nou",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 98e481e8d..67c2c0e75 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Skrýt panel pluginu"
},
"email_composer": {
+ "select_from_directory": "Vybrat z adresáře",
"minimize": "Minimalizovat",
"maximize": "Celá obrazovka",
"restore": "Obnovit",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Adresář organizace",
+ "search_placeholder": "Hledat jméno nebo e-mailovou adresu",
+ "refresh": "Obnovit adresář",
+ "retry": "Zkusit znovu",
+ "loading": "Načítání adresáře…",
+ "unavailable_title": "Žádný adresář",
+ "unavailable_hint": "Váš poštovní server nenabízí adresář účtů.",
+ "forbidden_title": "Adresář není dostupný",
+ "forbidden_hint": "Váš poštovní server tomuto účtu nepovoluje procházet adresář. Obraťte se na správce.",
+ "error_title": "Adresář se nepodařilo načíst",
+ "error_hint": "Zkontrolujte připojení a zkuste to znovu.",
+ "empty_title": "Adresář je prázdný",
+ "empty_hint": "Váš poštovní server neuvádí žádné účty s e-mailovou adresou.",
+ "no_match": "Hledání neodpovídá nikdo v adresáři",
+ "refresh_failed": "Adresář se nepodařilo obnovit. Zobrazuje se naposledy načtený seznam.",
+ "select_page": "Vybrat všechny na této stránce",
+ "deselect_page": "Zrušit výběr na této stránce",
+ "select_entry": "Vybrat {name}",
+ "range": "{from}–{to} z {total}",
+ "page": "Stránka {page} z {count}",
+ "previous_page": "Předchozí stránka",
+ "next_page": "Další stránka",
+ "picker_title": "Vybrat z adresáře",
+ "cancel": "Zrušit",
+ "selected_count": "Vybráno: {count}",
+ "add_to": "Přidat do Komu",
+ "add_cc": "Přidat do Kopie",
+ "add_bcc": "Přidat do Skrytá kopie",
+ "select_hint": "Vyberte osobu a zobrazte podrobnosti",
+ "write_email": "Napsat e-mail",
+ "add_to_contacts": "Přidat do kontaktů",
+ "email": "E-mail",
+ "read_only_note": "Položky adresáře pocházejí z vašeho poštovního serveru a nelze je zde upravit.",
+ "type": {
+ "individual": "Osoba",
+ "group": "Skupina",
+ "resource": "Prostředek",
+ "location": "Místo",
+ "other": "Jiné"
+ }
+ },
"title": "Kontakty",
"search_placeholder": "Hledat kontakty...",
"create_new": "Nový kontakt",
diff --git a/locales/da/common.json b/locales/da/common.json
index 2279fdd31..ffea72e05 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Skjul pluginpanel"
},
"email_composer": {
+ "select_from_directory": "Vælg fra adressefortegnelse",
"minimize": "Minimer",
"maximize": "Fuld skærm",
"restore": "Gendan",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Adressefortegnelse",
+ "search_placeholder": "Søg efter navn eller e-mailadresse",
+ "refresh": "Opdater adressefortegnelse",
+ "retry": "Prøv igen",
+ "loading": "Indlæser adressefortegnelse…",
+ "unavailable_title": "Ingen adressefortegnelse",
+ "unavailable_hint": "Din mailserver tilbyder ikke en fortegnelse over konti.",
+ "forbidden_title": "Adressefortegnelse ikke tilgængelig",
+ "forbidden_hint": "Din mailserver tillader ikke denne konto at gennemse fortegnelsen. Spørg din administrator.",
+ "error_title": "Adressefortegnelsen kunne ikke indlæses",
+ "error_hint": "Tjek din forbindelse, og prøv igen.",
+ "empty_title": "Adressefortegnelsen er tom",
+ "empty_hint": "Din mailserver viser ingen konti med en e-mailadresse.",
+ "no_match": "Ingen i fortegnelsen matcher din søgning",
+ "refresh_failed": "Adressefortegnelsen kunne ikke opdateres. Viser den senest indlæste liste.",
+ "select_page": "Vælg alle på denne side",
+ "deselect_page": "Fravælg alle på denne side",
+ "select_entry": "Vælg {name}",
+ "range": "{from}–{to} af {total}",
+ "page": "Side {page} af {count}",
+ "previous_page": "Forrige side",
+ "next_page": "Næste side",
+ "picker_title": "Vælg fra adressefortegnelse",
+ "cancel": "Annuller",
+ "selected_count": "{count} valgt",
+ "add_to": "Tilføj til Til",
+ "add_cc": "Tilføj til Cc",
+ "add_bcc": "Tilføj til Bcc",
+ "select_hint": "Vælg en person for at se detaljer",
+ "write_email": "Skriv e-mail",
+ "add_to_contacts": "Tilføj til kontakter",
+ "email": "E-mail",
+ "read_only_note": "Posterne kommer fra din mailserver og kan ikke redigeres her.",
+ "type": {
+ "individual": "Person",
+ "group": "Gruppe",
+ "resource": "Ressource",
+ "location": "Sted",
+ "other": "Andet"
+ }
+ },
"title": "Kontakter",
"search_placeholder": "Søg efter kontakter...",
"create_new": "Ny kontakt",
diff --git a/locales/de/common.json b/locales/de/common.json
index ce73a9998..2145b79e5 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Plugin-Bereich ausblenden"
},
"email_composer": {
+ "select_from_directory": "Aus Verzeichnis auswählen",
"minimize": "Minimieren",
"maximize": "Vollbild",
"restore": "Wiederherstellen",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Verzeichnis",
+ "search_placeholder": "Name oder E-Mail-Adresse suchen",
+ "refresh": "Verzeichnis aktualisieren",
+ "retry": "Erneut versuchen",
+ "loading": "Verzeichnis wird geladen…",
+ "unavailable_title": "Kein Verzeichnis",
+ "unavailable_hint": "Dein Mailserver bietet kein Verzeichnis der Konten an.",
+ "forbidden_title": "Verzeichnis nicht verfügbar",
+ "forbidden_hint": "Dein Mailserver erlaubt diesem Konto nicht, das Verzeichnis zu durchsuchen. Frag deine Administration.",
+ "error_title": "Verzeichnis konnte nicht geladen werden",
+ "error_hint": "Prüfe deine Verbindung und versuche es erneut.",
+ "empty_title": "Das Verzeichnis ist leer",
+ "empty_hint": "Dein Mailserver listet keine Konten mit E-Mail-Adresse.",
+ "no_match": "Niemand im Verzeichnis passt zu deiner Suche",
+ "refresh_failed": "Verzeichnis konnte nicht aktualisiert werden. Angezeigt wird die zuletzt geladene Liste.",
+ "select_page": "Alle auf dieser Seite auswählen",
+ "deselect_page": "Auswahl auf dieser Seite aufheben",
+ "select_entry": "{name} auswählen",
+ "range": "{from}–{to} von {total}",
+ "page": "Seite {page} von {count}",
+ "previous_page": "Vorherige Seite",
+ "next_page": "Nächste Seite",
+ "picker_title": "Aus Verzeichnis auswählen",
+ "cancel": "Abbrechen",
+ "selected_count": "{count} ausgewählt",
+ "add_to": "Zu An",
+ "add_cc": "Zu Cc",
+ "add_bcc": "Zu Bcc",
+ "select_hint": "Wähle jemanden aus, um die Details zu sehen",
+ "write_email": "E-Mail schreiben",
+ "add_to_contacts": "Zu Kontakten hinzufügen",
+ "email": "E-Mail",
+ "read_only_note": "Verzeichniseinträge kommen von deinem Mailserver und können hier nicht bearbeitet werden.",
+ "type": {
+ "individual": "Person",
+ "group": "Gruppe",
+ "resource": "Ressource",
+ "location": "Ort",
+ "other": "Sonstiges"
+ }
+ },
"title": "Kontakte",
"search_placeholder": "Kontakte suchen...",
"create_new": "Neuer Kontakt",
diff --git a/locales/en/common.json b/locales/en/common.json
index fb0d9fd56..691b71467 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Hide plugin panel"
},
"email_composer": {
+ "select_from_directory": "Select from directory",
"minimize": "Minimize",
"maximize": "Full screen",
"restore": "Restore",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Directory",
+ "search_placeholder": "Search name or email address",
+ "refresh": "Refresh directory",
+ "retry": "Try again",
+ "loading": "Loading directory…",
+ "unavailable_title": "No directory",
+ "unavailable_hint": "Your mail server doesn't offer a directory of accounts.",
+ "forbidden_title": "Directory not available",
+ "forbidden_hint": "Your mail server doesn't allow this account to browse the directory. Ask your administrator.",
+ "error_title": "Couldn't load the directory",
+ "error_hint": "Check your connection and try again.",
+ "empty_title": "The directory is empty",
+ "empty_hint": "Your mail server lists no accounts with an email address.",
+ "no_match": "No one in the directory matches your search",
+ "refresh_failed": "Couldn't refresh the directory. Showing the last loaded list.",
+ "select_page": "Select everyone on this page",
+ "deselect_page": "Deselect everyone on this page",
+ "select_entry": "Select {name}",
+ "range": "{from}–{to} of {total}",
+ "page": "Page {page} of {count}",
+ "previous_page": "Previous page",
+ "next_page": "Next page",
+ "picker_title": "Select from directory",
+ "cancel": "Cancel",
+ "selected_count": "{count} selected",
+ "add_to": "Add to To",
+ "add_cc": "Add to Cc",
+ "add_bcc": "Add to Bcc",
+ "select_hint": "Select someone to see their details",
+ "write_email": "Write email",
+ "add_to_contacts": "Add to contacts",
+ "email": "Email",
+ "read_only_note": "Directory entries come from your mail server and can't be edited here.",
+ "type": {
+ "individual": "Person",
+ "group": "Group",
+ "resource": "Resource",
+ "location": "Location",
+ "other": "Other"
+ }
+ },
"title": "Contacts",
"search_placeholder": "Search contacts...",
"create_new": "New Contact",
diff --git a/locales/es/common.json b/locales/es/common.json
index 37daa1e88..30bf026be 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Ocultar el panel del complemento"
},
"email_composer": {
+ "select_from_directory": "Seleccionar del directorio",
"minimize": "Minimizar",
"maximize": "Pantalla completa",
"restore": "Restaurar",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Directorio",
+ "search_placeholder": "Buscar nombre o dirección de correo",
+ "refresh": "Actualizar directorio",
+ "retry": "Reintentar",
+ "loading": "Cargando directorio…",
+ "unavailable_title": "Sin directorio",
+ "unavailable_hint": "Tu servidor de correo no ofrece un directorio de cuentas.",
+ "forbidden_title": "Directorio no disponible",
+ "forbidden_hint": "Tu servidor de correo no permite a esta cuenta consultar el directorio. Pregunta a tu administrador.",
+ "error_title": "No se pudo cargar el directorio",
+ "error_hint": "Comprueba tu conexión e inténtalo de nuevo.",
+ "empty_title": "El directorio está vacío",
+ "empty_hint": "Tu servidor de correo no lista ninguna cuenta con dirección de correo.",
+ "no_match": "Nadie en el directorio coincide con tu búsqueda",
+ "refresh_failed": "No se pudo actualizar el directorio. Se muestra la última lista cargada.",
+ "select_page": "Seleccionar a todos en esta página",
+ "deselect_page": "Deseleccionar a todos en esta página",
+ "select_entry": "Seleccionar a {name}",
+ "range": "{from}–{to} de {total}",
+ "page": "Página {page} de {count}",
+ "previous_page": "Página anterior",
+ "next_page": "Página siguiente",
+ "picker_title": "Seleccionar del directorio",
+ "cancel": "Cancelar",
+ "selected_count": "{count} seleccionados",
+ "add_to": "Añadir a Para",
+ "add_cc": "Añadir a CC",
+ "add_bcc": "Añadir a CCO",
+ "select_hint": "Selecciona a alguien para ver sus datos",
+ "write_email": "Escribir correo",
+ "add_to_contacts": "Añadir a contactos",
+ "email": "Correo",
+ "read_only_note": "Las entradas del directorio vienen de tu servidor de correo y no se pueden editar aquí.",
+ "type": {
+ "individual": "Persona",
+ "group": "Grupo",
+ "resource": "Recurso",
+ "location": "Ubicación",
+ "other": "Otro"
+ }
+ },
"title": "Contactos",
"search_placeholder": "Buscar contactos...",
"create_new": "Nuevo contacto",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 1dd5982d3..d7c3bf84b 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "پنهان کردن پنل افزونه"
},
"email_composer": {
+ "select_from_directory": "انتخاب از فهرست",
"minimize": "کوچک کردن",
"maximize": "تمامصفحه",
"restore": "بازگردانی",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "فهرست سازمانی",
+ "search_placeholder": "جستجوی نام یا نشانی ایمیل",
+ "refresh": "تازهسازی فهرست",
+ "retry": "تلاش دوباره",
+ "loading": "در حال بارگذاری فهرست…",
+ "unavailable_title": "فهرستی وجود ندارد",
+ "unavailable_hint": "سرور ایمیل شما فهرستی از حسابها ارائه نمیدهد.",
+ "forbidden_title": "فهرست در دسترس نیست",
+ "forbidden_hint": "سرور ایمیل اجازه مرور فهرست را به این حساب نمیدهد. از مدیر سیستم بپرسید.",
+ "error_title": "بارگذاری فهرست ممکن نشد",
+ "error_hint": "اتصال خود را بررسی کنید و دوباره تلاش کنید.",
+ "empty_title": "فهرست خالی است",
+ "empty_hint": "سرور ایمیل هیچ حسابی با نشانی ایمیل نشان نمیدهد.",
+ "no_match": "هیچکس در فهرست با جستجوی شما مطابقت ندارد",
+ "refresh_failed": "تازهسازی فهرست ممکن نشد. آخرین فهرست بارگذاریشده نمایش داده میشود.",
+ "select_page": "انتخاب همه در این صفحه",
+ "deselect_page": "لغو انتخاب همه در این صفحه",
+ "select_entry": "انتخاب {name}",
+ "range": "{from}–{to} از {total}",
+ "page": "صفحه {page} از {count}",
+ "previous_page": "صفحه قبل",
+ "next_page": "صفحه بعد",
+ "picker_title": "انتخاب از فهرست",
+ "cancel": "لغو",
+ "selected_count": "{count} انتخاب شد",
+ "add_to": "افزودن به «به»",
+ "add_cc": "افزودن به «رونوشت»",
+ "add_bcc": "افزودن به «رونوشت پنهان»",
+ "select_hint": "برای دیدن جزئیات، یک نفر را انتخاب کنید",
+ "write_email": "نوشتن ایمیل",
+ "add_to_contacts": "افزودن به مخاطبان",
+ "email": "ایمیل",
+ "read_only_note": "ورودیهای فهرست از سرور ایمیل شما میآیند و اینجا قابل ویرایش نیستند.",
+ "type": {
+ "individual": "شخص",
+ "group": "گروه",
+ "resource": "منبع",
+ "location": "مکان",
+ "other": "سایر"
+ }
+ },
"title": "مخاطبین",
"search_placeholder": "جستجوی مخاطبین...",
"create_new": "مخاطب جدید",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 630b4d3a8..f50ab8b68 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Masquer le panneau du plugin"
},
"email_composer": {
+ "select_from_directory": "Choisir dans l'annuaire",
"minimize": "Réduire",
"maximize": "Plein écran",
"restore": "Restaurer",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Annuaire",
+ "search_placeholder": "Rechercher un nom ou une adresse e-mail",
+ "refresh": "Actualiser l'annuaire",
+ "retry": "Réessayer",
+ "loading": "Chargement de l'annuaire…",
+ "unavailable_title": "Pas d'annuaire",
+ "unavailable_hint": "Votre serveur de messagerie ne propose pas d'annuaire des comptes.",
+ "forbidden_title": "Annuaire indisponible",
+ "forbidden_hint": "Votre serveur de messagerie n'autorise pas ce compte à parcourir l'annuaire. Contactez votre administrateur.",
+ "error_title": "Impossible de charger l'annuaire",
+ "error_hint": "Vérifiez votre connexion et réessayez.",
+ "empty_title": "L'annuaire est vide",
+ "empty_hint": "Votre serveur de messagerie ne liste aucun compte avec une adresse e-mail.",
+ "no_match": "Personne dans l'annuaire ne correspond à votre recherche",
+ "refresh_failed": "Impossible d'actualiser l'annuaire. Affichage de la dernière liste chargée.",
+ "select_page": "Sélectionner tout le monde sur cette page",
+ "deselect_page": "Désélectionner tout le monde sur cette page",
+ "select_entry": "Sélectionner {name}",
+ "range": "{from}–{to} sur {total}",
+ "page": "Page {page} sur {count}",
+ "previous_page": "Page précédente",
+ "next_page": "Page suivante",
+ "picker_title": "Choisir dans l'annuaire",
+ "cancel": "Annuler",
+ "selected_count": "{count} sélectionné(s)",
+ "add_to": "Ajouter à À",
+ "add_cc": "Ajouter à Cc",
+ "add_bcc": "Ajouter à Cci",
+ "select_hint": "Sélectionnez une personne pour voir ses détails",
+ "write_email": "Écrire un e-mail",
+ "add_to_contacts": "Ajouter aux contacts",
+ "email": "E-mail",
+ "read_only_note": "Les entrées de l'annuaire proviennent de votre serveur de messagerie et ne peuvent pas être modifiées ici.",
+ "type": {
+ "individual": "Personne",
+ "group": "Groupe",
+ "resource": "Ressource",
+ "location": "Lieu",
+ "other": "Autre"
+ }
+ },
"title": "Contacts",
"search_placeholder": "Rechercher des contacts...",
"create_new": "Nouveau contact",
diff --git a/locales/he/common.json b/locales/he/common.json
index 208910020..8151dfd5d 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -591,6 +591,7 @@
"plugin_panel_hide": "הסתרת חלונית התוסף"
},
"email_composer": {
+ "select_from_directory": "בחירה מהספרייה",
"minimize": "מזעור",
"maximize": "מסך מלא",
"restore": "שחזור",
@@ -2657,6 +2658,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "ספרייה",
+ "search_placeholder": "חיפוש שם או כתובת דוא״ל",
+ "refresh": "רענון הספרייה",
+ "retry": "ניסיון חוזר",
+ "loading": "טוען את הספרייה…",
+ "unavailable_title": "אין ספרייה",
+ "unavailable_hint": "שרת הדואר שלך אינו מציע ספריית חשבונות.",
+ "forbidden_title": "הספרייה אינה זמינה",
+ "forbidden_hint": "שרת הדואר אינו מאפשר לחשבון זה לעיין בספרייה. פנה למנהל המערכת.",
+ "error_title": "לא ניתן לטעון את הספרייה",
+ "error_hint": "בדוק את החיבור ונסה שוב.",
+ "empty_title": "הספרייה ריקה",
+ "empty_hint": "שרת הדואר אינו מציג חשבונות עם כתובת דוא״ל.",
+ "no_match": "אף אחד בספרייה אינו תואם לחיפוש",
+ "refresh_failed": "לא ניתן לרענן את הספרייה. מוצגת הרשימה האחרונה שנטענה.",
+ "select_page": "בחירת כולם בעמוד זה",
+ "deselect_page": "ביטול הבחירה בעמוד זה",
+ "select_entry": "בחירת {name}",
+ "range": "{from}–{to} מתוך {total}",
+ "page": "עמוד {page} מתוך {count}",
+ "previous_page": "העמוד הקודם",
+ "next_page": "העמוד הבא",
+ "picker_title": "בחירה מהספרייה",
+ "cancel": "ביטול",
+ "selected_count": "{count} נבחרו",
+ "add_to": "הוספה לשדה אל",
+ "add_cc": "הוספה לעותק",
+ "add_bcc": "הוספה לעותק מוסתר",
+ "select_hint": "בחר אדם כדי לראות את פרטיו",
+ "write_email": "כתיבת הודעה",
+ "add_to_contacts": "הוספה לאנשי קשר",
+ "email": "דוא״ל",
+ "read_only_note": "רשומות הספרייה מגיעות משרת הדואר ואי אפשר לערוך אותן כאן.",
+ "type": {
+ "individual": "אדם",
+ "group": "קבוצה",
+ "resource": "משאב",
+ "location": "מיקום",
+ "other": "אחר"
+ }
+ },
"title": "אנשי קשר",
"search_placeholder": "חפש אנשי קשר...",
"create_new": "איש קשר חדש",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 4dacbd4bc..7d1b453f5 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Bővítménypanel elrejtése"
},
"email_composer": {
+ "select_from_directory": "Kiválasztás a címtárból",
"minimize": "Kis méret",
"maximize": "Teljes képernyő",
"restore": "Visszaállítás",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Címtár",
+ "search_placeholder": "Név vagy e-mail-cím keresése",
+ "refresh": "Címtár frissítése",
+ "retry": "Újra",
+ "loading": "Címtár betöltése…",
+ "unavailable_title": "Nincs címtár",
+ "unavailable_hint": "A levelezőszerver nem kínál fiókcímtárat.",
+ "forbidden_title": "A címtár nem érhető el",
+ "forbidden_hint": "A levelezőszerver nem engedi, hogy ez a fiók böngéssze a címtárat. Kérdezd a rendszergazdát.",
+ "error_title": "Nem sikerült betölteni a címtárat",
+ "error_hint": "Ellenőrizd a kapcsolatot, és próbáld újra.",
+ "empty_title": "A címtár üres",
+ "empty_hint": "A levelezőszerver nem sorol fel e-mail-címmel rendelkező fiókot.",
+ "no_match": "Senki sem felel meg a keresésnek a címtárban",
+ "refresh_failed": "Nem sikerült frissíteni a címtárat. A legutóbb betöltött lista látható.",
+ "select_page": "Mindenki kijelölése ezen az oldalon",
+ "deselect_page": "Kijelölés megszüntetése ezen az oldalon",
+ "select_entry": "{name} kijelölése",
+ "range": "{from}–{to} / {total}",
+ "page": "{page}. oldal / {count}",
+ "previous_page": "Előző oldal",
+ "next_page": "Következő oldal",
+ "picker_title": "Kiválasztás a címtárból",
+ "cancel": "Mégse",
+ "selected_count": "{count} kijelölve",
+ "add_to": "Hozzáadás: Címzett",
+ "add_cc": "Hozzáadás: Másolat",
+ "add_bcc": "Hozzáadás: Titkos másolat",
+ "select_hint": "Válassz ki valakit a részletekhez",
+ "write_email": "E-mail írása",
+ "add_to_contacts": "Hozzáadás a névjegyekhez",
+ "email": "E-mail",
+ "read_only_note": "A címtár bejegyzései a levelezőszerverről származnak, itt nem szerkeszthetők.",
+ "type": {
+ "individual": "Személy",
+ "group": "Csoport",
+ "resource": "Erőforrás",
+ "location": "Helyszín",
+ "other": "Egyéb"
+ }
+ },
"title": "Névjegyek",
"search_placeholder": "Névjegyek keresése...",
"create_new": "Új névjegy",
diff --git a/locales/it/common.json b/locales/it/common.json
index f37082347..91238d980 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Nascondi il pannello del plugin"
},
"email_composer": {
+ "select_from_directory": "Seleziona dalla rubrica aziendale",
"minimize": "Riduci a icona",
"maximize": "Schermo intero",
"restore": "Ripristina",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Rubrica aziendale",
+ "search_placeholder": "Cerca nome o indirizzo email",
+ "refresh": "Aggiorna rubrica aziendale",
+ "retry": "Riprova",
+ "loading": "Caricamento della rubrica aziendale…",
+ "unavailable_title": "Nessuna rubrica aziendale",
+ "unavailable_hint": "Il tuo server di posta non offre un elenco degli account.",
+ "forbidden_title": "Rubrica aziendale non disponibile",
+ "forbidden_hint": "Il tuo server di posta non consente a questo account di consultare l'elenco. Chiedi all'amministratore.",
+ "error_title": "Impossibile caricare la rubrica aziendale",
+ "error_hint": "Controlla la connessione e riprova.",
+ "empty_title": "La rubrica aziendale è vuota",
+ "empty_hint": "Il tuo server di posta non elenca account con un indirizzo email.",
+ "no_match": "Nessuno nella rubrica aziendale corrisponde alla ricerca",
+ "refresh_failed": "Impossibile aggiornare la rubrica aziendale. Viene mostrato l'ultimo elenco caricato.",
+ "select_page": "Seleziona tutti in questa pagina",
+ "deselect_page": "Deseleziona tutti in questa pagina",
+ "select_entry": "Seleziona {name}",
+ "range": "{from}–{to} di {total}",
+ "page": "Pagina {page} di {count}",
+ "previous_page": "Pagina precedente",
+ "next_page": "Pagina successiva",
+ "picker_title": "Seleziona dalla rubrica aziendale",
+ "cancel": "Annulla",
+ "selected_count": "{count} selezionati",
+ "add_to": "Aggiungi ad A",
+ "add_cc": "Aggiungi a Cc",
+ "add_bcc": "Aggiungi a Ccn",
+ "select_hint": "Seleziona una persona per vederne i dettagli",
+ "write_email": "Scrivi email",
+ "add_to_contacts": "Aggiungi ai contatti",
+ "email": "Email",
+ "read_only_note": "Le voci provengono dal tuo server di posta e non possono essere modificate qui.",
+ "type": {
+ "individual": "Persona",
+ "group": "Gruppo",
+ "resource": "Risorsa",
+ "location": "Luogo",
+ "other": "Altro"
+ }
+ },
"title": "Contatti",
"search_placeholder": "Cerca contatti...",
"create_new": "Nuovo contatto",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 0c636cc1a..24edd109c 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "プラグインパネルを隠す"
},
"email_composer": {
+ "select_from_directory": "ディレクトリから選択",
"minimize": "最小化",
"maximize": "全画面表示",
"restore": "元に戻す",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "ディレクトリ",
+ "search_placeholder": "名前またはメールアドレスで検索",
+ "refresh": "ディレクトリを更新",
+ "retry": "再試行",
+ "loading": "ディレクトリを読み込み中…",
+ "unavailable_title": "ディレクトリがありません",
+ "unavailable_hint": "メールサーバーはアカウントのディレクトリを提供していません。",
+ "forbidden_title": "ディレクトリを利用できません",
+ "forbidden_hint": "メールサーバーがこのアカウントにディレクトリの閲覧を許可していません。管理者にお問い合わせください。",
+ "error_title": "ディレクトリを読み込めませんでした",
+ "error_hint": "接続を確認して、もう一度お試しください。",
+ "empty_title": "ディレクトリは空です",
+ "empty_hint": "メールサーバーにメールアドレスを持つアカウントがありません。",
+ "no_match": "検索に一致する人はディレクトリにいません",
+ "refresh_failed": "ディレクトリを更新できませんでした。最後に読み込んだ一覧を表示しています。",
+ "select_page": "このページの全員を選択",
+ "deselect_page": "このページの選択を解除",
+ "select_entry": "{name} を選択",
+ "range": "{total} 件中 {from}–{to}",
+ "page": "{count} ページ中 {page} ページ",
+ "previous_page": "前のページ",
+ "next_page": "次のページ",
+ "picker_title": "ディレクトリから選択",
+ "cancel": "キャンセル",
+ "selected_count": "{count} 件選択",
+ "add_to": "宛先に追加",
+ "add_cc": "CC に追加",
+ "add_bcc": "BCC に追加",
+ "select_hint": "詳細を表示するには相手を選択してください",
+ "write_email": "メールを作成",
+ "add_to_contacts": "連絡先に追加",
+ "email": "メール",
+ "read_only_note": "ディレクトリの項目はメールサーバーから取得され、ここでは編集できません。",
+ "type": {
+ "individual": "個人",
+ "group": "グループ",
+ "resource": "リソース",
+ "location": "場所",
+ "other": "その他"
+ }
+ },
"title": "連絡先",
"search_placeholder": "連絡先を検索...",
"create_new": "新しい連絡先",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index bb6db3845..3936e8a93 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "플러그인 패널 숨기기"
},
"email_composer": {
+ "select_from_directory": "디렉터리에서 선택",
"minimize": "최소화",
"maximize": "전체 화면",
"restore": "복원",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "디렉터리",
+ "search_placeholder": "이름 또는 이메일 주소 검색",
+ "refresh": "디렉터리 새로 고침",
+ "retry": "다시 시도",
+ "loading": "디렉터리 불러오는 중…",
+ "unavailable_title": "디렉터리 없음",
+ "unavailable_hint": "메일 서버가 계정 디렉터리를 제공하지 않습니다.",
+ "forbidden_title": "디렉터리를 사용할 수 없음",
+ "forbidden_hint": "메일 서버가 이 계정의 디렉터리 탐색을 허용하지 않습니다. 관리자에게 문의하세요.",
+ "error_title": "디렉터리를 불러오지 못했습니다",
+ "error_hint": "연결을 확인하고 다시 시도하세요.",
+ "empty_title": "디렉터리가 비어 있습니다",
+ "empty_hint": "메일 서버에 이메일 주소가 있는 계정이 없습니다.",
+ "no_match": "검색과 일치하는 사람이 디렉터리에 없습니다",
+ "refresh_failed": "디렉터리를 새로 고치지 못했습니다. 마지막으로 불러온 목록을 표시합니다.",
+ "select_page": "이 페이지의 모두 선택",
+ "deselect_page": "이 페이지의 선택 해제",
+ "select_entry": "{name} 선택",
+ "range": "{total}개 중 {from}–{to}",
+ "page": "{count}페이지 중 {page}페이지",
+ "previous_page": "이전 페이지",
+ "next_page": "다음 페이지",
+ "picker_title": "디렉터리에서 선택",
+ "cancel": "취소",
+ "selected_count": "{count}명 선택됨",
+ "add_to": "받는 사람에 추가",
+ "add_cc": "참조에 추가",
+ "add_bcc": "숨은 참조에 추가",
+ "select_hint": "세부 정보를 보려면 사람을 선택하세요",
+ "write_email": "메일 쓰기",
+ "add_to_contacts": "연락처에 추가",
+ "email": "이메일",
+ "read_only_note": "디렉터리 항목은 메일 서버에서 가져오며 여기에서 편집할 수 없습니다.",
+ "type": {
+ "individual": "개인",
+ "group": "그룹",
+ "resource": "리소스",
+ "location": "위치",
+ "other": "기타"
+ }
+ },
"title": "연락처",
"search_placeholder": "연락처 검색...",
"create_new": "새 연락처",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index b608b9e41..cde6e2891 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Slēpt spraudņa paneli"
},
"email_composer": {
+ "select_from_directory": "Izvēlēties no kataloga",
"minimize": "Minimizēt",
"maximize": "Pilnekrāns",
"restore": "Atjaunot",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Katalogs",
+ "search_placeholder": "Meklēt vārdu vai e-pasta adresi",
+ "refresh": "Atsvaidzināt katalogu",
+ "retry": "Mēģināt vēlreiz",
+ "loading": "Ielādē katalogu…",
+ "unavailable_title": "Nav kataloga",
+ "unavailable_hint": "Jūsu pasta serveris nepiedāvā kontu katalogu.",
+ "forbidden_title": "Katalogs nav pieejams",
+ "forbidden_hint": "Jūsu pasta serveris neļauj šim kontam pārlūkot katalogu. Jautājiet administratoram.",
+ "error_title": "Neizdevās ielādēt katalogu",
+ "error_hint": "Pārbaudiet savienojumu un mēģiniet vēlreiz.",
+ "empty_title": "Katalogs ir tukšs",
+ "empty_hint": "Jūsu pasta serveris nerāda nevienu kontu ar e-pasta adresi.",
+ "no_match": "Neviens katalogā neatbilst meklējumam",
+ "refresh_failed": "Neizdevās atsvaidzināt katalogu. Tiek rādīts pēdējais ielādētais saraksts.",
+ "select_page": "Atlasīt visus šajā lapā",
+ "deselect_page": "Noņemt atlasi šajā lapā",
+ "select_entry": "Atlasīt {name}",
+ "range": "{from}–{to} no {total}",
+ "page": "{page}. lapa no {count}",
+ "previous_page": "Iepriekšējā lapa",
+ "next_page": "Nākamā lapa",
+ "picker_title": "Izvēlēties no kataloga",
+ "cancel": "Atcelt",
+ "selected_count": "Atlasīti: {count}",
+ "add_to": "Pievienot laukam Kam",
+ "add_cc": "Pievienot laukam Kopija",
+ "add_bcc": "Pievienot laukam Slēptā kopija",
+ "select_hint": "Atlasiet personu, lai redzētu informāciju",
+ "write_email": "Rakstīt e-pastu",
+ "add_to_contacts": "Pievienot kontaktiem",
+ "email": "E-pasts",
+ "read_only_note": "Kataloga ieraksti nāk no jūsu pasta servera, un tos šeit nevar rediģēt.",
+ "type": {
+ "individual": "Persona",
+ "group": "Grupa",
+ "resource": "Resurss",
+ "location": "Vieta",
+ "other": "Cits"
+ }
+ },
"title": "Kontakti",
"search_placeholder": "Meklēt kontaktus...",
"create_new": "Jauns kontakts",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index ba5ed7f09..44de27627 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Залгаасын самбарыг нуух"
},
"email_composer": {
+ "select_from_directory": "Лавлахаас сонгох",
"minimize": "Багасгах",
"maximize": "Бүтэн дэлгэц",
"restore": "Сэргээх",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Лавлах",
+ "search_placeholder": "Нэр эсвэл имэйл хаяг хайх",
+ "refresh": "Лавлахыг шинэчлэх",
+ "retry": "Дахин оролдох",
+ "loading": "Лавлахыг ачаалж байна…",
+ "unavailable_title": "Лавлах алга",
+ "unavailable_hint": "Таны шуудангийн сервер бүртгэлийн лавлах санал болгодоггүй.",
+ "forbidden_title": "Лавлах ашиглах боломжгүй",
+ "forbidden_hint": "Таны шуудангийн сервер энэ бүртгэлд лавлахыг үзэхийг зөвшөөрөхгүй байна. Админаасаа асууна уу.",
+ "error_title": "Лавлахыг ачаалж чадсангүй",
+ "error_hint": "Холболтоо шалгаад дахин оролдоно уу.",
+ "empty_title": "Лавлах хоосон байна",
+ "empty_hint": "Таны шуудангийн сервер имэйл хаягтай бүртгэл жагсаахгүй байна.",
+ "no_match": "Лавлахад хайлтад тохирох хүн алга",
+ "refresh_failed": "Лавлахыг шинэчилж чадсангүй. Сүүлд ачаалсан жагсаалтыг харуулж байна.",
+ "select_page": "Энэ хуудсан дээрх бүгдийг сонгох",
+ "deselect_page": "Энэ хуудсан дээрх сонголтыг цуцлах",
+ "select_entry": "{name}-г сонгох",
+ "range": "{total}-аас {from}–{to}",
+ "page": "{count}-аас {page}-р хуудас",
+ "previous_page": "Өмнөх хуудас",
+ "next_page": "Дараагийн хуудас",
+ "picker_title": "Лавлахаас сонгох",
+ "cancel": "Цуцлах",
+ "selected_count": "{count} сонгосон",
+ "add_to": "Хэнд-д нэмэх",
+ "add_cc": "Cc-д нэмэх",
+ "add_bcc": "Bcc-д нэмэх",
+ "select_hint": "Дэлгэрэнгүйг харахын тулд хэн нэгнийг сонгоно уу",
+ "write_email": "Имэйл бичих",
+ "add_to_contacts": "Харилцагчид нэмэх",
+ "email": "Имэйл",
+ "read_only_note": "Лавлахын бичлэгүүд таны шуудангийн серверээс ирдэг тул энд засах боломжгүй.",
+ "type": {
+ "individual": "Хүн",
+ "group": "Бүлэг",
+ "resource": "Нөөц",
+ "location": "Байршил",
+ "other": "Бусад"
+ }
+ },
"title": "Харилцах утас",
"search_placeholder": "Харилцагч хайх...",
"create_new": "Шинэ холбоо барих",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 5cf593737..66737d882 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Skjul tilleggspanel"
},
"email_composer": {
+ "select_from_directory": "Velg fra katalog",
"minimize": "Minimer",
"maximize": "Fullskjerm",
"restore": "Gjenopprett",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Katalog",
+ "search_placeholder": "Søk etter navn eller e-postadresse",
+ "refresh": "Oppdater katalog",
+ "retry": "Prøv igjen",
+ "loading": "Laster katalog…",
+ "unavailable_title": "Ingen katalog",
+ "unavailable_hint": "E-postserveren din tilbyr ikke en katalog over kontoer.",
+ "forbidden_title": "Katalog ikke tilgjengelig",
+ "forbidden_hint": "E-postserveren din lar ikke denne kontoen bla gjennom katalogen. Spør administratoren.",
+ "error_title": "Kunne ikke laste katalogen",
+ "error_hint": "Sjekk tilkoblingen og prøv igjen.",
+ "empty_title": "Katalogen er tom",
+ "empty_hint": "E-postserveren din viser ingen kontoer med e-postadresse.",
+ "no_match": "Ingen i katalogen samsvarer med søket",
+ "refresh_failed": "Kunne ikke oppdatere katalogen. Viser sist lastede liste.",
+ "select_page": "Velg alle på denne siden",
+ "deselect_page": "Fjern valg på denne siden",
+ "select_entry": "Velg {name}",
+ "range": "{from}–{to} av {total}",
+ "page": "Side {page} av {count}",
+ "previous_page": "Forrige side",
+ "next_page": "Neste side",
+ "picker_title": "Velg fra katalog",
+ "cancel": "Avbryt",
+ "selected_count": "{count} valgt",
+ "add_to": "Legg til i Til",
+ "add_cc": "Legg til i Kopi",
+ "add_bcc": "Legg til i Blindkopi",
+ "select_hint": "Velg en person for å se detaljer",
+ "write_email": "Skriv e-post",
+ "add_to_contacts": "Legg til i kontakter",
+ "email": "E-post",
+ "read_only_note": "Oppføringene kommer fra e-postserveren din og kan ikke redigeres her.",
+ "type": {
+ "individual": "Person",
+ "group": "Gruppe",
+ "resource": "Ressurs",
+ "location": "Sted",
+ "other": "Annet"
+ }
+ },
"title": "Kontakter",
"search_placeholder": "Søk etter kontakter...",
"create_new": "Ny kontakt",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index e5c51f7ea..d2ee556a6 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Plug-inpaneel verbergen"
},
"email_composer": {
+ "select_from_directory": "Kiezen uit adresboek van de organisatie",
"minimize": "Minimaliseren",
"maximize": "Volledig scherm",
"restore": "Herstellen",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Adresboek van de organisatie",
+ "search_placeholder": "Zoek naam of e-mailadres",
+ "refresh": "Adresboek vernieuwen",
+ "retry": "Opnieuw proberen",
+ "loading": "Adresboek laden…",
+ "unavailable_title": "Geen adresboek van de organisatie",
+ "unavailable_hint": "Je mailserver biedt geen overzicht van accounts.",
+ "forbidden_title": "Adresboek niet beschikbaar",
+ "forbidden_hint": "Je mailserver staat dit account niet toe het overzicht te bekijken. Vraag het je beheerder.",
+ "error_title": "Adresboek kon niet worden geladen",
+ "error_hint": "Controleer je verbinding en probeer het opnieuw.",
+ "empty_title": "Het adresboek is leeg",
+ "empty_hint": "Je mailserver toont geen accounts met een e-mailadres.",
+ "no_match": "Niemand in het adresboek komt overeen met je zoekopdracht",
+ "refresh_failed": "Adresboek kon niet worden vernieuwd. De laatst geladen lijst wordt getoond.",
+ "select_page": "Iedereen op deze pagina selecteren",
+ "deselect_page": "Iedereen op deze pagina deselecteren",
+ "select_entry": "{name} selecteren",
+ "range": "{from}–{to} van {total}",
+ "page": "Pagina {page} van {count}",
+ "previous_page": "Vorige pagina",
+ "next_page": "Volgende pagina",
+ "picker_title": "Kiezen uit adresboek van de organisatie",
+ "cancel": "Annuleren",
+ "selected_count": "{count} geselecteerd",
+ "add_to": "Toevoegen aan Aan",
+ "add_cc": "Toevoegen aan Cc",
+ "add_bcc": "Toevoegen aan Bcc",
+ "select_hint": "Selecteer iemand om de details te zien",
+ "write_email": "E-mail schrijven",
+ "add_to_contacts": "Toevoegen aan contacten",
+ "email": "E-mail",
+ "read_only_note": "Deze gegevens komen van je mailserver en kunnen hier niet worden bewerkt.",
+ "type": {
+ "individual": "Persoon",
+ "group": "Groep",
+ "resource": "Middel",
+ "location": "Locatie",
+ "other": "Overig"
+ }
+ },
"title": "Contacten",
"search_placeholder": "Contacten zoeken...",
"create_new": "Nieuw contact",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 3f49754eb..56b4d52b7 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Ukryj panel wtyczki"
},
"email_composer": {
+ "select_from_directory": "Wybierz z katalogu",
"minimize": "Minimalizuj",
"maximize": "Pełny ekran",
"restore": "Przywróć",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Katalog",
+ "search_placeholder": "Szukaj nazwy lub adresu e-mail",
+ "refresh": "Odśwież katalog",
+ "retry": "Spróbuj ponownie",
+ "loading": "Wczytywanie katalogu…",
+ "unavailable_title": "Brak katalogu",
+ "unavailable_hint": "Twój serwer poczty nie udostępnia katalogu kont.",
+ "forbidden_title": "Katalog niedostępny",
+ "forbidden_hint": "Twój serwer poczty nie pozwala temu kontu przeglądać katalogu. Zapytaj administratora.",
+ "error_title": "Nie udało się wczytać katalogu",
+ "error_hint": "Sprawdź połączenie i spróbuj ponownie.",
+ "empty_title": "Katalog jest pusty",
+ "empty_hint": "Twój serwer poczty nie wymienia żadnych kont z adresem e-mail.",
+ "no_match": "Nikt w katalogu nie pasuje do wyszukiwania",
+ "refresh_failed": "Nie udało się odświeżyć katalogu. Wyświetlana jest ostatnio wczytana lista.",
+ "select_page": "Zaznacz wszystkich na tej stronie",
+ "deselect_page": "Odznacz wszystkich na tej stronie",
+ "select_entry": "Zaznacz {name}",
+ "range": "{from}–{to} z {total}",
+ "page": "Strona {page} z {count}",
+ "previous_page": "Poprzednia strona",
+ "next_page": "Następna strona",
+ "picker_title": "Wybierz z katalogu",
+ "cancel": "Anuluj",
+ "selected_count": "Zaznaczono: {count}",
+ "add_to": "Dodaj do Do",
+ "add_cc": "Dodaj do DW",
+ "add_bcc": "Dodaj do UDW",
+ "select_hint": "Wybierz osobę, aby zobaczyć szczegóły",
+ "write_email": "Napisz e-mail",
+ "add_to_contacts": "Dodaj do kontaktów",
+ "email": "E-mail",
+ "read_only_note": "Wpisy katalogu pochodzą z Twojego serwera poczty i nie można ich tu edytować.",
+ "type": {
+ "individual": "Osoba",
+ "group": "Grupa",
+ "resource": "Zasób",
+ "location": "Lokalizacja",
+ "other": "Inne"
+ }
+ },
"title": "Kontakty",
"search_placeholder": "Szukaj kontaktów...",
"create_new": "Nowy kontakt",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 3ab8ec837..d4af5cc23 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Ocultar o painel do plugin"
},
"email_composer": {
+ "select_from_directory": "Selecionar do diretório",
"minimize": "Minimizar",
"maximize": "Ecrã inteiro",
"restore": "Restaurar",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Diretório",
+ "search_placeholder": "Pesquisar nome ou endereço de e-mail",
+ "refresh": "Atualizar diretório",
+ "retry": "Tentar novamente",
+ "loading": "Carregando diretório…",
+ "unavailable_title": "Sem diretório",
+ "unavailable_hint": "Seu servidor de e-mail não oferece um diretório de contas.",
+ "forbidden_title": "Diretório indisponível",
+ "forbidden_hint": "Seu servidor de e-mail não permite que esta conta consulte o diretório. Fale com o administrador.",
+ "error_title": "Não foi possível carregar o diretório",
+ "error_hint": "Verifique sua conexão e tente novamente.",
+ "empty_title": "O diretório está vazio",
+ "empty_hint": "Seu servidor de e-mail não lista nenhuma conta com endereço de e-mail.",
+ "no_match": "Ninguém no diretório corresponde à sua pesquisa",
+ "refresh_failed": "Não foi possível atualizar o diretório. Exibindo a última lista carregada.",
+ "select_page": "Selecionar todos nesta página",
+ "deselect_page": "Desmarcar todos nesta página",
+ "select_entry": "Selecionar {name}",
+ "range": "{from}–{to} de {total}",
+ "page": "Página {page} de {count}",
+ "previous_page": "Página anterior",
+ "next_page": "Próxima página",
+ "picker_title": "Selecionar do diretório",
+ "cancel": "Cancelar",
+ "selected_count": "{count} selecionados",
+ "add_to": "Adicionar em Para",
+ "add_cc": "Adicionar em Cc",
+ "add_bcc": "Adicionar em Cco",
+ "select_hint": "Selecione alguém para ver os detalhes",
+ "write_email": "Escrever e-mail",
+ "add_to_contacts": "Adicionar aos contatos",
+ "email": "E-mail",
+ "read_only_note": "As entradas do diretório vêm do seu servidor de e-mail e não podem ser editadas aqui.",
+ "type": {
+ "individual": "Pessoa",
+ "group": "Grupo",
+ "resource": "Recurso",
+ "location": "Local",
+ "other": "Outro"
+ }
+ },
"title": "Contatos",
"search_placeholder": "Pesquisar contatos...",
"create_new": "Novo contato",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 2ce53a3bf..3903fcecb 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Ascunde panoul pluginului"
},
"email_composer": {
+ "select_from_directory": "Selectează din director",
"minimize": "Minimizează",
"maximize": "Ecran complet",
"restore": "Restabilește",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Director",
+ "search_placeholder": "Caută nume sau adresă de e-mail",
+ "refresh": "Reîmprospătează directorul",
+ "retry": "Încearcă din nou",
+ "loading": "Se încarcă directorul…",
+ "unavailable_title": "Niciun director",
+ "unavailable_hint": "Serverul de e-mail nu oferă un director al conturilor.",
+ "forbidden_title": "Director indisponibil",
+ "forbidden_hint": "Serverul de e-mail nu permite acestui cont să răsfoiască directorul. Întreabă administratorul.",
+ "error_title": "Directorul nu a putut fi încărcat",
+ "error_hint": "Verifică conexiunea și încearcă din nou.",
+ "empty_title": "Directorul este gol",
+ "empty_hint": "Serverul de e-mail nu listează niciun cont cu adresă de e-mail.",
+ "no_match": "Nimeni din director nu corespunde căutării",
+ "refresh_failed": "Directorul nu a putut fi reîmprospătat. Se afișează ultima listă încărcată.",
+ "select_page": "Selectează pe toată lumea de pe această pagină",
+ "deselect_page": "Deselectează pe toată lumea de pe această pagină",
+ "select_entry": "Selectează {name}",
+ "range": "{from}–{to} din {total}",
+ "page": "Pagina {page} din {count}",
+ "previous_page": "Pagina anterioară",
+ "next_page": "Pagina următoare",
+ "picker_title": "Selectează din director",
+ "cancel": "Anulează",
+ "selected_count": "{count} selectați",
+ "add_to": "Adaugă la Către",
+ "add_cc": "Adaugă la Cc",
+ "add_bcc": "Adaugă la Bcc",
+ "select_hint": "Selectează o persoană pentru detalii",
+ "write_email": "Scrie e-mail",
+ "add_to_contacts": "Adaugă la contacte",
+ "email": "E-mail",
+ "read_only_note": "Intrările din director provin de la serverul de e-mail și nu pot fi editate aici.",
+ "type": {
+ "individual": "Persoană",
+ "group": "Grup",
+ "resource": "Resursă",
+ "location": "Locație",
+ "other": "Altul"
+ }
+ },
"title": "Contacte",
"search_placeholder": "Căutare contacte...",
"create_new": "Contact nou",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 78d322d13..677b6a090 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Скрыть панель плагина"
},
"email_composer": {
+ "select_from_directory": "Выбрать из справочника",
"minimize": "Свернуть",
"maximize": "Во весь экран",
"restore": "Восстановить",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Справочник",
+ "search_placeholder": "Поиск по имени или адресу",
+ "refresh": "Обновить справочник",
+ "retry": "Повторить",
+ "loading": "Загрузка справочника…",
+ "unavailable_title": "Нет справочника",
+ "unavailable_hint": "Ваш почтовый сервер не предоставляет справочник учётных записей.",
+ "forbidden_title": "Справочник недоступен",
+ "forbidden_hint": "Ваш почтовый сервер не разрешает этой учётной записи просматривать справочник. Обратитесь к администратору.",
+ "error_title": "Не удалось загрузить справочник",
+ "error_hint": "Проверьте подключение и повторите попытку.",
+ "empty_title": "Справочник пуст",
+ "empty_hint": "Ваш почтовый сервер не возвращает учётных записей с адресом электронной почты.",
+ "no_match": "В справочнике никто не соответствует запросу",
+ "refresh_failed": "Не удалось обновить справочник. Показан последний загруженный список.",
+ "select_page": "Выбрать всех на этой странице",
+ "deselect_page": "Снять выбор на этой странице",
+ "select_entry": "Выбрать {name}",
+ "range": "{from}–{to} из {total}",
+ "page": "Страница {page} из {count}",
+ "previous_page": "Предыдущая страница",
+ "next_page": "Следующая страница",
+ "picker_title": "Выбрать из справочника",
+ "cancel": "Отмена",
+ "selected_count": "Выбрано: {count}",
+ "add_to": "Добавить в «Кому»",
+ "add_cc": "Добавить в «Копия»",
+ "add_bcc": "Добавить в «Скрытая копия»",
+ "select_hint": "Выберите человека, чтобы увидеть подробности",
+ "write_email": "Написать письмо",
+ "add_to_contacts": "Добавить в контакты",
+ "email": "Эл. почта",
+ "read_only_note": "Записи справочника приходят с вашего почтового сервера, здесь их нельзя изменить.",
+ "type": {
+ "individual": "Человек",
+ "group": "Группа",
+ "resource": "Ресурс",
+ "location": "Место",
+ "other": "Другое"
+ }
+ },
"title": "Контакты",
"search_placeholder": "Поиск контактов...",
"create_new": "Новый контакт",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 2ac4a6ef2..507d310af 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Skryť panel doplnku"
},
"email_composer": {
+ "select_from_directory": "Vybrať z adresára",
"minimize": "Minimalizovať",
"maximize": "Celá obrazovka",
"restore": "Obnoviť",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Adresár organizácie",
+ "search_placeholder": "Hľadať meno alebo e-mailovú adresu",
+ "refresh": "Obnoviť adresár",
+ "retry": "Skúsiť znova",
+ "loading": "Načítava sa adresár…",
+ "unavailable_title": "Žiadny adresár",
+ "unavailable_hint": "Váš poštový server neponúka adresár účtov.",
+ "forbidden_title": "Adresár nie je dostupný",
+ "forbidden_hint": "Váš poštový server tomuto účtu nepovoľuje prehliadať adresár. Obráťte sa na správcu.",
+ "error_title": "Adresár sa nepodarilo načítať",
+ "error_hint": "Skontrolujte pripojenie a skúste to znova.",
+ "empty_title": "Adresár je prázdny",
+ "empty_hint": "Váš poštový server neuvádza žiadne účty s e-mailovou adresou.",
+ "no_match": "Hľadaniu nezodpovedá nikto v adresári",
+ "refresh_failed": "Adresár sa nepodarilo obnoviť. Zobrazuje sa naposledy načítaný zoznam.",
+ "select_page": "Vybrať všetkých na tejto stránke",
+ "deselect_page": "Zrušiť výber na tejto stránke",
+ "select_entry": "Vybrať {name}",
+ "range": "{from}–{to} z {total}",
+ "page": "Strana {page} z {count}",
+ "previous_page": "Predchádzajúca strana",
+ "next_page": "Ďalšia strana",
+ "picker_title": "Vybrať z adresára",
+ "cancel": "Zrušiť",
+ "selected_count": "Vybrané: {count}",
+ "add_to": "Pridať do Komu",
+ "add_cc": "Pridať do Kópia",
+ "add_bcc": "Pridať do Skrytá kópia",
+ "select_hint": "Vyberte osobu a zobrazte podrobnosti",
+ "write_email": "Napísať e-mail",
+ "add_to_contacts": "Pridať do kontaktov",
+ "email": "E-mail",
+ "read_only_note": "Položky adresára pochádzajú z vášho poštového servera a nedajú sa tu upraviť.",
+ "type": {
+ "individual": "Osoba",
+ "group": "Skupina",
+ "resource": "Prostriedok",
+ "location": "Miesto",
+ "other": "Iné"
+ }
+ },
"title": "Kontakty",
"search_placeholder": "Hľadať kontakty...",
"create_new": "Nový kontakt",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 95b31efd0..37dec82dc 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Eklenti panelini gizle"
},
"email_composer": {
+ "select_from_directory": "Dizinden seç",
"minimize": "Simge durumuna küçült",
"maximize": "Tam ekran",
"restore": "Geri yükle",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Dizin",
+ "search_placeholder": "Ad veya e-posta adresi ara",
+ "refresh": "Dizini yenile",
+ "retry": "Tekrar dene",
+ "loading": "Dizin yükleniyor…",
+ "unavailable_title": "Dizin yok",
+ "unavailable_hint": "Posta sunucunuz hesap dizini sunmuyor.",
+ "forbidden_title": "Dizin kullanılamıyor",
+ "forbidden_hint": "Posta sunucunuz bu hesabın dizine göz atmasına izin vermiyor. Yöneticinize danışın.",
+ "error_title": "Dizin yüklenemedi",
+ "error_hint": "Bağlantınızı kontrol edip tekrar deneyin.",
+ "empty_title": "Dizin boş",
+ "empty_hint": "Posta sunucunuz e-posta adresi olan hiçbir hesap listelemiyor.",
+ "no_match": "Dizinde aramanızla eşleşen kimse yok",
+ "refresh_failed": "Dizin yenilenemedi. Son yüklenen liste gösteriliyor.",
+ "select_page": "Bu sayfadaki herkesi seç",
+ "deselect_page": "Bu sayfadaki seçimi kaldır",
+ "select_entry": "{name} kişisini seç",
+ "range": "{from}–{to} / {total}",
+ "page": "Sayfa {page} / {count}",
+ "previous_page": "Önceki sayfa",
+ "next_page": "Sonraki sayfa",
+ "picker_title": "Dizinden seç",
+ "cancel": "İptal",
+ "selected_count": "{count} seçildi",
+ "add_to": "Kime alanına ekle",
+ "add_cc": "Bilgi alanına ekle",
+ "add_bcc": "Gizli alanına ekle",
+ "select_hint": "Ayrıntıları görmek için birini seçin",
+ "write_email": "E-posta yaz",
+ "add_to_contacts": "Kişilere ekle",
+ "email": "E-posta",
+ "read_only_note": "Dizin girdileri posta sunucunuzdan gelir ve burada düzenlenemez.",
+ "type": {
+ "individual": "Kişi",
+ "group": "Grup",
+ "resource": "Kaynak",
+ "location": "Konum",
+ "other": "Diğer"
+ }
+ },
"title": "Kişiler",
"search_placeholder": "Kişilerde ara...",
"create_new": "Yeni Kişi",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 59c8facba..9b62e48fb 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Сховати панель плагіна"
},
"email_composer": {
+ "select_from_directory": "Вибрати з довідника",
"minimize": "Згорнути",
"maximize": "На весь екран",
"restore": "Відновити",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Довідник",
+ "search_placeholder": "Пошук за ім'ям або адресою",
+ "refresh": "Оновити довідник",
+ "retry": "Повторити",
+ "loading": "Завантаження довідника…",
+ "unavailable_title": "Немає довідника",
+ "unavailable_hint": "Ваш поштовий сервер не надає довідник облікових записів.",
+ "forbidden_title": "Довідник недоступний",
+ "forbidden_hint": "Ваш поштовий сервер не дозволяє цьому обліковому запису переглядати довідник. Зверніться до адміністратора.",
+ "error_title": "Не вдалося завантажити довідник",
+ "error_hint": "Перевірте з'єднання та спробуйте ще раз.",
+ "empty_title": "Довідник порожній",
+ "empty_hint": "Ваш поштовий сервер не повертає облікових записів з адресою електронної пошти.",
+ "no_match": "У довіднику ніхто не відповідає запиту",
+ "refresh_failed": "Не вдалося оновити довідник. Показано останній завантажений список.",
+ "select_page": "Вибрати всіх на цій сторінці",
+ "deselect_page": "Зняти вибір на цій сторінці",
+ "select_entry": "Вибрати {name}",
+ "range": "{from}–{to} з {total}",
+ "page": "Сторінка {page} з {count}",
+ "previous_page": "Попередня сторінка",
+ "next_page": "Наступна сторінка",
+ "picker_title": "Вибрати з довідника",
+ "cancel": "Скасувати",
+ "selected_count": "Вибрано: {count}",
+ "add_to": "Додати в «Кому»",
+ "add_cc": "Додати в «Копія»",
+ "add_bcc": "Додати в «Прихована копія»",
+ "select_hint": "Виберіть людину, щоб побачити подробиці",
+ "write_email": "Написати листа",
+ "add_to_contacts": "Додати до контактів",
+ "email": "Ел. пошта",
+ "read_only_note": "Записи довідника надходять з вашого поштового сервера, тут їх не можна змінити.",
+ "type": {
+ "individual": "Людина",
+ "group": "Група",
+ "resource": "Ресурс",
+ "location": "Місце",
+ "other": "Інше"
+ }
+ },
"title": "Контакти",
"search_placeholder": "Пошук контактів...",
"create_new": "Новий контакт",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 87b7185aa..f9d448445 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "隱藏外掛程式面板"
},
"email_composer": {
+ "select_from_directory": "從目錄中選取",
"minimize": "最小化",
"maximize": "全螢幕",
"restore": "還原",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "通訊錄目錄",
+ "search_placeholder": "搜尋姓名或電子郵件地址",
+ "refresh": "重新整理目錄",
+ "retry": "重試",
+ "loading": "正在載入目錄…",
+ "unavailable_title": "沒有目錄",
+ "unavailable_hint": "你的郵件伺服器不提供帳戶目錄。",
+ "forbidden_title": "目錄無法使用",
+ "forbidden_hint": "你的郵件伺服器不允許此帳戶瀏覽目錄。請聯絡管理員。",
+ "error_title": "無法載入目錄",
+ "error_hint": "請檢查網路連線後重試。",
+ "empty_title": "目錄是空的",
+ "empty_hint": "你的郵件伺服器沒有列出具有電子郵件地址的帳戶。",
+ "no_match": "目錄中沒有符合搜尋的人",
+ "refresh_failed": "無法重新整理目錄,顯示的是上次載入的清單。",
+ "select_page": "選取本頁所有人",
+ "deselect_page": "取消選取本頁所有人",
+ "select_entry": "選取 {name}",
+ "range": "第 {from}–{to} 項,共 {total} 項",
+ "page": "第 {page} 頁,共 {count} 頁",
+ "previous_page": "上一頁",
+ "next_page": "下一頁",
+ "picker_title": "從目錄中選取",
+ "cancel": "取消",
+ "selected_count": "已選取 {count} 個",
+ "add_to": "加入收件者",
+ "add_cc": "加入副本",
+ "add_bcc": "加入密件副本",
+ "select_hint": "選取一個人以查看詳細資料",
+ "write_email": "撰寫郵件",
+ "add_to_contacts": "加入聯絡人",
+ "email": "電子郵件",
+ "read_only_note": "目錄項目來自你的郵件伺服器,無法在此編輯。",
+ "type": {
+ "individual": "個人",
+ "group": "群組",
+ "resource": "資源",
+ "location": "地點",
+ "other": "其他"
+ }
+ },
"title": "聯絡人",
"search_placeholder": "搜尋聯絡人…",
"create_new": "新增聯絡人",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 7ba954f26..940124083 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "隐藏插件面板"
},
"email_composer": {
+ "select_from_directory": "从目录中选择",
"minimize": "最小化",
"maximize": "全屏",
"restore": "还原",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "通讯录目录",
+ "search_placeholder": "搜索姓名或邮箱地址",
+ "refresh": "刷新目录",
+ "retry": "重试",
+ "loading": "正在加载目录…",
+ "unavailable_title": "没有目录",
+ "unavailable_hint": "你的邮件服务器不提供账户目录。",
+ "forbidden_title": "目录不可用",
+ "forbidden_hint": "你的邮件服务器不允许此账户浏览目录。请联系管理员。",
+ "error_title": "无法加载目录",
+ "error_hint": "请检查网络连接后重试。",
+ "empty_title": "目录为空",
+ "empty_hint": "你的邮件服务器没有列出带邮箱地址的账户。",
+ "no_match": "目录中没有与搜索匹配的人",
+ "refresh_failed": "无法刷新目录,显示的是上次加载的列表。",
+ "select_page": "选择本页所有人",
+ "deselect_page": "取消选择本页所有人",
+ "select_entry": "选择 {name}",
+ "range": "第 {from}–{to} 项,共 {total} 项",
+ "page": "第 {page} 页,共 {count} 页",
+ "previous_page": "上一页",
+ "next_page": "下一页",
+ "picker_title": "从目录中选择",
+ "cancel": "取消",
+ "selected_count": "已选择 {count} 个",
+ "add_to": "添加到收件人",
+ "add_cc": "添加到抄送",
+ "add_bcc": "添加到密送",
+ "select_hint": "选择一个人以查看详情",
+ "write_email": "写邮件",
+ "add_to_contacts": "添加到联系人",
+ "email": "邮箱",
+ "read_only_note": "目录条目来自你的邮件服务器,无法在此编辑。",
+ "type": {
+ "individual": "个人",
+ "group": "群组",
+ "resource": "资源",
+ "location": "地点",
+ "other": "其他"
+ }
+ },
"title": "联系人",
"search_placeholder": "搜索联系人...",
"create_new": "新联系人",
diff --git a/stores/__tests__/contact-store.test.ts b/stores/__tests__/contact-store.test.ts
index ff9b46e3a..9cd8adf55 100644
--- a/stores/__tests__/contact-store.test.ts
+++ b/stores/__tests__/contact-store.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useContactStore, getContactPhotoUri, normalizeContactPhotoUri, TRUSTED_SENDERS_BOOK_NAME } from '../contact-store';
import type { ContactCard } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import { PrincipalsRequestError } from '@/lib/jmap/principals-error';
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' });
@@ -34,6 +35,7 @@ const defaultState = {
activeTab: 'all' as const,
directoryPrincipals: [],
directoryLoaded: false,
+ directoryStatus: 'idle' as const,
};
describe('contact-store', () => {
@@ -973,4 +975,54 @@ describe('contact-store', () => {
expect(persisted.supportsSync).toBe(true);
});
});
+
+ describe('fetchDirectory', () => {
+ const principal = (id: string, email: string | null, description?: string) =>
+ ({ id, type: 'individual' as const, name: email ?? id, email, description });
+
+ it('maps principals with an address to entries named by their description', async () => {
+ const client = {
+ supportsPrincipals: () => true,
+ getPrincipals: vi.fn().mockResolvedValue([
+ principal('a', 'ana@example.org', 'Ana Silva'),
+ principal('b', null),
+ principal('c', 'ANA@example.org', 'Duplicate'),
+ ]),
+ } as unknown as IJMAPClient;
+ await useContactStore.getState().fetchDirectory(client);
+ const state = useContactStore.getState();
+ expect(state.directoryStatus).toBe('ready');
+ expect(state.directoryPrincipals).toEqual([
+ { id: 'a', name: 'Ana Silva', email: 'ana@example.org', description: 'Ana Silva', type: 'individual' },
+ ]);
+ });
+
+ it('marks a refused query as forbidden, not as an empty directory', async () => {
+ const client = {
+ supportsPrincipals: () => true,
+ getPrincipals: vi.fn().mockRejectedValue(new PrincipalsRequestError('forbidden')),
+ } as unknown as IJMAPClient;
+ await useContactStore.getState().fetchDirectory(client);
+ expect(useContactStore.getState().directoryStatus).toBe('forbidden');
+ });
+
+ it('keeps the last list when a refresh fails', async () => {
+ useContactStore.setState({ directoryPrincipals: [{ name: 'Ana', email: 'ana@example.org' }], directoryStatus: 'ready' });
+ const client = {
+ supportsPrincipals: () => true,
+ getPrincipals: vi.fn().mockRejectedValue(new PrincipalsRequestError(null, 'network down')),
+ } as unknown as IJMAPClient;
+ await useContactStore.getState().fetchDirectory(client);
+ expect(useContactStore.getState().directoryStatus).toBe('error');
+ expect(useContactStore.getState().directoryPrincipals).toHaveLength(1);
+ });
+
+ it('reports a server without principals as unavailable', async () => {
+ const getPrincipals = vi.fn();
+ const client = { supportsPrincipals: () => false, getPrincipals } as unknown as IJMAPClient;
+ await useContactStore.getState().fetchDirectory(client);
+ expect(useContactStore.getState().directoryStatus).toBe('unavailable');
+ expect(getPrincipals).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/stores/contact-store.ts b/stores/contact-store.ts
index 0dfb3bc9d..d7847c729 100644
--- a/stores/contact-store.ts
+++ b/stores/contact-store.ts
@@ -9,6 +9,8 @@ import { getClientByLocalAccountId } from './client-registry';
import { sanitizeDisplayName, splitMailbox } from '@/lib/rfc5322-mailbox';
import { contactHooks } from '@/lib/plugin-hooks';
import { contactToView } from '@/lib/plugin-projection';
+import { PrincipalsRequestError } from '@/lib/jmap/principals-error';
+import type { Principal } from '@/lib/jmap/types';
/** A contact that is about to be created, projected for plugins (no id yet). */
function draftContactView(contact: Partial) {
@@ -348,9 +350,11 @@ interface ContactStore {
activeTab: 'all' | 'groups';
// Directory (RFC 9670 principals) — other users/groups on the server, used to
- // augment recipient autocomplete. Runtime only, not persisted.
- directoryPrincipals: Array<{ name: string; email: string; description?: string }>;
+ // augment recipient autocomplete and for the browsable company directory.
+ // Runtime only, not persisted.
+ directoryPrincipals: DirectoryEntry[];
directoryLoaded: boolean;
+ directoryStatus: DirectoryStatus;
fetchContacts: (client: IJMAPClient) => Promise;
fetchDirectory: (client: IJMAPClient) => Promise;
@@ -413,6 +417,43 @@ interface ContactStore {
searchRecipients: (client: IJMAPClient, query: string) => Promise>;
}
+/** One entry of the company directory: a principal that has an address. */
+export interface DirectoryEntry {
+ id?: string;
+ /** Display name: the principal's description, else its name, else the address. */
+ name: string;
+ email: string;
+ description?: string;
+ type?: Principal['type'];
+}
+
+/**
+ * Where the directory fetch stands. `unavailable`: the server has no
+ * principals capability. `forbidden`: the server refused this account's
+ * directory query. `error`: the request failed.
+ */
+export type DirectoryStatus = 'idle' | 'loading' | 'ready' | 'unavailable' | 'forbidden' | 'error';
+
+export function principalsToDirectory(principals: Principal[]): DirectoryEntry[] {
+ const entries: DirectoryEntry[] = [];
+ const seen = new Set();
+ for (const p of principals) {
+ // Stalwart reports a principal's account name in `email`; only those
+ // with an address are usable as recipients.
+ const email = p.email?.trim();
+ if (!email || seen.has(email.toLowerCase())) continue;
+ seen.add(email.toLowerCase());
+ entries.push({
+ id: p.id,
+ name: p.description || p.name || email,
+ email,
+ description: p.description ?? undefined,
+ type: p.type,
+ });
+ }
+ return entries;
+}
+
/** The Sent-folder read loadRecentRecipients has in flight, if any. */
let recentRecipientsLoading: Promise | null = null;
@@ -471,6 +512,7 @@ export const useContactStore = create()(
activeTab: 'all' as const,
directoryPrincipals: [],
directoryLoaded: false,
+ directoryStatus: 'idle' as DirectoryStatus,
fetchContacts: async (client) => {
set({ isLoading: true, error: null });
@@ -484,24 +526,19 @@ export const useContactStore = create()(
},
fetchDirectory: async (client) => {
- if (!client.supportsPrincipals()) return;
+ if (!client.supportsPrincipals()) {
+ set({ directoryStatus: 'unavailable' });
+ return;
+ }
+ set({ directoryStatus: 'loading' });
try {
- const principals = await client.getPrincipals();
- const entries: Array<{ name: string; email: string; description?: string }> = [];
- for (const p of principals) {
- // Stalwart reports a principal's account name in `email`; only those
- // with an address are usable as recipients.
- const email = p.email?.trim();
- if (!email) continue;
- entries.push({
- name: p.description || p.name || email,
- email,
- description: p.description ?? undefined,
- });
- }
- set({ directoryPrincipals: entries, directoryLoaded: true });
+ const principals = await client.getPrincipals(undefined, { throwOnError: true });
+ set({ directoryPrincipals: principalsToDirectory(principals), directoryLoaded: true, directoryStatus: 'ready' });
} catch (error) {
debug.error('Failed to fetch directory principals:', error);
+ // Keep what an earlier fetch returned: autocomplete still works off it.
+ const forbidden = error instanceof PrincipalsRequestError && error.type === 'forbidden';
+ set({ directoryStatus: forbidden ? 'forbidden' : 'error' });
}
},
@@ -711,6 +748,7 @@ export const useContactStore = create()(
activeTab: 'all',
directoryPrincipals: [],
directoryLoaded: false,
+ directoryStatus: 'idle',
}),
getAutocomplete: (query) => {
From 99b0bced3b34e20a3b3b17e1110800bb84ef9cff Mon Sep 17 00:00:00 2001
From: Jacob Zeitz
Date: Fri, 16 Oct 2026 20:29:45 +0200
Subject: [PATCH 013/238] feat: admin policy gate to turn off archiving #1257
A new feature gate, archiveActionEnabled (default on), lets the admin turn the
Archive action off for installations that archive mail elsewhere, such as a
mail gateway. With it off, Bulwark offers no Archive anywhere: the viewer's
button and its overflow entry, the context menu for one or several messages,
Mountain View's selection bar, the hover and swipe actions, the E shortcut and
its help row, archiving after an export, and the archive mode setting (also in
the settings search). With it off nothing creates an Archive folder:
ensureArchiveMailbox returns null. Existing Archive folders stay ordinary
folders, moving mail into them by hand still works, and the plugin API is
unchanged.
Co-Authored-By: Claude Opus 5.5
---
app/(main)/admin/_tabs/policy.tsx | 1 +
.../__tests__/archive-gate-viewer.test.tsx | 94 ++++++++++
.../email/__tests__/archive-gate.test.tsx | 165 ++++++++++++++++++
components/email/email-context-menu.tsx | 4 +-
components/email/email-hover-actions.tsx | 7 +-
components/email/email-viewer.tsx | 49 +++---
components/email/mv-list-toolbar.tsx | 30 ++--
components/keyboard-shortcuts-modal.tsx | 9 +-
components/mail/mail-app.tsx | 16 +-
components/pro/pro-email-tab-body.tsx | 4 +-
.../__tests__/archive-gate-settings.test.tsx | 67 +++++++
components/settings/downloads-settings.tsx | 8 +-
components/settings/reading-settings.tsx | 40 ++---
components/settings/settings-app.tsx | 5 +-
hooks/use-keyboard-shortcuts.ts | 5 +-
lib/admin/types.ts | 3 +
lib/archive-gate.ts | 21 +++
lib/settings-search.ts | 3 +
stores/email-store.ts | 4 +
19 files changed, 465 insertions(+), 70 deletions(-)
create mode 100644 components/email/__tests__/archive-gate-viewer.test.tsx
create mode 100644 components/email/__tests__/archive-gate.test.tsx
create mode 100644 components/settings/__tests__/archive-gate-settings.test.tsx
create mode 100644 lib/archive-gate.ts
diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx
index e42478bac..153c94354 100644
--- a/app/(main)/admin/_tabs/policy.tsx
+++ b/app/(main)/admin/_tabs/policy.tsx
@@ -40,6 +40,7 @@ const FEATURE_GATE_LABELS: Partial ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+}));
+
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({
+ PluginSlot: () => null,
+}));
+
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({
+ usePluginSlotOffers: () => [],
+}));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+const { usePolicyStore } = await import('@/stores/policy-store');
+
+const mailboxes: Mailbox[] = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+
+function makeEmail(overrides: Partial = {}): Email {
+ return {
+ id: 'e1',
+ blobId: 'b1',
+ threadId: 't1',
+ mailboxIds: { inbox: true },
+ keywords: { $seen: true },
+ from: [{ name: 'Alice', email: 'alice@example.com' }],
+ to: [{ name: 'Bob', email: 'bob@example.com' }],
+ subject: 'Hello',
+ receivedAt: '2026-01-01T00:00:00Z',
+ preview: 'Hello',
+ hasAttachment: false,
+ size: 100,
+ textBody: [],
+ htmlBody: [],
+ attachments: [],
+ bodyValues: {},
+ ...overrides,
+ } as unknown as Email;
+}
+
+function setArchiveGate(on: boolean) {
+ usePolicyStore.setState((state) => ({
+ policy: { ...state.policy, features: { ...state.policy.features, archiveActionEnabled: on } },
+ }));
+}
+
+afterEach(() => setArchiveGate(true));
+
+function viewer() {
+ return render(
+ ,
+ );
+}
+
+describe('the viewer and the Archive action gate', () => {
+ it('shows the Archive button while the gate is on', () => {
+ viewer();
+ expect(screen.getByTitle('tooltips.archive')).toBeInTheDocument();
+ });
+
+ it('has no Archive button with the gate off, Delete still there', () => {
+ setArchiveGate(false);
+ viewer();
+ expect(screen.queryByTitle('tooltips.archive')).toBeNull();
+ expect(screen.getByTitle('tooltips.delete')).toBeInTheDocument();
+ });
+});
diff --git a/components/email/__tests__/archive-gate.test.tsx b/components/email/__tests__/archive-gate.test.tsx
new file mode 100644
index 000000000..039212b4f
--- /dev/null
+++ b/components/email/__tests__/archive-gate.test.tsx
@@ -0,0 +1,165 @@
+import { render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+// The admin policy's archiveActionEnabled gate (#1257): off, Bulwark offers no
+// Archive action anywhere. vitest.setup.ts renders labels as their raw keys.
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActive: () => false }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
+
+const { EmailContextMenu } = await import('../email-context-menu');
+const { EmailHoverActions } = await import('../email-hover-actions');
+const { MvListToolbar } = await import('../mv-list-toolbar');
+const { KeyboardShortcutsModal } = await import('@/components/keyboard-shortcuts-modal');
+const { usePolicyStore } = await import('@/stores/policy-store');
+const { useEmailStore, ensureArchiveMailbox } = await import('@/stores/email-store');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { useSettingsStore } = await import('@/stores/settings-store');
+
+function setArchiveGate(on: boolean) {
+ usePolicyStore.setState((state) => ({
+ policy: { ...state.policy, features: { ...state.policy.features, archiveActionEnabled: on } },
+ }));
+}
+
+// Each test starts from the stores as they were before the first one.
+const initial = {
+ policy: usePolicyStore.getState(),
+ email: useEmailStore.getState(),
+ auth: useAuthStore.getState(),
+ settings: useSettingsStore.getState(),
+};
+afterEach(() => {
+ usePolicyStore.setState(initial.policy, true);
+ useEmailStore.setState(initial.email, true);
+ useAuthStore.setState(initial.auth, true);
+ useSettingsStore.setState(initial.settings, true);
+});
+
+const rights = {
+ mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
+ maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true,
+};
+const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, sortOrder: 0, totalEmails: 0,
+ unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: rights } as unknown as Mailbox;
+const email = {
+ id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Alice', email: 'alice@example.com' }], subject: 'Hello',
+ receivedAt: '2026-01-01T00:00:00Z', hasAttachment: false, size: 100,
+} as unknown as Email;
+
+function contextMenu(props: Partial> = {}) {
+ render(
+ ,
+ );
+}
+
+describe('the Archive action gate', () => {
+ it('context menu: archive for one message while the gate is on', () => {
+ contextMenu();
+ expect(screen.getByText('archive')).toBeInTheDocument();
+ });
+
+ it('context menu: no archive entry for one message with the gate off', () => {
+ setArchiveGate(false);
+ contextMenu();
+ expect(screen.queryByText('archive')).toBeNull();
+ });
+
+ it('context menu: archive for a multi-selection while the gate is on', () => {
+ contextMenu({ isMultiSelect: true, selectedCount: 3 });
+ expect(screen.getByText('archive')).toBeInTheDocument();
+ });
+
+ it('context menu: no archive entry for a multi-selection with the gate off', () => {
+ setArchiveGate(false);
+ contextMenu({ isMultiSelect: true, selectedCount: 3 });
+ expect(screen.queryByText('archive')).toBeNull();
+ });
+
+ it('hover actions: the configured archive button is left out with the gate off', () => {
+ useSettingsStore.setState({ hoverActions: ['archive', 'delete'], interfaceLayout: 'default' } as never);
+ const { unmount } = render( );
+ expect(screen.getByTestId('hover-action-archive')).toBeInTheDocument();
+ unmount();
+
+ setArchiveGate(false);
+ render( );
+ expect(screen.queryByTestId('hover-action-archive')).toBeNull();
+ expect(screen.getByTestId('hover-action-delete')).toBeInTheDocument();
+ });
+
+ it("Mountain View's selection bar: no archive button with the gate off", () => {
+ const emails = [{ ...email, id: 'a' }, { ...email, id: 'b' }] as Email[];
+ useAuthStore.setState({ client: {} as never } as never);
+ useEmailStore.setState({
+ emails, mailboxes: [inbox], selectedMailbox: 'inbox',
+ selectedEmailKeys: new Set(['a', 'b']), isUnifiedView: false, unifiedRole: null,
+ } as never);
+ const { unmount } = render( {}} />);
+ expect(screen.getByLabelText('archive')).toBeInTheDocument();
+ unmount();
+
+ setArchiveGate(false);
+ render( {}} />);
+ expect(screen.queryByLabelText('archive')).toBeNull();
+ });
+
+ it('shortcut help: the E row is listed only while the gate is on', () => {
+ const { unmount } = render( {}} />);
+ expect(screen.getByText('shortcuts.actions.archive')).toBeInTheDocument();
+ unmount();
+
+ setArchiveGate(false);
+ render( {}} />);
+ expect(screen.queryByText('shortcuts.actions.archive')).toBeNull();
+ });
+
+ it('the Archive folder: with the gate off none is created, an existing one not used', async () => {
+ setArchiveGate(false);
+ const client = { createMailbox: vi.fn() };
+ const archive = { ...inbox, id: 'archive', name: 'Archive', role: 'archive' } as Mailbox;
+ const refresh = vi.fn();
+ await expect(ensureArchiveMailbox({ client: client as never, mailboxes: [inbox], selectedMailboxId: 'inbox', refresh }))
+ .resolves.toBeNull();
+ await expect(ensureArchiveMailbox({ client: client as never, mailboxes: [inbox, archive], selectedMailboxId: 'inbox', refresh }))
+ .resolves.toBeNull();
+ expect(client.createMailbox).not.toHaveBeenCalled();
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it('the Archive folder: with the gate on it is created when missing', async () => {
+ const archive = { ...inbox, id: 'archive', name: 'Archive', role: 'archive' } as Mailbox;
+ const client = { createMailbox: vi.fn().mockResolvedValue(archive) };
+ await expect(ensureArchiveMailbox({
+ client: client as never, mailboxes: [inbox], selectedMailboxId: 'inbox',
+ refresh: async () => [inbox, archive],
+ })).resolves.toMatchObject({ id: 'archive' });
+ expect(client.createMailbox).toHaveBeenCalledWith('Archive', undefined, undefined, { role: 'archive' });
+ });
+
+ it('batch archive: with the gate off nothing is asked of the server', async () => {
+ setArchiveGate(false);
+ const request = vi.fn();
+ const client = { request, createMailbox: vi.fn(), batchMoveEmails: vi.fn() };
+ useEmailStore.setState({ emails: [email], mailboxes: [inbox], selectedEmailKeys: new Set(['e1']) } as never);
+ await expect(useEmailStore.getState().batchArchive(client as never)).resolves.toBe(false);
+ expect(client.createMailbox).not.toHaveBeenCalled();
+ expect(client.batchMoveEmails).not.toHaveBeenCalled();
+ expect(request).not.toHaveBeenCalled();
+ });
+});
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx
index 9ab7ed72c..0fc7e6c2c 100644
--- a/components/email/email-context-menu.tsx
+++ b/components/email/email-context-menu.tsx
@@ -48,6 +48,7 @@ import { localizeMailboxName } from "@/lib/mailbox-label";
import { getEmailTagIds } from "@/lib/thread-utils";
import { TagPicker } from "./tag-picker";
import { RulesContextSubMenu } from "./rules-menu";
+import { useArchiveActionEnabled } from "@/lib/archive-gate";
interface Position {
x: number;
@@ -181,6 +182,7 @@ export function EmailContextMenu({
onCopyToAccount,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
+ const archiveActionEnabled = useArchiveActionEnabled();
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
const tSidebar = useTranslations("sidebar");
const tEmailViewer = useTranslations("email_viewer");
@@ -349,7 +351,7 @@ export function EmailContextMenu({
)}
>
);
- const archiveItem = (
+ const archiveItem = archiveActionEnabled && (
<>
{/* Archive */}
state.hoverActions);
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
- const hoverActions = mountainView ? mountainViewHoverActions(configuredHoverActions) : configuredHoverActions;
+ const archiveActionEnabled = useArchiveActionEnabled();
+ const hoverActions = withoutArchiveIfOff(
+ mountainView ? mountainViewHoverActions(configuredHoverActions) : configuredHoverActions,
+ archiveActionEnabled,
+ );
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
const t = useTranslations("settings.email_behavior.hover_actions");
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 2d018bf88..c6b8b7d9d 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -135,6 +135,7 @@ import { resolveExternalLink } from "@/lib/plugin-hook-result";
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
+import { useArchiveActionEnabled } from "@/lib/archive-gate";
/** The More menu's two drill-downs: a folder list and a tag list. */
type MoreMenuSub = 'move' | 'tag' | 'rules';
@@ -1033,6 +1034,7 @@ export function EmailViewer({
className,
}: EmailViewerProps) {
const t = useTranslations('email_viewer');
+ const archiveActionEnabled = useArchiveActionEnabled();
const tComposer = useTranslations('email_composer');
const tNotifications = useTranslations('notifications');
const tCommon = useTranslations('common');
@@ -3145,7 +3147,8 @@ export function EmailViewer({
return;
}
const action = useSettingsStore.getState().postExportAction;
- if (action === 'archive') onArchive?.();
+ // With the Archive action off a stored 'archive' keeps the message.
+ if (action === 'archive' && archiveActionEnabled) onArchive?.();
else if (action === 'trash') onDelete?.();
};
@@ -3654,18 +3657,20 @@ export function EmailViewer({
{!isScheduled && (
{/* Archive */}
-
-
- {showToolbarLabels && {t('archive')} }
-
+ {archiveActionEnabled && (
+
+
+ {showToolbarLabels && {t('archive')} }
+
+ )}
{/* Delete */}
{/* Overflow: archive */}
- { onArchive?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(4) ? "" : "sm:hidden")}
- >
-
- {t('archive')}
-
+ {archiveActionEnabled && (
+ { onArchive?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
+ className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(4) ? "" : "sm:hidden")}
+ >
+
+ {t('archive')}
+
+ )}
{/* Overflow: move to folder - submenu */}
{moveTree.length > 0 && onMoveToMailbox && (
-
run(async () => { if (client) await batchArchive(client); }, {
- success: tNotifications("emails_archived", { count: selectionCount }),
- error: tNotifications("error_archiving"),
- describeError: (error) => error instanceof ArchiveMailboxNotFoundError
- ? tViewer("archive_mailbox_not_found")
- : undefined,
- })}
- disabled={isProcessing}
- busy={isProcessing}
- />
+ {archiveActionEnabled && (
+ run(async () => { if (client) await batchArchive(client); }, {
+ success: tNotifications("emails_archived", { count: selectionCount }),
+ error: tNotifications("error_archiving"),
+ describeError: (error) => error instanceof ArchiveMailboxNotFoundError
+ ? tViewer("archive_mailbox_not_found")
+ : undefined,
+ })}
+ disabled={isProcessing}
+ busy={isProcessing}
+ />
+ )}
{spamApplicable && (
state.interfaceLayout);
const shell = useProInterfaceActive() ? "pro" : "standard";
+ const features = usePolicyStore((s) => s.policy.features);
// Plugin buttons in the composer that have a shortcut (composer-toolbar-actions).
const pluginComposerShortcuts = usePluginActions("composer-toolbar-actions").filter((a) => a.shortcut);
- // Rows that belong to one interface layout (or one shell) are listed only under it.
+ // Rows that belong to one interface layout (or one shell) are listed only under it,
+ // and a row of a gated feature only while the admin has it on.
const shown = (entries: readonly ShortcutEntry[]) =>
- entries.filter((entry) => (!entry.layout || entry.layout === interfaceLayout) && (!entry.shell || entry.shell === shell));
+ entries.filter((entry) => (!entry.layout || entry.layout === interfaceLayout)
+ && (!entry.shell || entry.shell === shell)
+ && (!entry.feature || (features[entry.feature] ?? true)));
const modalRef = useFocusTrap({
isActive: isOpen,
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index d9b1b48f3..5841322a8 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -30,6 +30,7 @@ import { ThreadGroup, Email, Mailbox, type StateChange, isUnifiedMailboxId, UNIF
import { useAccountStore, waitForConnectedAccount } from "@/stores/account-store";
import { useShallow } from "zustand/react/shallow";
import { usePolicyStore } from "@/stores/policy-store";
+import { isArchiveActionEnabled, useArchiveActionEnabled } from "@/lib/archive-gate";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -638,6 +639,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
const unifiedCrossAccountGate = usePolicyStore((s) => s.isFeatureEnabled('unifiedCrossAccountEnabled'));
+ const archiveActionEnabled = useArchiveActionEnabled();
const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo(
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
@@ -880,7 +882,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (isScheduledView) return;
if (selectedEmail) handleToggleStar();
},
- onArchive: async () => {
+ onArchive: archiveActionEnabled ? async () => {
if (isScheduledView) return;
if (shortcutsTargetSelection && client) {
const count = selectedEmailKeys.size;
@@ -894,7 +896,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} else if (selectedEmail) {
handleArchive();
}
- },
+ } : undefined,
onDelete: async () => {
if (isScheduledView) return;
if (shortcutsTargetSelection && client) {
@@ -1061,7 +1063,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
- }), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailKeys, mailboxes, isScheduledView, mvShell, showCrossStarred]);
+ }), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailKeys, mailboxes, isScheduledView, mvShell, showCrossStarred, archiveActionEnabled]);
// Initialize keyboard shortcuts
// Mountain View keeps the ticked rows while a message is read (#1231). The
@@ -2726,7 +2728,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
- if (!client || !emailToArchive) return;
+ if (!client || !emailToArchive || !isArchiveActionEnabled()) return;
// In unified view the archive folder (and any year/month subfolders we
// create) must live in the email's own account, reached through the login it
@@ -5028,9 +5030,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onDelete={async (email) => {
await handleDelete(email);
}}
- onArchive={async (email) => {
+ onArchive={archiveActionEnabled ? async (email) => {
await handleArchive(email);
- }}
+ } : undefined}
onSetTag={(emailId, color) => {
handleSetTag(emailId, color);
}}
@@ -5195,7 +5197,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
handleDelete(target);
}}
- onArchive={() => handleArchive()}
+ onArchive={archiveActionEnabled ? () => handleArchive() : undefined}
onToggleStar={handleToggleStar}
onSetTag={handleSetTag}
onMarkAsSpam={() => handleMarkAsSpam()}
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index 46ae5496c..0a094bd90 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -22,6 +22,7 @@ import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
import { cn } from "@/lib/utils";
import { getDraftTextBody } from '@/lib/email-body-selection';
+import { useArchiveActionEnabled } from '@/lib/archive-gate';
interface ProEmailTabBodyProps {
tabId: string;
@@ -81,6 +82,7 @@ function buildReplyContext(email: Email): ProReplyContext {
export function ProEmailView({ emailId, client: clientOverride, accountId, onLoaded, onClose, className }: ProEmailViewProps) {
const t = useTranslations();
const tNotifications = useTranslations('notifications');
+ const archiveActionEnabled = useArchiveActionEnabled();
const activeClient = useAuthStore((s) => s.client);
const client = clientOverride ?? activeClient;
@@ -396,7 +398,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
onForward={handleForward}
onForwardAsAttachment={handleForwardAsAttachment}
onDelete={handleDelete}
- onArchive={handleArchive}
+ onArchive={archiveActionEnabled ? handleArchive : undefined}
onToggleStar={handleToggleStar}
onMarkAsRead={handleMarkAsRead}
onSetTag={handleSetTag}
diff --git a/components/settings/__tests__/archive-gate-settings.test.tsx b/components/settings/__tests__/archive-gate-settings.test.tsx
new file mode 100644
index 000000000..309841eac
--- /dev/null
+++ b/components/settings/__tests__/archive-gate-settings.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+// The admin policy's archiveActionEnabled gate (#1257): off, the settings offer
+// no archive mode, no archive hover or swipe action and no archive after an
+// export, and a stored 'archive' shows as the choice that now applies.
+// vitest.setup.ts renders labels as their raw keys.
+
+const { ReadingSettings } = await import('../reading-settings');
+const { DownloadsSettings } = await import('../downloads-settings');
+const { usePolicyStore } = await import('@/stores/policy-store');
+const { useSettingsStore } = await import('@/stores/settings-store');
+
+const initial = { policy: usePolicyStore.getState(), settings: useSettingsStore.getState() };
+afterEach(() => {
+ usePolicyStore.setState(initial.policy, true);
+ useSettingsStore.setState(initial.settings, true);
+});
+
+function setArchiveGate(on: boolean) {
+ usePolicyStore.setState((state) => ({
+ policy: { ...state.policy, features: { ...state.policy.features, archiveActionEnabled: on } },
+ }));
+}
+
+function selectFor(optionKey: string): HTMLSelectElement {
+ const option = screen.getAllByRole('option').find((o) => o.textContent === optionKey);
+ if (!option) throw new Error(`no option ${optionKey}`);
+ return option.closest('select') as HTMLSelectElement;
+}
+
+describe('the settings and the Archive action gate', () => {
+ it('reading: archive mode, archive hover and swipe actions while the gate is on', () => {
+ useSettingsStore.setState({ swipeRightAction: 'archive' } as never);
+ render( );
+ expect(screen.getByText('archive_mode.label')).toBeInTheDocument();
+ expect(screen.getAllByRole('option', { name: 'swipe_actions.archive' })).toHaveLength(2);
+ expect(screen.getByText('hover_actions.archive')).toBeInTheDocument();
+ expect(selectFor('swipe_actions.archive').value).toBe('archive');
+ });
+
+ it('reading: none of them with the gate off, a stored archive swipe shown as none', () => {
+ setArchiveGate(false);
+ useSettingsStore.setState({ swipeRightAction: 'archive' } as never);
+ render( );
+ expect(screen.queryByText('archive_mode.label')).toBeNull();
+ expect(screen.queryAllByRole('option', { name: 'swipe_actions.archive' })).toHaveLength(0);
+ expect(screen.queryByText('hover_actions.archive')).toBeNull();
+ const swipeSelects = screen.getAllByRole('option', { name: 'swipe_actions.none' }).map((o) => o.closest('select') as HTMLSelectElement);
+ expect(swipeSelects.map((s) => s.value)).toContain('none');
+ expect(useSettingsStore.getState().swipeRightAction).toBe('archive');
+ });
+
+ it('downloads: archive after export while the gate is on', () => {
+ useSettingsStore.setState({ postExportAction: 'archive' } as never);
+ render( );
+ expect(selectFor('after_export.archive').value).toBe('archive');
+ });
+
+ it('downloads: no archive after export with the gate off, a stored one shown as keep', () => {
+ setArchiveGate(false);
+ useSettingsStore.setState({ postExportAction: 'archive' } as never);
+ render( );
+ expect(screen.queryByRole('option', { name: 'after_export.archive' })).toBeNull();
+ expect(selectFor('after_export.keep').value).toBe('keep');
+ });
+});
diff --git a/components/settings/downloads-settings.tsx b/components/settings/downloads-settings.tsx
index c64a89e64..201e42c9b 100644
--- a/components/settings/downloads-settings.tsx
+++ b/components/settings/downloads-settings.tsx
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { SettingsSection, SettingItem, Select, ToggleSwitch } from "./settings-section";
import { RotateCcw } from "@/components/icons";
import { cn } from "@/lib/utils";
+import { useArchiveActionEnabled } from "@/lib/archive-gate";
import {
DEFAULT_ATTACHMENT_TEMPLATE,
DEFAULT_BUNDLE_TEMPLATE,
@@ -130,6 +131,7 @@ function TemplateEditor({
export function DownloadsSettings() {
const t = useTranslations("settings.downloads");
+ const archiveActionEnabled = useArchiveActionEnabled();
const {
emailDownloadTemplate,
attachmentDownloadTemplate,
@@ -141,6 +143,8 @@ export function DownloadsSettings() {
postExportAction,
updateSetting,
} = useSettingsStore();
+ // With the Archive action off a stored "archive" keeps the message, so it shows as "keep".
+ const postExportShown = archiveActionEnabled || postExportAction !== "archive" ? postExportAction : "keep";
const sampleEmail = useMemo(() => buildSampleEmail(), []);
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []);
@@ -252,11 +256,11 @@ export function DownloadsSettings() {
updateSetting("postExportAction", value as "keep" | "archive" | "trash")}
options={[
{ value: "keep", label: t("after_export.keep") },
- { value: "archive", label: t("after_export.archive") },
+ ...(archiveActionEnabled ? [{ value: "archive", label: t("after_export.archive") }] : []),
{ value: "trash", label: t("after_export.trash") },
]}
/>
diff --git a/components/settings/reading-settings.tsx b/components/settings/reading-settings.tsx
index b36b42ff7..31995ba22 100644
--- a/components/settings/reading-settings.tsx
+++ b/components/settings/reading-settings.tsx
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { useEffectiveMailLayout } from '@/hooks/use-effective-mail-layout';
-import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
+import type { ArchiveMode, HoverAction, SwipeAction } from '@/stores/settings-store';
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
@@ -20,6 +20,18 @@ export function ReadingSettings() {
const [isReorganizing, setIsReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
+ const archiveActionEnabled = isFeatureEnabled('archiveActionEnabled');
+ // With the Archive action off, a stored 'archive' swipe does nothing (the row
+ // has no archive handler), so the select shows it as "none".
+ const swipeShown = (action: SwipeAction) => (archiveActionEnabled || action !== 'archive' ? action : 'none');
+ const swipeOptions = [
+ { value: 'none', label: t('swipe_actions.none') },
+ ...(archiveActionEnabled ? [{ value: 'archive', label: t('swipe_actions.archive') }] : []),
+ { value: 'delete', label: t('swipe_actions.delete') },
+ { value: 'markRead', label: t('swipe_actions.mark_read') },
+ { value: 'star', label: t('swipe_actions.star') },
+ { value: 'spam', label: t('swipe_actions.spam') },
+ ];
const {
markAsReadDelay,
@@ -179,6 +191,7 @@ export function ReadingSettings() {
)}
+ {archiveActionEnabled && (
+ )}
updateSetting('swipeRightAction', value as typeof swipeRightAction)}
- options={[
- { value: 'none', label: t('swipe_actions.none') },
- { value: 'archive', label: t('swipe_actions.archive') },
- { value: 'delete', label: t('swipe_actions.delete') },
- { value: 'markRead', label: t('swipe_actions.mark_read') },
- { value: 'star', label: t('swipe_actions.star') },
- { value: 'spam', label: t('swipe_actions.spam') },
- ]}
+ options={swipeOptions}
/>
updateSetting('swipeLeftAction', value as typeof swipeLeftAction)}
- options={[
- { value: 'none', label: t('swipe_actions.none') },
- { value: 'archive', label: t('swipe_actions.archive') },
- { value: 'delete', label: t('swipe_actions.delete') },
- { value: 'markRead', label: t('swipe_actions.mark_read') },
- { value: 'star', label: t('swipe_actions.star') },
- { value: 'spam', label: t('swipe_actions.spam') },
- ]}
+ options={swipeOptions}
/>
@@ -315,7 +315,7 @@ export function ReadingSettings() {
{t('hover_actions.description')}
- {ALL_HOVER_ACTIONS.map((action) => {
+ {ALL_HOVER_ACTIONS.filter((action) => archiveActionEnabled || action.id !== 'archive').map((action) => {
const isEnabled = hoverActions.includes(action.id);
return (
(
() => tabFromDeepLink(linkSegments) ?? readPersistedTab(),
@@ -308,6 +310,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const strings: string[] = [tabId.replace(/_/g, ' '), tabKeywords[tabId] ?? ''];
const list: SubResult[] = [];
for (const path of tabSearchPaths[tabId]) {
+ if (!archiveActionEnabled && archiveActionSearchPaths.has(path)) continue;
const node = getByPath(messages, path);
flattenStrings(node, strings);
collectSubResults(node, list);
@@ -363,7 +366,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
];
}
return { tabSearchHaystacks: haystacks, tabSubResults: subs };
- }, [messages, installedPlugins, installedThemes, sidebarAppsList]);
+ }, [messages, installedPlugins, installedThemes, sidebarAppsList, archiveActionEnabled]);
// Sidebar resize state
const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => {
diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts
index 8c8579114..918097dc8 100644
--- a/hooks/use-keyboard-shortcuts.ts
+++ b/hooks/use-keyboard-shortcuts.ts
@@ -5,6 +5,7 @@ import { Email } from "@/lib/jmap/types";
import { isEditableEventTarget } from "@/lib/keyboard";
import { keyboardHooks } from "@/lib/plugin-hooks";
import type { InterfaceLayout } from "@/stores/settings-store";
+import type { FeatureGates } from "@/lib/admin/types";
export interface KeyboardShortcutHandlers {
// Navigation
@@ -366,6 +367,8 @@ export interface ShortcutEntry {
readonly layout?: InterfaceLayout;
/** Listed only in the Pro shell, or only outside it. */
readonly shell?: "pro" | "standard";
+ /** Listed only while this admin policy feature gate is on. */
+ readonly feature?: keyof FeatureGates;
}
// Shortcut definitions for the help modal
@@ -382,7 +385,7 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "R / a", description: "shortcuts.actions.reply_all" },
{ key: "f", description: "shortcuts.actions.forward" },
{ key: "s", description: "shortcuts.actions.star" },
- { key: "e", description: "shortcuts.actions.archive" },
+ { key: "e", description: "shortcuts.actions.archive", feature: "archiveActionEnabled" },
{ key: "# / Del", description: "shortcuts.actions.delete" },
{ key: "u", description: "shortcuts.actions.mark_unread" },
{ key: "Shift + I", description: "shortcuts.actions.mark_read" },
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 8e17ce6f0..0016476e1 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -65,6 +65,8 @@ export interface FeatureGates {
accountArchiveEnabled: boolean;
/** Composer "Custom" From: free-text sender address instead of an identity. */
customFromEnabled: boolean;
+ /** The Archive action on messages (button, menus, hover, swipe, shortcut). */
+ archiveActionEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -94,6 +96,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
unifiedCrossAccountEnabled: false,
accountArchiveEnabled: false,
customFromEnabled: true,
+ archiveActionEnabled: true,
};
export interface ThemePolicy {
diff --git a/lib/archive-gate.ts b/lib/archive-gate.ts
new file mode 100644
index 000000000..5998e2bde
--- /dev/null
+++ b/lib/archive-gate.ts
@@ -0,0 +1,21 @@
+import { usePolicyStore } from '@/stores/policy-store';
+
+// The admin policy's `archiveActionEnabled` gate (#1257). Installations that
+// archive mail elsewhere (a mail gateway) turn the Archive action off: no
+// button, menu entry, hover or swipe action, shortcut or archive-after-export,
+// and no Archive folder created. Existing Archive folders stay ordinary folders.
+
+/** Whether the Archive action is offered, for components. */
+export function useArchiveActionEnabled(): boolean {
+ return usePolicyStore((s) => s.isFeatureEnabled('archiveActionEnabled'));
+}
+
+/** Whether the Archive action is offered, outside React (handlers, stores). */
+export function isArchiveActionEnabled(): boolean {
+ return usePolicyStore.getState().isFeatureEnabled('archiveActionEnabled');
+}
+
+/** The actions without `archive` while the gate is off. */
+export function withoutArchiveIfOff(actions: readonly T[], archiveActionEnabled: boolean): readonly T[] {
+ return archiveActionEnabled ? actions : actions.filter((action) => action !== 'archive');
+}
diff --git a/lib/settings-search.ts b/lib/settings-search.ts
index d4b531aba..04ea55e66 100644
--- a/lib/settings-search.ts
+++ b/lib/settings-search.ts
@@ -137,6 +137,9 @@ export const tabSearchPaths: Record = {
// Extra English keywords per tab so common search terms hit even when the
// translation doesn't contain the literal word.
+/** Settings shown only while the admin has the Archive action on (#1257). */
+export const archiveActionSearchPaths: ReadonlySet = new Set(['settings.email_behavior.archive_mode']);
+
export const tabKeywords: Record = {
account: 'profile email password user signin signout reorder rearrange drag dropdown switcher multi-account',
language: 'locale region timezone date time format',
diff --git a/stores/email-store.ts b/stores/email-store.ts
index aecbea409..a90504c7c 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -25,6 +25,7 @@ import { keywordPointer } from "@/lib/jmap/patch-pointer";
import { useAccountStore } from "@/stores/account-store";
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
import type { StorageQuota } from "@/lib/quota";
+import { isArchiveActionEnabled } from "@/lib/archive-gate";
type ScheduledSubmissionMetadata = {
submissionId: string;
@@ -907,6 +908,8 @@ export async function ensureArchiveMailbox(opts: {
accountId?: string;
refresh: () => Promise;
}): Promise {
+ // The admin turned the Archive action off (#1257): never archive, never create the folder.
+ if (!isArchiveActionEnabled()) return null;
const existing = findArchiveMailbox(opts.mailboxes, opts.selectedMailboxId, opts.accountId);
if (existing) return existing;
@@ -4610,6 +4613,7 @@ export const useEmailStore = create((set, get) => ({
},
batchArchive: async (client): Promise => {
+ if (!isArchiveActionEnabled()) return false;
const { selectedEmailKeys, emails } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailKeys.size === 0) return true;
From 20b0c9b0b53f30780f7c8fc1691a26433e335a54 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 16 Oct 2026 22:35:07 +0200
Subject: [PATCH 014/238] fix: refuse OAuth auto-setup before touching Stalwart
when the config is read-only #1250
---
app/(main)/admin/_tabs/auth.tsx | 16 +++-
.../admin/oauth/setup/__tests__/route.test.ts | 82 +++++++++++++++++++
app/api/admin/oauth/setup/route.ts | 34 +++++++-
3 files changed, 129 insertions(+), 3 deletions(-)
create mode 100644 app/api/admin/oauth/setup/__tests__/route.test.ts
diff --git a/app/(main)/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx
index 2b3b977b1..1ee2a3c2a 100644
--- a/app/(main)/admin/_tabs/auth.tsx
+++ b/app/(main)/admin/_tabs/auth.tsx
@@ -20,8 +20,15 @@ export function AuthTab() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
+ const [configReadOnly, setConfigReadOnly] = useState(false);
- useEffect(() => { fetchConfig(); }, []);
+ useEffect(() => {
+ fetchConfig();
+ apiFetch('/api/setup/status')
+ .then(res => (res.ok ? res.json() : null))
+ .then(data => setConfigReadOnly(data?.readOnly === true))
+ .catch(() => {});
+ }, []);
async function fetchConfig() {
setLoading(true);
@@ -168,10 +175,15 @@ export function AuthTab() {
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
Requires your Stalwart account to have admin permissions.
+ {configReadOnly && (
+
+ Not available while the configuration is read-only (ADMIN_CONFIG_READONLY).
+
+ )}
{setupRunning ? : }
diff --git a/app/api/admin/oauth/setup/__tests__/route.test.ts b/app/api/admin/oauth/setup/__tests__/route.test.ts
new file mode 100644
index 000000000..f5939c382
--- /dev/null
+++ b/app/api/admin/oauth/setup/__tests__/route.test.ts
@@ -0,0 +1,82 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+
+let readOnly = false;
+let saveError: Error | null = null;
+const jmapCalls: Array<[string, Record]> = [];
+
+vi.mock('@/lib/admin/session', () => ({
+ requireAdminAuth: async () => ({ ok: true }),
+ getClientIP: () => '127.0.0.1',
+}));
+vi.mock('@/lib/stalwart/credentials', () => ({
+ getStalwartCredentials: async () => ({
+ serverUrl: 'https://mail.example.org',
+ authHeader: 'Basic x',
+ trusted: true,
+ username: 'admin@example.org',
+ }),
+}));
+vi.mock('@/lib/admin/paths', async (importOriginal) => ({
+ ...(await importOriginal()),
+ isConfigReadOnly: () => readOnly,
+}));
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: {
+ ensureLoaded: async () => {},
+ setAdminConfig: async () => { if (saveError) throw saveError; },
+ },
+}));
+vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} }));
+vi.mock('@/lib/stalwart/server-fetch', () => ({
+ fetchJmapServer: async (url: string, init: { body?: string }) => {
+ if (url.endsWith('/.well-known/jmap')) {
+ return Response.json({ primaryAccounts: { 'urn:stalwart:jmap': 'a' } });
+ }
+ const [name, args] = JSON.parse(init.body!).methodCalls[0];
+ jmapCalls.push([name, args]);
+ if (name === 'x:OAuthClient/query') return Response.json({ methodResponses: [[name, { ids: [] }, '0']] });
+ return Response.json({ methodResponses: [[name, { created: { new: { id: 'h' } } }, '0']] });
+ },
+}));
+
+import { POST } from '../route';
+
+function setup() {
+ return POST(new NextRequest('http://localhost/api/admin/oauth/setup', {
+ method: 'POST',
+ body: JSON.stringify({ origin: 'https://webmail.example.org', issuerUrl: 'https://mail.example.org' }),
+ }));
+}
+
+beforeEach(() => {
+ readOnly = false;
+ saveError = null;
+ jmapCalls.length = 0;
+});
+
+describe('POST /api/admin/oauth/setup', () => {
+ it('creates the client on Stalwart when the config is writable', async () => {
+ const res = await setup();
+ expect(res.status).toBe(200);
+ expect(jmapCalls.map(([name]) => name)).toEqual(['x:OAuthClient/query', 'x:OAuthClient/set']);
+ });
+
+ it('refuses in read-only mode without sending anything to Stalwart (#1250)', async () => {
+ readOnly = true;
+ const res = await setup();
+ expect(res.status).toBe(409);
+ expect((await res.json()).error).toMatch(/read-only/);
+ expect(jmapCalls).toEqual([]);
+ });
+
+ it('destroys the client it just created when saving the config fails', async () => {
+ saveError = new Error('EROFS');
+ const res = await setup();
+ expect(res.status).toBe(500);
+ expect(jmapCalls.at(-1)).toEqual(['x:OAuthClient/set', { accountId: 'a', destroy: ['h'] }]);
+ });
+});
diff --git a/app/api/admin/oauth/setup/route.ts b/app/api/admin/oauth/setup/route.ts
index 0bc03fb31..9eaff08f4 100644
--- a/app/api/admin/oauth/setup/route.ts
+++ b/app/api/admin/oauth/setup/route.ts
@@ -4,6 +4,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { getStalwartCredentials, type StalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
import { configManager } from '@/lib/admin/config-manager';
+import { isConfigReadOnly, ConfigReadOnlyError } from '@/lib/admin/paths';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { locales as ALL_LOCALES } from '@/i18n/routing';
@@ -90,6 +91,16 @@ export async function POST(request: NextRequest) {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
+ // The setup ends by saving the client secret here. Refuse before
+ // touching Stalwart, or a read-only config leaves an orphaned client
+ // (or a rotated secret nobody stored) on the server.
+ if (isConfigReadOnly()) {
+ return NextResponse.json(
+ { error: new ConfigReadOnlyError('set up OAuth').message },
+ { status: 409 },
+ );
+ }
+
const ip = getClientIP(request);
const creds = await getStalwartCredentials(request);
if (!creds) {
@@ -195,6 +206,7 @@ export async function POST(request: NextRequest) {
}, { status: 403 });
}
const setBody = setEntry[1] as {
+ created?: Record;
notCreated?: Record;
notUpdated?: Record;
};
@@ -219,7 +231,27 @@ export async function POST(request: NextRequest) {
oauthIssuerUrl: issuerUrl,
};
if (oauthOnly) updates.oauthOnly = true;
- await configManager.setAdminConfig(updates);
+ try {
+ await configManager.setAdminConfig(updates);
+ } catch (error) {
+ // Don't leave a client behind that this install never stored. An
+ // updated client can't be restored: Stalwart doesn't hand back the
+ // old secret.
+ if (action === 'created') {
+ const createdId = setBody.created?.new?.id;
+ if (createdId) {
+ await jmapCall(creds, {
+ using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
+ methodCalls: [['x:OAuthClient/set', { accountId, destroy: [createdId] }, '0']],
+ }).catch((rollbackError) => {
+ logger.error('Admin OAuth setup rollback failed', {
+ error: rollbackError instanceof Error ? rollbackError.message : 'Unknown error',
+ });
+ });
+ }
+ }
+ throw error;
+ }
await auditLog('admin.oauth_setup', {
action,
From 76d12cff30269aec347f6b2cf1fe36b0d989267c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 16 Oct 2026 22:44:38 +0200
Subject: [PATCH 015/238] feat: ask before leaving the admin panel with unsaved
changes #1255
---
.../__tests__/unsaved-changes-guard.test.tsx | 94 +++++++++++++++
app/(main)/admin/_tabs/auth.tsx | 3 +
app/(main)/admin/_tabs/branding.tsx | 3 +
.../admin/_tabs/plugin-config-panel.tsx | 23 +++-
app/(main)/admin/_tabs/plugins.tsx | 3 +
app/(main)/admin/_tabs/policy.tsx | 3 +
app/(main)/admin/_tabs/settings.tsx | 3 +
app/(main)/admin/_tabs/themes.tsx | 3 +
app/(main)/admin/_unsaved-changes-guard.tsx | 114 ++++++++++++++++++
app/(main)/admin/layout.tsx | 23 ++--
stores/admin-unsaved-store.ts | 66 ++++++++++
11 files changed, 325 insertions(+), 13 deletions(-)
create mode 100644 app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx
create mode 100644 app/(main)/admin/_unsaved-changes-guard.tsx
create mode 100644 stores/admin-unsaved-store.ts
diff --git a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx
new file mode 100644
index 000000000..34fa74eb8
--- /dev/null
+++ b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx
@@ -0,0 +1,94 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { UnsavedChangesGuard } from '../_unsaved-changes-guard';
+import { useAdminUnsavedChanges, useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
+
+function DirtySection({ id = 'settings', dirty }: { id?: string; dirty: boolean }) {
+ useAdminUnsavedChanges(id, dirty);
+ return null;
+}
+
+afterEach(() => {
+ useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null });
+});
+
+describe('admin unsaved changes guard (#1255)', () => {
+ it('runs the action straight away when nothing is unsaved', () => {
+ render(<> >);
+ const action = vi.fn();
+ act(() => useAdminUnsavedStore.getState().requestLeave(action));
+ expect(action).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole('alertdialog')).toBeNull();
+ });
+
+ it('asks before leaving with unsaved edits; Stay keeps the admin on the page', () => {
+ render(<> >);
+ const action = vi.fn();
+ act(() => useAdminUnsavedStore.getState().requestLeave(action));
+ expect(screen.getByRole('alertdialog')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Stay' }));
+ expect(action).not.toHaveBeenCalled();
+ expect(screen.queryByRole('alertdialog')).toBeNull();
+ expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true });
+ });
+
+ it('Leave runs the action and drops the unsaved flag', () => {
+ render(<> >);
+ const action = vi.fn();
+ act(() => useAdminUnsavedStore.getState().requestLeave(action));
+ fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
+ expect(action).toHaveBeenCalledTimes(1);
+ expect(useAdminUnsavedStore.getState().dirty).toEqual({});
+ });
+
+ it('Escape closes the prompt like Stay', () => {
+ render(<> >);
+ const action = vi.fn();
+ act(() => useAdminUnsavedStore.getState().requestLeave(action));
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByRole('alertdialog')).toBeNull();
+ expect(action).not.toHaveBeenCalled();
+ });
+
+ it('a scoped leave only looks at, and only clears, its own section', () => {
+ render(<> >);
+ const back = vi.fn();
+ act(() => useAdminUnsavedStore.getState().requestLeave(back, 'plugin-config'));
+ expect(back).toHaveBeenCalledTimes(1);
+
+ act(() => useAdminUnsavedStore.getState().setDirty('plugin-config', true));
+ act(() => useAdminUnsavedStore.getState().requestLeave(back, 'plugin-config'));
+ fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
+ expect(back).toHaveBeenCalledTimes(2);
+ expect(useAdminUnsavedStore.getState().dirty).toEqual({ plugins: true });
+ });
+
+ it('holds a link click until the admin picks Leave', () => {
+ const onLinkClick = vi.fn((e: React.MouseEvent) => e.preventDefault());
+ render(
+ <>
+
+
+ Change Password
+ >,
+ );
+ fireEvent.click(screen.getByText('Change Password'));
+ expect(onLinkClick).not.toHaveBeenCalled();
+ expect(screen.getByRole('alertdialog')).toBeTruthy();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
+ expect(onLinkClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('asks the browser to confirm closing the page only while edits are unsaved', () => {
+ const { rerender } = render(<> >);
+ const dirtyEvent = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(dirtyEvent);
+ expect(dirtyEvent.defaultPrevented).toBe(true);
+
+ rerender(<> >);
+ const cleanEvent = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(cleanEvent);
+ expect(cleanEvent.defaultPrevented).toBe(false);
+ });
+});
diff --git a/app/(main)/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx
index 1ee2a3c2a..7d28e969b 100644
--- a/app/(main)/admin/_tabs/auth.tsx
+++ b/app/(main)/admin/_tabs/auth.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw, Sparkles } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { DEFAULT_OAUTH_SCOPES } from '@/lib/oauth/scopes';
interface ConfigEntry {
@@ -135,6 +136,8 @@ export function AuthTab() {
const hasEdits = Object.keys(edits).length > 0;
+ useAdminUnsavedChanges('auth', hasEdits);
+
if (loading) {
return Loading...
;
}
diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx
index c93da7367..b899a8256 100644
--- a/app/(main)/admin/_tabs/branding.tsx
+++ b/app/(main)/admin/_tabs/branding.tsx
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from '@/components/icons';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import {
BRANDING_OVERRIDE_KEYS,
parseDomainBranding,
@@ -340,6 +341,8 @@ export function BrandingTab() {
const hasEdits = Object.keys(edits).length > 0;
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
+ useAdminUnsavedChanges('branding', hasEdits);
+
if (loading) {
return Loading...
;
}
diff --git a/app/(main)/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx
index d396c086a..9f5370614 100644
--- a/app/(main)/admin/_tabs/plugin-config-panel.tsx
+++ b/app/(main)/admin/_tabs/plugin-config-panel.tsx
@@ -1,8 +1,9 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
+import { useAdminUnsavedChanges, useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
@@ -73,9 +74,9 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
return () => { cancelled = true; };
}, [pluginId]);
- useEffect(() => {
- if (!plugin?.configSchema) return;
+ const savedValues = useMemo(() => {
const initial: Record = {};
+ if (!plugin?.configSchema) return initial;
for (const [key, field] of Object.entries(plugin.configSchema)) {
const stored = config[key];
if (stored !== undefined && stored !== null) {
@@ -86,9 +87,19 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
initial[key] = '';
}
}
- setFormValues(initial);
+ return initial;
}, [plugin, config]);
+ useEffect(() => {
+ if (!plugin?.configSchema) return;
+ setFormValues(savedValues);
+ }, [plugin, savedValues]);
+
+ const dirty = Object.keys(savedValues).some(key => (formValues[key] ?? '') !== savedValues[key]);
+ useAdminUnsavedChanges('plugin-config', dirty);
+ const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
+ const handleBack = () => requestLeave(onBack, 'plugin-config');
+
async function handleSaveAll() {
if (!plugin?.configSchema) return;
setSaving(true);
@@ -166,7 +177,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
Back to Plugins
@@ -184,7 +195,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
diff --git a/app/(main)/admin/_tabs/plugins.tsx b/app/(main)/admin/_tabs/plugins.tsx
index 908820b7e..fbd98f22b 100644
--- a/app/(main)/admin/_tabs/plugins.tsx
+++ b/app/(main)/admin/_tabs/plugins.tsx
@@ -5,6 +5,7 @@ import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save,
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { PluginConfigPanel } from './plugin-config-panel';
import { PluginApprovalsPanel } from './plugin-approvals-panel';
@@ -252,6 +253,8 @@ export function PluginsTab() {
}
}
+ useAdminUnsavedChanges('plugins', policyDirty);
+
if (configuringId) {
return { setConfiguringId(null); fetchPlugins(); }} />;
}
diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx
index e42478bac..e86bfb12a 100644
--- a/app/(main)/admin/_tabs/policy.tsx
+++ b/app/(main)/admin/_tabs/policy.tsx
@@ -6,6 +6,7 @@ import { iconForName } from '@/components/icons';
import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps';
import { generateUUID } from '@/lib/utils';
import {
@@ -249,6 +250,8 @@ export function PolicyTab() {
setSaving(false);
}
+ useAdminUnsavedChanges('policy', dirty);
+
if (loading) {
return Loading...
;
}
diff --git a/app/(main)/admin/_tabs/settings.tsx b/app/(main)/admin/_tabs/settings.tsx
index 9870d4ffa..40f2b098d 100644
--- a/app/(main)/admin/_tabs/settings.tsx
+++ b/app/(main)/admin/_tabs/settings.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { Save, RotateCcw, Loader2 } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
@@ -83,6 +84,8 @@ export function SettingsTab() {
const hasEdits = Object.keys(edits).length > 0;
+ useAdminUnsavedChanges('settings', hasEdits);
+
if (loading) {
return Loading...
;
}
diff --git a/app/(main)/admin/_tabs/themes.tsx b/app/(main)/admin/_tabs/themes.tsx
index 95ce4cf78..c7374c497 100644
--- a/app/(main)/admin/_tabs/themes.tsx
+++ b/app/(main)/admin/_tabs/themes.tsx
@@ -5,6 +5,7 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
// Derive from the single source of truth so newly added built-in themes show
@@ -297,6 +298,8 @@ export function ThemesTab() {
}
}
+ useAdminUnsavedChanges('themes', policyDirty);
+
if (loading) {
return Loading...
;
}
diff --git a/app/(main)/admin/_unsaved-changes-guard.tsx b/app/(main)/admin/_unsaved-changes-guard.tsx
new file mode 100644
index 000000000..ed07cfa58
--- /dev/null
+++ b/app/(main)/admin/_unsaved-changes-guard.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import { useEffect, useId } from 'react';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from '@/components/icons';
+import { useFocusTrap } from '@/hooks/use-focus-trap';
+import { useAdminUnsavedStore, selectHasUnsaved } from '@/stores/admin-unsaved-store';
+
+/**
+ * Asks before the admin leaves a section with unsaved edits (#1255). Tab
+ * buttons and sign out go through requestLeave themselves; links anywhere on
+ * the page are caught here, and closing or reloading the tab gets the
+ * browser's own prompt.
+ */
+export function UnsavedChangesGuard() {
+ const hasUnsaved = useAdminUnsavedStore(selectHasUnsaved);
+ const pendingLeave = useAdminUnsavedStore((s) => s.pendingLeave);
+ const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
+ const confirmLeave = useAdminUnsavedStore((s) => s.confirmLeave);
+ const cancelLeave = useAdminUnsavedStore((s) => s.cancelLeave);
+ const isOpen = pendingLeave !== null;
+ const id = useId();
+
+ const dialogRef = useFocusTrap({ isActive: isOpen, restoreFocus: true });
+
+ // On document, not the dialog, so Escape means "Stay" wherever focus is.
+ useEffect(() => {
+ if (!isOpen) return;
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== 'Escape') return;
+ e.preventDefault();
+ e.stopPropagation();
+ cancelLeave();
+ };
+ document.addEventListener('keydown', onKeyDown, true);
+ return () => document.removeEventListener('keydown', onKeyDown, true);
+ }, [isOpen, cancelLeave]);
+
+ useEffect(() => {
+ if (!hasUnsaved) return;
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
+ e.preventDefault();
+ e.returnValue = '';
+ };
+ window.addEventListener('beforeunload', onBeforeUnload);
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
+ }, [hasUnsaved]);
+
+ useEffect(() => {
+ if (!hasUnsaved) return;
+ // Capture phase on document runs before React's root listener, so a
+ // Next never starts its client-side navigation.
+ const onClick = (e: MouseEvent) => {
+ if (e.defaultPrevented || e.button !== 0) return;
+ // Read the store, not the render: after "Leave" the click is replayed
+ // before React has removed this listener.
+ if (!selectHasUnsaved(useAdminUnsavedStore.getState())) return;
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ const anchor = (e.target as Element | null)?.closest?.('a[href]');
+ if (!(anchor instanceof HTMLAnchorElement)) return;
+ if (anchor.target && anchor.target !== '_self') return;
+ if (anchor.hasAttribute('download')) return;
+ const url = new URL(anchor.href, window.location.href);
+ if (url.origin === window.location.origin
+ && url.pathname === window.location.pathname
+ && url.search === window.location.search) return;
+ e.preventDefault();
+ e.stopPropagation();
+ requestLeave(() => {
+ // Nothing is dirty any more, so the second click goes through.
+ if (anchor.isConnected) anchor.click();
+ else window.location.assign(anchor.href);
+ });
+ };
+ document.addEventListener('click', onClick, true);
+ return () => document.removeEventListener('click', onClick, true);
+ }, [hasUnsaved, requestLeave]);
+
+ if (!isOpen) return null;
+
+ return (
+ { if (e.target === e.currentTarget) cancelLeave(); }}
+ >
+
+
+
+
+
+ Unsaved changes
+
+
+ You have changes that haven't been saved. If you leave now, they will be lost.
+
+
+
+
+ Stay
+ Leave
+
+
+
+ );
+}
diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx
index 9507b774f..201cc68d7 100644
--- a/app/(main)/admin/layout.tsx
+++ b/app/(main)/admin/layout.tsx
@@ -33,6 +33,8 @@ import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
+import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
+import { UnsavedChangesGuard } from './_unsaved-changes-guard';
// Single-page tab navigation: clicks update a Zustand store. The URL stays
// at /admin so React doesn't fire a route transition on every tab switch -
@@ -80,6 +82,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const pathname = usePathname();
const storeActiveTab = useAdminTabStore((s) => s.activeTab);
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
+ const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
// Highlight the active tab only on /admin itself - on dynamic routes
// (e.g. /admin/plugins/[id]) no tab is "current".
const activeTab = pathname === '/admin' ? storeActiveTab : null;
@@ -175,9 +178,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return () => { cancelled = true; };
}, [pathname, router]);
- async function handleLogout() {
- await apiFetch('/api/admin/auth', { method: 'DELETE' });
- router.replace('/admin/login');
+ function handleLogout() {
+ requestLeave(async () => {
+ await apiFetch('/api/admin/auth', { method: 'DELETE' });
+ router.replace('/admin/login');
+ });
}
// Don't gate the login page
@@ -207,10 +212,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const active = activeTab === tab;
const showDot = tab === 'version' && hasUpdate;
const handleClick = () => {
- setActiveTab(tab);
- // From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug])
- // we still need a real navigation back to /admin so the page renders.
- if (pathname !== '/admin') router.push('/admin');
+ if (active) return;
+ requestLeave(() => {
+ setActiveTab(tab);
+ // From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug])
+ // we still need a real navigation back to /admin so the page renders.
+ if (pathname !== '/admin') router.push('/admin');
+ });
};
return (
+
{/* Slim webmail nav rail (desktop only) */}
{logoUrl ? (
diff --git a/stores/admin-unsaved-store.ts b/stores/admin-unsaved-store.ts
new file mode 100644
index 000000000..b4c9a7ee7
--- /dev/null
+++ b/stores/admin-unsaved-store.ts
@@ -0,0 +1,66 @@
+import { useEffect } from 'react';
+import { create } from 'zustand';
+
+interface AdminUnsavedState {
+ /** Sections of the admin panel that hold edits nobody has saved yet. */
+ dirty: Record;
+ /** What to run once the admin chooses "Leave" in the prompt. */
+ pendingLeave: (() => void) | null;
+ /** The one section the pending leave discards, or null for all of them. */
+ pendingScope: string | null;
+ setDirty: (id: string, dirty: boolean) => void;
+ /**
+ * Runs `action` now, or asks first when something is unsaved (#1255).
+ * With `scope`, only that section's edits count, for leaving a nested
+ * panel while the section around it stays mounted.
+ */
+ requestLeave: (action: () => void, scope?: string) => void;
+ confirmLeave: () => void;
+ cancelLeave: () => void;
+}
+
+export const useAdminUnsavedStore = create()((set, get) => ({
+ dirty: {},
+ pendingLeave: null,
+ pendingScope: null,
+ setDirty: (id, dirty) => {
+ const current = get().dirty;
+ if (dirty === (id in current)) return;
+ const next = { ...current };
+ if (dirty) next[id] = true;
+ else delete next[id];
+ set({ dirty: next });
+ },
+ requestLeave: (action, scope) => {
+ const dirty = get().dirty;
+ if (scope ? !(scope in dirty) : Object.keys(dirty).length === 0) {
+ action();
+ return;
+ }
+ set({ pendingLeave: action, pendingScope: scope ?? null });
+ },
+ confirmLeave: () => {
+ const { pendingLeave: action, pendingScope: scope, dirty } = get();
+ // Clear first: the action unmounts the dirty section, and a hard
+ // navigation must not trip the beforeunload prompt a second time.
+ let next: Record = {};
+ if (scope) {
+ next = { ...dirty };
+ delete next[scope];
+ }
+ set({ pendingLeave: null, pendingScope: null, dirty: next });
+ action?.();
+ },
+ cancelLeave: () => set({ pendingLeave: null, pendingScope: null }),
+}));
+
+export const selectHasUnsaved = (s: AdminUnsavedState) => Object.keys(s.dirty).length > 0;
+
+/** Reports a section's unsaved edits to the admin layout's leave prompt. */
+export function useAdminUnsavedChanges(id: string, dirty: boolean) {
+ const setDirty = useAdminUnsavedStore((s) => s.setDirty);
+ useEffect(() => {
+ setDirty(id, dirty);
+ }, [id, dirty, setDirty]);
+ useEffect(() => () => setDirty(id, false), [id, setDirty]);
+}
From 3a7ebdeaccb7d9ab14b1514ac00087d111960840 Mon Sep 17 00:00:00 2001
From: balabommablock-cpu
Date: Sat, 17 Oct 2026 03:38:45 +0000
Subject: [PATCH 016/238] fix: count calendar days in the time zone for the
relative date #1259
---
lib/__tests__/format-relative-ago.test.ts | 20 +++++++++++++++++++-
lib/utils.ts | 11 ++++++++++-
2 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/lib/__tests__/format-relative-ago.test.ts b/lib/__tests__/format-relative-ago.test.ts
index 14d69202b..befcfba80 100644
--- a/lib/__tests__/format-relative-ago.test.ts
+++ b/lib/__tests__/format-relative-ago.test.ts
@@ -1,9 +1,15 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, afterEach } from 'vitest';
import { formatRelativeAgo } from '../utils';
+import { AUTO_TIME_ZONE } from '../timezone';
+import { useSettingsStore } from '@/stores/settings-store';
describe('formatRelativeAgo', () => {
const now = new Date('2026-07-01T12:00:00Z');
+ afterEach(() => {
+ useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE });
+ });
+
it('says how long ago in the largest whole unit', () => {
expect(formatRelativeAgo(new Date('2026-07-01T11:57:00Z'), now)).toBe('3 minutes ago');
expect(formatRelativeAgo(new Date('2026-07-01T08:00:00Z'), now)).toBe('4 hours ago');
@@ -14,4 +20,16 @@ describe('formatRelativeAgo', () => {
expect(formatRelativeAgo(new Date('2026-05-01T12:00:00Z'), now)).toBe('');
expect(formatRelativeAgo('not a date', now)).toBe('');
});
+
+ it('counts calendar days in the effective time zone (#1259)', () => {
+ useSettingsStore.setState({ timeZone: 'America/Denver' });
+ // 16 Oct, 10:00 in Denver.
+ const denverNow = new Date('2026-10-16T16:00:00Z');
+ // 14 Oct, 20:51 in Denver: only 37 hours ago, but two days back.
+ expect(formatRelativeAgo(new Date('2026-10-15T02:51:30Z'), denverNow)).toBe('2 days ago');
+ // 15 Oct, 08:00 in Denver: 26 hours ago, and yesterday.
+ expect(formatRelativeAgo(new Date('2026-10-15T14:00:00Z'), denverNow)).toBe('yesterday');
+ // Under a day still reads in hours, even across midnight.
+ expect(formatRelativeAgo(new Date('2026-10-15T23:00:00Z'), denverNow)).toBe('17 hours ago');
+ });
});
diff --git a/lib/utils.ts b/lib/utils.ts
index 6d30d4514..1af71cfcd 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -258,6 +258,10 @@ export function formatDateTime(
* How long ago `date` was, in words ("3 minutes ago", "2 days ago"), for the
* parenthesis Gmail puts after a message's time. Empty once it is a month
* old, where the date alone says enough.
+ *
+ * Days are calendar days in the user's effective time zone, so a mail from
+ * the day before yesterday is never called "yesterday" just because fewer
+ * than 48 hours have passed. (#1259)
*/
export function formatRelativeAgo(date: Date | string, now: Date = new Date()): string {
const d = typeof date === 'string' ? new Date(date) : date;
@@ -265,7 +269,12 @@ export function formatRelativeAgo(date: Date | string, now: Date = new Date()):
const diff = Math.max(0, now.getTime() - d.getTime());
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
- const days = Math.floor(diff / 86400000);
+ const timeZone = getEffectiveTimeZone();
+ const dayNumber = (x: Date) => {
+ const w = getWallClock(x, timeZone);
+ return Date.UTC(w.year, w.month - 1, w.day) / 86400000;
+ };
+ const days = Math.max(1, dayNumber(now) - dayNumber(d));
if (days >= 30) return '';
const locale = getEffectiveLocale();
const rtf = new Intl.RelativeTimeFormat(locale === 'en' ? 'en-US' : locale, { numeric: 'auto' });
From 1b7597c82437f0e577bf01c858e0b8214248c2b4 Mon Sep 17 00:00:00 2001
From: Ali Yousuf
Date: Sat, 17 Oct 2026 04:40:14 -0400
Subject: [PATCH 017/238] fix: keep the category tab filter when the mailbox
refreshes
refreshCurrentMailbox re-queried the folder without the active category
tab's filter, so with a plugin tab open each refresh merged other tabs'
mail into the list and took the newest of it for new mail.
Fixes #1264
---
.../email-store-refresh-category-tab.test.ts | 81 +++++++++++++++++++
stores/email-store.ts | 5 +-
2 files changed, 85 insertions(+), 1 deletion(-)
create mode 100644 stores/__tests__/email-store-refresh-category-tab.test.ts
diff --git a/stores/__tests__/email-store-refresh-category-tab.test.ts b/stores/__tests__/email-store-refresh-category-tab.test.ts
new file mode 100644
index 000000000..5dc7b467d
--- /dev/null
+++ b/stores/__tests__/email-store-refresh-category-tab.test.ts
@@ -0,0 +1,81 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useEmailStore } from '../email-store';
+import { useSettingsStore } from '../settings-store';
+import { useAuthStore } from '../auth-store';
+import { useMessageListTabsStore } from '../message-list-tabs-store';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+// #1264: refreshing the open folder re-queries it with the active category
+// tab's filter, as the first load and pagination do. Without it the refresh
+// merged other tabs' mail into the list and took it for new mail.
+
+const inbox = { id: 'inbox', role: 'inbox', isShared: false } as Mailbox;
+const RECEIPTS = { subject: 'receipt' };
+
+function email(id: string, receivedAt: string): Email {
+ return {
+ id, threadId: `thread-${id}`, mailboxIds: { inbox: true }, keywords: {},
+ subject: id, receivedAt, from: [{ email: 'sender@example.com' }], to: [],
+ preview: '', size: 1, hasAttachment: false,
+ } as Email;
+}
+
+function client(rows: Email[]) {
+ return {
+ getEmails: vi.fn(async () => ({ emails: rows, total: rows.length, hasMore: false, state: 's' })),
+ getThreads: vi.fn(async () => []),
+ getAccountId: () => 'me',
+ } as unknown as IJMAPClient;
+}
+
+describe('refreshing a folder with a category tab open (#1264)', () => {
+ beforeEach(() => {
+ useAuthStore.setState(useAuthStore.getInitialState());
+ useMessageListTabsStore.setState(useMessageListTabsStore.getInitialState());
+ useSettingsStore.setState({
+ emailsPerPage: 25, messageListOrder: [], messageListOrderScope: 'inbox',
+ notificationMutes: { accounts: [], mailboxes: {} },
+ });
+ useEmailStore.setState({
+ ...useEmailStore.getInitialState(),
+ selectedMailbox: 'inbox', mailboxes: [inbox],
+ });
+ useMessageListTabsStore.getState().registerTabs('receipts-plugin', {
+ tabs: [
+ { id: 'other', label: 'Inbox', order: 1 },
+ { id: 'receipts', label: 'Receipts', order: 2, query: RECEIPTS },
+ ],
+ });
+ });
+
+ it('queries the folder with the active tab filter', async () => {
+ useMessageListTabsStore.getState().setActiveTab('receipts', 'inbox');
+ const c = client([]);
+ await useEmailStore.getState().refreshCurrentMailbox(c);
+ expect(vi.mocked(c.getEmails).mock.calls[0][6]).toEqual(RECEIPTS);
+ });
+
+ it('queries the default tab as everything the other tabs leave out', async () => {
+ const c = client([]);
+ await useEmailStore.getState().refreshCurrentMailbox(c);
+ expect(vi.mocked(c.getEmails).mock.calls[0][6]).toEqual({ operator: 'NOT', conditions: [RECEIPTS] });
+ });
+
+ it('does not take mail from another tab for new mail', async () => {
+ useMessageListTabsStore.getState().setActiveTab('receipts', 'inbox');
+ const receipt = email('receipt', '2026-10-01T00:00:00Z');
+ useEmailStore.setState({ emails: [receipt], totalEmails: 1 });
+ // A server that honours the filter answers with the tab's own mail only.
+ const c = {
+ ...client([]),
+ getEmails: vi.fn(async (...args: unknown[]) => {
+ const rows = args[6] ? [receipt] : [email('newsletter', '2026-10-15T00:00:00Z'), receipt];
+ return { emails: rows, total: rows.length, hasMore: false, state: 's' };
+ }),
+ } as unknown as IJMAPClient;
+ await useEmailStore.getState().refreshCurrentMailbox(c);
+ expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['receipt']);
+ expect(useEmailStore.getState().newEmailNotification).toBeNull();
+ });
+});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index aecbea409..05df7885a 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -5189,7 +5189,10 @@ export const useEmailStore = create((set, get) => ({
windowSize = Math.min(Math.max(emailsPerPage, inOrder), effectiveClient.getMaxObjectsInGet());
keepOpenThread = true;
}
- result = await effectiveClient.getEmails(jmapMailboxId, accountId, windowSize, 0, undefined, true, undefined, order);
+ // The open category tab filters the refresh like the first load and
+ // pagination, or other tabs' mail would merge into the list (#1264).
+ const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
+ result = await effectiveClient.getEmails(jmapMailboxId, accountId, windowSize, 0, undefined, true, categoryFilter ?? undefined, order);
// This page is the new delta-sync baseline for the folder; the
// search / unified paths above cannot be delta-synced.
syncAfterRefresh = result.state
From b4c7c6f5f45af96f90b15e6fcef0b61aca6b217b Mon Sep 17 00:00:00 2001
From: Sebastian Sanitz
Date: Sat, 17 Oct 2026 14:31:51 +0200
Subject: [PATCH 018/238] fix(calendar): refetch visible range after creating
recurring event
When creating a recurring event, the calendar store previously only added
the base event returned by the server to its state. The server-expanded
recurrence instances (synthetic IDs) were not loaded until the next manual
page refresh or visible range query.
Refetch the active visible range immediately after creating an event when
recurrenceRules are present, matching the behavior used when mutating
recurring occurrences.
---
...endar-store-create-recurring-event.test.ts | 82 +++++++++++++++++++
stores/calendar-store.ts | 3 +
2 files changed, 85 insertions(+)
create mode 100644 stores/__tests__/calendar-store-create-recurring-event.test.ts
diff --git a/stores/__tests__/calendar-store-create-recurring-event.test.ts b/stores/__tests__/calendar-store-create-recurring-event.test.ts
new file mode 100644
index 000000000..ef7c726f3
--- /dev/null
+++ b/stores/__tests__/calendar-store-create-recurring-event.test.ts
@@ -0,0 +1,82 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useCalendarStore } from '../calendar-store';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
+import { createRecurrenceRule } from '@/lib/recurrence-rule';
+
+const calendars = [
+ { id: 'cal-1', originalId: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true } },
+] as unknown as Calendar[];
+
+function fakeClient(overrides: Partial> = {}): IJMAPClient {
+ return {
+ createCalendarEvent: vi.fn().mockImplementation(async (event: Partial) => ({
+ id: 'ev-created-1',
+ ...event,
+ created: '2026-09-29T21:00:00Z',
+ updated: '2026-09-29T21:00:00Z',
+ })),
+ updateCalendarEvent: vi.fn().mockResolvedValue(undefined),
+ deleteCalendarEvent: vi.fn().mockResolvedValue(undefined),
+ queryAllCalendarEvents: vi.fn().mockResolvedValue([]),
+ ...overrides,
+ } as never;
+}
+
+describe('calendar-store createEvent with recurring rules', () => {
+ beforeEach(() => {
+ useCalendarStore.getState().clearState();
+ useCalendarStore.setState({ events: [], calendars, selectedCalendarIds: ['cal-1'], error: null });
+ });
+
+ it('creates an event with weekly recurrence rules and stores it', async () => {
+ const client = fakeClient();
+ const eventPayload: Partial = {
+ title: 'Weekly Sync',
+ start: '2026-09-30T10:00:00',
+ duration: 'PT1H',
+ timeZone: 'Europe/Berlin',
+ showWithoutTime: false,
+ calendarIds: { 'cal-1': true },
+ recurrenceRules: [createRecurrenceRule('weekly')],
+ };
+
+ const created = await useCalendarStore.getState().createEvent(client, eventPayload);
+
+ expect(created).not.toBeNull();
+ expect(created?.title).toBe('Weekly Sync');
+ expect(client.createCalendarEvent).toHaveBeenCalledTimes(1);
+ const passedEvent = (client.createCalendarEvent as ReturnType).mock.calls[0][0];
+ expect(passedEvent.recurrenceRules).toHaveLength(1);
+ expect(passedEvent.recurrenceRules[0].frequency).toBe('weekly');
+ });
+
+ it('refetches the visible range after creating a recurring event so all occurrences appear immediately', async () => {
+ const client = fakeClient({
+ queryAllCalendarEvents: vi.fn().mockResolvedValue([
+ { id: 'ev-created-1', title: 'Daily Standup', start: '2026-09-30T10:00:00', duration: 'PT30M', calendarIds: { 'cal-1': true } },
+ { id: 'ev-created-1-occ-1', title: 'Daily Standup', start: '2026-10-01T10:00:00', duration: 'PT30M', calendarIds: { 'cal-1': true } },
+ ]),
+ });
+
+ // Simulate viewing calendar
+ await useCalendarStore.getState().fetchEvents(client, '2026-09-01T00:00:00Z', '2026-10-31T23:59:59Z');
+ expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(1);
+
+ const recurringEvent: Partial = {
+ title: 'Daily Standup',
+ start: '2026-09-30T10:00:00',
+ duration: 'PT30M',
+ timeZone: 'Europe/Berlin',
+ calendarIds: { 'cal-1': true },
+ recurrenceRules: [createRecurrenceRule('daily')],
+ };
+
+ await useCalendarStore.getState().createEvent(client, recurringEvent);
+
+ // After creating a recurring event, the visible range must be refetched
+ expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(2);
+ expect(useCalendarStore.getState().events).toHaveLength(2);
+ });
+});
+
diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts
index 8efe1f660..ba96cafd3 100644
--- a/stores/calendar-store.ts
+++ b/stores/calendar-store.ts
@@ -910,6 +910,9 @@ export const useCalendarStore = create()(
set((state) => ({ events: [...state.events, mappedCreated] }));
void calendarHooks.onAfterEventCreate.emit(eventToView(mappedCreated));
+ if (mappedCreated.recurrenceRules && mappedCreated.recurrenceRules.length > 0) {
+ await refetchAfterOccurrenceMutation();
+ }
// Invitation emails are sent by the server: `sendSchedulingMessages`
// on CalendarEvent/set makes Stalwart queue the iTIP REQUEST itself.
// Sending a client-side iMIP copy here produced duplicate emails.
From 81ecc9020f4d2e81821f8dc5b5b63d8b9273a5b2 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:27:23 +0200
Subject: [PATCH 019/238] fix: open the composer when the mail client can't
report directory support
---
components/email/email-composer.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index a283e4b30..27a5b9e42 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -3750,7 +3750,7 @@ export function EmailComposer({
onMoveChip={handleMoveChip}
/>
- {activeClient?.supportsPrincipals() && (
+ {activeClient?.supportsPrincipals?.() && (
Date: Fri, 16 Oct 2026 23:37:15 +0200
Subject: [PATCH 020/238] fix: apply admin policy defaults to user settings
#1248
---
app/api/admin/policy/route.ts | 3 +
lib/admin/__tests__/policy-defaults.test.ts | 26 +++
lib/admin/config-manager.ts | 3 +
lib/admin/policy-defaults.ts | 23 +++
.../settings-store-policy-defaults.test.ts | 180 +++++++++++++++++
stores/settings-store.ts | 190 ++++++++++++++++--
6 files changed, 410 insertions(+), 15 deletions(-)
create mode 100644 lib/admin/__tests__/policy-defaults.test.ts
create mode 100644 lib/admin/policy-defaults.ts
create mode 100644 stores/__tests__/settings-store-policy-defaults.test.ts
diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts
index 56b3b8910..681aaae28 100644
--- a/app/api/admin/policy/route.ts
+++ b/app/api/admin/policy/route.ts
@@ -62,6 +62,9 @@ export async function PUT(request: NextRequest) {
if (policy.features && typeof policy.features !== 'object') {
return NextResponse.json({ error: 'features must be an object' }, { status: 400 });
}
+ if (policy.defaults !== undefined && (typeof policy.defaults !== 'object' || policy.defaults === null || Array.isArray(policy.defaults))) {
+ return NextResponse.json({ error: 'defaults must be an object' }, { status: 400 });
+ }
if (policy.themePolicy && typeof policy.themePolicy !== 'object') {
return NextResponse.json({ error: 'themePolicy must be an object' }, { status: 400 });
}
diff --git a/lib/admin/__tests__/policy-defaults.test.ts b/lib/admin/__tests__/policy-defaults.test.ts
new file mode 100644
index 000000000..3c64d9c0f
--- /dev/null
+++ b/lib/admin/__tests__/policy-defaults.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import { sanitizePolicyDefaults } from '../policy-defaults';
+
+describe('sanitizePolicyDefaults (#1248)', () => {
+ it('keeps plain values', () => {
+ expect(sanitizePolicyDefaults({ showSharedMailAccounts: false, emailsPerPage: 25, deleteAction: 'trash' }))
+ .toEqual({ showSharedMailAccounts: false, emailsPerPage: 25, deleteAction: 'trash' });
+ });
+
+ it('drops lists, maps, null and numbers that are not finite', () => {
+ expect(sanitizePolicyDefaults({ a: [], b: { c: 1 }, d: null, e: Number.NaN, f: Infinity })).toEqual({});
+ });
+
+ it('drops prototype keys', () => {
+ const out = sanitizePolicyDefaults(JSON.parse('{"__proto__": "x", "constructor": "y", "ok": true}'));
+ expect(Object.keys(out)).toEqual(['ok']);
+ expect(Object.getPrototypeOf(out)).toBe(Object.prototype);
+ });
+
+ it('turns a missing or malformed section into an empty one', () => {
+ expect(sanitizePolicyDefaults(undefined)).toEqual({});
+ expect(sanitizePolicyDefaults(null)).toEqual({});
+ expect(sanitizePolicyDefaults(['x'])).toEqual({});
+ expect(sanitizePolicyDefaults('x')).toEqual({});
+ });
+});
diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts
index fee57fe8d..403be7537 100644
--- a/lib/admin/config-manager.ts
+++ b/lib/admin/config-manager.ts
@@ -5,6 +5,7 @@ import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_PO
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
import { isValidRelayUrl, normalizeRelayUrl } from '@/lib/push-relays';
import { sanitizeDefaultSidebarApps } from '@/lib/sidebar-apps';
+import { sanitizePolicyDefaults } from './policy-defaults';
function parseEnvValue(value: string, type: string): unknown {
switch (type) {
@@ -213,6 +214,8 @@ class ConfigManager {
// Operator-pinned sidebar apps reach every user's rail, and policy.json can
// be hand-edited, so the list is validated here rather than at render time.
policy.defaultSidebarApps = sanitizeDefaultSidebarApps(policy.defaultSidebarApps);
+ // Same for the values users start with (#1248).
+ policy.defaults = sanitizePolicyDefaults(policy.defaults);
return policy;
}
diff --git a/lib/admin/policy-defaults.ts b/lib/admin/policy-defaults.ts
new file mode 100644
index 000000000..4d567c55d
--- /dev/null
+++ b/lib/admin/policy-defaults.ts
@@ -0,0 +1,23 @@
+/**
+ * `policy.defaults` (#1248): the values users start with for settings they
+ * have not chosen themselves. policy.json is written by hand and the server
+ * does not know the client's settings, so only the shape is checked here: a
+ * plain object of plain values (boolean, finite number, string). The client
+ * checks each key and value against its own settings before applying it
+ * (adminPolicyPatch in stores/settings-store.ts).
+ */
+export function sanitizePolicyDefaults(raw: unknown): Record {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
+ const out: Record = {};
+ for (const [key, value] of Object.entries(raw as Record)) {
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
+ if (
+ typeof value === 'boolean' ||
+ typeof value === 'string' ||
+ (typeof value === 'number' && Number.isFinite(value))
+ ) {
+ out[key] = value;
+ }
+ }
+ return out;
+}
diff --git a/stores/__tests__/settings-store-policy-defaults.test.ts b/stores/__tests__/settings-store-policy-defaults.test.ts
new file mode 100644
index 000000000..d1ec27ee2
--- /dev/null
+++ b/stores/__tests__/settings-store-policy-defaults.test.ts
@@ -0,0 +1,180 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const apiFetch = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/browser-navigation', () => ({ apiFetch }));
+
+import { useSettingsStore, migrateSettings } from '../settings-store';
+import { usePolicyStore } from '../policy-store';
+import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
+
+// #1248: `policy.defaults` from policy.json never reached the settings store.
+
+const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+let serverFiles: Record = {};
+
+function loadPolicy(patch: Partial) {
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, ...patch }, loaded: true });
+}
+
+async function signIn(username: string) {
+ const serverUrl = 'https://mail.example.com';
+ await useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
+ useSettingsStore.getState().enableSync(username, serverUrl);
+ });
+}
+
+const settings = () => useSettingsStore.getState();
+
+beforeEach(() => {
+ localStorage.clear();
+ serverFiles = {};
+ apiFetch.mockReset();
+ apiFetch.mockImplementation(async (_url: string, init?: RequestInit) => {
+ if (init?.method === 'POST') return json({ ok: true });
+ const username = (init?.headers as Record)['x-settings-username'];
+ return json({ settings: serverFiles[username] ?? null });
+ });
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, loaded: false });
+ useSettingsStore.getState().disableSync();
+ useSettingsStore.getState().resetToDefaults();
+});
+
+describe('admin policy defaults (#1248)', () => {
+ it('applies a default to a user who never changed the setting', () => {
+ expect(settings().showSharedMailAccounts).toBe(true);
+ loadPolicy({ defaults: { showSharedMailAccounts: false, enableUnifiedMailbox: true } });
+ expect(settings().showSharedMailAccounts).toBe(false);
+ expect(settings().enableUnifiedMailbox).toBe(true);
+ });
+
+ it('keeps a value the user chose, even the build default', () => {
+ settings().updateSetting('showSharedMailAccounts', true);
+ loadPolicy({ defaults: { showSharedMailAccounts: false } });
+ expect(settings().showSharedMailAccounts).toBe(true);
+ });
+
+ it('keeps a choice made after the default applied when the policy loads again', () => {
+ loadPolicy({ defaults: { showSharedMailAccounts: false } });
+ settings().updateSetting('showSharedMailAccounts', true);
+ loadPolicy({ defaults: { showSharedMailAccounts: false } });
+ expect(settings().showSharedMailAccounts).toBe(true);
+ });
+
+ it('follows the operator changing a default the user never touched', () => {
+ loadPolicy({ defaults: { deleteAction: 'trash-and-read' } });
+ expect(settings().deleteAction).toBe('trash-and-read');
+ loadPolicy({ defaults: { deleteAction: 'permanent' } });
+ expect(settings().deleteAction).toBe('permanent');
+ });
+
+ it('ignores keys that are not settings and values of the wrong type', () => {
+ const updateSetting = settings().updateSetting;
+ loadPolicy({
+ defaults: {
+ updateSetting: 'boom',
+ explicitSettings: 'boom',
+ notASetting: true,
+ showSharedMailAccounts: 'no',
+ emailsPerPage: '25',
+ emailKeywords: [],
+ sendDelaySeconds: 15,
+ },
+ });
+ expect(settings().updateSetting).toBe(updateSetting);
+ expect(settings().explicitSettings).toEqual([]);
+ expect('notASetting' in settings()).toBe(false);
+ expect(settings().showSharedMailAccounts).toBe(true);
+ expect(settings().emailsPerPage).toBe(50);
+ expect(settings().sendDelaySeconds).toBe(0);
+ });
+
+ it('respects the allowed values of a restriction', () => {
+ loadPolicy({
+ defaults: { emailsPerPage: 500 },
+ restrictions: { emailsPerPage: { max: 100 } },
+ });
+ expect(settings().emailsPerPage).toBe(50);
+ });
+
+ it('enforces a locked value over the user\'s choice', () => {
+ settings().updateSetting('deleteAction', 'permanent');
+ loadPolicy({ restrictions: { deleteAction: { locked: true, value: 'trash' } } });
+ expect(settings().deleteAction).toBe('trash');
+ });
+
+ it('enforces the default of a locked setting that has no value of its own', () => {
+ settings().updateSetting('showSharedMailAccounts', true);
+ loadPolicy({
+ defaults: { showSharedMailAccounts: false },
+ restrictions: { showSharedMailAccounts: { locked: true } },
+ });
+ expect(settings().showSharedMailAccounts).toBe(false);
+ });
+
+ it('lands on the operator default after a reset', () => {
+ loadPolicy({ defaults: { showSharedMailAccounts: false } });
+ settings().updateSetting('showSharedMailAccounts', true);
+ settings().resetToDefaults();
+ expect(settings().showSharedMailAccounts).toBe(false);
+ expect(settings().explicitSettings).toEqual([]);
+ });
+
+ it('syncs which settings the user chose', () => {
+ settings().updateSetting('showSharedMailAccounts', false);
+ expect(JSON.parse(settings().exportSettings()).explicitSettings).toEqual(['showSharedMailAccounts']);
+ });
+});
+
+describe('admin policy defaults and the server copy (#1248)', () => {
+ it('applies to an account signing in for the first time in a browser another account used', async () => {
+ serverFiles['a@example.com'] = { fontSize: 'large' };
+ await signIn('a@example.com');
+ loadPolicy({ defaults: { showSharedMailAccounts: false } });
+ settings().updateSetting('showSharedMailAccounts', true);
+
+ // b has no server settings yet and starts from defaults (#1185): the
+ // operator's, not the build's.
+ useSettingsStore.getState().disableSync();
+ await signIn('b@example.com');
+ expect(settings().showSharedMailAccounts).toBe(false);
+ expect(settings().fontSize).toBe('medium');
+ });
+
+ it('applies over a server copy written before the default existed', async () => {
+ loadPolicy({ defaults: { showSharedMailAccounts: false, deleteAction: 'trash-and-read' } });
+ // Written by a build without explicitSettings: the build default for
+ // showSharedMailAccounts, a choice of its own for deleteAction.
+ serverFiles['a@example.com'] = { showSharedMailAccounts: true, deleteAction: 'permanent' };
+ await signIn('a@example.com');
+ expect(settings().showSharedMailAccounts).toBe(false);
+ expect(settings().deleteAction).toBe('permanent');
+ expect(settings().explicitSettings).toEqual(['deleteAction']);
+ });
+
+ it('keeps a choice the server copy records, even the build default', async () => {
+ loadPolicy({ defaults: { showSharedMailAccounts: false } });
+ serverFiles['a@example.com'] = { showSharedMailAccounts: true, explicitSettings: ['showSharedMailAccounts'] };
+ await signIn('a@example.com');
+ expect(settings().showSharedMailAccounts).toBe(true);
+ });
+
+ it('applies when the policy arrives after the server copy', async () => {
+ serverFiles['a@example.com'] = { showSharedMailAccounts: true, explicitSettings: [] };
+ await signIn('a@example.com');
+ expect(settings().showSharedMailAccounts).toBe(true);
+ loadPolicy({ defaults: { showSharedMailAccounts: false } });
+ expect(settings().showSharedMailAccounts).toBe(false);
+ });
+});
+
+describe('settings stored before #1248', () => {
+ it('counts a value that differs from the build default as the user\'s choice', () => {
+ const migrated = migrateSettings(
+ { showSharedMailAccounts: false, deleteAction: 'trash', density: 'compact', preferredIdentityIds: {} },
+ 7,
+ );
+ expect([...migrated.explicitSettings].sort()).toEqual(['density', 'showSharedMailAccounts']);
+ });
+});
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index bff3b1660..8e62c285b 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -2,6 +2,8 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useThemeStore } from './theme-store';
import { useLocaleStore } from './locale-store';
+import { usePolicyStore } from './policy-store';
+import type { SettingsPolicy, SettingRestriction } from '@/lib/admin/types';
import type { EmailTemplate } from '@/lib/template-types';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '@/lib/notification-mutes';
@@ -697,6 +699,13 @@ interface SettingsState {
debugMode: boolean;
debugCategories: Record;
settingsSyncDisabled: boolean;
+ /**
+ * Keys the user changed themselves (#1248). An admin policy default
+ * (`policy.defaults`) only fills in settings that are not listed here, so a
+ * choice the user made survives the operator setting or changing a default.
+ * Synced with the rest of the settings.
+ */
+ explicitSettings: string[];
// Actions
/**
@@ -710,6 +719,13 @@ interface SettingsState {
opts?: { internal?: boolean }
) => void;
resetToDefaults: () => void;
+ /**
+ * Applies the admin policy (#1248): `policy.defaults` to every setting the
+ * user has not chosen, and a locked restriction's value to its setting
+ * whatever the user chose. Runs when the policy loads and after every
+ * import, reset and server load.
+ */
+ applyAdminPolicy: () => void;
/** `forSync` builds the snapshot pushed to the server; it is not a user export. */
exportSettings: (opts?: { forSync?: boolean; subscriptionOwner?: string }) => string;
importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean; subscriptionOwner?: string }) => boolean;
@@ -967,8 +983,85 @@ const DEFAULT_SETTINGS = {
push: true,
} as Record,
settingsSyncDisabled: false,
+ explicitSettings: [] as string[],
};
+const hasOwn = (obj: object, key: string) => Object.prototype.hasOwnProperty.call(obj, key);
+const builtInDefault = (key: string) => (DEFAULT_SETTINGS as Record)[key];
+
+/**
+ * Settings an admin policy may set (#1248): plain values (boolean, number,
+ * string). Lists and maps (tags, folder icons, per-account maps) stay the
+ * user's own.
+ */
+function isPolicySettingKey(key: string): boolean {
+ if (key === 'explicitSettings' || !hasOwn(DEFAULT_SETTINGS, key)) return false;
+ const type = typeof builtInDefault(key);
+ return type === 'boolean' || type === 'number' || type === 'string';
+}
+
+/**
+ * policy.json is written by hand, so a value only applies when it has the
+ * build default's type, passes the checks an import makes, and stays within
+ * the restriction's allowed values and bounds.
+ */
+function isAllowedPolicyValue(key: string, value: unknown, restriction?: SettingRestriction): boolean {
+ if (!isPolicySettingKey(key) || typeof value !== typeof builtInDefault(key)) return false;
+ if (typeof value === 'number' && !Number.isFinite(value)) return false;
+ if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(value as number)) return false;
+ if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(value as string)) return false;
+ if (key === 'messageListOrderScope' && value !== 'inbox' && value !== 'all') return false;
+ if (restriction && typeof restriction === 'object') {
+ if (Array.isArray(restriction.allowedValues) && !restriction.allowedValues.includes(value)) return false;
+ if (typeof value === 'number') {
+ if (typeof restriction.min === 'number' && value < restriction.min) return false;
+ if (typeof restriction.max === 'number' && value > restriction.max) return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Local state and server blobs from before #1248 carry no explicitSettings.
+ * There, a value that differs from the build default can only have come from
+ * the user, so it counts as their choice. One that matches it is taken as
+ * never chosen and picks up the operator's default.
+ */
+export function legacyExplicitSettings(source: Record): string[] {
+ return Object.keys(source).filter(
+ (key) => isPolicySettingKey(key) && source[key] !== builtInDefault(key),
+ );
+}
+
+/**
+ * What the admin policy changes in `state` (#1248): each `policy.defaults`
+ * value for a setting the user has not chosen, then each locked
+ * restriction's value (`value`, or the default when it has none) whatever
+ * the user chose. Exported for tests.
+ */
+export function adminPolicyPatch(
+ state: Record,
+ policy: Partial>,
+): Record {
+ const explicit = new Set(Array.isArray(state.explicitSettings) ? (state.explicitSettings as string[]) : []);
+ const defaults = isPlainRecord(policy.defaults) ? policy.defaults : {};
+ const restrictions: Record = isPlainRecord(policy.restrictions) ? policy.restrictions : {};
+ const restrictionFor = (key: string) => (hasOwn(restrictions, key) ? restrictions[key] : undefined);
+ const patch: Record = {};
+ for (const [key, value] of Object.entries(defaults)) {
+ if (explicit.has(key) || !isAllowedPolicyValue(key, value, restrictionFor(key))) continue;
+ if (state[key] !== value) patch[key] = value;
+ }
+ for (const [key, restriction] of Object.entries(restrictions)) {
+ if (!isPlainRecord(restriction) || restriction.locked !== true) continue;
+ const value = hasOwn(restriction, 'value') ? restriction.value : hasOwn(defaults, key) ? defaults[key] : undefined;
+ if (!isAllowedPolicyValue(key, value, restriction)) continue;
+ if (state[key] !== value) patch[key] = value;
+ else delete patch[key];
+ }
+ return patch;
+}
+
export const useSettingsStore = create()(
persist(
(set, get) => {
@@ -981,18 +1074,38 @@ export const useSettingsStore = create()(
const defaults: Record = { ...DEFAULT_SETTINGS };
for (const key of DEVICE_LOCAL_SETTING_KEYS) delete defaults[key];
if (opts?.keepCrossAccountViews) for (const key of CROSS_ACCOUNT_VIEW_KEYS) delete defaults[key];
+ // Settings left as they are keep their "chosen by the user" mark.
+ defaults.explicitSettings = get().explicitSettings.filter((key) => !hasOwn(defaults, key));
const wasLoading = isLoadingFromServer;
isLoadingFromServer = true;
try {
set(defaults as Partial);
templateSyncBridge?.applySyncedState([], {}, { merge: false });
+ // A new account starts from this instance's defaults (#1248).
+ applyAdminPolicyPatch();
} finally {
isLoadingFromServer = wasLoading;
}
- applyFontSize(DEFAULT_SETTINGS.fontSize);
- applyDensity(DEFAULT_SETTINGS.density, DEFAULT_SETTINGS.interfaceLayout);
- applyInterfaceLayout(DEFAULT_SETTINGS.interfaceLayout, DEFAULT_SETTINGS.density);
- applyAnimations(DEFAULT_SETTINGS.animationsEnabled);
+ applyVisualSettings();
+ };
+
+ const applyVisualSettings = () => {
+ const { fontSize, density, interfaceLayout, animationsEnabled } = get();
+ applyFontSize(fontSize);
+ applyDensity(density, interfaceLayout);
+ applyInterfaceLayout(interfaceLayout, density);
+ applyAnimations(animationsEnabled);
+ };
+
+ /** Sets what the admin policy changes; true when anything changed. */
+ const applyAdminPolicyPatch = (): boolean => {
+ const patch = adminPolicyPatch(
+ get() as unknown as Record,
+ usePolicyStore.getState().policy,
+ );
+ if (Object.keys(patch).length === 0) return false;
+ set(patch as Partial);
+ return true;
};
const applySetting = (key: K, value: SettingsState[K]) => {
@@ -1138,6 +1251,7 @@ export const useSettingsStore = create()(
debugMode: state.debugMode,
debugCategories: state.debugCategories,
settingsSyncDisabled: state.settingsSyncDisabled,
+ explicitSettings: state.explicitSettings,
// Cross-store settings
theme: useThemeStore.getState().theme,
locale: useLocaleStore.getState().locale,
@@ -1176,15 +1290,25 @@ export const useSettingsStore = create()(
applySetting(key, value);
return;
}
- changePreference(key, value, get()[key], () => applySetting(key, value));
+ changePreference(key, value, get()[key], () => {
+ applySetting(key, value);
+ // The user chose this value, so an admin default no longer replaces it.
+ const { explicitSettings } = get();
+ if (hasOwn(DEFAULT_SETTINGS, key) && key !== 'explicitSettings' && !explicitSettings.includes(key)) {
+ set({ explicitSettings: [...explicitSettings, key] });
+ }
+ });
+ },
+
+ applyAdminPolicy: () => {
+ if (applyAdminPolicyPatch()) applyVisualSettings();
},
resetToDefaults: () => {
set(DEFAULT_SETTINGS);
- applyFontSize(DEFAULT_SETTINGS.fontSize);
- applyDensity(DEFAULT_SETTINGS.density, DEFAULT_SETTINGS.interfaceLayout);
- applyInterfaceLayout(DEFAULT_SETTINGS.interfaceLayout, DEFAULT_SETTINGS.density);
- applyAnimations(DEFAULT_SETTINGS.animationsEnabled);
+ // The defaults of this instance are the operator's, then the build's.
+ applyAdminPolicyPatch();
+ applyVisualSettings();
void settingsHooks.onSettingsReset.emit({});
},
@@ -1218,6 +1342,10 @@ export const useSettingsStore = create()(
};
Object.keys(settings).forEach((key) => {
if (key in DEFAULT_SETTINGS) {
+ // Merged below, once it is known which settings were taken over.
+ if (key === 'explicitSettings') {
+ return;
+ }
if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) {
return;
}
@@ -1286,11 +1414,27 @@ export const useSettingsStore = create()(
}
});
- // Apply visual settings
- applyFontSize(get().fontSize);
- applyDensity(get().density, get().interfaceLayout);
- applyInterfaceLayout(get().interfaceLayout, get().density);
- applyAnimations(get().animationsEnabled);
+ // Which settings the user chose (#1248): for every setting taken
+ // over from the blob, what the blob says; for the rest, what this
+ // device knew. A blob from before explicitSettings existed counts a
+ // value that differs from the build default as chosen.
+ const takenOver = new Set(imported);
+ const blobExplicit: string[] = Array.isArray(settings.explicitSettings)
+ ? settings.explicitSettings.filter((key: unknown): key is string => typeof key === 'string')
+ : legacyExplicitSettings(settings);
+ set({
+ explicitSettings: [
+ ...new Set([
+ ...get().explicitSettings.filter((key) => !takenOver.has(key)),
+ ...blobExplicit.filter((key) => takenOver.has(key)),
+ ]),
+ ],
+ });
+ // A blob carries whatever defaults applied when it was written, so
+ // the operator's current ones go over the settings nobody chose.
+ applyAdminPolicyPatch();
+
+ applyVisualSettings();
// Apply cross-store settings
if (settings.theme) {
@@ -1569,7 +1713,7 @@ export const useSettingsStore = create()(
},
{
name: 'settings-storage',
- version: 7,
+ version: 8,
migrate: migrateSettings,
onRehydrateStorage: () => {
return (state) => {
@@ -1586,6 +1730,9 @@ export const useSettingsStore = create()(
if (!isPlainRecord(state.hiddenSharedMailAccounts)) {
state.hiddenSharedMailAccounts = {};
}
+ if (!Array.isArray(state.explicitSettings)) {
+ state.explicitSettings = [];
+ }
state.notificationMutes = normalizeNotificationMutes(state.notificationMutes);
state.messageListOrder = sanitizeSortLevels(state.messageListOrder);
if (state.messageListOrderScope !== 'inbox' && state.messageListOrderScope !== 'all') {
@@ -1665,6 +1812,12 @@ export function migrateSettings(persisted: unknown, version: number): SettingsSt
if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) {
state.preferredIdentityIds = {};
}
+ // v8: admin policy defaults (#1248) skip settings the user chose.
+ // Earlier builds did not record that, so a setting that differs from
+ // the build default is taken as chosen.
+ if (version < 8 || !Array.isArray(state.explicitSettings)) {
+ state.explicitSettings = legacyExplicitSettings(state);
+ }
return state as unknown as SettingsState;
}
@@ -1795,6 +1948,13 @@ if (typeof window !== 'undefined') {
applyInterfaceLayout(store.interfaceLayout, store.density);
applyAnimations(store.animationsEnabled);
+ // The policy arrives after this store has rehydrated (and again with the
+ // full scope once signed in), so its defaults apply whenever it changes.
+ usePolicyStore.subscribe((policyState, prev) => {
+ if (policyState.policy === prev.policy) return;
+ useSettingsStore.getState().applyAdminPolicy();
+ });
+
const triggerSync = () => {
if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return;
pendingSync = {
From deb106736ea23af638f74ed833ef5f2ab34f64dd Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 16 Oct 2026 23:37:00 +0200
Subject: [PATCH 021/238] fix: load the admin panel when the config is
read-only #1249
---
.../admin/_tabs/plugin-config-panel.tsx | 13 +-
app/(main)/admin/layout.tsx | 26 +++-
.../admin/__tests__/read-only-config.test.ts | 125 ++++++++++++++++++
app/api/admin/branding/route.ts | 13 +-
app/api/admin/change-password/route.ts | 3 +
app/api/admin/config/route.ts | 5 +
app/api/admin/marketplace/route.ts | 3 +
app/api/admin/plugin-approvals/route.ts | 5 +
app/api/admin/plugins/[id]/config/route.ts | 9 +-
app/api/admin/plugins/route.ts | 7 +
app/api/admin/policy/route.ts | 3 +
app/api/admin/themes/route.ts | 7 +
lib/admin/config-read-only-response.ts | 13 ++
lib/admin/password.ts | 7 +-
lib/admin/plugin-approvals.ts | 3 +
15 files changed, 233 insertions(+), 9 deletions(-)
create mode 100644 app/api/admin/__tests__/read-only-config.test.ts
create mode 100644 lib/admin/config-read-only-response.ts
diff --git a/app/(main)/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx
index 9f5370614..723d18839 100644
--- a/app/(main)/admin/_tabs/plugin-config-panel.tsx
+++ b/app/(main)/admin/_tabs/plugin-config-panel.tsx
@@ -115,6 +115,13 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
try {
let hasError = false;
+ let firstError: string | null = null;
+ const noteError = async (res: Response) => {
+ hasError = true;
+ if (firstError) return;
+ const data = await res.json().catch(() => null);
+ if (typeof data?.error === 'string') firstError = data.error;
+ };
for (const [key, field] of Object.entries(plugin.configSchema)) {
const newVal = formValues[key] ?? '';
const oldVal = config[key] !== undefined ? String(config[key]) : '';
@@ -135,7 +142,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
if (res.ok) {
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
} else {
- hasError = true;
+ await noteError(res);
}
continue;
}
@@ -148,12 +155,12 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
if (res.ok) {
setConfig(prev => ({ ...prev, [key]: value }));
} else {
- hasError = true;
+ await noteError(res);
}
}
setMessage(hasError
- ? { type: 'error', text: 'Some settings failed to save' }
+ ? { type: 'error', text: firstError ?? 'Some settings failed to save' }
: { type: 'success', text: 'Configuration saved' }
);
} catch {
diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx
index 201cc68d7..ab54a0e44 100644
--- a/app/(main)/admin/layout.tsx
+++ b/app/(main)/admin/layout.tsx
@@ -90,6 +90,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const [authError, setAuthError] = useState(null);
const [isStalwartAutoLogin, setIsStalwartAutoLogin] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
+ const [configReadOnly, setConfigReadOnly] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
@@ -178,6 +179,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return () => { cancelled = true; };
}, [pathname, router]);
+ // With ADMIN_CONFIG_READONLY the panel still shows every setting, but
+ // nothing can be saved; say so up front instead of after a failed save (#1249).
+ useEffect(() => {
+ if (!authenticated) return;
+ let cancelled = false;
+ apiFetch('/api/setup/status')
+ .then(res => (res.ok ? res.json() : null))
+ .then(data => { if (!cancelled) setConfigReadOnly(data?.readOnly === true); })
+ .catch(() => {});
+ return () => { cancelled = true; };
+ }, [authenticated]);
+
function handleLogout() {
requestLeave(async () => {
await apiFetch('/api/admin/auth', { method: 'DELETE' });
@@ -425,7 +438,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
Loading admin panel…
) : authenticated ? (
- children
+ <>
+ {configReadOnly && (
+
+
The configuration is read-only
+
+ ADMIN_CONFIG_READONLY is set, so you can look at the settings here but not save changes.
+ Change them in the mounted config files or environment instead.
+
+
+ )}
+ {children}
+ >
) : null}
diff --git a/app/api/admin/__tests__/read-only-config.test.ts b/app/api/admin/__tests__/read-only-config.test.ts
new file mode 100644
index 000000000..f7e7a429c
--- /dev/null
+++ b/app/api/admin/__tests__/read-only-config.test.ts
@@ -0,0 +1,125 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
+import { NextRequest } from 'next/server';
+import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
+import path from 'node:path';
+
+// Writes from the admin panel must answer 409 with the read-only message
+// instead of a bare 500, and must not change anything on the way (#1249).
+
+// Hoisted so the dirs are set before any admin module loads.
+const configDir = await vi.hoisted(async () => {
+ const { mkdtempSync } = await import('node:fs');
+ const { tmpdir } = await import('node:os');
+ const { join } = await import('node:path');
+ const dir = mkdtempSync(join(tmpdir(), 'bulwark-readonly-'));
+ process.env.ADMIN_CONFIG_DIR = dir;
+ process.env.ADMIN_STATE_DIR = join(dir, 'state');
+ return dir;
+});
+
+let readOnly = false;
+
+vi.mock('@/lib/admin/session', () => ({
+ requireAdminAuth: async () => ({ payload: { role: 'admin' } }),
+ getClientIP: () => '127.0.0.1',
+}));
+vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} }));
+vi.mock('@/lib/admin/paths', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ isConfigReadOnly: () => readOnly,
+ assertWritable: (operation: string) => {
+ if (readOnly) throw new actual.ConfigReadOnlyError(operation);
+ },
+ };
+});
+
+import { PATCH as patchConfig, DELETE as deleteConfig } from '../config/route';
+import { PUT as putPolicy } from '../policy/route';
+import { DELETE as deleteBranding } from '../branding/route';
+import { POST as changePassword } from '../change-password/route';
+import { POST as decidePluginApproval } from '../plugin-approvals/route';
+import { setInitialAdminPassword, verifyAdminPassword } from '@/lib/admin/password';
+import { requestApproval, listApprovals } from '@/lib/admin/plugin-approvals';
+import { configManager } from '@/lib/admin/config-manager';
+
+function req(url: string, method: string, body: unknown) {
+ return new NextRequest(`http://localhost${url}`, {
+ method,
+ body: JSON.stringify(body),
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+async function expectReadOnly(res: Response) {
+ expect(res.status).toBe(409);
+ expect((await res.json()).error).toMatch(/configuration is read-only/);
+}
+
+beforeAll(async () => {
+ await setInitialAdminPassword('old-password-123');
+ await requestApproval('demo-plugin', 'abcdef0123456789', { name: 'Demo', version: '1.0.0' } as never, 'user@example.org');
+ await configManager.ensureLoaded();
+});
+
+beforeEach(() => {
+ readOnly = true;
+});
+
+afterAll(() => {
+ rmSync(configDir, { recursive: true, force: true });
+});
+
+describe('admin writes on a read-only config (#1249)', () => {
+ it('PATCH /api/admin/config answers 409 with the reason', async () => {
+ await expectReadOnly(await patchConfig(req('/api/admin/config', 'PATCH', { appName: 'Changed' })));
+ expect(configManager.get('appName')).not.toBe('Changed');
+ });
+
+ it('DELETE /api/admin/config answers 409 with the reason', async () => {
+ await expectReadOnly(await deleteConfig(req('/api/admin/config', 'DELETE', { key: 'appName' })));
+ });
+
+ it('PUT /api/admin/policy answers 409 with the reason', async () => {
+ await expectReadOnly(await putPolicy(req('/api/admin/policy', 'PUT', { restrictions: {} })));
+ });
+
+ it('DELETE /api/admin/branding refuses before deleting the file', async () => {
+ const dir = path.join(configDir, 'branding');
+ mkdirSync(dir, { recursive: true });
+ const file = path.join(dir, 'faviconUrl.png');
+ writeFileSync(file, 'png');
+
+ await expectReadOnly(await deleteBranding(req('/api/admin/branding', 'DELETE', { slot: 'faviconUrl' })));
+ expect(existsSync(file)).toBe(true);
+ });
+
+ it('POST /api/admin/change-password refuses and keeps the old password', async () => {
+ await expectReadOnly(await changePassword(req('/api/admin/change-password', 'POST', {
+ currentPassword: 'old-password-123',
+ newPassword: 'new-password-456',
+ })));
+ expect(await verifyAdminPassword('old-password-123')).toBe(true);
+ expect(await verifyAdminPassword('new-password-456')).toBe(false);
+ });
+
+ it('POST /api/admin/plugin-approvals refuses and leaves the request pending', async () => {
+ await expectReadOnly(await decidePluginApproval(req('/api/admin/plugin-approvals', 'POST', {
+ pluginId: 'demo-plugin',
+ bundleHash: 'abcdef0123456789',
+ decision: 'approved',
+ })));
+ expect((await listApprovals()).map(e => e.status)).toEqual(['pending']);
+ });
+
+ it('still saves when the config is writable', async () => {
+ readOnly = false;
+ const res = await patchConfig(req('/api/admin/config', 'PATCH', { appName: 'Writable' }));
+ expect(res.status).toBe(200);
+ expect(configManager.get('appName')).toBe('Writable');
+ });
+});
diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts
index 11f68b1e6..b7d5e4ff5 100644
--- a/app/api/admin/branding/route.ts
+++ b/app/api/admin/branding/route.ts
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { configManager } from '@/lib/admin/config-manager';
-import { getConfigDir } from '@/lib/admin/paths';
+import { getConfigDir, assertWritable } from '@/lib/admin/paths';
import {
parseDomainBranding,
type DomainBrandingEntry,
@@ -12,6 +12,7 @@ import { logger } from '@/lib/logger';
import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
+import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
function getBrandingDir(): string {
return path.join(getConfigDir(), 'branding');
@@ -124,6 +125,9 @@ export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
+ // Refuse before touching the branding dir: the config write that
+ // follows would fail only after the files had already changed.
+ assertWritable('change branding');
const ip = getClientIP(request);
const formData = await request.formData();
@@ -206,6 +210,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ url: servedUrl, filename: safeName });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -224,6 +230,9 @@ export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
+ // Refuse before touching the branding dir: the config write that
+ // follows would fail only after the files had already changed.
+ assertWritable('change branding');
const ip = getClientIP(request);
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
@@ -271,6 +280,8 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ success: true });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
diff --git a/app/api/admin/change-password/route.ts b/app/api/admin/change-password/route.ts
index e29cc542c..2cc06b3db 100644
--- a/app/api/admin/change-password/route.ts
+++ b/app/api/admin/change-password/route.ts
@@ -3,6 +3,7 @@ import { changeAdminPassword } from '@/lib/admin/password';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
+import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/**
* POST /api/admin/change-password - Change admin password
@@ -31,6 +32,8 @@ export async function POST(request: NextRequest) {
await auditLog('admin.change-password', {}, ip);
return NextResponse.json({ ok: true });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Admin change password error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts
index c65dcb10a..450b88674 100644
--- a/app/api/admin/config/route.ts
+++ b/app/api/admin/config/route.ts
@@ -8,6 +8,7 @@ import { parseDomainBranding } from '@/lib/admin/domain-branding';
import { initAdminPassword, isAdminEnabled } from '@/lib/admin/password';
import { logger } from '@/lib/logger';
import { hasSessionSecret } from '@/lib/auth/session-secret';
+import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
// Strings that count as "no real secret configured" - used so the dashboard
// can warn about a placeholder session secret without us ever returning the
@@ -145,6 +146,8 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ ok: true });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Admin config update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -175,6 +178,8 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ ok: true });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Admin config revert error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts
index 9cf833457..233b5974c 100644
--- a/app/api/admin/marketplace/route.ts
+++ b/app/api/admin/marketplace/route.ts
@@ -23,6 +23,7 @@ import {
} from '@/lib/extension-package';
import { configManager } from '@/lib/admin/config-manager';
import { checkBundleSignature } from '@/lib/admin/directory-keys';
+import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
async function getDirectoryUrl(): Promise {
await configManager.ensureLoaded();
@@ -303,6 +304,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: true, plugin, warnings: pkg.warnings, updated: isUpdate, signature: signature.status });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
}
diff --git a/app/api/admin/plugin-approvals/route.ts b/app/api/admin/plugin-approvals/route.ts
index 200f3ad78..684f3e706 100644
--- a/app/api/admin/plugin-approvals/route.ts
+++ b/app/api/admin/plugin-approvals/route.ts
@@ -3,6 +3,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
+import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/**
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
@@ -55,6 +56,8 @@ export async function POST(request: NextRequest) {
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
return NextResponse.json({ entry });
} catch (err) {
+ const readOnly = configReadOnlyResponse(err);
+ if (readOnly) return readOnly;
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -79,6 +82,8 @@ export async function DELETE(request: NextRequest) {
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
return NextResponse.json({ ok: true });
} catch (err) {
+ const readOnly = configReadOnlyResponse(err);
+ if (readOnly) return readOnly;
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
diff --git a/app/api/admin/plugins/[id]/config/route.ts b/app/api/admin/plugins/[id]/config/route.ts
index 7e75f1e61..b6b8a61a4 100644
--- a/app/api/admin/plugins/[id]/config/route.ts
+++ b/app/api/admin/plugins/[id]/config/route.ts
@@ -4,6 +4,7 @@ import { getDevPlugin } from '@/lib/admin/plugin-dev';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
+import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/** Resolve a plugin from the persisted registry first, then PLUGIN_DEV_DIR. */
async function resolvePlugin(id: string) {
@@ -128,7 +129,9 @@ export async function PUT(
await setPluginConfig(id, body.key, body.value);
return NextResponse.json({ ok: true });
- } catch {
+ } catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -165,7 +168,9 @@ export async function DELETE(
await deletePluginConfigKey(id, body.key);
return NextResponse.json({ ok: true });
- } catch {
+ } catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts
index 7e0306515..afed1c56a 100644
--- a/app/api/admin/plugins/route.ts
+++ b/app/api/admin/plugins/route.ts
@@ -14,6 +14,7 @@ import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
import { cleanUpDeletedPlugin } from '@/lib/admin/extension-cleanup';
import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package';
import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types';
+import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/**
* GET /api/admin/plugins - List all admin-managed plugins
@@ -124,6 +125,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ plugin, warnings: pkg.warnings });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -175,6 +178,8 @@ export async function PATCH(request: NextRequest) {
await auditLog('plugin.update', { id, ...updates }, ip);
return NextResponse.json({ plugin: updated });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -206,6 +211,8 @@ export async function DELETE(request: NextRequest) {
await auditLog('plugin.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts
index 681aaae28..6e707577e 100644
--- a/app/api/admin/policy/route.ts
+++ b/app/api/admin/policy/route.ts
@@ -5,6 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types';
import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
+import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/**
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this;
@@ -81,6 +82,8 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ ok: true });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Policy update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts
index 3950021aa..70d88d06f 100644
--- a/app/api/admin/themes/route.ts
+++ b/app/api/admin/themes/route.ts
@@ -13,6 +13,7 @@ import {
import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup';
import { readThemePackage } from '@/lib/extension-package';
+import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/**
* GET /api/admin/themes - List all admin-managed themes
@@ -76,6 +77,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ theme, warnings });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Theme install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -113,6 +116,8 @@ export async function PATCH(request: NextRequest) {
await auditLog('theme.update', { id, ...updates }, ip);
return NextResponse.json({ theme: updated });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -143,6 +148,8 @@ export async function DELETE(request: NextRequest) {
await auditLog('theme.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
logger.error('Theme delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
diff --git a/lib/admin/config-read-only-response.ts b/lib/admin/config-read-only-response.ts
new file mode 100644
index 000000000..c334d3799
--- /dev/null
+++ b/lib/admin/config-read-only-response.ts
@@ -0,0 +1,13 @@
+import { NextResponse } from 'next/server';
+import { ConfigReadOnlyError } from './paths';
+
+/**
+ * The 409 an admin route answers when a write hit ADMIN_CONFIG_READONLY,
+ * carrying the ConfigReadOnlyError message so the panel can show why
+ * nothing was saved (#1249). Returns null for any other error, which the
+ * route keeps reporting as a 500.
+ */
+export function configReadOnlyResponse(error: unknown): NextResponse | null {
+ if (!(error instanceof ConfigReadOnlyError)) return null;
+ return NextResponse.json({ error: error.message }, { status: 409 });
+}
diff --git a/lib/admin/password.ts b/lib/admin/password.ts
index d9dff58a5..c9772bd4a 100644
--- a/lib/admin/password.ts
+++ b/lib/admin/password.ts
@@ -185,9 +185,12 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
const valid = await verifyAdminPassword(currentPassword);
if (!valid) return false;
+ // Write first, then swap the cache: a refused write (read-only config)
+ // must not leave the new password active until the next restart.
const hash = await hashPassword(newPassword);
- cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
- await writeConfigData(cachedConfig);
+ const next: AdminConfigData = { passwordHash: hash, passwordHashFile: undefined };
+ await writeConfigData(next);
+ cachedConfig = next;
cachedState = {
...(cachedState ?? freshState()),
diff --git a/lib/admin/plugin-approvals.ts b/lib/admin/plugin-approvals.ts
index 2654b9298..ef0ac516b 100644
--- a/lib/admin/plugin-approvals.ts
+++ b/lib/admin/plugin-approvals.ts
@@ -148,6 +148,7 @@ export async function requestApproval(
const file = cached!;
const existing = findEntry(file, pluginId, bundleHash);
if (existing) return existing;
+ assertWritable('request plugin approval');
const pendingByUser = file.entries.filter(e => e.status === 'pending' && e.requestedBy === requestedBy).length;
if (pendingByUser >= MAX_PENDING_PER_USER) {
throw new ApprovalLimitError(`You already have ${MAX_PENDING_PER_USER} plugins waiting for approval`);
@@ -182,6 +183,7 @@ export async function decideApproval(
decision: 'approved' | 'denied',
decidedBy: string,
): Promise {
+ assertWritable('decide plugin approval');
await ensureLoaded();
const file = cached!;
const entry = findEntry(file, pluginId, bundleHash);
@@ -194,6 +196,7 @@ export async function decideApproval(
}
export async function revokeApproval(pluginId: string, bundleHash: string): Promise {
+ assertWritable('revoke plugin approval');
await ensureLoaded();
const file = cached!;
const idx = file.entries.findIndex(e => e.pluginId === pluginId && e.bundleHash === bundleHash);
From d47f26da209a14cf0af085e1d5a420c2d018d68c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:51:05 +0200
Subject: [PATCH 022/238] fix: show invitation times in your time zone #1252
---
.../email/calendar-invitation-banner.tsx | 56 ++---
lib/__tests__/calendar-invitation.test.ts | 101 +++++++-
lib/__tests__/calendar-utils.test.ts | 27 +++
lib/calendar-invitation.ts | 220 ++++++++++++++----
lib/calendar-utils.ts | 10 +-
lib/timezone.ts | 18 ++
6 files changed, 352 insertions(+), 80 deletions(-)
diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx
index 395beac22..cbc1a18b2 100644
--- a/components/email/calendar-invitation-banner.tsx
+++ b/components/email/calendar-invitation-banner.tsx
@@ -33,12 +33,15 @@ import {
getInvitationActorSummary,
getInvitationTrustAssessment,
formatEventSummary,
+ formatInvitationSchedule,
findParticipantByEmail,
extractMethodFromRawIcs,
+ type EventSummary,
type InvitationMethod,
type InvitationTrustAssessment,
} from '@/lib/calendar-invitation';
import { cn } from '@/lib/utils';
+import { getEventStartDate } from '@/lib/calendar-utils';
import { sanitizeColor } from '@/components/calendar/event-card';
import { RecipientPopover } from './recipient-popover';
@@ -260,7 +263,7 @@ function buildInvitationChangeItems(
t: ReturnType,
currentEvent: Partial | null,
proposedEvent: Partial | null,
- formatDateTime: (dateStr: string | null) => string,
+ formatSchedule: (summary: EventSummary) => string,
): InvitationChangeItem[] {
if (!currentEvent || !proposedEvent) {
return [];
@@ -279,10 +282,10 @@ function buildInvitationChangeItems(
}
const currentSchedule = currentSummary.start
- ? `${formatDateTime(currentSummary.start)}${currentSummary.end ? ` - ${formatDateTime(currentSummary.end)}` : ''}`
+ ? formatSchedule(currentSummary)
: t('change_empty');
const proposedSchedule = proposedSummary.start
- ? `${formatDateTime(proposedSummary.start)}${proposedSummary.end ? ` - ${formatDateTime(proposedSummary.end)}` : ''}`
+ ? formatSchedule(proposedSummary)
: t('change_empty');
if (currentSchedule !== proposedSchedule && proposedSummary.start) {
changes.push({
@@ -667,13 +670,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
};
const handleViewInCalendar = () => {
- const targetDate = existingEvent?.utcStart
- || existingEvent?.start
- || parsedEvent?.utcStart
- || parsedEvent?.start;
-
- if (targetDate) {
- const parsedDate = new Date(targetDate);
+ const target = existingEvent?.start ? existingEvent : parsedEvent;
+ if (target?.start) {
+ const parsedDate = getEventStartDate({
+ start: target.start,
+ utcStart: target.utcStart ?? null,
+ showWithoutTime: target.showWithoutTime ?? false,
+ timeZone: target.timeZone,
+ });
if (!Number.isNaN(parsedDate.getTime())) {
setSelectedDate(parsedDate);
}
@@ -708,31 +712,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
}
};
- const isAllDayEvent = parsedEvent?.showWithoutTime ?? false;
-
- const formatDateTime = (dateStr: string | null) => {
- if (!dateStr) return '';
- const date = new Date(dateStr);
- if (isNaN(date.getTime())) return dateStr;
- if (isAllDayEvent) {
- return format.dateTime(date, {
- weekday: 'short',
- month: 'short',
- day: 'numeric',
- });
- }
- return format.dateTime(date, {
- weekday: 'short',
- month: 'short',
- day: 'numeric',
- hour: 'numeric',
- minute: '2-digit',
- hour12: timeFormat === '12h',
- });
- };
+ const formatSchedule = (eventSummary: EventSummary) => formatInvitationSchedule(
+ eventSummary,
+ (date, options) => format.dateTime(date, options),
+ timeFormat === '12h',
+ );
const proposedChanges = method === 'counter' && existingEvent && parsedEvent
- ? buildInvitationChangeItems(t, existingEvent, parsedEvent, formatDateTime)
+ ? buildInvitationChangeItems(t, existingEvent, parsedEvent, formatSchedule)
: [];
const canApplyProposal = Boolean(
client
@@ -887,8 +874,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
- {formatDateTime(summary.start)}
- {summary.end && ` – ${formatDateTime(summary.end)}`}
+ {formatSchedule(summary)}
)}
diff --git a/lib/__tests__/calendar-invitation.test.ts b/lib/__tests__/calendar-invitation.test.ts
index 68813a274..7b548e036 100644
--- a/lib/__tests__/calendar-invitation.test.ts
+++ b/lib/__tests__/calendar-invitation.test.ts
@@ -1,10 +1,11 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import {
findCalendarAttachment,
getInvitationActorSummary,
getInvitationMethod,
getInvitationTrustAssessment,
formatEventSummary,
+ formatInvitationSchedule,
findParticipantByEmail,
} from '../calendar-invitation';
import type { Email, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
@@ -357,7 +358,7 @@ describe('formatEventSummary', () => {
showWithoutTime: true,
});
expect(summary.start).toBe('2026-03-16T00:00:00');
- expect(summary.end).toBe('2026-03-17T00:00:00Z');
+ expect(summary.end).toBe('2026-03-17T00:00:00');
expect(summary.isAllDay).toBe(true);
});
@@ -532,3 +533,99 @@ describe('findParticipantByEmail', () => {
expect(findParticipantByEmail(event, '')).toBeNull();
});
});
+
+describe('invitation times across time zones (#1252)', () => {
+ // A process zone that differs from both the organizer's and the viewer's,
+ // so reading a LocalDateTime as browser-local would show up as a shift.
+ let originalTZ: string | undefined;
+ beforeAll(() => {
+ originalTZ = process.env.TZ;
+ process.env.TZ = 'America/New_York';
+ });
+ afterAll(() => {
+ if (originalTZ === undefined) delete process.env.TZ;
+ else process.env.TZ = originalTZ;
+ });
+
+ // What next-intl's format.dateTime does with the provider's display zone.
+ const formatIn = (timeZone: string) => (date: Date, options: Intl.DateTimeFormatOptions) =>
+ new Intl.DateTimeFormat('en-US', { timeZone, ...options }).format(date);
+
+ // CalendarEvent/parse output for the Exchange invite in the issue.
+ const londonInvite: Partial = {
+ title: 'Sync',
+ start: '2026-10-20T15:00:00',
+ timeZone: 'Europe/London',
+ duration: 'PT25M',
+ };
+
+ it('resolves start + timeZone to a real instant when there is no utcStart', () => {
+ const summary = formatEventSummary(londonInvite);
+ expect(summary.start).toBe('2026-10-20T14:00:00.000Z');
+ expect(summary.end).toBe('2026-10-20T14:25:00.000Z');
+ expect(summary.isFloating).toBe(false);
+ });
+
+ it('shows the invite in the viewer zone with a zone label', () => {
+ const summary = formatEventSummary(londonInvite);
+ expect(formatInvitationSchedule(summary, formatIn('Europe/Warsaw'), false))
+ .toBe('Tue, Oct 20, 16:00 – Tue, Oct 20, 16:25 GMT+2');
+ expect(formatInvitationSchedule(summary, formatIn('America/New_York'), true))
+ .toBe('Tue, Oct 20, 10:00 AM – Tue, Oct 20, 10:25 AM EDT');
+ });
+
+ it('treats a UTC start as UTC', () => {
+ const summary = formatEventSummary({ start: '2026-10-20T14:00:00', timeZone: 'Etc/UTC', duration: 'PT1H' });
+ expect(summary.start).toBe('2026-10-20T14:00:00.000Z');
+ expect(summary.end).toBe('2026-10-20T15:00:00.000Z');
+ });
+
+ it('keeps nominal days in the event zone across a DST change', () => {
+ // Oct 25 2026 is the end of BST: 15:00 the next day is 15:00 GMT.
+ const summary = formatEventSummary({ start: '2026-10-24T15:00:00', timeZone: 'Europe/London', duration: 'P1DT1H' });
+ expect(summary.start).toBe('2026-10-24T14:00:00.000Z');
+ expect(summary.end).toBe('2026-10-25T16:00:00.000Z');
+ });
+
+ it('shows floating times as the same wall clock everywhere, without a zone label', () => {
+ const summary = formatEventSummary({ start: '2026-10-20T15:00:00', timeZone: null, duration: 'PT30M' });
+ expect(summary.isFloating).toBe(true);
+ expect(formatInvitationSchedule(summary, formatIn('Asia/Tokyo'), false))
+ .toBe('Tue, Oct 20, 15:00 – Tue, Oct 20, 15:30');
+ });
+
+ it('does not shift all-day dates in zones west or east of UTC', () => {
+ const oneDay = formatEventSummary({ start: '2026-03-16T00:00:00', duration: 'P1D', showWithoutTime: true, timeZone: 'Europe/London' });
+ expect(formatInvitationSchedule(oneDay, formatIn('America/Los_Angeles'), false)).toBe('Mon, Mar 16');
+ expect(formatInvitationSchedule(oneDay, formatIn('Pacific/Auckland'), false)).toBe('Mon, Mar 16');
+
+ const threeDays = formatEventSummary({
+ start: '2026-03-16T00:00:00',
+ showWithoutTime: true,
+ utcStart: '2026-03-16T00:00:00Z',
+ utcEnd: '2026-03-19T00:00:00Z',
+ });
+ expect(formatInvitationSchedule(threeDays, formatIn('America/Los_Angeles'), false)).toBe('Mon, Mar 16 – Wed, Mar 18');
+ });
+
+ it('shows only the start for a zero-duration event', () => {
+ const summary = formatEventSummary({
+ ...londonInvite,
+ duration: 'PT0S',
+ utcStart: '2026-10-20T14:00:00Z',
+ utcEnd: '2026-10-20T14:00:00Z',
+ });
+ expect(formatInvitationSchedule(summary, formatIn('Europe/Warsaw'), false))
+ .toBe('Tue, Oct 20, 16:00 GMT+2');
+ });
+
+ it('still prefers the server utcStart/utcEnd when present', () => {
+ const summary = formatEventSummary({
+ ...londonInvite,
+ utcStart: '2026-10-20T14:00:00Z',
+ utcEnd: '2026-10-20T14:25:00Z',
+ });
+ expect(formatInvitationSchedule(summary, formatIn('UTC'), false))
+ .toBe('Tue, Oct 20, 14:00 – Tue, Oct 20, 14:25 UTC');
+ });
+});
diff --git a/lib/__tests__/calendar-utils.test.ts b/lib/__tests__/calendar-utils.test.ts
index 7a90f367f..c34990532 100644
--- a/lib/__tests__/calendar-utils.test.ts
+++ b/lib/__tests__/calendar-utils.test.ts
@@ -126,6 +126,33 @@ describe('calendar-utils all-day handling', () => {
expect(getEventEndDate(event).toISOString()).toBe('2026-03-15T14:00:00.000Z');
});
+ it('reads start in the event timezone when utcStart is missing (CalendarEvent/parse, #1252)', () => {
+ const event = makeEvent({
+ start: '2026-10-20T15:00:00',
+ duration: 'PT25M',
+ timeZone: 'Europe/London',
+ showWithoutTime: false,
+ utcStart: undefined,
+ utcEnd: undefined,
+ });
+
+ expect(getEventStartDate(event).toISOString()).toBe('2026-10-20T14:00:00.000Z');
+ expect(getEventEndDate(event).toISOString()).toBe('2026-10-20T14:25:00.000Z');
+ });
+
+ it('keeps floating start times as wall clock when utcStart is missing', () => {
+ const event = makeEvent({
+ start: '2026-10-20T15:00:00',
+ duration: 'PT1H',
+ timeZone: null,
+ showWithoutTime: false,
+ utcStart: undefined,
+ utcEnd: undefined,
+ });
+
+ expectLocalDateParts(getEventStartDate(event), 2026, 10, 20, 15);
+ });
+
it('clips timed multi-day events to the visible day bounds', () => {
const event = makeEvent({
start: '2026-03-14T22:00:00',
diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts
index 009ea1249..a49766e16 100644
--- a/lib/calendar-invitation.ts
+++ b/lib/calendar-invitation.ts
@@ -1,6 +1,6 @@
-import { parseISO } from 'date-fns';
import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPart } from '@/lib/jmap/types';
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
+import { fromWallClock, isValidTimeZone, parseLocalDateTime, type WallClock } from '@/lib/timezone';
export type InvitationMethod =
| 'publish'
@@ -450,6 +450,8 @@ export interface EventSummary {
start: string | null;
end: string | null;
isAllDay: boolean;
+ /** Timed event without a usable time zone: start/end are wall-clock times to show as they are. */
+ isFloating: boolean;
location: string | null;
organizer: string | null;
organizerEmail: string | null;
@@ -498,25 +500,14 @@ export function formatEventSummary(event: Partial): EventSummary
}
const isAllDay = normalizedEvent.showWithoutTime ?? false;
-
- let end: string | null = null;
- if (normalizedEvent.utcEnd) {
- end = normalizedEvent.utcEnd;
- } else if (normalizedEvent.start && normalizedEvent.duration) {
- end = addDurationToDate(normalizedEvent.start, normalizedEvent.duration, normalizedEvent.timeZone);
- }
-
- // For all-day events, prefer the local start (no timezone) to avoid
- // UTC conversion shifting the displayed date in non-UTC timezones.
- const start = isAllDay
- ? (normalizedEvent.start || null)
- : (normalizedEvent.utcStart || normalizedEvent.start || null);
+ const { start, end, isFloating } = resolveSummaryTimes(normalizedEvent, isAllDay);
return {
title: normalizedEvent.title || '',
start,
end,
isAllDay,
+ isFloating,
location,
organizer,
organizerEmail,
@@ -524,41 +515,188 @@ export function formatEventSummary(event: Partial): EventSummary
};
}
-function addDurationToDate(start: string, duration: string, _timeZone?: string | null): string | null {
+const HAS_UTC_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
+
+/** Wall-clock arithmetic on plain fields, independent of any zone. */
+function shiftWallClock(w: WallClock, days: number, seconds: number): WallClock {
+ const d = new Date(0);
+ d.setUTCFullYear(w.year, w.month - 1, w.day + days);
+ d.setUTCHours(w.hour, w.minute, w.second + seconds, 0);
+ return {
+ year: d.getUTCFullYear(),
+ month: d.getUTCMonth() + 1,
+ day: d.getUTCDate(),
+ hour: d.getUTCHours(),
+ minute: d.getUTCMinutes(),
+ second: d.getUTCSeconds(),
+ };
+}
+
+function formatLocalDateTime(w: WallClock): string {
+ const pad = (n: number, len = 2) => String(n).padStart(len, '0');
+ return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`;
+}
+
+/** Days (nominal) and seconds (exact) of an ISO 8601 / JSCalendar duration. */
+function parseDurationParts(duration: string): { days: number; seconds: number } | null {
const match = duration.match(/^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
if (!match) return null;
+ return {
+ days: Number(match[1] || 0) * 7 + Number(match[2] || 0),
+ seconds: Number(match[3] || 0) * 3600 + Number(match[4] || 0) * 60 + Number(match[5] || 0),
+ };
+}
- const weeks = parseInt(match[1] || '0');
- const days = parseInt(match[2] || '0') + weeks * 7;
- const hours = parseInt(match[3] || '0');
- const minutes = parseInt(match[4] || '0');
- const seconds = parseInt(match[5] || '0');
+function isValidInstant(value: string | null | undefined): value is string {
+ return typeof value === 'string' && HAS_UTC_OFFSET.test(value) && !Number.isNaN(new Date(value).getTime());
+}
- const date = parseISO(start);
- if (isNaN(date.getTime())) return null;
+/**
+ * Start and end of an event for display. Timed events come back as UTC
+ * instants (ISO strings ending in `Z`) so they can be shown in the user's
+ * zone; all-day and floating events come back as LocalDateTime strings that
+ * must be shown as the wall clock they are (#1252).
+ *
+ * CalendarEvent/parse returns `start` as a LocalDateTime in `timeZone` and
+ * no `utcStart`, so reading `start` as an instant showed the organizer's
+ * wall clock as if it were the user's.
+ */
+function resolveSummaryTimes(
+ event: Partial,
+ isAllDay: boolean,
+): { start: string | null; end: string | null; isFloating: boolean } {
+ const duration = event.duration ? parseDurationParts(event.duration) : null;
+ const localStart = event.start ? parseLocalDateTime(event.start) : null;
- const isUTC = start.endsWith('Z') || start.includes('+');
-
- if (isUTC) {
- date.setUTCDate(date.getUTCDate() + days);
- date.setUTCHours(date.getUTCHours() + hours);
- date.setUTCMinutes(date.getUTCMinutes() + minutes);
- date.setUTCSeconds(date.getUTCSeconds() + seconds);
- return date.toISOString();
+ if (isAllDay) {
+ // The date is the same everywhere: never convert it through a zone.
+ if (!localStart) return { start: event.start || null, end: null, isFloating: false };
+ let end: string | null = null;
+ if (duration) {
+ end = formatLocalDateTime(shiftWallClock(localStart, duration.days, duration.seconds));
+ } else if (event.utcEnd) {
+ const endDate = parseLocalDateTime(event.utcEnd.slice(0, 10));
+ if (endDate) end = formatLocalDateTime(endDate);
+ }
+ return { start: formatLocalDateTime(localStart), end, isFloating: false };
}
- date.setDate(date.getDate() + days);
- date.setHours(date.getHours() + hours);
- date.setMinutes(date.getMinutes() + minutes);
- date.setSeconds(date.getSeconds() + seconds);
+ const addExact = (instant: Date, days: number, seconds: number) =>
+ new Date(instant.getTime() + (days * 86400 + seconds) * 1000).toISOString();
- const y = date.getFullYear();
- const m = String(date.getMonth() + 1).padStart(2, '0');
- const d = String(date.getDate()).padStart(2, '0');
- const h = String(date.getHours()).padStart(2, '0');
- const min = String(date.getMinutes()).padStart(2, '0');
- const s = String(date.getSeconds()).padStart(2, '0');
- return `${y}-${m}-${d}T${h}:${min}:${s}`;
+ if (isValidInstant(event.utcStart)) {
+ const end = isValidInstant(event.utcEnd)
+ ? event.utcEnd
+ : duration ? addExact(new Date(event.utcStart), duration.days, duration.seconds) : null;
+ return { start: event.utcStart, end, isFloating: false };
+ }
+
+ if (isValidInstant(event.start)) {
+ const end = isValidInstant(event.utcEnd)
+ ? event.utcEnd
+ : duration ? addExact(new Date(event.start), duration.days, duration.seconds) : null;
+ return { start: new Date(event.start).toISOString(), end, isFloating: false };
+ }
+
+ if (!localStart) return { start: event.start || null, end: null, isFloating: false };
+
+ if (event.timeZone && isValidTimeZone(event.timeZone)) {
+ const timeZone = event.timeZone;
+ const startInstant = fromWallClock(localStart, timeZone);
+ let end: string | null = null;
+ if (isValidInstant(event.utcEnd)) {
+ end = event.utcEnd;
+ } else if (duration) {
+ // Days are nominal (same wall clock N days later in the event's zone),
+ // hours/minutes/seconds are exact.
+ const dayShifted = fromWallClock(shiftWallClock(localStart, duration.days, 0), timeZone);
+ end = addExact(dayShifted, 0, duration.seconds);
+ }
+ return { start: startInstant.toISOString(), end, isFloating: false };
+ }
+
+ // Floating time (no zone, or one we can't resolve): same wall clock everywhere.
+ const end = duration
+ ? formatLocalDateTime(shiftWallClock(localStart, duration.days, duration.seconds))
+ : null;
+ return { start: formatLocalDateTime(localStart), end, isFloating: true };
+}
+
+export interface InvitationDisplayTime {
+ date: Date;
+ /**
+ * True when `date` carries a wall clock in its UTC fields (all-day or
+ * floating). Format it with `timeZone: 'UTC'` so it is not converted.
+ */
+ wallClock: boolean;
+}
+
+function toInvitationDisplayTime(value: string | null): InvitationDisplayTime | null {
+ if (!value) return null;
+ if (HAS_UTC_OFFSET.test(value)) {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : { date, wallClock: false };
+ }
+ const w = parseLocalDateTime(value);
+ if (!w) return null;
+ const date = new Date(0);
+ date.setUTCFullYear(w.year, w.month - 1, w.day);
+ date.setUTCHours(w.hour, w.minute, w.second, 0);
+ return { date, wallClock: true };
+}
+
+/**
+ * What the invitation banner shows for an event summary. All-day events get
+ * their last day (the stored end is exclusive), or no end for a single day.
+ */
+export function getInvitationDisplayRange(
+ summary: Pick,
+): { start: InvitationDisplayTime; end: InvitationDisplayTime | null } | null {
+ const start = toInvitationDisplayTime(summary.start);
+ if (!start) return null;
+ let end = toInvitationDisplayTime(summary.end);
+ if (end && summary.isAllDay) {
+ const lastDay = new Date(end.date.getTime());
+ lastDay.setUTCDate(lastDay.getUTCDate() - 1);
+ end = lastDay.getTime() > start.date.getTime() ? { date: lastDay, wallClock: true } : null;
+ } else if (end && end.date.getTime() <= start.date.getTime()) {
+ // Zero-duration event (utcEnd == utcStart): no separate end to show.
+ end = null;
+ }
+ return { start, end };
+}
+
+/** Intl options limited to what next-intl's format.dateTime accepts. */
+export type InvitationDateFormatOptions = Pick<
+ Intl.DateTimeFormatOptions,
+ 'weekday' | 'month' | 'day' | 'hour' | 'minute' | 'hour12' | 'timeZone'
+> & {
+ timeZoneName?: 'short' | 'long';
+};
+
+/**
+ * The banner's "when" line. `formatDate` is next-intl's `format.dateTime`
+ * (which formats in the user's display zone) or an Intl equivalent. Timed
+ * events with a real zone get a zone label so it is clear which clock the
+ * time is on.
+ */
+export function formatInvitationSchedule(
+ summary: Pick,
+ formatDate: (date: Date, options: InvitationDateFormatOptions) => string,
+ hour12: boolean,
+): string {
+ const range = getInvitationDisplayRange(summary);
+ if (!range) return summary.start ?? '';
+ const base: InvitationDateFormatOptions = summary.isAllDay
+ ? { weekday: 'short', month: 'short', day: 'numeric' }
+ : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12 };
+ const formatPoint = (point: InvitationDisplayTime, last: boolean) => formatDate(point.date, {
+ ...base,
+ ...(point.wallClock ? { timeZone: 'UTC' } : {}),
+ ...(last && !point.wallClock && !summary.isAllDay ? { timeZoneName: 'short' } : {}),
+ });
+ const start = formatPoint(range.start, !range.end);
+ return range.end ? `${start} – ${formatPoint(range.end, true)}` : start;
}
export function findParticipantByEmail(
diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts
index ab9c0434f..a6d41eb66 100644
--- a/lib/calendar-utils.ts
+++ b/lib/calendar-utils.ts
@@ -1,7 +1,7 @@
import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
import { parseDuration } from "@/components/calendar/event-card";
import type { CalendarEvent } from "@/lib/jmap/types";
-import { toDisplayDate } from "@/lib/timezone";
+import { fromWallClock, isValidTimeZone, parseLocalDateTime, toDisplayDate } from "@/lib/timezone";
export interface CalendarWeekSegment {
event: CalendarEvent;
@@ -30,7 +30,7 @@ export interface TimedEventLayout {
* with `fromDisplayDate` before turning grid positions into instants.
*/
export function getEventStartDate(
- event: Pick,
+ event: Pick & Partial>,
): Date {
// Prefer utcStart for timed events but fall back to start if utcStart is
// missing or unparseable - a malformed utcStart used to surface as an
@@ -39,6 +39,12 @@ export function getEventStartDate(
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return toDisplayDate(utc);
}
+ // CalendarEvent/parse (.ics import, invitations) returns no utcStart: read
+ // `start` as the wall clock in the event's own zone, not the user's (#1252).
+ if (!event.showWithoutTime && event.timeZone && isValidTimeZone(event.timeZone)) {
+ const wall = event.start ? parseLocalDateTime(event.start) : null;
+ if (wall) return toDisplayDate(fromWallClock(wall, event.timeZone));
+ }
return parseISO(event.start);
}
diff --git a/lib/timezone.ts b/lib/timezone.ts
index ef27591f7..a1f7d34a8 100644
--- a/lib/timezone.ts
+++ b/lib/timezone.ts
@@ -89,6 +89,24 @@ export interface WallClock {
second: number;
}
+/**
+ * Wall-clock fields of a JMAP LocalDateTime ("YYYY-MM-DDTHH:mm:ss", or a bare
+ * date). Returns null for anything else, including strings with `Z` or an
+ * offset, which are instants rather than wall clocks.
+ */
+export function parseLocalDateTime(value: string): WallClock | null {
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/);
+ if (!match) return null;
+ return {
+ year: Number(match[1]),
+ month: Number(match[2]),
+ day: Number(match[3]),
+ hour: Number(match[4] || 0),
+ minute: Number(match[5] || 0),
+ second: Number(match[6] || 0),
+ };
+}
+
// Constructing Intl.DateTimeFormat is expensive (ICU data lookup); cache one
// formatter per zone - the calendar calls into this for every event.
const wallClockFormatterCache = new Map();
From 49beaef852ba2ec0a79c6ecd2b46257d72646946 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:07:16 +0200
Subject: [PATCH 023/238] fix: show zero-length events ending at their start
instead of midnight #837
---
.../calendar-event-normalization.test.ts | 34 ++++++++++++++++++-
lib/__tests__/calendar-invitation.test.ts | 1 +
lib/calendar-event-normalization.ts | 15 +++++++-
.../calendar-store-event-hooks.test.ts | 16 +++++++++
stores/calendar-store.ts | 5 ++-
5 files changed, 68 insertions(+), 3 deletions(-)
diff --git a/lib/__tests__/calendar-event-normalization.test.ts b/lib/__tests__/calendar-event-normalization.test.ts
index 846e75620..33d0642b6 100644
--- a/lib/__tests__/calendar-event-normalization.test.ts
+++ b/lib/__tests__/calendar-event-normalization.test.ts
@@ -84,4 +84,36 @@ describe('calendar event normalization', () => {
expect(result.excludedRecurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
});
});
-});
\ No newline at end of file
+
+ describe('zero-duration timed events (#837)', () => {
+ // Stalwart drops a DTEND equal to DTSTART and returns no duration, but a
+ // utcEnd at 23:59:59 of the start day.
+ const fromServer = {
+ start: '2026-09-30T10:00:00',
+ timeZone: 'Etc/UTC',
+ showWithoutTime: false,
+ utcStart: '2026-09-30T10:00:00Z',
+ utcEnd: '2026-09-30T23:59:59Z',
+ } as Partial;
+
+ it('ends a server event without a duration at its start', () => {
+ expect(normalizeCalendarEventLike(fromServer)).toMatchObject({
+ duration: 'PT0S',
+ utcEnd: '2026-09-30T10:00:00Z',
+ });
+ });
+
+ it('keeps the end of a server event that has a duration', () => {
+ const event = { ...fromServer, duration: 'PT8H', utcEnd: '2026-09-30T18:00:00Z' };
+ expect(normalizeCalendarEventLike(event)).toMatchObject({
+ duration: 'PT8H',
+ utcEnd: '2026-09-30T18:00:00Z',
+ });
+ });
+
+ it('leaves update patches without a duration alone', () => {
+ const patch = { start: '2026-09-30T11:00:00' } as Partial;
+ expect(sanitizeOutgoingCalendarEventData(patch)).not.toHaveProperty('duration');
+ });
+ });
+});
diff --git a/lib/__tests__/calendar-invitation.test.ts b/lib/__tests__/calendar-invitation.test.ts
index 7b548e036..311ee4a99 100644
--- a/lib/__tests__/calendar-invitation.test.ts
+++ b/lib/__tests__/calendar-invitation.test.ts
@@ -344,6 +344,7 @@ describe('formatEventSummary', () => {
utcStart: '2026-02-17T15:00:00Z',
utcEnd: '2026-02-17T16:00:00Z',
start: '2026-02-17T10:00:00',
+ duration: 'PT1H',
});
expect(summary.start).toBe('2026-02-17T15:00:00Z');
expect(summary.end).toBe('2026-02-17T16:00:00Z');
diff --git a/lib/calendar-event-normalization.ts b/lib/calendar-event-normalization.ts
index 5805875c9..6dd31bf83 100644
--- a/lib/calendar-event-normalization.ts
+++ b/lib/calendar-event-normalization.ts
@@ -113,12 +113,25 @@ function normalizeStalwartPropertyNames>(event:
return result;
}
+/**
+ * A timed event without a duration lasts zero seconds (RFC 8984 default
+ * PT0S). Stalwart drops a DTEND equal to DTSTART and then ends such an
+ * event at 23:59:59 of its start day, so its utcEnd is wrong. Only server
+ * objects carry utcStart, which keeps partial update patches untouched. (#837)
+ */
+function withZeroDurationEnd>(event: T): T {
+ if (event.showWithoutTime || event.duration || !event.start || !event.utcStart) {
+ return event;
+ }
+ return { ...event, duration: 'PT0S', utcEnd: event.utcStart } as T;
+}
+
export function normalizeCalendarEventLike>(event: T): T {
// First normalize Stalwart's singular property names to RFC 8984 plural forms
const normalized = normalizeStalwartPropertyNames(event);
if (!isAllDayEventLike(normalized)) {
- return normalized;
+ return withZeroDurationEnd(normalized);
}
return {
diff --git a/stores/__tests__/calendar-store-event-hooks.test.ts b/stores/__tests__/calendar-store-event-hooks.test.ts
index ff1c04ba9..6ebd55f0c 100644
--- a/stores/__tests__/calendar-store-event-hooks.test.ts
+++ b/stores/__tests__/calendar-store-event-hooks.test.ts
@@ -275,3 +275,19 @@ describe('importEvents hooks', () => {
expect(after).not.toHaveBeenCalled();
});
});
+
+describe('importEvents zero-duration events (#837)', () => {
+ it('sends an explicit zero duration for a timed event without one', async () => {
+ const client = fakeClient();
+ // CalendarEvent/parse of DTSTART == DTEND returns no duration.
+ const parsed = [
+ { uid: 'ci', title: 'C/I', start: '2026-09-30T10:00:00', timeZone: 'Etc/UTC' },
+ { uid: 'sb', title: 'Standby', start: '2026-09-30T10:00:00', timeZone: 'Etc/UTC', duration: 'PT8H' },
+ ] as Partial[];
+
+ await useCalendarStore.getState().importEvents(client, parsed, 'c1');
+
+ const batch = vi.mocked(client.batchCreateCalendarEvents).mock.calls[0][0];
+ expect(batch.map((e) => e.duration)).toEqual(['PT0S', 'PT8H']);
+ });
+});
diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts
index 8efe1f660..56fe1e961 100644
--- a/stores/calendar-store.ts
+++ b/stores/calendar-store.ts
@@ -1909,7 +1909,10 @@ async function importEventsUnhooked(
description: src.description,
descriptionContentType: src.descriptionContentType,
start: src.start,
- duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration,
+ // CalendarEvent/parse drops a DTEND equal to DTSTART; without an
+ // explicit zero duration Stalwart stores the event ending at
+ // 23:59:59 of its start day. (#837)
+ duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : (src.duration || 'PT0S'),
timeZone: src.showWithoutTime ? null : src.timeZone,
showWithoutTime: src.showWithoutTime,
status: src.status,
From bfe95e0ebd69acbc43a9c189d306108bdf7ab59e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:28:57 +0200
Subject: [PATCH 024/238] fix: keep your own address out of reply-all before
your identities have loaded #803
---
.../email/__tests__/reply-addressing.test.tsx | 56 ++++++++++++++++++-
components/email/email-composer.tsx | 42 +++++++++++++-
lib/__tests__/reply-recipients.test.ts | 28 +++++++++-
lib/reply-recipients.ts | 25 +++++++++
4 files changed, 147 insertions(+), 4 deletions(-)
diff --git a/components/email/__tests__/reply-addressing.test.tsx b/components/email/__tests__/reply-addressing.test.tsx
index f8afaa5e8..8a489d537 100644
--- a/components/email/__tests__/reply-addressing.test.tsx
+++ b/components/email/__tests__/reply-addressing.test.tsx
@@ -1,10 +1,11 @@
-import { render, screen } from '@testing-library/react';
+import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useSettingsStore } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
+import { useIdentityStore } from '@/stores/identity-store';
import { usePolicyStore } from '@/stores/policy-store';
// ─── Heavy component mocks (mirrors recipient-paste.test.tsx) ─────────────────
@@ -491,6 +492,59 @@ describe('composer reply addressing', () => {
});
});
+// #803: identities load from the server after the app starts and aren't
+// persisted. A reply-all opened before they arrive used to keep our own
+// address in To, and nothing fixed it once they loaded.
+describe('reply-all before identities have loaded', () => {
+ const identityStore = useIdentityStore as unknown as {
+ getState: () => { identities: unknown[] };
+ setState: (p: Record) => void;
+ };
+ const accountStore = useAccountStore as unknown as { setState: (p: Record) => void };
+ const authStore = useAuthStore as unknown as { setState: (p: Record) => void };
+ let loaded: unknown[];
+
+ beforeEach(() => {
+ loaded = identityStore.getState().identities;
+ identityStore.setState({ identities: [] });
+ });
+
+ afterEach(() => {
+ identityStore.setState({ identities: loaded });
+ accountStore.setState({ accounts: [] });
+ authStore.setState({ activeAccountId: null });
+ });
+
+ it('drops our own address using the account address', () => {
+ accountStore.setState({
+ accounts: [{ id: 'local-1', email: 'me@example.com', username: 'me@example.com', isConnected: true }],
+ });
+ authStore.setState({ activeAccountId: 'local-1' });
+ render( );
+ expect(toChips()).toEqual(['Bob (bob@other.com)', 'Carol (carol@other.com)']);
+ });
+
+ it('drops our own address once the identities arrive', () => {
+ const { rerender } = render( );
+ expect(toChips()).toContain('Me (me@example.com)');
+ identityStore.setState({ identities: loaded });
+ rerender( );
+ expect(toChips()).toEqual(['Bob (bob@other.com)', 'Carol (carol@other.com)']);
+ expect(ccChips()).toEqual(['Dave (dave@other.com)']);
+ });
+
+ it('leaves the recipients alone once the user has changed them', () => {
+ const { rerender } = render( );
+ const carol = Array.from(screen.getByTestId('composer-to').querySelectorAll('[draggable]'))
+ .find((el) => el.textContent?.includes('carol@other.com')) as HTMLElement;
+ fireEvent.click(carol.querySelector('button') as HTMLElement);
+ expect(toChips()).toEqual(['Bob (bob@other.com)', 'Me (me@example.com)']);
+ identityStore.setState({ identities: loaded });
+ rerender( );
+ expect(toChips()).toEqual(['Bob (bob@other.com)', 'Me (me@example.com)']);
+ });
+});
+
describe('composer @-mention candidates', () => {
const offered = () => editorProps.mentionCandidates.map((c) => `${c.label} <${c.email}>`);
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 27a5b9e42..afda215ab 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -91,7 +91,7 @@ import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils";
import { wantsSignatureSeparator } from "@/lib/signature-delimiter";
import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
-import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
+import { buildReplyRecipients, isSelfSent, ownReplyAddresses } from "@/lib/reply-recipients";
import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading";
import { RecipientsRejectedError, RequestTimeoutError, ScheduleTooLateError, SendUnconfirmedError, formatRejectedRecipients } from "@/lib/jmap/client";
import {
@@ -520,7 +520,17 @@ export function EmailComposer({
const toRecipient = (r: { name?: string; email?: string }): Recipient =>
({ name: r.name && r.name !== r.email ? r.name : undefined, email: r.email ?? "" });
- const ownIdentityEmails = identities.map(i => i.email).filter((e): e is string => Boolean(e));
+ // Identities can still be loading when the composer opens (cold start, PWA
+ // relaunch), so the account's own addresses stand in until they arrive (#803).
+ const composerAccount = activeAccountId ? useAccountStore.getState().getAccountById(activeAccountId) : undefined;
+ const replyAccount = sourceAccountId && sourceAccountId !== activeAccountId
+ ? useAccountStore.getState().getAccountById(sourceAccountId)
+ : undefined;
+ const ownIdentityEmails = ownReplyAddresses(
+ identities.map(i => i.email),
+ [composerAccount?.email, composerAccount?.username, replyAccount?.email, replyAccount?.username],
+ );
+ const ownIdentityEmailsKey = ownIdentityEmails.map((e) => e.toLowerCase()).join(',');
// Initialize with reply/forward data if provided
const getInitialTo = (): Recipient[] => {
@@ -726,6 +736,34 @@ export function EmailComposer({
const [showCc, setShowCc] = useState(() => initialData?.showCc
?? (prefill?.cc ? prefill.cc.length > 0 : getInitialCc().length > 0));
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
+ // The reply recipients worked out when the composer opened. When the
+ // identities arrive later (#803) they're worked out again, but only while
+ // To and Cc still hold exactly what was filled in, so nothing the user
+ // changed is overwritten. Drafts and prefilled composes are left alone.
+ const autoRecipientsRef = useRef<{ to: Recipient[]; cc: Recipient[] } | null>(
+ !initialData && !prefill && (mode === 'reply' || mode === 'replyAll')
+ ? { to, cc }
+ : null,
+ );
+ useEffect(() => {
+ const auto = autoRecipientsRef.current;
+ if (!auto) return;
+ const same = (a: Recipient[], b: Recipient[]) =>
+ a.length === b.length && a.every((r, i) => r.email === b[i].email && r.name === b[i].name);
+ if (!same(to, auto.to) || !same(cc, auto.cc)) {
+ autoRecipientsRef.current = null;
+ return;
+ }
+ const nextTo = getInitialTo();
+ const nextCc = getInitialCc();
+ if (same(nextTo, auto.to) && same(nextCc, auto.cc)) return;
+ autoRecipientsRef.current = { to: nextTo, cc: nextCc };
+ setTo(nextTo);
+ setCc(nextCc);
+ if (nextCc.length > 0) setShowCc(true);
+ // Only a change in the user's own addresses should redo the recipients.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ownIdentityEmailsKey]);
// Committed recipients plus any not-yet-committed text the user has typed.
// Send/validation/draft paths treat a typed-but-uncommitted address as a
// real recipient, matching the previous string-based behavior.
diff --git a/lib/__tests__/reply-recipients.test.ts b/lib/__tests__/reply-recipients.test.ts
index 66d2e2e72..c3966e514 100644
--- a/lib/__tests__/reply-recipients.test.ts
+++ b/lib/__tests__/reply-recipients.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { buildReplyRecipients, isSelfSent } from '@/lib/reply-recipients';
+import { buildReplyRecipients, isSelfSent, ownReplyAddresses } from '@/lib/reply-recipients';
const OWN = ['me@example.com', 'info@example.com'];
@@ -135,3 +135,29 @@ describe('buildReplyRecipients', () => {
expect(isSelfSent(undefined, OWN)).toBe(false);
});
});
+
+// #803: identities load after the app starts, so a composer opened before
+// they arrive had no own addresses and reply-all kept the user in To.
+describe('ownReplyAddresses', () => {
+ const received = {
+ from: [{ email: 'bob@other.com' }],
+ to: [{ email: 'me@example.com' }, { email: 'carol@other.com' }],
+ cc: [{ email: 'Me@Example.com' }],
+ };
+
+ it('drops our own address on reply-all before identities have loaded', () => {
+ const own = ownReplyAddresses([], ['me@example.com', 'me']);
+ const { to, cc } = buildReplyRecipients(received, 'replyAll', own);
+ expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']);
+ expect(cc).toEqual([]);
+ });
+
+ it('merges identities with the account addresses, once each', () => {
+ expect(ownReplyAddresses(['me@example.com', undefined, ''], ['ME@example.com ', 'info@example.com']))
+ .toEqual(['me@example.com', 'info@example.com']);
+ });
+
+ it('skips a login name without a domain', () => {
+ expect(ownReplyAddresses([], ['linus', null, undefined])).toEqual([]);
+ });
+});
diff --git a/lib/reply-recipients.ts b/lib/reply-recipients.ts
index f98a50c2f..fdf260825 100644
--- a/lib/reply-recipients.ts
+++ b/lib/reply-recipients.ts
@@ -44,6 +44,31 @@ function isOwnAddress(email: string | undefined, ownEmails: string[]): boolean {
return ownEmails.some((own) => normalizeBase(own) === base);
}
+/**
+ * The addresses a reply treats as the user's own: their identities plus the
+ * account's login and primary address. Identities load from the server after
+ * the app starts and aren't persisted, so a composer opened before they arrive
+ * saw none and reply-all kept the user in To/Cc (#803). The account's
+ * addresses are known from the start and cover the usual case until then.
+ * A bare login name without a domain can't match an address, so it's skipped.
+ */
+export function ownReplyAddresses(
+ identityEmails: (string | undefined | null)[],
+ accountAddresses: (string | undefined | null)[] = [],
+): string[] {
+ const seen = new Set();
+ const result: string[] = [];
+ for (const email of [...identityEmails, ...accountAddresses]) {
+ const trimmed = email?.trim();
+ if (!trimmed || !trimmed.includes('@')) continue;
+ const key = normalize(trimmed);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ result.push(trimmed);
+ }
+ return result;
+}
+
/**
* Is this a message the user themself sent? True when the From address is one
* of their own identities - the case that shows up when browsing a thread and
From 89ffb7270a6ed006681c73589e5bb1d7ac9348cc Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:28:25 +0200
Subject: [PATCH 025/238] fix: stop sending a contact group whose member is not
an address #795
---
.../composer-recipient-validation.test.tsx | 13 +++++++++++++
components/email/email-composer.tsx | 7 ++++---
lib/__tests__/email-composer-utils.test.ts | 11 +++++++++++
lib/email-composer-utils.ts | 18 +++++++++++++++---
4 files changed, 43 insertions(+), 6 deletions(-)
diff --git a/components/email/__tests__/composer-recipient-validation.test.tsx b/components/email/__tests__/composer-recipient-validation.test.tsx
index de2414ee6..5f250a716 100644
--- a/components/email/__tests__/composer-recipient-validation.test.tsx
+++ b/components/email/__tests__/composer-recipient-validation.test.tsx
@@ -256,6 +256,19 @@ describe('composer recipient validation', () => {
expect(toastError).toHaveBeenCalledWith('validation.invalid_recipients');
});
+ it('does not send a group whose member is not an address and names the member', async () => {
+ const onSend = vi.fn();
+ render( );
+
+ expect(screen.getByText('Team (2)').closest('[data-invalid]')).not.toBeNull();
+
+ fireEvent.click(sendButton());
+ await act(async () => {});
+
+ expect(onSend).not.toHaveBeenCalled();
+ expect(toastError).toHaveBeenCalledWith('validation.invalid_recipients');
+ });
+
it('sends when every recipient is an address', async () => {
const onSend = vi.fn();
render( );
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index afda215ab..6983aa4b6 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -105,6 +105,7 @@ import {
formatRecipientList,
expandRecipients,
splitPastedRecipients,
+ invalidRecipientAddresses,
defaultSuggestionIndex,
waitForPendingUploads,
extractUserAuthoredText,
@@ -2881,11 +2882,11 @@ export function EmailComposer({
// Catch addresses the server would reject before asking it. Its answer
// ("No recipients found in email") does not say which one is wrong.
- const invalidRecipients = [
+ const invalidRecipients = invalidRecipientAddresses([
...withInput(to, toInput),
...withInput(cc, ccInput),
...withInput(bcc, bccInput),
- ].filter(r => !r.group && !isValidEmail(r.email)).map(r => r.email);
+ ]);
if (invalidRecipients.length > 0) {
toast.error(t('validation.invalid_recipients', { addresses: invalidRecipients.join(', ') }));
return;
@@ -4999,7 +5000,7 @@ function RecipientChipInput({
if(chip.extra?.icon){
IconComponent = ICON_MAP[chip.extra?.icon];
}
- const isInvalid = !chip.group && !isValidEmail(chip.email);
+ const isInvalid = invalidRecipientAddresses([chip]).length > 0;
const customColor = isInvalid ? 'destructive' : chip.extra?.color;
return (
diff --git a/lib/__tests__/email-composer-utils.test.ts b/lib/__tests__/email-composer-utils.test.ts
index 117614a79..c835f0963 100644
--- a/lib/__tests__/email-composer-utils.test.ts
+++ b/lib/__tests__/email-composer-utils.test.ts
@@ -16,6 +16,7 @@ import {
extractUserAuthoredText,
formatRecipientEntry,
expandRecipients,
+ invalidRecipientAddresses,
getQuoteBodies,
} from '../email-composer-utils';
@@ -594,6 +595,16 @@ describe('contact group recipients (RFC 5322 group syntax)', () => {
expect(parsed[1].group?.members).toHaveLength(2);
});
+ it('keeps typed text with a colon as a plain recipient when no member is an address', () => {
+ expect(parseRecipient('Subject: hello;')).toEqual({ email: 'Subject: hello;' });
+ });
+
+ it('lists group members that are not addresses as invalid', () => {
+ const parsed = parseRecipientList('ok@x.de, Team: a@x.de, junk;, nope');
+ expect(invalidRecipientAddresses(parsed)).toEqual(['junk', 'nope']);
+ expect(invalidRecipientAddresses([group])).toEqual([]);
+ });
+
it('expandRecipients flattens groups and dedupes against individuals', () => {
const expanded = expandRecipients([
{ name: 'Anna Alt', email: 'ANNA@example.com' },
diff --git a/lib/email-composer-utils.ts b/lib/email-composer-utils.ts
index a3cc1c1b6..0ac4babef 100644
--- a/lib/email-composer-utils.ts
+++ b/lib/email-composer-utils.ts
@@ -383,9 +383,9 @@ export function parseRecipient(s: string): Recipient {
const members = splitRecipients(trimmed.slice(colon + 1, -1))
.map(parseRecipient)
.filter((m) => m.email && !m.group);
- // Only accept the group form when it actually carries members - typed
- // garbage like "Subject: hello;" stays a plain (invalid) recipient.
- if (members.length > 0) {
+ // Only accept the group form when at least one member is an address -
+ // typed garbage like "Subject: hello;" stays a plain (invalid) recipient.
+ if (members.some((m) => isValidEmail(m.email))) {
return { name: unquoteName(trimmed.slice(0, colon)), email: '', group: { members } };
}
}
@@ -462,6 +462,18 @@ export function expandRecipients(recipients: Recipient[]): Recipient[] {
return out;
}
+/**
+ * The entries of `recipients` that are not email addresses, group members
+ * included. Sending any of them puts a malformed mailbox into the header,
+ * which relays reject only after the whole message went through (#795).
+ */
+export function invalidRecipientAddresses(recipients: Recipient[]): string[] {
+ return recipients
+ .flatMap((r) => (r.group ? r.group.members : [r]))
+ .filter((r) => !isValidEmail(r.email))
+ .map((r) => r.email);
+}
+
/**
* Top-level split of a pasted block into recipient entries on commas,
* semicolons and newlines (separators inside a quoted name or angle brackets
From aa112b3397331b38115ff193ef3a90acacd384fb Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:52:49 +0200
Subject: [PATCH 026/238] fix: sign replies whose From is picked after the
composer opens #842
---
.../email/__tests__/compose-format.test.ts | 38 +++
.../composer-reply-signature.test.tsx | 318 ++++++++++++++++++
components/email/compose-format.ts | 37 +-
components/email/email-composer.tsx | 45 ++-
4 files changed, 428 insertions(+), 10 deletions(-)
create mode 100644 components/email/__tests__/composer-reply-signature.test.tsx
diff --git a/components/email/__tests__/compose-format.test.ts b/components/email/__tests__/compose-format.test.ts
index 53b34f970..939c856c7 100644
--- a/components/email/__tests__/compose-format.test.ts
+++ b/components/email/__tests__/compose-format.test.ts
@@ -5,6 +5,7 @@ import {
plainComposeBodyToHtml,
replaceEmbeddedSignature,
replacePlainTextSignature,
+ replacePlainTextSignatureAboveQuote,
stripEmbeddedSignature,
} from '../compose-format';
import { SIGNATURE_BLOCK_MARKER, SIGNATURE_RANGE_MARKER, containsEmbeddedSignature } from '../signature-block';
@@ -131,6 +132,43 @@ describe('switching identity swaps the signature', () => {
it('adds a signature at the end when there is no quote', () => {
expect(replaceEmbeddedSignature('Hi
', embed(other))).toBe(dom(`Hi
${embed(other)}`));
});
+
+ it('adds a signature above the quote header when the body had none (#842)', () => {
+ const html = 'Hi
On Mon, Bob wrote:
original ';
+ expect(replaceEmbeddedSignature(html, embed(other), { quoteHeader: 'On Mon, Bob wrote:' }))
+ .toBe(dom(`Hi
${embed(other)}On Mon, Bob wrote:
original `));
+ });
+
+ it('adds a signature above a forward header that has no quote element', () => {
+ const html = 'Hi
---------- Forwarded message ---------- From: Bob
original
';
+ expect(replaceEmbeddedSignature(html, embed(other), { quoteHeader: '---------- Forwarded message ----------' }))
+ .toBe(dom(`Hi
${embed(other)}---------- Forwarded message ---------- From: Bob
original
`));
+ });
+ });
+
+ describe('replacePlainTextSignatureAboveQuote (#842)', () => {
+ const header = 'On Mon, Bob wrote:';
+ const quote = `${header}\n> original`;
+
+ it('adds the signature between the reply and the quote header', () => {
+ expect(replacePlainTextSignatureAboveQuote(`\n\n${quote}`, header, none, identity, { separator: true }))
+ .toBe(`\n\n-- \nAlice\nAcme\n\n${quote}`);
+ expect(replacePlainTextSignatureAboveQuote(`Hi\n\n${quote}`, header, none, identity, { separator: true }))
+ .toBe(`Hi\n\n-- \nAlice\nAcme\n\n${quote}`);
+ });
+
+ it('swaps or removes the signature above the quote', () => {
+ const signed = `Hi\n\n-- \nAlice\nAcme\n\n${quote}`;
+ expect(replacePlainTextSignatureAboveQuote(signed, header, identity, other, { separator: true }))
+ .toBe(`Hi\n\n-- \nTeam\n\n${quote}`);
+ expect(replacePlainTextSignatureAboveQuote(signed, header, identity, none, { separator: true }))
+ .toBe(`Hi\n\n${quote}`);
+ });
+
+ it('leaves a body alone whose quote header is gone', () => {
+ expect(replacePlainTextSignatureAboveQuote('Hi\n\n> original', header, none, identity, { separator: true }))
+ .toBe('Hi\n\n> original');
+ });
});
describe('replacePlainTextSignature', () => {
diff --git a/components/email/__tests__/composer-reply-signature.test.tsx b/components/email/__tests__/composer-reply-signature.test.tsx
new file mode 100644
index 000000000..2cb02c410
--- /dev/null
+++ b/components/email/__tests__/composer-reply-signature.test.tsx
@@ -0,0 +1,318 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import React from 'react';
+import { EmailComposer } from '../email-composer';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useIdentityStore } from '@/stores/identity-store';
+import type { Identity } from '@/lib/jmap/types';
+
+// ─── Heavy component mocks (mirrors composer-format-toggle.test.tsx) ─
+
+// A stand-in for the TipTap editor: it hands the composer an editor object
+// whose content is whatever the composer last rendered into it, the way the
+// real editor reports itself ready from an effect after mounting.
+const editorState = vi.hoisted(() => ({ html: '' }));
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: ({ content, onChange, onEditorReady }: {
+ content: string;
+ onChange: (html: string) => void;
+ onEditorReady?: (editor: unknown) => void;
+ }) => {
+ editorState.html = content;
+ const onChangeRef = React.useRef(onChange);
+ onChangeRef.current = onChange;
+ React.useEffect(() => {
+ onEditorReady?.({
+ isDestroyed: false,
+ commands: {
+ focus: () => {},
+ setContent: (html: string) => {
+ editorState.html = html;
+ onChangeRef.current(html);
+ },
+ },
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-html': content });
+ },
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [
+ { id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: 'Alice ', textSignature: 'Alice' },
+ { id: 'id-team', email: 'team@example.com', name: 'Team', htmlSignature: '', textSignature: '' },
+ ] as Array>,
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook, notifyIdentitySelected: () => {} };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ sendDelaySeconds: 0,
+ // The default position: replies/forwards get the signature appended at
+ // send time rather than embedded above the quote.
+ signaturePosition: 'below_quote',
+ signatureSeparatorEnabled: true,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ externalContentPolicy: 'block',
+ isSenderTrusted: () => false,
+ trustedSendersAddressBook: null,
+ updateSetting: () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: { takeOver: async () => 'default' },
+ onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeSignatureHtmlForDisplay: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ sanitizePluginBodyHtml: (v: string) => v,
+ escapeHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/email/quoted-html', () => ({
+ buildQuotedHtmlBlock: (html: string) => `${html}
`,
+ serializeEditorContent: () => editorState.html,
+}));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+/**
+ * #842: replying did not insert the signature. With the signature above the
+ * quote, a reply's body is built before its From is known - the composer
+ * picks the identity the mail was sent to in an effect, and the identities
+ * may load after the composer opened. A plain-text reply never received the
+ * signature that arrived then, and the send path doesn't append one to an
+ * above-quote reply, so the mail went out unsigned. An HTML reply got it, but
+ * between the "On ... wrote:" line and the quote.
+ */
+
+const ME = { id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: 'Alice ', textSignature: 'Alice' };
+const UNSIGNED_ME = { ...ME, htmlSignature: '', textSignature: '' };
+const SALES = { id: 'id-sales', email: 'sales@example.com', name: 'Sales', htmlSignature: 'Sales team ', textSignature: 'Sales team' };
+
+const REPLY_TO = {
+ from: [{ name: 'Bob', email: 'bob@example.com' }],
+ to: [{ email: 'sales@example.com' }],
+ subject: 'Question',
+ body: 'Original text',
+ htmlBody: 'Original text
',
+ receivedAt: '2026-08-01T10:00:00Z',
+};
+const TO_ME = { ...REPLY_TO, to: [{ email: 'me@example.com' }] };
+
+const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement;
+
+async function send(onSend: ReturnType) {
+ fireEvent.click(sendButton());
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ return onSend.mock.calls[0][0] as { body: string; htmlBody?: string };
+}
+
+function setIdentities(list: Array>) {
+ useIdentityStore.setState({ identities: list as Identity[] });
+}
+
+describe('reply signature above the quote (#842)', () => {
+ afterEach(() => {
+ useSettingsStore.setState({ plainTextMode: false, signaturePosition: 'below_quote' });
+ setIdentities([ME]);
+ vi.clearAllMocks();
+ });
+
+ describe('plain text', () => {
+ it('signs a reply from an alias the composer picks itself', async () => {
+ useSettingsStore.setState({ signaturePosition: 'above_quote', plainTextMode: true });
+ setIdentities([UNSIGNED_ME, SALES]);
+ const onSend = vi.fn();
+ render( );
+
+ const sent = await send(onSend);
+ expect(sent.body).toBe('\n\n-- \nSales team\n\nreply_line\n> Original text');
+ });
+
+ it('swaps in the signature of the picked alias instead of the default one', async () => {
+ useSettingsStore.setState({ signaturePosition: 'above_quote', plainTextMode: true });
+ setIdentities([ME, SALES]);
+ const onSend = vi.fn();
+ render( );
+
+ const sent = await send(onSend);
+ expect(sent.body).toBe('\n\n-- \nSales team\n\nreply_line\n> Original text');
+ });
+
+ it('signs a forward when the identities load after it opened', async () => {
+ useSettingsStore.setState({ signaturePosition: 'above_quote', plainTextMode: true });
+ setIdentities([]);
+ const onSend = vi.fn();
+ const props = { mode: 'forward' as const, replyTo: TO_ME, prefill: { to: ['carol@example.com'] }, onSend };
+ const { rerender } = render( );
+ setIdentities([ME]);
+ rerender( );
+
+ const sent = await send(onSend);
+ expect(sent.body.startsWith('\n\n-- \nAlice\n\nforwarded_separator\n')).toBe(true);
+ });
+ });
+
+ describe('HTML', () => {
+ it('puts the signature of the picked alias above the "wrote:" line', async () => {
+ useSettingsStore.setState({ signaturePosition: 'above_quote' });
+ setIdentities([UNSIGNED_ME, SALES]);
+ const onSend = vi.fn();
+ render( );
+
+ const sent = await send(onSend);
+ const html = sent.htmlBody ?? '';
+ expect(html).toContain('Sales team ');
+ expect(html.indexOf('Sales team ')).toBeLessThan(html.indexOf('reply_line'));
+ });
+ });
+
+ it('still signs a reply below the quote at send time by default', async () => {
+ setIdentities([UNSIGNED_ME, SALES]);
+ const onSend = vi.fn();
+ render( );
+
+ const sent = await send(onSend);
+ expect(sent.htmlBody).toMatch(/Original text[\s\S]*Sales team<\/i>$/);
+ expect(sent.body).toMatch(/Original text[\s\S]*Sales team$/);
+ });
+});
diff --git a/components/email/compose-format.ts b/components/email/compose-format.ts
index 4cd9987a0..82e9e9081 100644
--- a/components/email/compose-format.ts
+++ b/components/email/compose-format.ts
@@ -120,15 +120,25 @@ export function stripEmbeddedSignature(html: string): { html: string; hadSignatu
* The HTML body with its embedded signature swapped for `signatureHtml` (from
* buildEmbeddedSignatureHtml; empty for an identity without a signature).
* The new signature goes where the old one was. A body without one - the
- * previous identity had none - gets it before the quote, or at the end when
- * there is no quote, which is where getInitialBody would have put it.
+ * previous identity had none - gets it where getInitialBody would have put
+ * it: above the quote header of a reply or forward (`quoteHeader`, the
+ * header's first line, e.g. "On ... wrote:"), else before the quote, else at
+ * the end.
*/
-export function replaceEmbeddedSignature(html: string, signatureHtml: string): string {
+export function replaceEmbeddedSignature(
+ html: string,
+ signatureHtml: string,
+ options: { quoteHeader?: string } = {},
+): string {
const doc = new DOMParser().parseFromString(html, 'text/html');
let removed = removeSignatureRange(doc);
if (!removed) {
if (!signatureHtml) return html;
- const quote = Array.from(doc.body.childNodes).find(isQuoteBoundary) ?? null;
+ const children = Array.from(doc.body.childNodes);
+ const header = options.quoteHeader
+ ? children.find((node) => node.textContent?.includes(options.quoteHeader!))
+ : undefined;
+ const quote = header ?? children.find(isQuoteBoundary) ?? null;
removed = { parent: doc.body, insertBefore: quote };
}
const host = doc.createElement('div');
@@ -157,6 +167,25 @@ export function replacePlainTextSignature(
return appendPlainTextSignature(bare, next, options);
}
+/**
+ * replacePlainTextSignature for a plain-text reply or forward with the
+ * signature above the quote: the signature ends the part before the quote
+ * header (`quoteHeader`, its first line). A body whose header can't be found
+ * is returned unchanged.
+ */
+export function replacePlainTextSignatureAboveQuote(
+ body: string,
+ quoteHeader: string,
+ previous: SignatureIdentityLike,
+ next: SignatureIdentityLike,
+ options: { separator: boolean },
+): string {
+ const at = quoteHeader ? body.indexOf(quoteHeader) : -1;
+ if (at < 0) return body;
+ const head = body.slice(0, at).replace(/\n+$/, '');
+ return `${replacePlainTextSignature(head, previous, next, options)}\n\n${body.slice(at)}`;
+}
+
/**
* Convert the rich-text editor's body to a plain-text body. Formatting is
* dropped (that is the point of plain text); an embedded signature comes back
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 6983aa4b6..434544fd0 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -58,6 +58,7 @@ import {
plainComposeBodyToHtml,
replaceEmbeddedSignature,
replacePlainTextSignature,
+ replacePlainTextSignatureAboveQuote,
} from "@/components/email/compose-format";
import { emailHooks, contactHooks, isExternalAttachmentResult } from "@/lib/plugin-hooks";
import { onUploadProgress } from "@/lib/upload-progress";
@@ -554,6 +555,29 @@ export function EmailComposer({
return "";
};
+ // The first line of the quote header getInitialBody writes under the reply
+ // area ("On ... wrote:", the forwarded-message line, or a plugin's header).
+ // A signature that arrives after the body was built - From resolves to a
+ // matching identity, or the identities load late - goes above it.
+ const getQuoteHeaderLine = (): string => {
+ if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return '';
+ if (!replyTo?.body && !replyTo?.htmlBody) return '';
+ const pluginHeader = replyTo.quoteHeaderText
+ ?? (replyTo.quoteHeaderHtml !== undefined ? htmlToPlainText(replyTo.quoteHeaderHtml) : undefined);
+ if (pluginHeader !== undefined) {
+ return pluginHeader.split('\n').map((line) => line.trim()).find(Boolean) ?? '';
+ }
+ if (mode === 'forward') return tQuote('forwarded_separator');
+ const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
+ const from = replyTo.from?.[0];
+ const fromStrFull = from
+ ? (from.name && from.email && from.name !== from.email
+ ? `${from.name} <${from.email}>`
+ : (from.email || from.name || tCommon('unknown')))
+ : tCommon('unknown');
+ return tQuote('reply_line', { date, from: fromStrFull });
+ };
+
const getInitialBody = () => {
if (plainTextMode) {
// Plain text mode: produce plain text body with no HTML
@@ -1062,11 +1086,20 @@ export function EmailComposer({
if (plainTextMode) {
// A new plain-text message carries the signature at the end of the
- // body; in a reply the quote follows it.
- if (!identityChanged || mode !== 'compose') return;
- setBody((prev) => replacePlainTextSignature(prev, previousIdentity, signatureIdentity, {
- separator: signatureSeparatorEnabled,
- }));
+ // body, a reply or forward above its quote header.
+ if (!identityChanged) return;
+ const options = { separator: signatureSeparatorEnabled };
+ if (mode === 'compose') {
+ setBody((prev) => replacePlainTextSignature(prev, previousIdentity, signatureIdentity, options));
+ return;
+ }
+ // The signature identity of a reply is often only known after the body
+ // was built: the reply's From resolves to the alias it was sent to, or
+ // the identities load late. Swap it above the quote header like an
+ // HTML body does; leaving it out lost it, since an above-quote reply
+ // is never signed at send time (#842).
+ const quoteHeader = getQuoteHeaderLine();
+ setBody((prev) => replacePlainTextSignatureAboveQuote(prev, quoteHeader, previousIdentity, signatureIdentity, options));
return;
}
if (!editor) return;
@@ -1082,7 +1115,7 @@ export function EmailComposer({
const nextHtml = replaceEmbeddedSignature(currentHtml, buildEmbeddedSignatureHtml(signatureIdentity, {
embed: true,
separator: signatureSeparatorEnabled,
- }));
+ }), { quoteHeader: getQuoteHeaderLine() });
if (nextHtml !== currentHtml) {
editor.commands.setContent(nextHtml, { emitUpdate: true });
}
From 0fb4fc53cfd0a2203ca24becfe28cbdc43175482 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:51:49 +0200
Subject: [PATCH 027/238] test: cover keeping typed text when a template goes
into a reply #911
---
.../composer-template-reply.test.tsx | 275 ++++++++++++++++++
1 file changed, 275 insertions(+)
create mode 100644 components/email/__tests__/composer-template-reply.test.tsx
diff --git a/components/email/__tests__/composer-template-reply.test.tsx b/components/email/__tests__/composer-template-reply.test.tsx
new file mode 100644
index 000000000..f804dde67
--- /dev/null
+++ b/components/email/__tests__/composer-template-reply.test.tsx
@@ -0,0 +1,275 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import type { Editor } from '@tiptap/core';
+import { EmailComposer } from '../email-composer';
+import { useSettingsStore } from '@/stores/settings-store';
+
+// The real composer around the real editor: applying a template to a reply
+// the user has already started (#911, follow-up to #540).
+
+const TEMPLATE = vi.hoisted(() => ({
+ id: 'tpl-1',
+ name: 'Greeting',
+ category: '',
+ subject: '',
+ body: 'Dear reader',
+ isHTML: false,
+ isFavorite: false,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+}));
+
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+// The picker hands the composer a template, as choosing one does. It takes
+// the focus away from the editor first, as the real dialog does.
+vi.mock('@/components/templates/template-picker', () => ({
+ TemplatePicker: ({ onSelect }: { onSelect: (t: unknown, v: Record) => void }) =>
+ React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
+}));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: 'Cheers, Me
' }],
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ recipientMentionsEnabled: false,
+ sendDelaySeconds: 0,
+ signaturePosition: 'above_quote',
+ signatureSeparatorEnabled: false,
+ requestReadReceiptDefault: false,
+ externalContentPolicy: 'block',
+ isSenderTrusted: () => false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook, registerTemplateSyncBridge: () => {} };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: { takeOver: async () => 'default' },
+ onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+const REPLY_TO = {
+ from: [{ email: 'bob@example.com', name: 'Bob' }],
+ to: [{ email: 'me@example.com', name: 'Me' }],
+ subject: 'Lunch',
+ htmlBody: 'Original message
',
+ receivedAt: '2026-01-01T10:00:00.000Z',
+};
+
+const nextFrame = () => act(() => new Promise((resolve) => requestAnimationFrame(() => resolve())));
+
+async function openReply() {
+ render( );
+ const dom = await waitFor(() => {
+ const el = document.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null;
+ expect(el?.editor).toBeTruthy();
+ return el!;
+ });
+ await nextFrame();
+ return dom.editor!;
+}
+
+async function applyTemplate() {
+ act(() => { fireEvent.keyDown(window, { key: 't' }); });
+ const pick = await screen.findByTestId('pick-template');
+ // The picker dialog takes the focus.
+ act(() => { pick.focus(); });
+ fireEvent.click(pick);
+ await waitFor(() => expect(screen.queryByTestId('pick-template')).toBeNull());
+ await nextFrame();
+}
+
+/** Top-level blocks as text, so the order of greeting, template, signature and quote shows. */
+const blocks = (editor: Editor) => {
+ const out: string[] = [];
+ editor.state.doc.forEach((node) => {
+ if (node.type.name === 'quotedHtml') out.push(`[quote:${new DOMParser().parseFromString(node.attrs.html as string, 'text/html').body.textContent}]`);
+ else if (node.type.name === 'signatureBlock') out.push('[signature]');
+ else if (node.textContent) out.push(node.textContent);
+ });
+ return out;
+};
+
+describe('applying a template to a reply (#911)', () => {
+ const range = Range.prototype as unknown as Record;
+ const doc = document as unknown as Record;
+ beforeAll(() => {
+ range.getClientRects = () => [];
+ range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
+ doc.elementFromPoint = () => null;
+ });
+ afterAll(() => {
+ delete range.getClientRects;
+ delete range.getBoundingClientRect;
+ delete doc.elementFromPoint;
+ });
+ afterEach(() => {
+ useSettingsStore.setState({ signaturePosition: 'above_quote', plainTextMode: false });
+ });
+
+ // The user starts typing right away, at the caret the reply opened with.
+ it('keeps the greeting and puts the template after it, above the signature and the quote', async () => {
+ const editor = await openReply();
+ act(() => { editor.view.dispatch(editor.state.tr.insertText('Hello Mr. X')); });
+
+ await applyTemplate();
+
+ expect(blocks(editor)).toEqual(['Hello Mr. X', 'Dear reader', '[signature]', 'reply_line', '[quote:Original message]']);
+ });
+
+ it('does the same when the signature goes below the quote', async () => {
+ useSettingsStore.setState({ signaturePosition: 'below_quote' });
+ const editor = await openReply();
+ act(() => { editor.view.dispatch(editor.state.tr.insertText('Hello Mr. X')); });
+
+ await applyTemplate();
+
+ expect(blocks(editor)).toEqual(['Hello Mr. X', 'Dear reader', 'reply_line', '[quote:Original message]']);
+ });
+
+ it('keeps the greeting in a plain text reply', async () => {
+ useSettingsStore.setState({ plainTextMode: true });
+ render( );
+ const textarea = await waitFor(() => {
+ const el = document.querySelector('textarea');
+ expect(el).toBeTruthy();
+ expect(document.activeElement).toBe(el);
+ return el as HTMLTextAreaElement;
+ });
+ const caret = textarea.selectionStart;
+ fireEvent.change(textarea, {
+ target: { value: textarea.value.slice(0, caret) + 'Hello Mr. X' + textarea.value.slice(caret) },
+ });
+ textarea.setSelectionRange(caret + 11, caret + 11);
+
+ await applyTemplate();
+
+ expect(textarea.value).toBe('Hello Mr. XDear reader\n\nCheers, Me\n\nreply_line\n> Original message');
+ });
+});
From 5515297857b3d640f9ebec41e32e8fe76fe64eda Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:53:14 +0200
Subject: [PATCH 028/238] fix: keep blank lines in sent mail instead of
collapsing them #1015
---
.../rich-text-editor-empty-lines.test.tsx | 96 +++++++++++++++++++
.../email/__tests__/signature-block.test.ts | 3 +-
components/email/paragraph-breaks.ts | 57 +++++++++++
components/email/quoted-html.ts | 11 ++-
components/email/rich-text-editor.tsx | 5 +
5 files changed, 170 insertions(+), 2 deletions(-)
create mode 100644 components/email/__tests__/rich-text-editor-empty-lines.test.tsx
create mode 100644 components/email/paragraph-breaks.ts
diff --git a/components/email/__tests__/rich-text-editor-empty-lines.test.tsx b/components/email/__tests__/rich-text-editor-empty-lines.test.tsx
new file mode 100644
index 000000000..5678f5788
--- /dev/null
+++ b/components/email/__tests__/rich-text-editor-empty-lines.test.tsx
@@ -0,0 +1,96 @@
+import { act, render, waitFor } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import React from 'react';
+import type { Editor } from '@tiptap/core';
+
+import { RichTextEditor } from '../rich-text-editor';
+import { serializeEditorContent } from '../quoted-html';
+
+async function setup(content: string) {
+ let editor: Editor | null = null;
+ render(
+ {}}
+ onEditorReady={(ed) => { editor = ed as unknown as Editor; }}
+ />
+ );
+ await waitFor(() => expect(editor).not.toBeNull());
+ return editor as unknown as Editor;
+}
+
+// The composer marks every block dir="auto"; that's not what these tests are about.
+const serialize = (editor: Editor) => serializeEditorContent(editor).replace(/ dir="auto"/g, '');
+
+// Re-open what was saved, the way a draft or a signature swap does.
+function reload(editor: Editor, html: string) {
+ act(() => { editor.commands.setContent(html, { emitUpdate: false }); });
+}
+
+describe('RichTextEditor empty lines (#1015)', () => {
+ it('sends an empty line as a paragraph with a line break, so it keeps its height', async () => {
+ const editor = await setup('one
two
');
+
+ expect(serialize(editor)).toBe('one
two
');
+ });
+
+ it('does not add a line when the saved body is opened again', async () => {
+ const editor = await setup('one
two
');
+ const saved = serialize(editor);
+
+ reload(editor, saved);
+
+ expect(editor.state.doc.child(1).type.name).toBe('paragraph');
+ expect(editor.state.doc.child(1).childCount).toBe(0);
+ expect(serialize(editor)).toBe(saved);
+ reload(editor, serializeEditorContent(editor));
+ expect(serialize(editor)).toBe(saved);
+ });
+
+ it('keeps a line break at the end of a line visible and stable', async () => {
+ const editor = await setup('one
');
+ act(() => {
+ editor.commands.setContent({
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }, { type: 'hardBreak' }] }],
+ }, { emitUpdate: false });
+ });
+ const saved = serialize(editor);
+ expect(saved).toBe('one
');
+
+ reload(editor, saved);
+
+ expect(editor.getJSON().content?.[0].content).toEqual([{ type: 'text', text: 'one' }, { type: 'hardBreak' }]);
+ expect(serialize(editor)).toBe(saved);
+ });
+
+ it('leaves line breaks inside a line alone', async () => {
+ const editor = await setup('one two
');
+
+ expect(serialize(editor)).toBe('one two
');
+ });
+
+ it('does not touch the quoted original or the signature markers', async () => {
+ const quoted = 'quoted
more
';
+ const editor = await setup(
+ 'reply
'
+ + '
Alice
'
+ + `${quoted}
`,
+ );
+
+ const html = serialize(editor);
+
+ expect(html).toBe(
+ 'reply
'
+ + '
Alice
'
+ // The editor's own empty paragraph after the quote adds no line.
+ + `${quoted}
`,
+ );
+ });
+
+ it('adds no blank lines at the end of the message', async () => {
+ const editor = await setup('one
two
');
+
+ expect(serialize(editor)).toBe('one
two
');
+ });
+});
diff --git a/components/email/__tests__/signature-block.test.ts b/components/email/__tests__/signature-block.test.ts
index ece6c27aa..2f657ff20 100644
--- a/components/email/__tests__/signature-block.test.ts
+++ b/components/email/__tests__/signature-block.test.ts
@@ -300,7 +300,8 @@ Der Inhalt dieser E-Mail ist vertraulich.
const out = serializeEditorContent(editor);
// Leading paragraph + separator + ONE signature paragraph + end marker.
expect((out.match(/
`, which a browser or mail
+ * client renders zero pixels high: the blank lines vanished for the recipient
+ * and in the Sent folder. Browsers only show a block's last line when it holds
+ * something, which is why Gmail and Thunderbird write `
`.
+ *
+ * The rule here mirrors the browser's: a at the very end of a paragraph
+ * doesn't show. On the way out every paragraph that is empty or ends in a line
+ * break gets that extra ; on the way back in (re-opened draft, signature
+ * swap) the parse rule below drops it again, so the editor content round-trips
+ * without gaining a line each time.
+ */
+
+function lastChild(el: Element): ChildNode | null {
+ let node = el.lastChild;
+ while (node && node.nodeType === 3 && !(node.textContent ?? "").trim()) {
+ node = node.previousSibling;
+ }
+ return node;
+}
+
+/**
+ * Add the trailing to every paragraph under `root` that would otherwise
+ * lose its last line. The signature's range marker paragraphs stay as they
+ * are: they only bracket the signature for the composer.
+ */
+export function addTrailingParagraphBreaks(root: ParentNode): void {
+ root.querySelectorAll("p").forEach((p) => {
+ if (p.hasAttribute(SIGNATURE_RANGE_MARKER)) return;
+ const last = lastChild(p);
+ if (!last || last.nodeName === "BR") {
+ p.appendChild(p.ownerDocument.createElement("br"));
+ }
+ });
+}
+
+/** Whether `el` is the a paragraph ends on, which a browser doesn't show. */
+export function isTrailingParagraphBreak(el: HTMLElement): boolean {
+ if (el.parentElement?.nodeName !== "P") return false;
+ return lastChild(el.parentElement) === el;
+}
+
+/**
+ * Parse rule for the paragraph node: skip the invisible trailing instead
+ * of turning it into a hard break. Ranks above the hard break's own rule.
+ */
+export const trailingParagraphBreakRule = {
+ tag: "br",
+ priority: 60,
+ ignore: true,
+ getAttrs: (el: HTMLElement) => (isTrailingParagraphBreak(el) ? null : false),
+} as const;
diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts
index 40d708bbc..604d4d34e 100644
--- a/components/email/quoted-html.ts
+++ b/components/email/quoted-html.ts
@@ -5,6 +5,7 @@ import { DOMSerializer } from "@tiptap/pm/model";
import { NodeSelection, Plugin, PluginKey, Selection, TextSelection } from "@tiptap/pm/state";
import type { Editor } from "@tiptap/react";
+import { addTrailingParagraphBreaks } from "@/components/email/paragraph-breaks";
import { buildSignatureBlock } from "@/components/email/signature-block";
import { parkRemoteResources, restoreRemoteResources } from "@/lib/email-sanitization";
import { useSettingsStore } from "@/stores/settings-store";
@@ -235,7 +236,14 @@ export const QuotedHtml = TiptapNode.create({
export function serializeEditorContent(editor: Editor): string {
const serializer = DOMSerializer.fromSchema(editor.schema);
const parts: string[] = [];
- editor.state.doc.forEach((node) => {
+ // Empty paragraphs at the very end (the editor keeps one after a quote)
+ // stay invisible; every other empty line gets a so it keeps its
+ // height in the recipient's client (#1015).
+ let lastLine = -1;
+ editor.state.doc.forEach((node, _offset, index) => {
+ if (node.type.name !== "paragraph" || node.childCount > 0) lastLine = index;
+ });
+ editor.state.doc.forEach((node, _offset, index) => {
if (node.type.name === "quotedHtml") {
// Emit the SAME wrapper buildQuotedHtmlBlock produces, so a saved draft
// round-trips: re-opening parses this back into a QuotedHtml node
@@ -253,6 +261,7 @@ export function serializeEditorContent(editor: Editor): string {
const fragment = serializer.serializeNode(node);
const tmp = document.createElement("div");
tmp.appendChild(fragment);
+ if (index <= lastLine) addTrailingParagraphBreaks(tmp);
parts.push(tmp.innerHTML);
});
return parts.join("");
diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx
index 35413bd10..a92c28c4d 100644
--- a/components/email/rich-text-editor.tsx
+++ b/components/email/rich-text-editor.tsx
@@ -23,6 +23,7 @@ import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
+import { trailingParagraphBreakRule } from "@/components/email/paragraph-breaks";
import { SignatureBlock } from "@/components/email/signature-block";
import { styledBlockAttributes } from "@/components/email/styled-block-attributes";
import { RecipientMention, RecipientMentionList, type RecipientMentionListHandle } from "@/components/email/recipient-mention";
@@ -69,6 +70,10 @@ const StyledParagraph = Paragraph.extend({
...styledBlockAttributes,
};
},
+ // The serializeEditorContent adds to empty lines isn't a hard break.
+ parseHTML() {
+ return [...(this.parent?.() ?? []), trailingParagraphBreakRule];
+ },
});
const StyledHeading = Heading.extend({
From b646ff0dc1fd8c4957769383648250627754ff93 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:48:05 +0200
Subject: [PATCH 029/238] fix: preview attachments in the installed app instead
of opening a blank window #865
---
components/email/email-viewer.tsx | 51 +++++++++++--
.../file-preview-modal-standalone.test.tsx | 58 +++++++++++++++
components/files/file-preview-modal.tsx | 5 +-
components/files/pdf-mobile-viewer.tsx | 23 +++---
lib/__tests__/standalone-display.test.ts | 72 +++++++++++++++++++
lib/standalone-display.ts | 39 ++++++++++
6 files changed, 232 insertions(+), 16 deletions(-)
create mode 100644 components/files/__tests__/file-preview-modal-standalone.test.tsx
create mode 100644 lib/__tests__/standalone-display.test.ts
create mode 100644 lib/standalone-display.ts
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 2d018bf88..251c27709 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -116,6 +116,8 @@ import { RecipientPopover } from "./recipient-popover";
import { MailtoLink } from "@/components/ui/mailto-link";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
+import { openPreviewInNewWindow } from "@/lib/standalone-display";
+import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug";
@@ -1274,7 +1276,26 @@ export function EmailViewer({
// at a part can be routed through the attachment preview/download gate
// instead of window.open() (GHSA-xvjh-v9c6-qcvc). A ref because the iframe
// click handler is bound once per document.
- const cidBlobPartsRef = useRef>(new Map());
+ const cidBlobPartsRef = useRef>(new Map());
+ // A locally held attachment (TNEF, decrypted, cid: part) shown in the
+ // in-app preview because an installed app cannot open its blob: URL in a
+ // new window (#865).
+ const [inAppPreview, setInAppPreview] = useState<{ name: string; downloadName: string; blob: Blob; contentType: string } | null>(null);
+ const getInAppPreviewContent = useCallback(async () => {
+ if (!inAppPreview) throw new Error('No attachment selected');
+ return { blob: inAppPreview.blob, contentType: inAppPreview.contentType };
+ }, [inAppPreview]);
+ const downloadInAppPreview = useCallback(() => {
+ if (!inAppPreview) return;
+ const url = URL.createObjectURL(inAppPreview.blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = inAppPreview.downloadName;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
+ }, [inAppPreview]);
const [quickReplyText, setQuickReplyText] = useState("");
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
@@ -2089,7 +2110,7 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob);
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
- cidBlobParts.set(objectUrl, { name: att.filename || cidValue, type: att.mimeType });
+ cidBlobParts.set(objectUrl, { name: att.filename || cidValue, type: att.mimeType, blob });
});
setCidBlobUrls(urls);
@@ -2123,11 +2144,12 @@ export function EmailViewer({
// the Blob type; re-type anything that could execute as our origin
// before it becomes a blob: URL (GHSA-xvjh-v9c6-qcvc).
const blob = await blobClient!.fetchBlob(att.blobId, att.name || 'inline', att.type, blobAccountId);
- const objectUrl = URL.createObjectURL(toInertBlob(blob));
+ const inertBlob = toInertBlob(blob);
+ const objectUrl = URL.createObjectURL(inertBlob);
if (!cancelled) {
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
- cidBlobParts.set(objectUrl, { name: att.name || cidValue, type: att.type });
+ cidBlobParts.set(objectUrl, { name: att.name || cidValue, type: att.type, blob: inertBlob });
} else {
URL.revokeObjectURL(objectUrl);
}
@@ -2494,7 +2516,9 @@ export function EmailViewer({
if (opensPreview) {
const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info);
- window.open(transformed.previewUrl || objectUrl, '_blank', 'noopener,noreferrer');
+ if (!openPreviewInNewWindow(transformed.previewUrl || objectUrl)) {
+ setInAppPreview({ name: attachment.name || downloadName, downloadName, blob, contentType: blob.type });
+ }
} else {
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
@@ -2524,7 +2548,9 @@ export function EmailViewer({
if (opensPreview) {
const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info);
- window.open(transformed.previewUrl || objectUrl, '_blank', 'noopener,noreferrer');
+ if (!openPreviewInNewWindow(transformed.previewUrl || objectUrl)) {
+ setInAppPreview({ name: attachment.name || downloadName, downloadName, blob, contentType: blob.type });
+ }
} else {
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
@@ -2940,7 +2966,9 @@ export function EmailViewer({
&& isFilePreviewable(part.name, part.type)
&& isMimeTypeSafeForInlinePreview(part.type);
if (opensPreview) {
- window.open(href, '_blank', 'noopener,noreferrer');
+ if (!openPreviewInNewWindow(href)) {
+ setInAppPreview({ name: part.name, downloadName: part.name, blob: part.blob, contentType: part.type || part.blob.type });
+ }
return;
}
const anchor = document.createElement('a');
@@ -6332,6 +6360,15 @@ export function EmailViewer({
/>
)}
+ {inAppPreview && (
+ setInAppPreview(null)}
+ onDownload={downloadInAppPreview}
+ getFileContent={getInAppPreviewContent}
+ />
+ )}
+
);
}
diff --git a/components/files/__tests__/file-preview-modal-standalone.test.tsx b/components/files/__tests__/file-preview-modal-standalone.test.tsx
new file mode 100644
index 000000000..72147fcad
--- /dev/null
+++ b/components/files/__tests__/file-preview-modal-standalone.test.tsx
@@ -0,0 +1,58 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string) => key,
+}));
+
+import { FilePreviewModal } from '../file-preview-modal';
+
+// In an installed app (PWA) "open in new tab" hands the blob: URL to the
+// system browser, which cannot load it: the button opened a blank window
+// (#865). It must not be offered there; the in-app preview and download stay.
+
+function mockDisplayMode(mode: string) {
+ vi.stubGlobal('matchMedia', vi.fn((query: string) => ({
+ matches: query === `(display-mode: ${mode})`,
+ media: query,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ })));
+}
+
+beforeEach(() => {
+ vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:test');
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+function renderPng() {
+ render(
+ {}}
+ onDownload={() => {}}
+ getFileContent={async () => ({ blob: new Blob([new Uint8Array([137, 80, 78, 71])], { type: 'image/png' }), contentType: 'image/png' })}
+ />,
+ );
+}
+
+describe('FilePreviewModal open in new tab', () => {
+ it('is offered in a browser tab', async () => {
+ mockDisplayMode('browser');
+ renderPng();
+ await screen.findByAltText('photo.png');
+ expect(screen.getByLabelText('open_in_new_tab')).toBeTruthy();
+ });
+
+ it('is not offered in an installed app, where the image still previews in-app', async () => {
+ mockDisplayMode('standalone');
+ renderPng();
+ expect((await screen.findByAltText('photo.png')).getAttribute('src')).toBe('blob:test');
+ expect(screen.queryByLabelText('open_in_new_tab')).toBeNull();
+ });
+});
diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx
index c83e2a471..7e8156a9d 100644
--- a/components/files/file-preview-modal.tsx
+++ b/components/files/file-preview-modal.tsx
@@ -9,6 +9,7 @@ import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previ
import dynamic from "next/dynamic";
import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview";
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
+import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
import type { FileResourceView } from "@/lib/plugin-types";
// pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded
@@ -274,7 +275,9 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
e.stopPropagation()}>
{name}
- {objectUrl && canOpenInNewTab && (
+ {/* An installed app hands the new window to the system browser,
+ which cannot load our blob: URL (#865): no dead button there. */}
+ {objectUrl && canOpenInNewTab && canOpenUrlInNewWindow(objectUrl) && (
approach
@@ -216,15 +217,21 @@ export function PdfMobileViewer({ url, blob }: { url: string; blob: Blob }) {
{status === "error" && (
// pdf.js couldn't render this document (corrupt/encrypted, worker load
// failure, ...). Offer the OS/native viewer instead of a stuck frame.
+ // An installed app cannot open the blob: URL in a new window (#865),
+ // so it only says so; the header's download button still works.
-
window.open(url, "_blank", "noopener,noreferrer")}
- >
-
- {t("open_in_new_tab")}
-
+ {canOpenUrlInNewWindow(url) ? (
+
window.open(url, "_blank", "noopener,noreferrer")}
+ >
+
+ {t("open_in_new_tab")}
+
+ ) : (
+
{t("preview_error")}
+ )}
)}
diff --git a/lib/__tests__/standalone-display.test.ts b/lib/__tests__/standalone-display.test.ts
new file mode 100644
index 000000000..3869bf457
--- /dev/null
+++ b/lib/__tests__/standalone-display.test.ts
@@ -0,0 +1,72 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { canOpenUrlInNewWindow, isStandaloneDisplayMode, openPreviewInNewWindow } from '../standalone-display';
+
+// An installed app (PWA) hands window.open(url, "_blank") to the system
+// browser, which cannot resolve the app's blob: URLs, so attachment previews
+// opened that way came up blank (#865).
+
+function mockDisplayMode(mode: string | null) {
+ vi.stubGlobal('matchMedia', vi.fn((query: string) => ({
+ matches: mode !== null && query === `(display-mode: ${mode})`,
+ media: query,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ })));
+}
+
+let openSpy: ReturnType;
+
+beforeEach(() => {
+ openSpy = vi.fn(() => null);
+ vi.stubGlobal('open', openSpy);
+ mockDisplayMode('browser');
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ delete (navigator as Navigator & { standalone?: boolean }).standalone;
+});
+
+describe('isStandaloneDisplayMode', () => {
+ it('is false in a browser tab', () => {
+ expect(isStandaloneDisplayMode()).toBe(false);
+ });
+
+ it.each(['standalone', 'fullscreen', 'window-controls-overlay'])('is true in %s display mode', (mode) => {
+ mockDisplayMode(mode);
+ expect(isStandaloneDisplayMode()).toBe(true);
+ });
+
+ it('is true for an iOS home-screen app that only reports navigator.standalone', () => {
+ mockDisplayMode(null);
+ (navigator as Navigator & { standalone?: boolean }).standalone = true;
+ expect(isStandaloneDisplayMode()).toBe(true);
+ });
+});
+
+describe('openPreviewInNewWindow', () => {
+ it('opens a blob: URL in a new window from a browser tab', () => {
+ expect(openPreviewInNewWindow('blob:https://mail.example/abc')).toBe(true);
+ expect(openSpy).toHaveBeenCalledWith('blob:https://mail.example/abc', '_blank', 'noopener,noreferrer');
+ });
+
+ it('does not open a blob: URL in an installed app and tells the caller to preview in-app', () => {
+ mockDisplayMode('standalone');
+ expect(openPreviewInNewWindow('blob:https://mail.example/abc')).toBe(false);
+ expect(openSpy).not.toHaveBeenCalled();
+ });
+
+ it('does not open a blob: URL in an iOS home-screen app', () => {
+ mockDisplayMode(null);
+ (navigator as Navigator & { standalone?: boolean }).standalone = true;
+ expect(openPreviewInNewWindow('blob:https://mail.example/abc')).toBe(false);
+ expect(openSpy).not.toHaveBeenCalled();
+ });
+
+ it('still opens a web URL (e.g. a plugin viewer) in an installed app', () => {
+ mockDisplayMode('standalone');
+ expect(canOpenUrlInNewWindow('https://viewer.example/doc')).toBe(true);
+ expect(openPreviewInNewWindow('https://viewer.example/doc')).toBe(true);
+ expect(openSpy).toHaveBeenCalledWith('https://viewer.example/doc', '_blank', 'noopener,noreferrer');
+ });
+});
diff --git a/lib/standalone-display.ts b/lib/standalone-display.ts
new file mode 100644
index 000000000..05fbe8961
--- /dev/null
+++ b/lib/standalone-display.ts
@@ -0,0 +1,39 @@
+/**
+ * Installed-app (PWA) display modes and what they break.
+ *
+ * In an installed web app, window.open(url, "_blank") is handed to the system
+ * browser: a separate browsing context that cannot resolve the app's blob:
+ * URLs, so the new window opens blank or errors (#865). Callers that would
+ * open a blob: URL in a new window ask here first and show the file in-app
+ * instead.
+ */
+
+type StandaloneNavigator = Navigator & { standalone?: boolean };
+
+const APP_DISPLAY_MODES = ["standalone", "fullscreen", "window-controls-overlay"] as const;
+
+/** Whether the page runs as an installed app rather than in a browser tab. */
+export function isStandaloneDisplayMode(): boolean {
+ if (typeof window === "undefined") return false;
+ // iOS Safari home-screen apps report navigator.standalone and (on older
+ // versions) no display-mode media feature at all.
+ if ((window.navigator as StandaloneNavigator).standalone === true) return true;
+ if (typeof window.matchMedia !== "function") return false;
+ return APP_DISPLAY_MODES.some((mode) => window.matchMedia(`(display-mode: ${mode})`).matches);
+}
+
+/** Whether a new window could load this URL. Only blob: URLs in an installed app cannot. */
+export function canOpenUrlInNewWindow(url: string): boolean {
+ return !url.startsWith("blob:") || !isStandaloneDisplayMode();
+}
+
+/**
+ * Opens a preview URL in a new window when that window can load it. Returns
+ * false without opening anything when it cannot, so the caller shows the
+ * file in-app (or downloads it) instead of leaving the user a dead window.
+ */
+export function openPreviewInNewWindow(url: string): boolean {
+ if (!canOpenUrlInNewWindow(url)) return false;
+ window.open(url, "_blank", "noopener,noreferrer");
+ return true;
+}
From ce600995234a9aa45784b6a91a93ad2b231d092e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:59:27 +0200
Subject: [PATCH 030/238] fix: decrypt PGP and S/MIME messages inside
conversations #902
---
.../thread-conversation-plugin-body.test.tsx | 110 ++++++++++++++++
components/email/thread-conversation-view.tsx | 31 ++++-
hooks/use-plugin-rendered-body.ts | 118 ++++++++++++++++++
3 files changed, 257 insertions(+), 2 deletions(-)
create mode 100644 components/email/__tests__/thread-conversation-plugin-body.test.tsx
create mode 100644 hooks/use-plugin-rendered-body.ts
diff --git a/components/email/__tests__/thread-conversation-plugin-body.test.tsx b/components/email/__tests__/thread-conversation-plugin-body.test.tsx
new file mode 100644
index 000000000..52d79fc16
--- /dev/null
+++ b/components/email/__tests__/thread-conversation-plugin-body.test.tsx
@@ -0,0 +1,110 @@
+// #902: the phone conversation view (also used by Mountain View's expanded
+// messages) rendered the raw message body and never asked the
+// onRenderEmailBody hook, so a PGP message the plugin decrypts in the single
+// reader stayed an armored block inside a thread.
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { act, render, screen, waitFor } from '@testing-library/react';
+
+const t = Object.assign((k: string) => k, { raw: (k: string) => k, rich: (k: string) => k });
+vi.mock('next-intl', () => ({
+ useTranslations: () => t,
+ useLocale: () => 'en',
+}));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
+vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { renderHooks, clearAllHooks } = await import('@/lib/plugin-hooks');
+const { ThreadConversationView } = await import('@/components/email/thread-conversation-view');
+
+const ARMORED = '-----BEGIN PGP MESSAGE-----\nhQEMA1234\n-----END PGP MESSAGE-----';
+
+const pgpEmail = (id: string, receivedAt: string) => ({
+ id, blobId: `blob-${id}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: false },
+ from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ email: 'me@example.org' }],
+ subject: 'Secret', receivedAt, size: 1, preview: '-----BEGIN PGP MESSAGE-----', hasAttachment: false,
+ textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
+ bodyValues: { '1': { value: ARMORED } },
+});
+
+const renderThread = () => {
+ const newer = pgpEmail('new', '2026-10-02T10:00:00Z');
+ const older = pgpEmail('old', '2026-10-01T10:00:00Z');
+ return render(
+ {}}
+ />,
+ );
+};
+
+afterEach(() => clearAllHooks());
+
+describe('conversation view runs the onRenderEmailBody hook', () => {
+ it('shows the plain text a plugin decrypted for each unfolded message', async () => {
+ const handler = vi.fn(async (_body: unknown, ctx: { id: string }) => ({
+ html: '', text: `Decrypted ${ctx.id}`, attachments: [], handledBy: 'pgp',
+ }));
+ renderHooks.onRenderEmailBody.register('pgp', handler as never);
+
+ renderThread();
+
+ expect(await screen.findByText('Decrypted new')).toBeTruthy();
+ expect(await screen.findByText('Decrypted old')).toBeTruthy();
+ expect(screen.queryByText(/BEGIN PGP MESSAGE/)).toBeNull();
+ expect(handler).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ id: 'new', blobId: 'blob-new' }),
+ );
+ }, 30_000);
+
+ it('puts decrypted HTML into the sandboxed body frame, sanitized', async () => {
+ renderHooks.onRenderEmailBody.register('pgp', (async () => ({
+ html: 'Decrypted html body
', text: '', attachments: [], handledBy: 'pgp',
+ })) as never);
+
+ const { container } = renderThread();
+
+ await waitFor(() => {
+ const srcdocs = [...container.querySelectorAll('iframe')].map((f) => f.getAttribute('srcdoc') ?? '');
+ expect(srcdocs).toHaveLength(2);
+ for (const doc of srcdocs) {
+ expect(doc).toContain('Decrypted html body');
+ expect(doc).not.toContain('alert(1)');
+ }
+ });
+ }, 30_000);
+
+ it('keeps the original body when no plugin handles the message', async () => {
+ renderHooks.onRenderEmailBody.register('pgp', (async (body: unknown) => body) as never);
+
+ renderThread();
+ await act(async () => {});
+
+ expect(screen.getAllByText(/BEGIN PGP MESSAGE/).length).toBe(2);
+ }, 30_000);
+
+ it('decrypts again when a plugin asks for a re-render', async () => {
+ let unlocked = false;
+ renderHooks.onRenderEmailBody.register('pgp', (async () => (unlocked
+ ? { html: '', text: 'Unlocked body', attachments: [], handledBy: 'pgp' }
+ : { html: '', text: '', attachments: [] })) as never);
+
+ renderThread();
+ await act(async () => {});
+ expect(screen.queryByText('Unlocked body')).toBeNull();
+
+ unlocked = true;
+ act(() => { window.dispatchEvent(new Event('plugin:rerender-email')); });
+ expect((await screen.findAllByText('Unlocked body')).length).toBe(2);
+ }, 30_000);
+});
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index c60315b12..7170d85fa 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -3,7 +3,7 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
-import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
+import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizeEmailBodyForIframe, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
import { VerificationCodeChip } from "./verification-code-chip";
@@ -46,6 +46,7 @@ import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
+import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
import { emailToReadView } from "@/lib/plugin-projection";
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
@@ -515,6 +516,8 @@ export function ConversationEmailBody({
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const [cidBlobUrls, setCidBlobUrls] = useState>({});
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
+ // A crypto plugin's decrypted body (PGP, S/MIME) replaces the message's own.
+ const pluginBody = usePluginRenderedBody(email);
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
const [htmlBodyEl, setHtmlBodyEl] = useState(null);
const [plainBodyEl, setPlainBodyEl] = useState(null);
@@ -575,6 +578,30 @@ export function ConversationEmailBody({
const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false };
+ // Plugin-rendered bodies get the reader's sanitizer and remote-content
+ // blocking, like any other mail body (#797).
+ if (pluginBody.html) {
+ const withCids = pluginBody.html.replace(
+ /\bcid:([^"'\s)]+)/gi,
+ (_match, cidRef) => pluginBody.cidUrls[cidRef] || cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ );
+ const { html, blockedExternalContent } = sanitizeEmailBodyForIframe(withCids, !allowExternal);
+ if (blockedExternalContent) {
+ if (!hasBlockedContent) setHasBlockedContent(true);
+ return { html: collapseBlockedImageContainers(html), isHtml: true };
+ }
+ return { html, isHtml: true };
+ }
+ if (pluginBody.text) {
+ return {
+ html: collapsePlainTextQuotes(plainTextToSafeHtml(pluginBody.text, 'text-primary hover:underline'), {
+ show: t('email_viewer.show_quoted_text'),
+ hide: t('email_viewer.hide_quoted_text'),
+ }),
+ isHtml: false,
+ };
+ }
+
if (email.bodyValues) {
let htmlContent = getRenderableHtmlBody(email);
@@ -651,7 +678,7 @@ export function ConversationEmailBody({
// `hasBlockedContent` is only read to skip a redundant setState; as a
// dependency it would re-run the sanitizer once the banner shows.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [email, allowExternal, cidBlobUrls, t]);
+ }, [email, allowExternal, cidBlobUrls, pluginBody, t]);
// Parts the body embeds via cid: stay out of the attachment row while the
// user hides inline images - the desktop viewer's rule, shared through
diff --git a/hooks/use-plugin-rendered-body.ts b/hooks/use-plugin-rendered-body.ts
new file mode 100644
index 000000000..34bdce248
--- /dev/null
+++ b/hooks/use-plugin-rendered-body.ts
@@ -0,0 +1,118 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { Email } from "@/lib/jmap/types";
+import { renderHooks } from "@/lib/plugin-hooks";
+import { toInertBlob } from "@/lib/file-preview";
+import { debug } from "@/lib/debug";
+
+export interface PluginRenderedBody {
+ /** HTML a crypto plugin (PGP, S/MIME) rendered for the message, unsanitized. */
+ html: string | null;
+ /** Plain text the plugin rendered, when it gave no HTML. */
+ text: string | null;
+ /** blob: URLs for the decrypted inline parts, keyed by Content-ID without brackets. */
+ cidUrls: Record;
+}
+
+const EMPTY: PluginRenderedBody = { html: null, text: null, cidUrls: {} };
+
+function dataUrlToBytes(dataUrl: string): Uint8Array | null {
+ try {
+ const comma = dataUrl.indexOf(',');
+ if (comma < 0) return null;
+ const meta = dataUrl.slice(0, comma);
+ const data = dataUrl.slice(comma + 1);
+ if (meta.includes(';base64')) {
+ const bin = atob(data);
+ const u8 = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
+ return u8;
+ }
+ return new TextEncoder().encode(decodeURIComponent(data));
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Asks the onRenderEmailBody hook for a replacement body, the way the single
+ * reader does, so a message a PGP or S/MIME plugin decrypts also shows
+ * decrypted inside a conversation (#902). Re-runs when a plugin calls
+ * api.ui.rerenderEmail, e.g. after the user unlocked a key.
+ */
+export function usePluginRenderedBody(email: Email): PluginRenderedBody {
+ const [body, setBody] = useState(EMPTY);
+ const [nonce, setNonce] = useState(0);
+
+ useEffect(() => {
+ const bump = () => setNonce((n) => n + 1);
+ window.addEventListener('plugin:rerender-email', bump);
+ return () => window.removeEventListener('plugin:rerender-email', bump);
+ }, []);
+
+ useEffect(() => {
+ if (renderHooks.onRenderEmailBody.size === 0) {
+ setBody(EMPTY);
+ return;
+ }
+ let cancelled = false;
+ const objectUrls: string[] = [];
+
+ (async () => {
+ try {
+ const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type'];
+ const contentType = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType;
+ const result = await renderHooks.onRenderEmailBody.transform(
+ { html: '', text: '', attachments: [] as unknown[] },
+ {
+ id: email.id,
+ contentType,
+ bodyStructure: email.bodyStructure,
+ bodyValues: email.bodyValues,
+ attachments: email.attachments,
+ blobId: email.blobId,
+ from: email.from,
+ },
+ ) as {
+ html?: string;
+ text?: string;
+ attachments?: Array<{ name?: string; type?: string; dataUrl?: string; cid?: string }>;
+ handledBy?: string;
+ };
+ if (cancelled) return;
+ if (!result || !result.handledBy) {
+ setBody(EMPTY);
+ return;
+ }
+ const cidUrls: Record = {};
+ for (const att of Array.isArray(result.attachments) ? result.attachments : []) {
+ if (!att.cid || !att.dataUrl) continue;
+ const bytes = dataUrlToBytes(att.dataUrl);
+ if (!bytes) continue;
+ // Same re-typing as fetched cid parts before they get a blob: URL
+ // in our origin (GHSA-xvjh-v9c6-qcvc).
+ const url = URL.createObjectURL(toInertBlob(new Blob([bytes as BlobPart], { type: att.type || 'application/octet-stream' })));
+ objectUrls.push(url);
+ cidUrls[att.cid.replace(/^<|>$/g, '')] = url;
+ }
+ setBody({
+ html: typeof result.html === 'string' && result.html ? result.html : null,
+ text: typeof result.text === 'string' && result.text ? result.text : null,
+ cidUrls,
+ });
+ } catch (err) {
+ if (cancelled) return;
+ debug.error('onRenderEmailBody hook failed:', err);
+ setBody(EMPTY);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ objectUrls.forEach((url) => URL.revokeObjectURL(url));
+ };
+ }, [email, nonce]);
+
+ return body;
+}
From 4a69a7df8a629cb7f4abd5f5ba35dd9b5a46d9b8 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:58:27 +0200
Subject: [PATCH 031/238] test: darken cells coloured with bgcolor in dark mode
#693
---
lib/__tests__/email-dark-mode.test.ts | 69 +++++++++++++++++++++++++++
1 file changed, 69 insertions(+)
diff --git a/lib/__tests__/email-dark-mode.test.ts b/lib/__tests__/email-dark-mode.test.ts
index 52b6dd28c..298084a9f 100644
--- a/lib/__tests__/email-dark-mode.test.ts
+++ b/lib/__tests__/email-dark-mode.test.ts
@@ -163,3 +163,72 @@ describe('applyEmailDarkMode', () => {
expect(doc.documentElement.classList.contains(DARK_PENDING_CLASS)).toBe(false);
});
});
+
+// #693: cells coloured through the legacy bgcolor attribute stayed white in
+// dark mode. Browsers map bgcolor to background-color as a presentational hint
+// (below every author style), which the pass reads back through computed
+// style. jsdom has no presentational hints, so the helper below adds the same
+// mapping as zero-specificity rules at the top of the cascade.
+describe('applyEmailDarkMode with bgcolor', () => {
+ const mapBgcolorLikeBrowser = (doc: Document) => {
+ const rules: string[] = [];
+ doc.querySelectorAll('[bgcolor]').forEach((el, i) => {
+ el.setAttribute('data-bgcolor-hint', String(i));
+ rules.push(`:where([data-bgcolor-hint="${i}"]) { background-color: ${el.getAttribute('bgcolor')}; }`);
+ });
+ const sheet = doc.createElement('style');
+ sheet.textContent = rules.join('\n');
+ doc.head.prepend(sheet);
+ };
+
+ const render = (mail: string, bodyBgcolor?: string) => {
+ const { html } = sanitizeEmailBodyForIframe(mail, true);
+ const frame = document.createElement('iframe');
+ document.body.appendChild(frame);
+ const doc = frame.contentDocument!;
+ doc.documentElement.className = DARK_PENDING_CLASS;
+ doc.documentElement.style.backgroundColor = 'rgb(20, 20, 20)';
+ if (bodyBgcolor) doc.body.setAttribute('bgcolor', bodyBgcolor);
+ doc.body.innerHTML = html;
+ mapBgcolorLikeBrowser(doc);
+ applyEmailDarkMode(doc);
+ return doc;
+ };
+
+ // The darkened colour the pass wrote over the sender's one.
+ const rewrittenBg = (el: HTMLElement) => {
+ expect(el.style.getPropertyPriority('background-color')).toBe('important');
+ return c(el.ownerDocument.defaultView!.getComputedStyle(el).backgroundColor);
+ };
+
+ it.each([
+ ['td', ''],
+ ['tr', ''],
+ ['table', ''],
+ ['td with a named colour', ''],
+ ['td with a light tint', ''],
+ ])('darkens a light bgcolor on %s and keeps its text readable', (_, mail) => {
+ const doc = render(mail);
+ const bg = rewrittenBg(doc.getElementById('x')!);
+ expect(lightness(bg)).toBeLessThan(40);
+ const text = c(doc.defaultView!.getComputedStyle(doc.getElementById('p')!).color);
+ expect(contrastRatio(text, bg)).toBeGreaterThan(4.5);
+ });
+
+ it('darkens the inline background when a cell has both', () => {
+ const doc = render('');
+ expect(lightness(rewrittenBg(doc.getElementById('x')!))).toBeLessThan(40);
+ });
+
+ it('keeps a vivid bgcolor as sent', () => {
+ const doc = render('');
+ const cell = doc.getElementById('x')!;
+ expect(c(doc.defaultView!.getComputedStyle(cell).backgroundColor)).toEqual(c('rgb(227, 6, 19)'));
+ expect(cell.style.getPropertyValue('background-color')).toBe('');
+ });
+
+ it('darkens a white body bgcolor', () => {
+ const doc = render('Hello
', '#ffffff');
+ expect(lightness(rewrittenBg(doc.body))).toBeLessThan(40);
+ });
+});
From cbf917372ce05001f6ade367f876434b41218ddb Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:08:04 +0200
Subject: [PATCH 032/238] fix: show the mail's own date in the list and keep it
on .eml import #891
---
components/contacts/contact-activity.tsx | 3 +-
.../email/__tests__/thread-list-item.test.tsx | 36 ++++++++++++++++
components/email/email-viewer.tsx | 8 ++--
components/email/thread-conversation-view.tsx | 3 +-
components/email/thread-email-item.tsx | 3 +-
components/email/thread-list-item.tsx | 9 ++--
components/mail/mail-app.tsx | 4 +-
components/pro/pro-folder-tab-body.tsx | 3 +-
lib/__tests__/eml-import.test.ts | 41 ++++++++++++++++++-
lib/eml-import.ts | 35 +++++++++++++++-
10 files changed, 128 insertions(+), 17 deletions(-)
diff --git a/components/contacts/contact-activity.tsx b/components/contacts/contact-activity.tsx
index 4a95ffcd2..3f818d3f7 100644
--- a/components/contacts/contact-activity.tsx
+++ b/components/contacts/contact-activity.tsx
@@ -9,6 +9,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { Avatar } from "@/components/ui/avatar";
import { cleanPreview } from "@/lib/utils";
+import { emailDisplayDate } from "@/lib/email-date";
import { Section } from "./contact-detail";
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
@@ -239,7 +240,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
{sender.name || t("unknown_sender")}
- {formatEmailDate(email.receivedAt)}
+ {formatEmailDate(emailDisplayDate(email))}
diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx
index 90d21f827..f91eadd12 100644
--- a/components/email/__tests__/thread-list-item.test.tsx
+++ b/components/email/__tests__/thread-list-item.test.tsx
@@ -646,3 +646,39 @@ describe('ThreadListItem prefetch on mouse press', () => {
expect(onPrefetch).not.toHaveBeenCalled();
});
});
+
+describe('ThreadListItem date (#891)', () => {
+ // A migrated mailbox: the server stamped every message with the import day,
+ // but the Date header still carries when the mail was really sent.
+ const imported = { receivedAt: '2024-01-15T10:00:00Z', sentAt: '2019-03-05T08:30:00Z' };
+
+ beforeEach(() => {
+ useSettingsStore.setState({
+ emailKeywords: [...DEFAULT_KEYWORDS],
+ showPreview: false,
+ mailLayout: 'split',
+ interfaceLayout: 'default',
+ dateFormat: 'full',
+ });
+ useEmailStore.setState({ selectedEmailKeys: new Set
(), selectedMailbox: 'inbox' });
+ });
+
+ it('shows the Date header, not the import time, on a single-message row', () => {
+ const { container } = renderRow(makeEmail(imported));
+ expect(container.textContent).toMatch(/2019/);
+ expect(container.textContent).not.toMatch(/2024/);
+ });
+
+ it('shows the Date header of the latest message on a thread row', () => {
+ const emails = [
+ makeEmail({ id: 'a', receivedAt: '2024-01-15T09:00:00Z', sentAt: '2019-03-04T08:30:00Z' }),
+ makeEmail({ id: 'b', ...imported }),
+ ];
+ const [thread] = groupEmailsByThread(emails);
+ const { container } = render(
+ {}} onEmailSelect={() => {}} />,
+ );
+ expect(container.textContent).toMatch(/2019/);
+ expect(container.textContent).not.toMatch(/2024/);
+ });
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 251c27709..dd0cc525a 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -711,7 +711,7 @@ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Ema
{sender?.name || sender?.email || ''}
- {compact ? formatDate(email.receivedAt) : mvSenderDate(email.receivedAt, timeFormat)}
+ {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
{cleanPreview(email.preview)}
@@ -826,7 +826,7 @@ export function MvExpandedConversationCard({
<{sender.email}>
)}
- {compact ? formatDate(email.receivedAt) : mvSenderDate(email.receivedAt, timeFormat)}
+ {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
- {formatDate(email.receivedAt)}
+ {formatDate(emailDisplayDate(email))}
{!isExpanded && density !== 'extra-compact' && (
diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx
index c099fb225..dfda8bddf 100644
--- a/components/email/thread-email-item.tsx
+++ b/components/email/thread-email-item.tsx
@@ -1,6 +1,7 @@
"use client";
import { useCallback } from "react";
+import { emailDisplayDate } from "@/lib/email-date";
import { useTranslations } from "next-intl";
import { cleanPreview, formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
@@ -221,7 +222,7 @@ export function ThreadEmailItem({
? "text-foreground font-medium"
: "text-muted-foreground"
)}>
- {formatDate(email.receivedAt)}
+ {formatDate(emailDisplayDate(email))}
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index 63738940d..e48819e45 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -2,6 +2,7 @@
import React, { useCallback } from "react";
import { cleanPreview, formatDate } from "@/lib/utils";
+import { emailDisplayDate } from "@/lib/email-date";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
@@ -688,7 +689,7 @@ const SingleEmailItem = React.forwardRef(
'text-xs tabular-nums',
isUnread ? (mvRow ? 'text-foreground font-bold' : 'text-foreground font-semibold') : 'text-muted-foreground'
)}>
- {formatDate(email.receivedAt, mvRow ? { listStyle: 'mountain-view' } : undefined)}
+ {formatDate(emailDisplayDate(email), mvRow ? { listStyle: 'mountain-view' } : undefined)}
)}
@@ -766,7 +767,7 @@ const SingleEmailItem = React.forwardRef(
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
- {formatDate(email.receivedAt)}
+ {formatDate(emailDisplayDate(email))}
)}
{mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && (
@@ -1289,7 +1290,7 @@ export const ThreadListItem = React.forwardRef
- {formatDate(latestEmail.receivedAt, mvRow ? { listStyle: 'mountain-view' } : undefined)}
+ {formatDate(emailDisplayDate(latestEmail), mvRow ? { listStyle: 'mountain-view' } : undefined)}
)}
@@ -1374,7 +1375,7 @@ export const ThreadListItem = React.forwardRef
- {formatDate(latestEmail.receivedAt)}
+ {formatDate(emailDisplayDate(latestEmail))}
)}
{mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && (
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index d9b1b48f3..767bef433 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -3450,9 +3450,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
let imported = 0;
let failed = 0;
- for (const { blob } of emails) {
+ for (const { blob, receivedAt } of emails) {
try {
- await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true });
+ await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true }, undefined, receivedAt);
imported++;
} catch {
failed++;
diff --git a/components/pro/pro-folder-tab-body.tsx b/components/pro/pro-folder-tab-body.tsx
index 3132e3ff2..4479ebbd9 100644
--- a/components/pro/pro-folder-tab-body.tsx
+++ b/components/pro/pro-folder-tab-body.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Folder, Loader2, Paperclip, RefreshCw, Star } from "@/components/icons";
import { cn, cleanPreview, formatDate } from "@/lib/utils";
+import { emailDisplayDate } from "@/lib/email-date";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { EmailViewer } from "@/components/email/email-viewer";
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
@@ -235,7 +236,7 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
{who}
- {formatDate(email.receivedAt)}
+ {formatDate(emailDisplayDate(email))}
diff --git a/lib/__tests__/eml-import.test.ts b/lib/__tests__/eml-import.test.ts
index cf0b794ac..70dae52b4 100644
--- a/lib/__tests__/eml-import.test.ts
+++ b/lib/__tests__/eml-import.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import JSZip from 'jszip';
-import { expandImportableEmails, EML_IMPORT_ACCEPT } from '@/lib/eml-import';
+import { expandImportableEmails, readMessageDate, EML_IMPORT_ACCEPT } from '@/lib/eml-import';
const emlFile = (name: string, content = 'raw email', type = 'message/rfc822') =>
new File([content], name, { type });
@@ -49,3 +49,42 @@ describe('expandImportableEmails', () => {
expect(EML_IMPORT_ACCEPT).toBe('.eml,.zip,message/rfc822,application/zip');
});
});
+
+describe('Date header as receivedAt (#891)', () => {
+ const CRLF = String.fromCharCode(13, 10);
+ const raw = [
+ 'From: Alice
',
+ 'Subject: old mail',
+ 'Date: Tue, 5 Mar 2019 09:30:00',
+ ' +0100 (CET)',
+ '',
+ 'Date: Wed, 1 Jan 2025 00:00:00 +0000',
+ 'body',
+ ].join(CRLF);
+
+ it('carries the Date header of an .eml file as a JMAP UTCDate', async () => {
+ const out = await expandImportableEmails([emlFile('old.eml', raw)]);
+ expect(out[0].receivedAt).toBe('2019-03-05T08:30:00Z');
+ });
+
+ it('carries the Date header of each .eml inside a .zip', async () => {
+ const zip = new JSZip();
+ zip.file('old.eml', raw);
+ const blob = await zip.generateAsync({ type: 'blob' });
+ const out = await expandImportableEmails([new File([blob], 'a.zip', { type: 'application/zip' })]);
+ expect(out[0].receivedAt).toBe('2019-03-05T08:30:00Z');
+ });
+
+ it('leaves receivedAt unset when there is no usable Date header', async () => {
+ const bodyOnly = ['Subject: x', '', 'Date: Tue, 5 Mar 2019 09:30:00 +0100'].join(CRLF);
+ await expect(readMessageDate(new Blob([bodyOnly]))).resolves.toBeUndefined();
+ const garbage = ['Date: not a date', '', 'body'].join(CRLF);
+ await expect(readMessageDate(new Blob([garbage]))).resolves.toBeUndefined();
+ });
+
+ it('ignores a Date header far in the future', async () => {
+ const now = Date.parse('2026-01-01T00:00:00Z');
+ const future = ['Date: Fri, 1 Jan 2027 00:00:00 +0000', '', 'body'].join(CRLF);
+ await expect(readMessageDate(new Blob([future]), now)).resolves.toBeUndefined();
+ });
+});
diff --git a/lib/eml-import.ts b/lib/eml-import.ts
index 3056cd35c..dae322846 100644
--- a/lib/eml-import.ts
+++ b/lib/eml-import.ts
@@ -1,6 +1,14 @@
+import { MAX_FUTURE_SENT_AT_MS } from "./email-date";
+
export interface ImportableEmail {
name: string;
blob: Blob;
+ /**
+ * The message's `Date` header as a JMAP UTCDate, passed as `receivedAt` on
+ * Email/import. Without it the server stamps the import time and every
+ * imported message looks like it arrived today (#891).
+ */
+ receivedAt?: string;
}
const EMAIL_MIME = "message/rfc822";
@@ -13,6 +21,27 @@ function isZipName(name: string): boolean {
return /\.zip$/i.test(name);
}
+/** Only the header block is needed; don't decode a whole large message. */
+const HEADER_SCAN_BYTES = 64 * 1024;
+
+/**
+ * Read the `Date` header of a raw RFC 5322 message and return it as a JMAP
+ * UTCDate (no fractional seconds). Returns undefined when the header is
+ * missing, unparsable, or more than a day in the future.
+ */
+export async function readMessageDate(blob: Blob, now: number = Date.now()): Promise {
+ const head = await blob.slice(0, HEADER_SCAN_BYTES).text();
+ const end = head.search(/\r?\n\r?\n/);
+ const headers = (end === -1 ? head : head.slice(0, end)).replace(/\r?\n[ \t]+/g, " ");
+ const match = /^Date:[ \t]*(.+)$/im.exec(headers);
+ if (!match) return undefined;
+ // Drop RFC 5322 comments such as "(CEST)" before parsing.
+ const value = match[1].replace(/\([^)]*\)/g, " ").trim();
+ const time = Date.parse(value);
+ if (Number.isNaN(time) || time - now > MAX_FUTURE_SENT_AT_MS) return undefined;
+ return new Date(time).toISOString().replace(/\.\d{3}Z$/, "Z");
+}
+
async function extractEmlsFromZip(file: File): Promise {
const { default: JSZip } = await import("jszip");
const zip = await JSZip.loadAsync(await file.arrayBuffer());
@@ -22,9 +51,11 @@ async function extractEmlsFromZip(file: File): Promise {
if (entry.dir) continue;
if (!isEmlName(entry.name)) continue;
const data = await entry.async("arraybuffer");
+ const blob = new Blob([data], { type: EMAIL_MIME });
out.push({
name: entry.name.split(/[\\/]/).pop() || entry.name,
- blob: new Blob([data], { type: EMAIL_MIME }),
+ blob,
+ receivedAt: await readMessageDate(blob),
});
}
return out;
@@ -40,7 +71,7 @@ export async function expandImportableEmails(
continue;
}
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
- out.push({ name: file.name, blob });
+ out.push({ name: file.name, blob, receivedAt: await readMessageDate(blob) });
}
return out;
}
From f8ef54dbea58443f91703e1e1ab7eba39828bd6d Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:18:02 +0200
Subject: [PATCH 033/238] fix: enable push notifications when another user
shares a folder with you #1050
---
lib/__tests__/web-push.test.ts | 85 ++++++++++++++++++++++++
lib/jmap/client-interface.ts | 2 +
lib/web-push.ts | 115 +++++++++++++++++++++++++++++++--
3 files changed, 198 insertions(+), 4 deletions(-)
diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts
index 48d2d7b99..6b5662efd 100644
--- a/lib/__tests__/web-push.test.ts
+++ b/lib/__tests__/web-push.test.ts
@@ -484,6 +484,91 @@ describe('enableWebPush delivery filter (emailPush)', () => {
});
});
+// Stalwart refuses emailPush entries for accounts that only shared a folder
+// with the user; only the own account and groups the user belongs to pass.
+describe('enableWebPush with a folder shared by another user (#1050)', () => {
+ const GROUP_ACCOUNT_ID = 'acct-group';
+ const FORBIDDEN = 'PushSubscription/set create failed: {"type":"forbidden","description":"No access to one of the accounts in the emailPush map."}';
+ const pushParams = (client: IJMAPClient, call: number) =>
+ (client.createPushSubscription as ReturnType).mock.calls[call][0];
+
+ // Behaves like Stalwart: any map naming the sharing account is refused.
+ function refuseSharedAccount(client: IJMAPClient) {
+ vi.mocked(client.createPushSubscription).mockImplementation(async (params) => {
+ if (params.emailPush && SHARED_ACCOUNT_ID in params.emailPush) throw new Error(FORBIDDEN);
+ return 'push-new';
+ });
+ vi.mocked(client.updatePushSubscription).mockImplementation(async (_id, patch) =>
+ !(patch.emailPush && SHARED_ACCOUNT_ID in patch.emailPush));
+ }
+
+ it('still creates the subscription, filtered for the own account only', async () => {
+ localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
+ const client = makeClient([], { emailPushCapability: true });
+ refuseSharedAccount(client);
+ installFetch({});
+
+ const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
+
+ expect(result.subscriptionId).toBe('push-new');
+ expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code-123');
+ expect(client.createPushSubscription).toHaveBeenCalledTimes(2);
+ expect(pushParams(client, 1).emailPush).toEqual({ [ACCOUNT_ID]: EXPECTED_EMAIL_PUSH[ACCOUNT_ID] });
+ });
+
+ it('keeps the filter for a group account the user belongs to', async () => {
+ localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
+ const client = makeClient([], { emailPushCapability: true });
+ const base = await client.getAllMailboxes();
+ vi.mocked(client.getAllMailboxes).mockResolvedValue([
+ ...base,
+ { ...base[0], id: `${GROUP_ACCOUNT_ID}:mb-group-inbox`, originalId: 'mb-group-inbox', accountId: GROUP_ACCOUNT_ID },
+ ]);
+ // What Stalwart's session says: create rights on owned accounts (own and
+ // group), none on an account that only shared a folder.
+ (client as { getAccountCapability?: unknown }).getAccountCapability = vi.fn(
+ (urn: string, accountId: string) => (urn === 'urn:ietf:params:jmap:calendars'
+ ? { mayCreateCalendar: accountId !== SHARED_ACCOUNT_ID }
+ : undefined),
+ );
+ refuseSharedAccount(client);
+ installFetch({});
+
+ await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
+
+ expect(client.createPushSubscription).toHaveBeenCalledTimes(2);
+ expect(Object.keys(pushParams(client, 1).emailPush).sort()).toEqual([GROUP_ACCOUNT_ID, ACCOUNT_ID].sort());
+ });
+
+ it('does not retry a create that failed for another reason', async () => {
+ localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
+ const client = makeClient([], { emailPushCapability: true });
+ vi.mocked(client.createPushSubscription).mockRejectedValue(new Error('PushSubscription/set create failed: {"type":"overQuota"}'));
+ installFetch({});
+
+ await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow(/overQuota/);
+ expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
+ });
+
+ it('refreshes an existing subscription with the accounts the server accepts', async () => {
+ localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
+ localStorage.setItem(SUB_KEY, 'push-old');
+ const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true });
+ refuseSharedAccount(client);
+ installFetch({});
+
+ const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
+
+ expect(result.subscriptionId).toBe('push-old');
+ expect(client.createPushSubscription).not.toHaveBeenCalled();
+ expect(client.destroyed).toEqual([]);
+ expect(client.updatePushSubscription).toHaveBeenLastCalledWith(
+ 'push-old',
+ expect.objectContaining({ emailPush: { [ACCOUNT_ID]: EXPECTED_EMAIL_PUSH[ACCOUNT_ID] } }),
+ );
+ });
+});
+
// Inbox-only mode swaps the "not in Junk" mailbox condition for a positive
// "in Inbox" one, so mail a Sieve rule files into any other folder never
// reaches the push subscription.
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 74496d2a7..baab195cf 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -88,6 +88,8 @@ export interface IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
getCapabilities(): Record;
hasAccountCapability(capability: string, accountId?: string): boolean;
+ /** The value of an account capability (primary account by default). */
+ getAccountCapability?(capability: string, accountId?: string): unknown;
/** FileNode naming rules the server publishes (Stalwart 0.16.6+), if any. */
getFileNameRules?(accountId?: string): FileNameRules | null;
getMaxSizeUpload(): number;
diff --git a/lib/web-push.ts b/lib/web-push.ts
index 3efa0a20c..0bf26daf2 100644
--- a/lib/web-push.ts
+++ b/lib/web-push.ts
@@ -202,6 +202,99 @@ function sameEmailPush(
return normalizeEmailPush(a) === normalizeEmailPush(b);
}
+function pickAccounts(
+ config: Record,
+ accountIds: readonly string[],
+): Record {
+ const out: Record = {};
+ for (const id of accountIds) if (config[id]) out[id] = config[id];
+ return out;
+}
+
+// A stored filter that the fallback below narrowed down still counts as up to
+// date: same entries for the accounts it covers, and it covers the primary.
+// Without this every resync would retry the full map and get refused again.
+function coveredByEmailPush(
+ stored: Record | null | undefined,
+ desired: Record,
+ primary: string,
+): boolean {
+ if (!stored || !stored[primary]) return false;
+ const keys = Object.keys(stored);
+ if (!keys.every((id) => desired[id])) return false;
+ return sameEmailPush(stored, pickAccounts(desired, keys));
+}
+
+function isForbiddenError(err: unknown): boolean {
+ return err instanceof Error && err.message.includes('"type":"forbidden"');
+}
+
+/**
+ * Smaller emailPush maps to retry with after the server refused the full one.
+ *
+ * Stalwart only accepts emailPush entries for the user's own account and the
+ * groups they belong to. An entry for an account that merely shared a folder
+ * with them fails the whole PushSubscription/set with `forbidden` (#1050).
+ * Those accounts never push to this subscription anyway - Stalwart fans out
+ * to member accounts only - so leaving their entries out loses nothing. The
+ * session lists both kinds alike, so first keep the accounts that look owned
+ * (see OWNERSHIP_FLAGS), then fall back to the primary account alone. The
+ * native app does the same (repos/react-native emailPushFallbacks).
+ */
+function narrowedEmailPushConfigs(
+ client: IJMAPClient,
+ config: Record,
+): Record[] {
+ const primary = client.getAccountId();
+ const others = Object.keys(config).filter((id) => id !== primary);
+ if (others.length === 0) return [];
+ const candidates: Record[] = [];
+ const owned = others.filter((id) => looksOwned(client, id));
+ if (owned.length > 0 && owned.length < others.length) {
+ candidates.push(pickAccounts(config, [primary, ...owned]));
+ }
+ candidates.push(pickAccounts(config, [primary]));
+ return candidates;
+}
+
+// Stalwart marks the accounts a user owns - their own and the groups they
+// belong to - through the create flags of its per-account collection
+// capabilities: true there, false on accounts shared with them by ACL.
+const OWNERSHIP_FLAGS: ReadonlyArray = [
+ ['urn:ietf:params:jmap:calendars', 'mayCreateCalendar'],
+ ['urn:ietf:params:jmap:contacts', 'mayCreateAddressBook'],
+ ['urn:ietf:params:jmap:filenode', 'mayCreateTopLevelFileNode'],
+];
+
+function looksOwned(client: IJMAPClient, accountId: string): boolean {
+ return OWNERSHIP_FLAGS.some(([urn, flag]) => {
+ const capability = client.getAccountCapability?.(urn, accountId) as Record | undefined;
+ return capability?.[flag] === true;
+ });
+}
+
+async function createPushSubscriptionWithEmailPush(
+ client: IJMAPClient,
+ params: Omit[0], 'emailPush'>,
+ emailPush: Record | null,
+): Promise {
+ try {
+ return await client.createPushSubscription({ ...params, ...(emailPush ? { emailPush } : {}) });
+ } catch (err) {
+ if (!emailPush || !isForbiddenError(err)) throw err;
+ let lastError = err;
+ for (const narrowed of narrowedEmailPushConfigs(client, emailPush)) {
+ try {
+ return await client.createPushSubscription({ ...params, emailPush: narrowed });
+ } catch (retryErr) {
+ if (!isForbiddenError(retryErr)) throw retryErr;
+ lastError = retryErr;
+ }
+ }
+ throw lastError;
+ }
+}
+
export interface EnableWebPushParams {
client: IJMAPClient;
// Optional - falls back to DEFAULT_RELAY_BASE_URL.
@@ -473,7 +566,22 @@ async function refreshSubscriptionExpires(
};
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
if (emailPushNeedsUpdate && desiredEmailPush) patch.emailPush = desiredEmailPush;
- return await client.updatePushSubscription(sub.id, patch);
+ if (await client.updatePushSubscription(sub.id, patch)) return true;
+ if (!patch.emailPush) return false;
+ // The server may refuse entries for accounts that only shared a folder
+ // with us (#1050). Retry with the accounts it accepted last time, then
+ // with the same smaller maps a fresh create would fall back to.
+ const desired = patch.emailPush;
+ const candidates: Record[] = [];
+ if (coveredByEmailPush(sub.emailPush, desired, client.getAccountId())) {
+ candidates.push(pickAccounts(desired, Object.keys(sub.emailPush ?? {})));
+ } else {
+ candidates.push(...narrowedEmailPushConfigs(client, desired));
+ }
+ for (const narrowed of candidates) {
+ if (await client.updatePushSubscription(sub.id, { ...patch, emailPush: narrowed })) return true;
+ }
+ return false;
} catch {
return false;
}
@@ -595,13 +703,12 @@ export async function enableWebPush(
}
}
- const serverAssignedId = await params.client.createPushSubscription({
+ const serverAssignedId = await createPushSubscriptionWithEmailPush(params.client, {
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
- ...(emailPush ? { emailPush } : {}),
- });
+ }, emailPush);
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
From 7d882be734e998fc710d8033ba4a2bc215a89132 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:37:44 +0200
Subject: [PATCH 034/238] fix: delete only the clicked mail when another
account has a message with the same id #857
---
components/mail/mail-app.tsx | 6 +-
.../__tests__/email-single-namesakes.test.ts | 106 ++++++++++++++++++
stores/email-store.ts | 60 ++++++++--
3 files changed, 157 insertions(+), 15 deletions(-)
create mode 100644 stores/__tests__/email-single-namesakes.test.ts
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 767bef433..170d9c29a 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -61,7 +61,7 @@ import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
import { cn, getMailboxPath } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
-import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, groupEmailsByThread, threadKeyFor } from "@/lib/thread-utils";
+import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, emailKeyFor, groupEmailsByThread, threadKeyFor } from "@/lib/thread-utils";
import { resolveThreadRoute } from "@/lib/thread-routing";
import {
ErrorBoundary,
@@ -2692,7 +2692,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
try {
// false: a plugin stopped it and said why, so no "deleted" here.
- if (await deleteEmail(client, emailToDelete.id, true)) toast.success(t('notifications.email_deleted'));
+ if (await deleteEmail(client, emailToDelete.id, true, emailKeyFor(emailToDelete))) toast.success(t('notifications.email_deleted'));
} catch (error) {
console.error("Failed to permanently delete email:", error);
toast.error(t('notifications.error_deleting'), error instanceof Error ? error.message : undefined);
@@ -2714,7 +2714,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
if (trashMailbox) {
try {
- if (await moveToMailbox(client, emailToDelete.id, trashMailbox.id)) toast.success(t('notifications.email_deleted'));
+ if (await moveToMailbox(client, emailToDelete.id, trashMailbox.id, emailKeyFor(emailToDelete))) toast.success(t('notifications.email_deleted'));
} catch (error) {
console.error("Failed to move email to trash:", error);
toast.error(error instanceof Error ? error.message : 'Failed to move email to trash');
diff --git a/stores/__tests__/email-single-namesakes.test.ts b/stores/__tests__/email-single-namesakes.test.ts
new file mode 100644
index 000000000..bfc466d82
--- /dev/null
+++ b/stores/__tests__/email-single-namesakes.test.ts
@@ -0,0 +1,106 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useEmailStore } from '../email-store';
+import { useAuthStore } from '../auth-store';
+import { useSettingsStore } from '../settings-store';
+import { emailKeyFor } from '@/lib/thread-utils';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+/**
+ * Deleting one row of the unified view while another account holds a message
+ * with the same id (Stalwart hands out the same ids, and the same trash id,
+ * in every account). The action must reach the row that was clicked and leave
+ * its namesake listed and untouched (#857).
+ */
+const twin = (account: string, receivedAt: string): Email => ({
+ id: 'eiaaaabc', threadId: `t-${account}`, blobId: `blob-${account}`,
+ mailboxIds: { 'a': true }, keywords: { $seen: true },
+ from: [{ email: `someone@${account}` }], to: [], subject: account,
+ receivedAt, size: 42, preview: '', hasAttachment: false,
+ sourceClientAccountId: account, sourceAccountId: account,
+} as unknown as Email);
+
+const mailbox = (overrides: Partial): Mailbox => ({
+ id: 'a', name: 'Inbox', sortOrder: 0,
+ totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0,
+ myRights: {
+ mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
+ maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true,
+ },
+ isSubscribed: true, isShared: false,
+ ...overrides,
+} as Mailbox);
+
+const makeClient = () => ({
+ moveEmail: vi.fn().mockResolvedValue(undefined),
+ moveToTrash: vi.fn().mockResolvedValue(undefined),
+ deleteEmail: vi.fn().mockResolvedValue(undefined),
+ getMailboxes: vi.fn().mockResolvedValue([]),
+}) as unknown as IJMAPClient & Record>;
+
+// The list is newest first: account A's namesake sits right above the row
+// that gets deleted.
+const above = twin('account-a', '2026-10-16T09:00:00Z');
+const clicked = twin('account-b', '2026-10-16T08:00:00Z');
+let clientA: ReturnType;
+let clientB: ReturnType;
+
+const accountMailboxes = (account: string) => [
+ mailbox({ id: 'a', role: 'inbox', accountId: account }),
+ mailbox({ id: 'b', name: 'Trash', role: 'trash', accountId: account }),
+];
+
+beforeEach(() => {
+ clientA = makeClient();
+ clientB = makeClient();
+ useAuthStore.setState({
+ activeAccountId: 'account-a',
+ client: clientA,
+ getClientForAccount: (id: string) =>
+ (id === 'account-a' ? clientA : id === 'account-b' ? clientB : undefined) as never,
+ } as never);
+ useSettingsStore.setState({ deleteAction: 'trash' } as never);
+ useEmailStore.setState({
+ isUnifiedView: true,
+ unifiedRole: 'inbox',
+ selectedMailbox: '',
+ selectedKeyword: null,
+ emails: [above, clicked],
+ selectedEmail: clicked,
+ selectedEmailKeys: new Set(),
+ mailboxes: [],
+ accountMailboxes: {
+ 'account-a': accountMailboxes('account-a'),
+ 'account-b': accountMailboxes('account-b'),
+ },
+ error: null,
+ } as never);
+});
+
+describe('single-email actions with a namesake in another account', () => {
+ it('moves only the clicked row to trash and keeps the row above it', async () => {
+ await useEmailStore.getState().moveToMailbox(clientA, clicked.id, 'b', emailKeyFor(clicked));
+
+ expect(clientB.moveEmail).toHaveBeenCalledWith('eiaaaabc', 'b', 'account-b');
+ expect(clientA.moveEmail).not.toHaveBeenCalled();
+ const state = useEmailStore.getState();
+ expect(state.emails.map(e => e.sourceAccountId)).toEqual(['account-a']);
+ expect(state.selectedEmail?.sourceAccountId).toBe('account-a');
+ });
+
+ it('permanently deletes only the clicked row', async () => {
+ await useEmailStore.getState().deleteEmail(clientA, clicked.id, true, emailKeyFor(clicked));
+
+ expect(clientB.deleteEmail).toHaveBeenCalledWith('eiaaaabc', 'account-b');
+ expect(clientA.deleteEmail).not.toHaveBeenCalled();
+ expect(useEmailStore.getState().emails.map(e => e.sourceAccountId)).toEqual(['account-a']);
+ });
+
+ it('moves only the clicked row with the trash delete action', async () => {
+ await useEmailStore.getState().deleteEmail(clientA, clicked.id, false, emailKeyFor(clicked));
+
+ expect(clientB.moveToTrash).toHaveBeenCalledWith('eiaaaabc', 'b', 'account-b', false);
+ expect(clientA.moveToTrash).not.toHaveBeenCalled();
+ expect(useEmailStore.getState().emails.map(e => e.sourceAccountId)).toEqual(['account-a']);
+ });
+});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index aecbea409..177fe04a7 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -307,9 +307,11 @@ interface EmailStore {
fetchQuota: (client: IJMAPClient) => Promise;
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; localAccountId?: string }) => Promise;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise;
- deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise;
+ /** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
+ deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean, emailKey?: string) => Promise;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise;
- moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise;
+ /** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
+ moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string, emailKey?: string) => Promise;
/**
* Move a single email, routing across the account boundary when the
* destination folder is owned by a different JMAP account (a delegated/shared
@@ -502,6 +504,38 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
}
+/**
+ * Matches the list row a single-email action is about. Email ids repeat across
+ * accounts, so in a merged view (unified inbox, tags) the bare id also matches
+ * the other account's namesake: deleting one row moved or hid the other as
+ * well (#857). With the row's `emailKeyFor` key only that row matches.
+ */
+function singleEmailMatcher(emailId: string, emailKey?: string): (email: Email) => boolean {
+ return emailKey ? (email) => emailKeyFor(email) === emailKey : (email) => email.id === emailId;
+}
+
+/** getNextSelectedEmail for the one row `isRemoved` matches. */
+function getNextSelectedEmailAfterRemovingRow(
+ state: { emails: Email[]; selectedEmail: Email | null },
+ isRemoved: (email: Email) => boolean,
+): Email | null {
+ const selected = state.selectedEmail;
+ if (!selected || !isRemoved(selected)) return selected;
+
+ const selectedKey = emailKeyFor(selected);
+ let idx = state.emails.findIndex(e => emailKeyFor(e) === selectedKey);
+ if (idx === -1) idx = state.emails.findIndex(e => e.id === selected.id);
+ if (idx === -1) return null;
+
+ for (let nextIndex = idx + 1; nextIndex < state.emails.length; nextIndex++) {
+ if (!isRemoved(state.emails[nextIndex])) return state.emails[nextIndex];
+ }
+ for (let prevIndex = idx - 1; prevIndex >= 0; prevIndex--) {
+ if (!isRemoved(state.emails[prevIndex])) return state.emails[prevIndex];
+ }
+ return null;
+}
+
function annotateScheduledEmails(
emails: Email[],
scheduledSubmissionByEmailId: Map
@@ -3080,10 +3114,11 @@ export const useEmailStore = create((set, get) => ({
}
},
- deleteEmail: async (client, emailId, forceDelete): Promise => {
+ deleteEmail: async (client, emailId, forceDelete, emailKey): Promise => {
try {
// Get the email to check if it's unread and which mailboxes it belongs to
- const email = get().emails.find(e => e.id === emailId);
+ const isTarget = singleEmailMatcher(emailId, emailKey);
+ const email = get().emails.find(isTarget);
if (!email) return false;
const isUnread = !email.keywords?.$seen;
@@ -3157,8 +3192,8 @@ export const useEmailStore = create((set, get) => ({
: {};
return {
- emails: state.emails.filter(e => e.id !== emailId),
- selectedEmail: getNextSelectedEmail(state, emailId),
+ emails: state.emails.filter(e => !isTarget(e)),
+ selectedEmail: getNextSelectedEmailAfterRemovingRow(state, isTarget),
...mailboxPatch,
};
});
@@ -3191,8 +3226,8 @@ export const useEmailStore = create((set, get) => ({
: {};
return {
- emails: state.emails.filter(e => e.id !== emailId),
- selectedEmail: getNextSelectedEmail(state, emailId),
+ emails: state.emails.filter(e => !isTarget(e)),
+ selectedEmail: getNextSelectedEmailAfterRemovingRow(state, isTarget),
...mailboxPatch,
};
});
@@ -3380,9 +3415,10 @@ export const useEmailStore = create((set, get) => ({
);
},
- moveToMailbox: async (client, emailId, destinationMailboxId): Promise => {
+ moveToMailbox: async (client, emailId, destinationMailboxId, emailKey): Promise => {
try {
- const email = get().emails.find(e => e.id === emailId);
+ const isTarget = singleEmailMatcher(emailId, emailKey);
+ const email = get().emails.find(isTarget);
if (!email) return false;
const isUnread = !email.keywords?.$seen;
@@ -3426,8 +3462,8 @@ export const useEmailStore = create((set, get) => ({
});
return {
- emails: state.emails.filter(e => e.id !== emailId),
- selectedEmail: getNextSelectedEmail(state, emailId),
+ emails: state.emails.filter(e => !isTarget(e)),
+ selectedEmail: getNextSelectedEmailAfterRemovingRow(state, isTarget),
...mailboxPatch,
};
});
From f47fe778d0813f7a4a31a2e29566ace505f74bc3 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:17:19 +0200
Subject: [PATCH 035/238] feat: add a setting to hide the account dot in the
unified view #912
---
.../thread-list-item-account-dot.test.tsx | 110 ++++++++++++++++++
components/email/thread-list-item.tsx | 12 +-
components/settings/layout-settings.tsx | 9 +-
locales/ar/common.json | 4 +
locales/ca/common.json | 4 +
locales/cs/common.json | 4 +
locales/da/common.json | 4 +
locales/de/common.json | 4 +
locales/en/common.json | 4 +
locales/es/common.json | 4 +
locales/fa/common.json | 4 +
locales/fr/common.json | 4 +
locales/he/common.json | 4 +
locales/hu/common.json | 4 +
locales/it/common.json | 4 +
locales/ja/common.json | 4 +
locales/ko/common.json | 4 +
locales/lv/common.json | 4 +
locales/mn/common.json | 4 +
locales/nb/common.json | 4 +
locales/nl/common.json | 4 +
locales/pl/common.json | 4 +
locales/pt/common.json | 4 +
locales/ro/common.json | 4 +
locales/ru/common.json | 4 +
locales/sk/common.json | 4 +
locales/tr/common.json | 4 +
locales/uk/common.json | 4 +
locales/zh-TW/common.json | 4 +
locales/zh/common.json | 4 +
stores/settings-store.ts | 3 +
31 files changed, 237 insertions(+), 5 deletions(-)
create mode 100644 components/email/__tests__/thread-list-item-account-dot.test.tsx
diff --git a/components/email/__tests__/thread-list-item-account-dot.test.tsx b/components/email/__tests__/thread-list-item-account-dot.test.tsx
new file mode 100644
index 000000000..482048d1f
--- /dev/null
+++ b/components/email/__tests__/thread-list-item-account-dot.test.tsx
@@ -0,0 +1,110 @@
+import { render } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ThreadListItem } from '../thread-list-item';
+import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useUIStore } from '@/stores/ui-store';
+import { groupEmailsByThread } from '@/lib/thread-utils';
+import type { Email } from '@/lib/jmap/types';
+
+vi.mock('@/hooks/use-email-drag', () => ({
+ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
+}));
+
+vi.mock('@/stores/auth-store', () => {
+ const state = { identities: [], username: null };
+ return {
+ useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
+ };
+});
+
+const ACCOUNT_TEAL = { id: 'acct-teal', avatarColor: '#0d9488', label: 'Teal account' };
+const accountState = {
+ getAccountById: (id: string) => (id === ACCOUNT_TEAL.id ? ACCOUNT_TEAL : undefined),
+};
+vi.mock('@/stores/account-store', () => ({
+ useAccountStore: Object.assign(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (selector: any) => (selector ? selector(accountState) : accountState),
+ { getState: () => accountState },
+ ),
+}));
+
+const makeEmail = (overrides: Partial = {}): Email => ({
+ id: 'email-1',
+ threadId: 'thread-1',
+ mailboxIds: { inbox: true },
+ keywords: { $seen: true },
+ size: 1000,
+ receivedAt: '2024-01-15T10:00:00Z',
+ from: [{ name: 'Alice', email: 'alice@example.com' }],
+ subject: 'Test Subject',
+ hasAttachment: false,
+ accountId: ACCOUNT_TEAL.id,
+ accountLabel: ACCOUNT_TEAL.label,
+ ...overrides,
+});
+
+function renderEmails(emails: Email[]) {
+ const [thread] = groupEmailsByThread(emails);
+ return render(
+ {}} onEmailSelect={() => {}} />,
+ );
+}
+
+const single = () => [makeEmail()];
+const collapsedThread = () => [
+ makeEmail({ id: 'e1', threadId: 't1' }),
+ makeEmail({ id: 'e2', threadId: 't1', receivedAt: '2024-01-16T10:00:00Z' }),
+];
+
+// The dot is the only element painted in the account colour.
+const accountDot = (c: HTMLElement) =>
+ Array.from(c.querySelectorAll('span.rounded-full'))
+ .find((el) => el.style.backgroundColor === 'rgb(13, 148, 136)') ?? null;
+
+describe('ThreadListItem account dot (#912)', () => {
+ beforeEach(() => {
+ useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useSettingsStore.setState({
+ emailKeywords: [...DEFAULT_KEYWORDS],
+ showPreview: false,
+ interfaceLayout: 'default',
+ tintListRowsByAccount: false,
+ showAccountDot: true,
+ });
+ useEmailStore.setState({
+ selectedEmailKeys: new Set(),
+ selectedMailbox: 'inbox',
+ isUnifiedView: true,
+ });
+ });
+
+ it('is on by default', () => {
+ expect(useSettingsStore.getInitialState().showAccountDot).toBe(true);
+ });
+
+ describe.each([
+ ['single message', single],
+ ['collapsed thread', collapsedThread],
+ ])('%s', (_name, emails) => {
+ describe.each(['split', 'focus'] as const)('%s layout', (layout) => {
+ beforeEach(() => {
+ useSettingsStore.setState({ mailLayout: layout });
+ });
+
+ it('shows the dot in the unified view', () => {
+ const { container } = renderEmails(emails());
+ expect(accountDot(container)).not.toBeNull();
+ });
+
+ it('hides the dot when the setting is off', () => {
+ useSettingsStore.setState({ showAccountDot: false });
+ const { container } = renderEmails(emails());
+ expect(accountDot(container)).toBeNull();
+ // The rest of the row still renders.
+ expect(container.textContent).toContain('Test Subject');
+ });
+ });
+ });
+});
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index e48819e45..d35508403 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -360,6 +360,8 @@ const SingleEmailItem = React.forwardRef(
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
+ // The tint replaces the dot, and the dot can be switched off on its own (#912).
+ const showAccountDot = useSettingsStore((state) => state.showAccountDot) && !tintListRowsByAccount;
const density = useSettingsStore((state) => state.density);
const mailLayout = useEffectiveMailLayout();
const timeFormat = useSettingsStore((state) => state.timeFormat);
@@ -624,7 +626,7 @@ const SingleEmailItem = React.forwardRef(
<>
- {isUnifiedView && email.accountId && accountColor && !tintListRowsByAccount && (
+ {isUnifiedView && email.accountId && accountColor && showAccountDot && (
(
<>
- {isUnifiedView && email.accountId && accountColor && !tintListRowsByAccount && (
+ {isUnifiedView && email.accountId && accountColor && showAccountDot && (
state.tintListRowsByTag);
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
+ // The tint replaces the dot, and the dot can be switched off on its own (#912).
+ const showAccountDot = useSettingsStore((state) => state.showAccountDot) && !tintListRowsByAccount;
// A collapsed row speaks for every message under it, so it carries their tags too.
const tagIds = sortTagIds(getThreadTagIds(thread.emails));
const threadTintSource = threadAccountColor
@@ -1204,7 +1208,7 @@ export const ThreadListItem = React.forwardRef
- {isUnifiedView && latestEmail.accountId && threadAccountColor && !tintListRowsByAccount && (
+ {isUnifiedView && latestEmail.accountId && threadAccountColor && showAccountDot && (
- {isUnifiedView && latestEmail.accountId && threadAccountColor && !tintListRowsByAccount && (
+ {isUnifiedView && latestEmail.accountId && threadAccountColor && showAccountDot && (
s.accounts);
const activeAccountId = useAccountStore(s => s.activeAccountId);
@@ -263,6 +263,13 @@ export function LayoutSettings() {
/>
+
+ updateSetting('showAccountDot', checked)}
+ />
+
+
,
@@ -1231,6 +1233,7 @@ export const useSettingsStore = create()(
colorfulSidebarIcons: state.colorfulSidebarIcons,
tintListRowsByTag: state.tintListRowsByTag,
tintListRowsByAccount: state.tintListRowsByAccount,
+ showAccountDot: state.showAccountDot,
showFolderTotalCount: state.showFolderTotalCount,
showSharedMailAccounts: state.showSharedMailAccounts,
hiddenSharedMailAccounts: state.hiddenSharedMailAccounts,
From 5d5f099cff91140030662b8d0e0e1d68c37463fb Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:17:36 +0200
Subject: [PATCH 036/238] feat: tick a row by clicking anywhere on it while
selecting #1014
---
.../email/__tests__/thread-list-item.test.tsx | 105 +++++++++++++++++-
components/email/thread-email-item.tsx | 9 +-
components/email/thread-list-item.tsx | 32 ++++--
3 files changed, 129 insertions(+), 17 deletions(-)
diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx
index f91eadd12..37c42021d 100644
--- a/components/email/__tests__/thread-list-item.test.tsx
+++ b/components/email/__tests__/thread-list-item.test.tsx
@@ -327,10 +327,109 @@ describe('ThreadListItem keeping the selection when a message opens', () => {
expect(onEmailSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'newer' }));
expect([...useEmailStore.getState().selectedEmailKeys]).toEqual(['other']);
});
+});
- it('still clears them in the default layout', () => {
- open('default', [makeEmail()]);
- expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
+// #1014: once something is ticked, a click anywhere on the row ticks it
+// instead of opening the message and dropping the selection.
+describe('ThreadListItem whole row toggles in selection mode', () => {
+ const conversation = () => [
+ makeEmail({ id: 'older', receivedAt: '2024-01-14T10:00:00Z' }),
+ makeEmail({ id: 'newer', receivedAt: '2024-01-15T10:00:00Z' }),
+ ];
+
+ function setup(
+ emails: Email[],
+ { layout = 'default', mobile = false, selected = ['other'], isExpanded = false }: {
+ layout?: 'mountain-view' | 'default'; mobile?: boolean; selected?: string[]; isExpanded?: boolean;
+ } = {},
+ ) {
+ useSettingsStore.setState({ interfaceLayout: layout, mailLayout: 'split', density: 'regular' } as never);
+ useUIStore.setState({ isMobile: mobile, isTablet: false, isDesktop: !mobile });
+ useEmailStore.setState({ selectedEmailKeys: new Set(selected), selectedMailbox: 'inbox' });
+ const onEmailSelect = vi.fn();
+ const onEmailDoubleClick = vi.fn();
+ const onOpenConversation = vi.fn();
+ const onToggleExpand = vi.fn();
+ const [thread] = groupEmailsByThread(emails);
+ const utils = render(
+ ,
+ );
+ return { ...utils, onEmailSelect, onEmailDoubleClick, onOpenConversation, onToggleExpand };
+ }
+ const selection = () => [...useEmailStore.getState().selectedEmailKeys].sort();
+
+ it('ticks a message row instead of opening it', () => {
+ const { onEmailSelect } = setup([makeEmail()]);
+ fireEvent.click(screen.getByText('Test Subject'));
+ expect(onEmailSelect).not.toHaveBeenCalled();
+ expect(selection()).toEqual(['email-1', 'other']);
+ });
+
+ it('unticks it on the next click', () => {
+ const { onEmailSelect } = setup([makeEmail()], { selected: ['email-1', 'other'] });
+ fireEvent.click(screen.getByText('Test Subject'));
+ expect(onEmailSelect).not.toHaveBeenCalled();
+ expect(selection()).toEqual(['other']);
+ });
+
+ it('opens the message as before when nothing is ticked', () => {
+ const { onEmailSelect } = setup([makeEmail()], { selected: [] });
+ fireEvent.click(screen.getByText('Test Subject'));
+ expect(onEmailSelect).toHaveBeenCalledTimes(1);
+ expect(selection()).toEqual([]);
+ });
+
+ it('does not open the message on a double click', () => {
+ const { onEmailDoubleClick } = setup([makeEmail()]);
+ fireEvent.doubleClick(screen.getByText('Test Subject'));
+ expect(onEmailDoubleClick).not.toHaveBeenCalled();
+ });
+
+ it('ticks every message of a thread row without unfolding it', () => {
+ const { onEmailSelect, onToggleExpand } = setup(conversation());
+ fireEvent.click(screen.getByText('Test Subject'));
+ expect(onEmailSelect).not.toHaveBeenCalled();
+ expect(onToggleExpand).not.toHaveBeenCalled();
+ expect(selection()).toEqual(['newer', 'older', 'other']);
+ });
+
+ it('still unfolds the thread from its chevron', () => {
+ const { container, onToggleExpand } = setup(conversation());
+ fireEvent.click(container.querySelector('[data-expand-toggle]')!);
+ expect(onToggleExpand).toHaveBeenCalledTimes(1);
+ expect(selection()).toEqual(['other']);
+ });
+
+ it('ticks a thread on the phone instead of opening the thread view', () => {
+ const { onOpenConversation, onEmailSelect } = setup(conversation(), { mobile: true });
+ fireEvent.click(screen.getByText('Test Subject'));
+ expect(onOpenConversation).not.toHaveBeenCalled();
+ expect(onEmailSelect).not.toHaveBeenCalled();
+ expect(selection()).toEqual(['newer', 'older', 'other']);
+ });
+
+ it('ticks a Mountain View phone row', () => {
+ const { onEmailSelect } = setup([makeEmail()], { layout: 'mountain-view', mobile: true });
+ fireEvent.click(screen.getByText('Test Subject'));
+ expect(onEmailSelect).not.toHaveBeenCalled();
+ expect(selection()).toEqual(['email-1', 'other']);
+ });
+
+ it('ticks a message inside an unfolded thread', () => {
+ const { onEmailSelect } = setup(conversation(), { isExpanded: true });
+ // The unfolded messages show the sender; the header shows the subject.
+ const older = screen.getAllByText('Alice').find((el) => el.closest('.ps-12'))!;
+ fireEvent.click(older);
+ expect(onEmailSelect).not.toHaveBeenCalled();
+ expect(selection().filter((k) => k !== 'other')).toHaveLength(1);
});
});
diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx
index dfda8bddf..6d1fc5426 100644
--- a/components/email/thread-email-item.tsx
+++ b/components/email/thread-email-item.tsx
@@ -47,12 +47,11 @@ export function ThreadEmailItem({
// header pools them, so without this they disappear on the way in.
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
const sender = email.from?.[0];
- const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(useShallow((state) => ({
+ const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails } = useEmailStore(useShallow((state) => ({
selectedMailbox: state.selectedMailbox,
selectedEmailKeys: state.selectedEmailKeys,
toggleEmailSelection: state.toggleEmailSelection,
selectRangeEmails: state.selectRangeEmails,
- clearSelection: state.clearSelection,
})));
const density = useSettingsStore((state) => state.density);
const isChecked = selectedEmailKeys.has(emailKeyFor(email));
@@ -91,8 +90,10 @@ export function ThreadEmailItem({
} else if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(email);
+ } else if (selectedEmailKeys.size > 0) {
+ // Selection mode: the whole row is the checkbox (#1014).
+ toggleEmailSelection(email);
} else {
- if (selectedEmailKeys.size > 0) clearSelection();
onClick?.();
}
};
@@ -117,7 +118,7 @@ export function ThreadEmailItem({
onClick={handleClick}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
- if (!onDoubleClick) return;
+ if (!onDoubleClick || selectedEmailKeys.size > 0) return;
e.preventDefault();
onDoubleClick();
}}
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index d35508403..b9ce054bd 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -339,7 +339,7 @@ const SingleEmailItem = React.forwardRef(
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const showPaperclip = useListHasAttachment(email, loadAttachments);
- const { selectedMailbox, currentMailboxRole, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(useShallow((state) => ({
+ const { selectedMailbox, currentMailboxRole, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, isUnifiedView } = useEmailStore(useShallow((state) => ({
selectedMailbox: state.selectedMailbox,
// The role, not the folder list: the list is a new array on every
// count update, and subscribing to it re-rendered every row for each.
@@ -347,7 +347,6 @@ const SingleEmailItem = React.forwardRef(
selectedEmailKeys: state.selectedEmailKeys,
toggleEmailSelection: state.toggleEmailSelection,
selectRangeEmails: state.selectRangeEmails,
- clearSelection: state.clearSelection,
isUnifiedView: state.isUnifiedView,
})));
// In Sent/Drafts folders, show recipient instead of sender (which is always
@@ -486,6 +485,11 @@ const SingleEmailItem = React.forwardRef(
onContextMenu?.(e, email);
};
+ // Once something is ticked, the whole row is the checkbox, as in phone
+ // mail apps (#1014). Mountain View on the desktop opens the message and
+ // keeps the ticks instead, as Gmail does (#1231).
+ const togglesOnClick = selectedEmailKeys.size > 0 && !mvRow;
+
const handleClick = (e: React.MouseEvent) => {
if (swipeEnabled && consumeTap()) { return; }
if (e.ctrlKey || e.metaKey) {
@@ -494,10 +498,11 @@ const SingleEmailItem = React.forwardRef(
} else if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(email);
+ } else if (togglesOnClick) {
+ toggleEmailSelection(email);
} else {
// Mountain View keeps the ticked rows while a message is read, as
// Gmail does, so selecting can carry on afterwards. (#1231)
- if (selectedEmailKeys.size > 0 && !mvRow) clearSelection();
onClick();
}
};
@@ -542,7 +547,7 @@ const SingleEmailItem = React.forwardRef(
onClick={handleClick}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
- if (!onDoubleClick) return;
+ if (!onDoubleClick || togglesOnClick) return;
e.preventDefault();
onDoubleClick();
}}
@@ -908,7 +913,7 @@ export const ThreadListItem = React.forwardRef ({
+ const { selectedMailbox, currentMailboxRole, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, isUnifiedView } = useEmailStore(useShallow((state) => ({
selectedMailbox: state.selectedMailbox,
// The role, not the folder list: the list is a new array on every
// count update, and subscribing to it re-rendered every row for each.
@@ -916,7 +921,6 @@ export const ThreadListItem = React.forwardRef 0 && !mvRow;
+
const handleHeaderClick = (e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
@@ -1039,6 +1046,14 @@ export const ThreadListItem = React.forwardRef 0 && !mvRow) clearSelection();
onEmailSelect(latestEmail);
return;
}
- const target = e.target as HTMLElement;
if (target.closest('[data-expand-toggle]')) {
onToggleExpand();
} else {
- if (selectedEmailKeys.size > 0) clearSelection();
if (!isExpanded) {
onCollapseAllThreads?.();
onToggleExpand();
@@ -1110,7 +1122,7 @@ export const ThreadListItem = React.forwardRef {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
- if (!onEmailDoubleClick) return;
+ if (!onEmailDoubleClick || threadTogglesOnClick) return;
e.preventDefault();
onEmailDoubleClick(latestEmail);
}}
From b76624466f2ff33a224b5028064ce21ed6e68e58 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:24:41 +0200
Subject: [PATCH 037/238] feat: show the folder on search results from all
folders #1172
---
.../thread-list-item-source-folder.test.tsx | 127 ++++++++++++++++++
components/email/thread-list-item.tsx | 15 ++-
stores/email-store.ts | 2 +-
3 files changed, 140 insertions(+), 4 deletions(-)
create mode 100644 components/email/__tests__/thread-list-item-source-folder.test.tsx
diff --git a/components/email/__tests__/thread-list-item-source-folder.test.tsx b/components/email/__tests__/thread-list-item-source-folder.test.tsx
new file mode 100644
index 000000000..023ab287d
--- /dev/null
+++ b/components/email/__tests__/thread-list-item-source-folder.test.tsx
@@ -0,0 +1,127 @@
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ThreadListItem } from '../thread-list-item';
+import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useUIStore } from '@/stores/ui-store';
+import { groupEmailsByThread } from '@/lib/thread-utils';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+import { SEARCH_SCOPE_ALL_FOLDERS } from '@/lib/search-scope-folders';
+import type { Email } from '@/lib/jmap/types';
+
+vi.mock('@/hooks/use-email-drag', () => ({
+ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
+}));
+
+vi.mock('@/stores/auth-store', () => {
+ const state = { identities: [], username: null };
+ return {
+ useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
+ };
+});
+
+const accountState = { getAccountById: () => undefined };
+vi.mock('@/stores/account-store', () => ({
+ useAccountStore: Object.assign(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (selector: any) => (selector ? selector(accountState) : accountState),
+ { getState: () => accountState },
+ ),
+}));
+
+const makeEmail = (overrides: Partial = {}): Email => ({
+ id: 'email-1',
+ threadId: 'thread-1',
+ mailboxIds: { archive: true },
+ keywords: { $seen: true },
+ size: 1000,
+ receivedAt: '2024-01-15T10:00:00Z',
+ from: [{ name: 'Alice', email: 'alice@example.com' }],
+ subject: 'Invoice',
+ hasAttachment: false,
+ // Stamped by the all-folders search fan-out (searchAcrossAccounts).
+ sourceFolder: 'Archive',
+ ...overrides,
+});
+
+function renderThread(emails: Email[]) {
+ const [thread] = groupEmailsByThread(emails);
+ return render(
+ {}}
+ onEmailSelect={() => {}}
+ />,
+ );
+}
+
+const single = () => [makeEmail()];
+const conversation = () => [
+ makeEmail({ id: 'email-1', receivedAt: '2024-01-15T10:00:00Z' }),
+ makeEmail({ id: 'email-2', receivedAt: '2024-01-16T10:00:00Z' }),
+];
+
+describe('ThreadListItem folder chip in search results (#1172)', () => {
+ beforeEach(() => {
+ useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useSettingsStore.setState({
+ emailKeywords: [...DEFAULT_KEYWORDS],
+ showPreview: false,
+ mailLayout: 'split',
+ interfaceLayout: 'default',
+ });
+ useEmailStore.setState({
+ selectedEmailKeys: new Set(),
+ selectedMailbox: 'inbox',
+ isUnifiedView: false,
+ selectedKeyword: null,
+ searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS },
+ searchMailboxId: '',
+ });
+ });
+
+ describe.each([
+ ['single message', single],
+ ['conversation', conversation],
+ ])('%s row', (_label, emails) => {
+ it('shows the folder when the search covers all folders except Spam and Trash', () => {
+ useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: '' });
+ renderThread(emails());
+ expect(screen.getByTitle('Archive')).toBeTruthy();
+ });
+
+ it('shows the folder when the search covers all folders', () => {
+ useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: SEARCH_SCOPE_ALL_FOLDERS });
+ renderThread(emails());
+ expect(screen.getByTitle('Archive')).toBeTruthy();
+ });
+
+ it('shows the folder when only advanced filters drive the search', () => {
+ useEmailStore.setState({
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'alice' },
+ searchMailboxId: '',
+ });
+ renderThread(emails());
+ expect(screen.getByTitle('Archive')).toBeTruthy();
+ });
+
+ it('hides the folder when the search is scoped to one folder', () => {
+ useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: 'archive' });
+ renderThread(emails());
+ expect(screen.queryByTitle('Archive')).toBeNull();
+ });
+
+ it('hides the folder outside a search', () => {
+ renderThread(emails());
+ expect(screen.queryByTitle('Archive')).toBeNull();
+ });
+
+ it('still shows the folder in the unified view', () => {
+ useEmailStore.setState({ isUnifiedView: true });
+ renderThread(emails());
+ expect(screen.getByTitle('Archive')).toBeTruthy();
+ });
+ });
+});
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index b9ce054bd..95d7c3bff 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -18,7 +18,7 @@ import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { accountTintKey, generateAvatarColor } from "@/lib/account-utils";
import { useUIStore } from "@/stores/ui-store";
-import { useEmailStore } from "@/stores/email-store";
+import { useEmailStore, isUnscopedSearchActive } from "@/stores/email-store";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { emailKeyFor, getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
@@ -249,6 +249,15 @@ function SourceFolderTag({ name }: { name: string }) {
);
}
+/**
+ * Whether rows show their folder chip: in the aggregate "All …" views, and in
+ * search results that span every folder rather than the one picked in the
+ * search panel (#1172). Rows of a single-folder list carry no sourceFolder.
+ */
+function useShowSourceFolder(): boolean {
+ return useEmailStore((state) => state.isUnifiedView || isUnscopedSearchActive(state));
+}
+
/**
* How many messages a collapsed thread stands for.
*
@@ -368,7 +377,7 @@ const SingleEmailItem = React.forwardRef(
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
// Show the originating folder in the aggregate "All …" views.
- const showSourceFolder = isUnifiedView && !!email.sourceFolder;
+ const showSourceFolder = useShowSourceFolder() && !!email.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const account = email.accountId ? getAccountById(email.accountId) : undefined;
const accountColor = account?.avatarColor;
@@ -923,7 +932,7 @@ export const ThreadListItem = React.forwardRef state.getAccountById);
const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined;
const threadAccountColor = threadAccount?.avatarColor;
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 177fe04a7..14049f158 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -1569,7 +1569,7 @@ export function buildTagViewAccountClients(passedClient: IJMAPClient): UnifiedAc
* with their source account (#1082). A search scoped to one folder stays
* single-account.
*/
-function isUnscopedSearchActive(
+export function isUnscopedSearchActive(
s: Pick,
): boolean {
return !s.isUnifiedView && isAllFoldersSearchScope(s.searchMailboxId) && (!!s.searchQuery || !isFilterEmpty(s.searchFilters));
From 3fd93df4b2d024c085923547c9b28f10dbf10809 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:29:19 +0200
Subject: [PATCH 038/238] feat: extend the message selection with shift+arrow
keys #992
---
components/email/email-list.tsx | 9 +++
components/mail/mail-app.tsx | 6 ++
.../__tests__/use-keyboard-shortcuts.test.ts | 49 +++++++++++++++
hooks/use-keyboard-shortcuts.ts | 17 ++++-
locales/ar/common.json | 3 +-
locales/ca/common.json | 3 +-
locales/cs/common.json | 3 +-
locales/da/common.json | 3 +-
locales/de/common.json | 3 +-
locales/en/common.json | 3 +-
locales/es/common.json | 3 +-
locales/fa/common.json | 3 +-
locales/fr/common.json | 3 +-
locales/he/common.json | 3 +-
locales/hu/common.json | 3 +-
locales/it/common.json | 3 +-
locales/ja/common.json | 3 +-
locales/ko/common.json | 3 +-
locales/lv/common.json | 3 +-
locales/mn/common.json | 3 +-
locales/nb/common.json | 3 +-
locales/nl/common.json | 3 +-
locales/pl/common.json | 3 +-
locales/pt/common.json | 3 +-
locales/ro/common.json | 3 +-
locales/ru/common.json | 3 +-
locales/sk/common.json | 3 +-
locales/tr/common.json | 3 +-
locales/uk/common.json | 3 +-
locales/zh-TW/common.json | 3 +-
locales/zh/common.json | 3 +-
stores/__tests__/email-selection.test.ts | 63 +++++++++++++++++++
stores/email-store.ts | 56 ++++++++++++++++-
33 files changed, 250 insertions(+), 31 deletions(-)
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index 394384fc0..42914a057 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -683,6 +683,15 @@ export function EmailList({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmailId]);
+ // Keep the moving end of a Shift+Arrow range on screen (#992).
+ const selectionFocusKey = useEmailStore((state) => state.selectionFocus?.focus ?? null);
+ useEffect(() => {
+ if (!selectionFocusKey) return;
+ const index = threadGroups.findIndex((thread) => thread.emails.some((e) => emailKeyFor(e) === selectionFocusKey));
+ if (index >= 0) virtualizer.scrollToIndex(index, { align: 'auto' });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectionFocusKey]);
+
// Re-measure all items when density or preview settings change
useEffect(() => {
virtualizer.measure();
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 170d9c29a..2672d322e 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -1007,6 +1007,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onDeselectAll: () => {
clearSelection();
},
+ // Shift + ↓/↑ (and Shift + j/k): grow or shrink the ticked range from the
+ // open message without opening each row on the way. (#992)
+ onExtendSelection: (direction: 1 | -1) => {
+ if (isScheduledView) return;
+ useEmailStore.getState().extendSelection(direction);
+ },
// `x` under Mountain View: put the focused conversation in or out of the
// selection. Only reachable there, where `x` is handed over from thread
// expansion.
diff --git a/hooks/__tests__/use-keyboard-shortcuts.test.ts b/hooks/__tests__/use-keyboard-shortcuts.test.ts
index 7074abc79..9d20d9e0b 100644
--- a/hooks/__tests__/use-keyboard-shortcuts.test.ts
+++ b/hooks/__tests__/use-keyboard-shortcuts.test.ts
@@ -41,3 +41,52 @@ describe('useKeyboardShortcuts: x toggles thread expansion', () => {
expect(onToggleThreadExpansion).not.toHaveBeenCalled();
});
});
+
+// #992: Shift + arrow (or j/k) extends the selection instead of moving to the
+// next message; plain arrows still move.
+describe('useKeyboardShortcuts: Shift + arrow extends the selection', () => {
+ function press(key: string, code: string, shiftKey: boolean) {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key, code, shiftKey, bubbles: true }));
+ }
+
+ it.each([
+ ['ArrowDown', 'ArrowDown', 1],
+ ['ArrowUp', 'ArrowUp', -1],
+ ['J', 'KeyJ', 1],
+ ['K', 'KeyK', -1],
+ ] as const)('Shift + %s (%s) extends by %i', (key, code, direction) => {
+ const onExtendSelection = vi.fn();
+ const onNextEmail = vi.fn();
+ const onPreviousEmail = vi.fn();
+ renderHook(() =>
+ useKeyboardShortcuts({
+ emails: [],
+ selectedEmailId: 'e1',
+ handlers: { onExtendSelection, onNextEmail, onPreviousEmail },
+ }),
+ );
+
+ press(key, code, true);
+
+ expect(onExtendSelection).toHaveBeenCalledWith(direction);
+ expect(onNextEmail).not.toHaveBeenCalled();
+ expect(onPreviousEmail).not.toHaveBeenCalled();
+ });
+
+ it('plain ArrowDown still moves to the next message', () => {
+ const onExtendSelection = vi.fn();
+ const onNextEmail = vi.fn();
+ renderHook(() =>
+ useKeyboardShortcuts({
+ emails: [],
+ selectedEmailId: 'e1',
+ handlers: { onExtendSelection, onNextEmail },
+ }),
+ );
+
+ press('ArrowDown', 'ArrowDown', false);
+
+ expect(onNextEmail).toHaveBeenCalledTimes(1);
+ expect(onExtendSelection).not.toHaveBeenCalled();
+ });
+});
diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts
index 8c8579114..30e9de777 100644
--- a/hooks/use-keyboard-shortcuts.ts
+++ b/hooks/use-keyboard-shortcuts.ts
@@ -33,6 +33,8 @@ export interface KeyboardShortcutHandlers {
// Selection
onSelectAll?: () => void;
onDeselectAll?: () => void;
+ /** Shift + ↓/j and Shift + ↑/k: grow or shrink the selection by one row. */
+ onExtendSelection?: (direction: 1 | -1) => void;
// Thread actions
onToggleThreadExpansion?: () => void;
@@ -174,13 +176,23 @@ export function useKeyboardShortcuts({
case "j":
case "arrowdown":
event.preventDefault();
- run("next-email", h.onNextEmail);
+ if (event.shiftKey && h.onExtendSelection) {
+ const extend = h.onExtendSelection;
+ run("extend-selection-down", () => extend(1));
+ } else {
+ run("next-email", h.onNextEmail);
+ }
break;
case "k":
case "arrowup":
event.preventDefault();
- run("previous-email", h.onPreviousEmail);
+ if (event.shiftKey && h.onExtendSelection) {
+ const extend = h.onExtendSelection;
+ run("extend-selection-up", () => extend(-1));
+ } else {
+ run("previous-email", h.onPreviousEmail);
+ }
break;
case "enter":
@@ -396,6 +408,7 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "?", description: "shortcuts.global.help" },
{ key: "Shift + G", description: "shortcuts.global.refresh" },
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
+ { key: "Shift + ↓ / ↑", description: "shortcuts.global.extend_selection" },
],
threads: [
{ key: "x", description: "shortcuts.threads.expand_collapse", layout: "default" },
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 0574e9019..b6333fac9 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -2625,7 +2625,8 @@
"help": "إظهار الاختصارات",
"refresh": "تحديث الرسائل",
"select_all": "تحديد الكل",
- "global_search": "البحث في كل شيء"
+ "global_search": "البحث في كل شيء",
+ "extend_selection": "توسيع التحديد لأسفل / لأعلى"
},
"threads": {
"expand_collapse": "توسيع/طي المحادثة",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index c37ce806e..166314246 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -2625,7 +2625,8 @@
"help": "Mostra les dreceres",
"refresh": "Actualitza els correus",
"select_all": "Selecciona-ho tot",
- "global_search": "Cerca-ho tot"
+ "global_search": "Cerca-ho tot",
+ "extend_selection": "Amplia la selecció cap avall / amunt"
},
"threads": {
"expand_collapse": "Desplega/replega el fil",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index b86d7b6c0..be1175d69 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -2625,7 +2625,8 @@
"help": "Zobrazit zkratky",
"refresh": "Obnovit zprávy",
"select_all": "Vybrat vše",
- "global_search": "Hledat ve všem"
+ "global_search": "Hledat ve všem",
+ "extend_selection": "Rozšířit výběr dolů / nahoru"
},
"threads": {
"expand_collapse": "Rozbalit/sbalit vlákno",
diff --git a/locales/da/common.json b/locales/da/common.json
index d564cd684..5e47ee71b 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -2625,7 +2625,8 @@
"help": "Vis genveje",
"refresh": "Opdatér e-mails",
"select_all": "Vælg alle",
- "global_search": "Søg i alt"
+ "global_search": "Søg i alt",
+ "extend_selection": "Udvid markering ned / op"
},
"threads": {
"expand_collapse": "Udvid/skjul tråd",
diff --git a/locales/de/common.json b/locales/de/common.json
index 3ef83bf87..c0876d03b 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -2625,7 +2625,8 @@
"help": "Tastaturkürzel anzeigen",
"refresh": "E-Mails aktualisieren",
"select_all": "Alle auswählen",
- "global_search": "Alles durchsuchen"
+ "global_search": "Alles durchsuchen",
+ "extend_selection": "Auswahl nach unten / oben erweitern"
},
"threads": {
"expand_collapse": "Unterhaltung erweitern/einklappen",
diff --git a/locales/en/common.json b/locales/en/common.json
index bea47352e..68028cbff 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -2625,7 +2625,8 @@
"help": "Show shortcuts",
"refresh": "Refresh emails",
"select_all": "Select all",
- "global_search": "Search everything"
+ "global_search": "Search everything",
+ "extend_selection": "Extend selection down / up"
},
"threads": {
"expand_collapse": "Expand/collapse thread",
diff --git a/locales/es/common.json b/locales/es/common.json
index 00af2bc88..9861f7bb3 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -2625,7 +2625,8 @@
"help": "Mostrar atajos",
"refresh": "Actualizar correos",
"select_all": "Seleccionar todo",
- "global_search": "Buscar en todo"
+ "global_search": "Buscar en todo",
+ "extend_selection": "Ampliar la selección hacia abajo / arriba"
},
"threads": {
"expand_collapse": "Expandir/contraer conversación",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 8047731ff..15f3c42ce 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -2625,7 +2625,8 @@
"help": "نمایش میانبرها",
"refresh": "بازنشانی ایمیلها",
"select_all": "انتخاب همه",
- "global_search": "جستجو در همه جا"
+ "global_search": "جستجو در همه جا",
+ "extend_selection": "گسترش انتخاب به پایین / بالا"
},
"threads": {
"expand_collapse": "باز/بسته کردن رشته",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 6608cb9c3..7651271fe 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -2625,7 +2625,8 @@
"help": "Afficher les raccourcis",
"refresh": "Actualiser les emails",
"select_all": "Tout sélectionner",
- "global_search": "Tout rechercher"
+ "global_search": "Tout rechercher",
+ "extend_selection": "Étendre la sélection vers le bas / le haut"
},
"threads": {
"expand_collapse": "Développer/réduire la conversation",
diff --git a/locales/he/common.json b/locales/he/common.json
index 852dd56b3..2a298f83c 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -2543,7 +2543,8 @@
"help": "הצג קיצורי דרך",
"refresh": "רענן מיילים",
"select_all": "בחר הכל",
- "global_search": "חיפוש בכל מקום"
+ "global_search": "חיפוש בכל מקום",
+ "extend_selection": "הרחבת הבחירה למטה / למעלה"
},
"threads": {
"expand_collapse": "הרחב/כווץ את השרשור",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index d680ae1b3..6edde12a6 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -2625,7 +2625,8 @@
"help": "Parancsok mutatása",
"refresh": "E-mailek frissítése",
"select_all": "Összes kijelölése",
- "global_search": "Keresés mindenben"
+ "global_search": "Keresés mindenben",
+ "extend_selection": "Kijelölés bővítése lefelé / felfelé"
},
"threads": {
"expand_collapse": "Beszélgetés kibontása/összecsukása",
diff --git a/locales/it/common.json b/locales/it/common.json
index bb3de32e6..b19f0a543 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -2625,7 +2625,8 @@
"help": "Mostra scorciatoie",
"refresh": "Aggiorna messaggi",
"select_all": "Seleziona tutto",
- "global_search": "Cerca ovunque"
+ "global_search": "Cerca ovunque",
+ "extend_selection": "Estendi la selezione in basso / in alto"
},
"threads": {
"expand_collapse": "Espandi/comprimi conversazione",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index c4b4c64c9..bdd2cca20 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -2625,7 +2625,8 @@
"help": "ショートカットを表示",
"refresh": "メールを更新",
"select_all": "すべて選択",
- "global_search": "すべてを検索"
+ "global_search": "すべてを検索",
+ "extend_selection": "選択範囲を下 / 上に広げる"
},
"threads": {
"expand_collapse": "スレッドの展開/折りたたみ",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 1f9ae6c00..aa75e5e85 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -2625,7 +2625,8 @@
"help": "단축키 보기",
"refresh": "메일 새로고침",
"select_all": "모두 선택",
- "global_search": "전체 검색"
+ "global_search": "전체 검색",
+ "extend_selection": "선택 범위를 아래 / 위로 확장"
},
"threads": {
"expand_collapse": "대화 펼치기/접기",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 3b7b61d26..e91ecde6f 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -2625,7 +2625,8 @@
"help": "Rādīt īsinājumtaustiņus",
"refresh": "Atsvaidzināt vēstules",
"select_all": "Atlasīt visas",
- "global_search": "Meklēt visā"
+ "global_search": "Meklēt visā",
+ "extend_selection": "Paplašināt atlasi uz leju / uz augšu"
},
"threads": {
"expand_collapse": "Izvērst/sairt sarunu",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index a783015d8..b4b7e0d85 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -2625,7 +2625,8 @@
"help": "Товчлол харуулах",
"refresh": "Имэйл сэргээх",
"select_all": "Бүгдийг сонго",
- "global_search": "Бүгдийг хайх"
+ "global_search": "Бүгдийг хайх",
+ "extend_selection": "Сонголтыг доош / дээш өргөтгөх"
},
"threads": {
"expand_collapse": "Хэлхээсийг дэлгэх/хумих",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index bb5328de9..6d35a0a9e 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -2625,7 +2625,8 @@
"help": "Vis tastatursnarveier",
"refresh": "Oppdater e-poster",
"select_all": "Velg alle",
- "global_search": "Søk i alt"
+ "global_search": "Søk i alt",
+ "extend_selection": "Utvid markeringen ned / opp"
},
"threads": {
"expand_collapse": "Utvid eller fold sammen tråden",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index db6b242d4..2845b20d2 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -2625,7 +2625,8 @@
"help": "Sneltoetsen tonen",
"refresh": "E-mails vernieuwen",
"select_all": "Alles selecteren",
- "global_search": "Alles doorzoeken"
+ "global_search": "Alles doorzoeken",
+ "extend_selection": "Selectie omlaag / omhoog uitbreiden"
},
"threads": {
"expand_collapse": "Gesprek uitklappen/inklappen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index d14172b27..e957fce90 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -2625,7 +2625,8 @@
"help": "Pokaż skróty",
"refresh": "Odśwież wiadomości",
"select_all": "Zaznacz wszystko",
- "global_search": "Szukaj wszędzie"
+ "global_search": "Szukaj wszędzie",
+ "extend_selection": "Rozszerz zaznaczenie w dół / w górę"
},
"threads": {
"expand_collapse": "Rozwiń/zwiń wątek",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 784fd0456..6d92d4602 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -2625,7 +2625,8 @@
"help": "Mostrar atalhos",
"refresh": "Atualizar e-mails",
"select_all": "Selecionar todos",
- "global_search": "Pesquisar em tudo"
+ "global_search": "Pesquisar em tudo",
+ "extend_selection": "Expandir a seleção para baixo / cima"
},
"threads": {
"expand_collapse": "Expandir/recolher conversa",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 6890d530c..f0cf6b503 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -2625,7 +2625,8 @@
"help": "Afișați comenzile rapide",
"refresh": "Reîmprospătați e-mailurile",
"select_all": "Selectează tot",
- "global_search": "Caută în tot"
+ "global_search": "Caută în tot",
+ "extend_selection": "Extinde selecția în jos / în sus"
},
"threads": {
"expand_collapse": "Extindeți/reduceți firul de discuție",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 5df224fe4..2c4b9c474 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -2625,7 +2625,8 @@
"help": "Показать сочетания клавиш",
"refresh": "Обновить письма",
"select_all": "Выбрать все",
- "global_search": "Искать везде"
+ "global_search": "Искать везде",
+ "extend_selection": "Расширить выделение вниз / вверх"
},
"threads": {
"expand_collapse": "Развернуть/свернуть цепочку",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index b8ad26c65..216b75f86 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -2625,7 +2625,8 @@
"help": "Zobraziť skratky",
"refresh": "Obnoviť e-maily",
"select_all": "Vybrať všetko",
- "global_search": "Hľadať všade"
+ "global_search": "Hľadať všade",
+ "extend_selection": "Rozšíriť výber nadol / nahor"
},
"threads": {
"expand_collapse": "Rozbaliť/zbaliť vlákno",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index c76a6fb32..b40d8206f 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -2625,7 +2625,8 @@
"help": "Kısayolları göster",
"refresh": "E-postaları yenile",
"select_all": "Tümünü seç",
- "global_search": "Her yerde ara"
+ "global_search": "Her yerde ara",
+ "extend_selection": "Seçimi aşağı / yukarı genişlet"
},
"threads": {
"expand_collapse": "İleti dizisini genişlet/daralt",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 3b507e5f2..580e25b0f 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -2625,7 +2625,8 @@
"help": "Показати ярлики",
"refresh": "Оновити електронні листи",
"select_all": "Вибрати все",
- "global_search": "Шукати скрізь"
+ "global_search": "Шукати скрізь",
+ "extend_selection": "Розширити виділення вниз / вгору"
},
"threads": {
"expand_collapse": "Розгорнути/згорнути ланцюжок",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 7ec086cf6..593b9cfec 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -2625,7 +2625,8 @@
"help": "顯示快速鍵",
"refresh": "重新整理郵件",
"select_all": "全部選取",
- "global_search": "全域搜尋"
+ "global_search": "全域搜尋",
+ "extend_selection": "向下 / 向上擴展選取"
},
"threads": {
"expand_collapse": "展開/收合對話串",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index a735101ef..b8b0ab9c1 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -2625,7 +2625,8 @@
"help": "显示快捷方式",
"refresh": "刷新邮件",
"select_all": "全选",
- "global_search": "全局搜索"
+ "global_search": "全局搜索",
+ "extend_selection": "向下 / 向上扩展选择"
},
"threads": {
"expand_collapse": "展开/折叠会话",
diff --git a/stores/__tests__/email-selection.test.ts b/stores/__tests__/email-selection.test.ts
index d46f0ff95..b5dbc7695 100644
--- a/stores/__tests__/email-selection.test.ts
+++ b/stores/__tests__/email-selection.test.ts
@@ -146,6 +146,69 @@ describe('email-store selection', () => {
});
});
+ // #992: Shift+Arrow grows the selection from the open message one row at a
+ // time and shrinks it again when the direction reverses.
+ describe('extendSelection', () => {
+ const keys = () => [...useEmailStore.getState().selectedEmailKeys].sort();
+
+ beforeEach(() => {
+ // Opening a message makes it the anchor, as selectEmail does.
+ useEmailStore.setState({ lastSelectedEmailKey: 'b', selectionFocus: null });
+ });
+
+ it('selects the open message and the next one on the first press', () => {
+ useEmailStore.getState().extendSelection(1);
+ expect(keys()).toEqual(['b', 'c']);
+ });
+
+ it('keeps growing in the same direction', () => {
+ useEmailStore.getState().extendSelection(1);
+ useEmailStore.getState().extendSelection(1);
+ expect(keys()).toEqual(['b', 'c', 'd']);
+ });
+
+ it('shrinks back when the direction reverses', () => {
+ useEmailStore.getState().extendSelection(1);
+ useEmailStore.getState().extendSelection(1);
+ useEmailStore.getState().extendSelection(-1);
+ expect(keys()).toEqual(['b', 'c']);
+ });
+
+ it('crosses the anchor and grows the other way', () => {
+ useEmailStore.getState().extendSelection(1);
+ useEmailStore.getState().extendSelection(-1);
+ useEmailStore.getState().extendSelection(-1);
+ expect(keys()).toEqual(['a', 'b']);
+ });
+
+ it('stops at the end of the list', () => {
+ for (let i = 0; i < 10; i++) useEmailStore.getState().extendSelection(1);
+ expect(keys()).toEqual(['b', 'c', 'd', 'e']);
+ });
+
+ it('continues a shift-click range', () => {
+ useEmailStore.getState().selectRangeEmails({ id: 'd' });
+ useEmailStore.getState().extendSelection(-1);
+ expect(keys()).toEqual(['b', 'c']);
+ });
+
+ it('walks list rows, taking a whole thread with its row', () => {
+ useEmailStore.setState({
+ emails: ['t2', 'x', 't1', 'y'].map((id) => makeEmail(id, id.startsWith('t') ? 'thread-t' : `thread-${id}`)),
+ listRows: [
+ { key: 'x', members: ['x'] },
+ { key: 't2', members: ['t2', 't1'] },
+ { key: 'y', members: ['y'] },
+ ],
+ lastSelectedEmailKey: 'x',
+ });
+ useEmailStore.getState().extendSelection(1);
+ expect(keys()).toEqual(['t1', 't2', 'x']);
+ useEmailStore.getState().extendSelection(-1);
+ expect(keys()).toEqual(['x']);
+ });
+ });
+
describe('selectAllEmails', () => {
it('should select all emails', () => {
useEmailStore.getState().selectAllEmails();
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 14049f158..e508ca6b3 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -283,7 +283,17 @@ interface EmailStore {
/** Take the email, not its id: the id alone collides across accounts. */
toggleEmailSelection: (email: SelectableEmail) => void;
selectRangeEmails: (target: SelectableEmail) => void;
+ /**
+ * Shift+Arrow (and Shift+J/K): grow or shrink the selection by one row,
+ * from the open or last ticked message, like a desktop mail client. (#992)
+ */
+ extendSelection: (direction: 1 | -1) => void;
lastSelectedEmailKey: string | null;
+ /**
+ * The moving end of a keyboard range and the anchor it was taken from.
+ * Only valid while the anchor is still lastSelectedEmailKey.
+ */
+ selectionFocus: { anchor: string; focus: string } | null;
/**
* The message list's rows in screen order, each with the keys a click on it
* selects. Shift-click ranges walk these, so a thread is in a range only
@@ -2140,6 +2150,7 @@ export const useEmailStore = create((set, get) => ({
selectedEmailKeys: new Set(),
retainedInViewIds: new Set(),
lastSelectedEmailKey: null,
+ selectionFocus: null,
listRows: null,
hasMoreEmails: false,
totalEmails: 0,
@@ -2411,7 +2422,7 @@ export const useEmailStore = create((set, get) => ({
for (let i = Math.min(anchorRow, targetRow); i <= Math.max(anchorRow, targetRow); i++) {
for (const key of listRows[i].members) newSelection.add(key);
}
- set({ selectedEmailKeys: newSelection });
+ set({ selectedEmailKeys: newSelection, selectionFocus: { anchor: anchorKey, focus: targetKeyForRows } });
return;
}
}
@@ -2425,7 +2436,48 @@ export const useEmailStore = create((set, get) => ({
for (let i = start; i <= end; i++) {
newSelection.add(emailKeyFor(emails[i]));
}
- set({ selectedEmailKeys: newSelection });
+ set({ selectedEmailKeys: newSelection, selectionFocus: { anchor: anchorKey, focus: targetKey } });
+ },
+
+ extendSelection: (direction) => {
+ const { emails, listRows, lastSelectedEmailKey, selectedEmail, selectedEmailKeys, selectionFocus } = get();
+ // Walk the rows on screen, as shift-click does; without them, the emails.
+ const rows: ListRow[] = listRows && listRows.length > 0
+ ? listRows
+ : emails.map((e) => ({ key: emailKeyFor(e), members: [emailKeyFor(e)] }));
+ if (rows.length === 0) return;
+ const membersOf = (from: number, to: number) => {
+ const keys: string[] = [];
+ for (let i = Math.min(from, to); i <= Math.max(from, to); i++) keys.push(...rows[i].members);
+ return keys;
+ };
+
+ const anchorKey = lastSelectedEmailKey ?? (selectedEmail ? emailKeyFor(selectedEmail) : null);
+ const anchor = anchorKey ? findListRow(rows, anchorKey) : -1;
+ if (!anchorKey || anchor === -1) {
+ // Nothing to extend from: start with the row at the end the key points from.
+ const start = rows[direction > 0 ? 0 : rows.length - 1];
+ set({
+ selectedEmailKeys: new Set([...selectedEmailKeys, ...start.members]),
+ lastSelectedEmailKey: start.key,
+ selectionFocus: { anchor: start.key, focus: start.key },
+ });
+ return;
+ }
+
+ // Continue the range this anchor already has, or begin one at the anchor.
+ const previous = selectionFocus && selectionFocus.anchor === anchorKey && selectedEmailKeys.size > 0
+ ? findListRow(rows, selectionFocus.focus)
+ : -1;
+ const focus = previous === -1 ? anchor : previous;
+ const nextFocus = Math.max(0, Math.min(rows.length - 1, focus + direction));
+
+ const newSelection = new Set(selectedEmailKeys);
+ if (previous !== -1) {
+ for (const key of membersOf(anchor, previous)) newSelection.delete(key);
+ }
+ for (const key of membersOf(anchor, nextFocus)) newSelection.add(key);
+ set({ selectedEmailKeys: newSelection, selectionFocus: { anchor: anchorKey, focus: rows[nextFocus].key } });
},
selectAllEmails: () => {
From 1c3cc35a7c7d850325550ee82d202de2488c29b0 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:38:12 +0200
Subject: [PATCH 039/238] feat: set the counter per folder, e.g. keep the
Drafts count with totals off #982
---
.../sidebar-folder-count-modes.test.tsx | 69 +++++++++++++++++++
components/layout/sidebar.tsx | 21 ++++--
.../folder-settings-count-mode.test.tsx | 64 +++++++++++++++++
components/settings/folder-settings.tsx | 18 ++++-
lib/__tests__/folder-counts.test.ts | 40 +++++++++++
lib/folder-counts.ts | 49 +++++++++++++
locales/ar/common.json | 5 ++
locales/ca/common.json | 5 ++
locales/cs/common.json | 5 ++
locales/da/common.json | 5 ++
locales/de/common.json | 5 ++
locales/en/common.json | 5 ++
locales/es/common.json | 5 ++
locales/fa/common.json | 5 ++
locales/fr/common.json | 5 ++
locales/he/common.json | 5 ++
locales/hu/common.json | 5 ++
locales/it/common.json | 5 ++
locales/ja/common.json | 5 ++
locales/ko/common.json | 5 ++
locales/lv/common.json | 5 ++
locales/mn/common.json | 5 ++
locales/nb/common.json | 5 ++
locales/nl/common.json | 5 ++
locales/pl/common.json | 5 ++
locales/pt/common.json | 5 ++
locales/ro/common.json | 5 ++
locales/ru/common.json | 5 ++
locales/sk/common.json | 5 ++
locales/tr/common.json | 5 ++
locales/uk/common.json | 5 ++
locales/zh-TW/common.json | 5 ++
locales/zh/common.json | 5 ++
stores/settings-store.ts | 4 ++
34 files changed, 394 insertions(+), 6 deletions(-)
create mode 100644 components/layout/__tests__/sidebar-folder-count-modes.test.tsx
create mode 100644 components/settings/__tests__/folder-settings-count-mode.test.tsx
create mode 100644 lib/__tests__/folder-counts.test.ts
create mode 100644 lib/folder-counts.ts
diff --git a/components/layout/__tests__/sidebar-folder-count-modes.test.tsx b/components/layout/__tests__/sidebar-folder-count-modes.test.tsx
new file mode 100644
index 000000000..9625b729e
--- /dev/null
+++ b/components/layout/__tests__/sidebar-folder-count-modes.test.tsx
@@ -0,0 +1,69 @@
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import type { Mailbox } from '@/lib/jmap/types';
+
+// #982: the per-folder counter setting overrides the global total count.
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+vi.mock('@/contexts/drag-drop-context', () => ({
+ useDragDropContext: () => ({ isDragging: false }),
+}));
+vi.mock('@/hooks/use-mailbox-drop', () => ({
+ useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
+}));
+vi.mock('@/hooks/use-tag-drop', () => ({
+ useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
+}));
+vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
+
+const { Sidebar } = await import('../sidebar');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { useSettingsStore } = await import('@/stores/settings-store');
+
+const box = (id: string, name: string, role: string | null, extra: Partial = {}) => ({
+ id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, ...extra,
+}) as unknown as Mailbox;
+
+const mailboxes: Mailbox[] = [
+ box('inbox', 'Inbox', 'inbox', { totalEmails: 120, unreadEmails: 4 }),
+ box('drafts', 'Drafts', 'drafts', { totalEmails: 7 }),
+ box('news', 'News', null, { totalEmails: 30, unreadEmails: 2 }),
+];
+
+const counts = (mailboxId: string) => {
+ const row = screen.getAllByTestId('folder-row').find((el) => el.getAttribute('data-mailbox-id') === mailboxId)!;
+ const el = row.querySelector('[data-testid="folder-counts"]');
+ return el ? { unread: el.getAttribute('data-unread'), total: el.getAttribute('data-total') } : null;
+};
+
+describe('Sidebar folder counter modes (#982)', () => {
+ beforeEach(() => {
+ useAuthStore.setState({ activeAccountId: 'login-a', client: { getAccountId: () => 'me', getCapabilities: () => ({}) } } as never);
+ useSettingsStore.setState({
+ hideAccountSwitcher: true,
+ interfaceLayout: 'default',
+ showFolderTotalCount: false,
+ folderCountModes: {},
+ } as never);
+ });
+
+ it('keeps the Drafts counter while total counts are off elsewhere', () => {
+ useSettingsStore.setState({ folderCountModes: { drafts: 'all' } } as never);
+ render( );
+
+ expect(counts('drafts')).toEqual({ unread: '0', total: '7' });
+ expect(counts('inbox')).toEqual({ unread: '4', total: '0' });
+ });
+
+ it('hides one folder counter and limits another to unread with total counts on', () => {
+ useSettingsStore.setState({ showFolderTotalCount: true, folderCountModes: { news: 'none', inbox: 'unread' } } as never);
+ render( );
+
+ expect(counts('news')).toBeNull();
+ expect(counts('inbox')).toEqual({ unread: '4', total: '0' });
+ expect(counts('drafts')).toEqual({ unread: '0', total: '7' });
+ });
+});
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 4852587a9..4a11dc6fa 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -88,6 +88,7 @@ import {
type NotificationMutes,
} from "@/lib/notification-mutes";
import { serverSupportsEmailPush } from "@/lib/web-push";
+import { resolveFolderCounts } from "@/lib/folder-counts";
interface SidebarProps {
mailboxes: Mailbox[];
@@ -210,34 +211,43 @@ function SidebarRowCounts({
unread,
total,
role,
+ mailboxId,
onUnreadClick,
}: {
unread?: number;
total?: number;
role?: string | null;
+ mailboxId?: string;
isSelected: boolean;
onUnreadClick?: () => void;
}) {
const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount);
const mountainView = useSettingsStore(s => s.interfaceLayout === 'mountain-view');
+ // Per-folder override from the folder settings (#982).
+ const countMode = useSettingsStore(s => (mailboxId ? s.folderCountModes?.[mailboxId] : undefined));
+ const counts = resolveFolderCounts({
+ unread, total, role, mode: countMode,
+ showTotalGlobally: showFolderTotalCount, mountainView,
+ });
// Mountain View counts one thing per row: what is unread, or for Drafts,
// how many there are, since a draft is never unread.
if (mountainView) {
- const count = role === 'drafts' ? (total ?? 0) : (unread ?? 0);
+ const showsTotal = counts.total > 0;
+ const count = showsTotal ? counts.total : counts.unread;
if (count === 0) return null;
return (
{count.toLocaleString()}
);
}
- const unreadCount = unread ?? 0;
- const totalCount = showFolderTotalCount ? (total ?? 0) : 0;
+ const unreadCount = counts.unread;
+ const totalCount = counts.total;
if (unreadCount === 0 && totalCount === 0) return null;
@@ -426,6 +436,7 @@ function SidebarRow({
unread={unread}
total={total}
role={testRole}
+ mailboxId={testMailboxId}
isSelected={isSelected}
onUnreadClick={onUnreadClick}
/>
diff --git a/components/settings/__tests__/folder-settings-count-mode.test.tsx b/components/settings/__tests__/folder-settings-count-mode.test.tsx
new file mode 100644
index 000000000..154d15908
--- /dev/null
+++ b/components/settings/__tests__/folder-settings-count-mode.test.tsx
@@ -0,0 +1,64 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { FolderSettings } from '../folder-settings';
+import { useEmailStore } from '@/stores/email-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import type { Mailbox } from '@/lib/jmap/types';
+
+// #982: each folder row has a counter select that stores a per-folder override.
+
+vi.mock('../settings-section', async (importOriginal) => ({
+ ...(await importOriginal()),
+ SettingsSection: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+function makeMailbox(overrides: Partial): Mailbox {
+ return {
+ id: 'mb',
+ name: 'Folder',
+ sortOrder: 0,
+ totalEmails: 0,
+ unreadEmails: 0,
+ totalThreads: 0,
+ unreadThreads: 0,
+ myRights: {
+ mayReadItems: true,
+ mayAddItems: true,
+ mayRemoveItems: true,
+ maySetSeen: true,
+ maySetKeywords: true,
+ mayCreateChild: true,
+ mayRename: true,
+ mayDelete: true,
+ maySubmit: true,
+ },
+ isSubscribed: true,
+ isShared: false,
+ ...overrides,
+ } as Mailbox;
+}
+
+describe('FolderSettings counter mode (#982)', () => {
+ beforeEach(() => {
+ useAuthStore.setState({ client: null } as never);
+ useSettingsStore.setState({ folderCountModes: { other: 'none' } } as never);
+ useEmailStore.setState({
+ mailboxes: [makeMailbox({ id: 'drafts', name: 'Drafts', role: 'drafts' })],
+ fetchMailboxes: vi.fn(),
+ } as never);
+ });
+
+ it('stores the chosen mode for the folder and removes it again on default', () => {
+ render( );
+ const select = screen.getByRole('combobox', { name: /count_mode/ }) as HTMLSelectElement;
+ expect(select.value).toBe('');
+
+ fireEvent.change(select, { target: { value: 'all' } });
+ expect(useSettingsStore.getState().folderCountModes).toEqual({ other: 'none', drafts: 'all' });
+ expect(select.value).toBe('all');
+
+ fireEvent.change(select, { target: { value: '' } });
+ expect(useSettingsStore.getState().folderCountModes).toEqual({ other: 'none' });
+ });
+});
diff --git a/components/settings/folder-settings.tsx b/components/settings/folder-settings.tsx
index 955114756..64cf6ade0 100644
--- a/components/settings/folder-settings.tsx
+++ b/components/settings/folder-settings.tsx
@@ -32,6 +32,7 @@ import {
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
+import { isFolderCountMode } from '@/lib/folder-counts';
// Must match the per-level indentation used in the row styles below: dragging
// a folder this many pixels right/left targets one nesting level deeper/higher.
@@ -164,7 +165,7 @@ export function FolderSettings() {
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
- const { folderIcons, setFolderIcon } = useSettingsStore();
+ const { folderIcons, setFolderIcon, folderCountModes, updateSetting } = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore();
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
@@ -719,6 +720,21 @@ export function FolderSettings() {
)}
+
{
+ const { [mb.id]: _, ...rest } = folderCountModes ?? {};
+ updateSetting('folderCountModes', isFolderCountMode(value) ? { ...rest, [mb.id]: value } : rest);
+ }}
+ ariaLabel={t('count_mode', { name: mb.name })}
+ className="px-1.5 py-0.5 text-xs max-w-[9rem]"
+ options={[
+ { value: '', label: t('count_mode_default') },
+ { value: 'unread', label: t('count_mode_unread') },
+ { value: 'all', label: t('count_mode_all') },
+ { value: 'none', label: t('count_mode_none') },
+ ]}
+ />
{mb.myRights?.mayCreateChild && (
startCreateSubfolder(mb.id)}
diff --git a/lib/__tests__/folder-counts.test.ts b/lib/__tests__/folder-counts.test.ts
new file mode 100644
index 000000000..6d2074733
--- /dev/null
+++ b/lib/__tests__/folder-counts.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest';
+import { resolveFolderCounts } from '@/lib/folder-counts';
+
+// #982: a folder can override the global "show total count" setting.
+
+const base = { unread: 3, total: 40, showTotalGlobally: true, mountainView: false };
+
+describe('resolveFolderCounts', () => {
+ it('follows the global setting without an override', () => {
+ expect(resolveFolderCounts(base)).toEqual({ unread: 3, total: 40 });
+ expect(resolveFolderCounts({ ...base, showTotalGlobally: false })).toEqual({ unread: 3, total: 0 });
+ });
+
+ it('keeps the Drafts total visible with the global total count off', () => {
+ expect(resolveFolderCounts({
+ unread: 0, total: 5, role: 'drafts', mode: 'all', showTotalGlobally: false, mountainView: false,
+ })).toEqual({ unread: 0, total: 5 });
+ });
+
+ it('shows only unread with "unread", even with the global total count on', () => {
+ expect(resolveFolderCounts({ ...base, mode: 'unread' })).toEqual({ unread: 3, total: 0 });
+ });
+
+ it('hides everything with "none"', () => {
+ expect(resolveFolderCounts({ ...base, mode: 'none' })).toEqual({ unread: 0, total: 0 });
+ expect(resolveFolderCounts({ ...base, mode: 'none', mountainView: true })).toEqual({ unread: 0, total: 0 });
+ });
+
+ it('treats an unknown synced value as no override', () => {
+ expect(resolveFolderCounts({ ...base, showTotalGlobally: false, mode: 'bogus' })).toEqual({ unread: 3, total: 0 });
+ });
+
+ it('shows one number in Mountain View', () => {
+ const mv = { ...base, mountainView: true };
+ expect(resolveFolderCounts(mv)).toEqual({ unread: 3, total: 0 });
+ expect(resolveFolderCounts({ ...mv, role: 'drafts' })).toEqual({ unread: 0, total: 40 });
+ expect(resolveFolderCounts({ ...mv, mode: 'all' })).toEqual({ unread: 0, total: 40 });
+ expect(resolveFolderCounts({ ...mv, role: 'drafts', mode: 'unread' })).toEqual({ unread: 3, total: 0 });
+ });
+});
diff --git a/lib/folder-counts.ts b/lib/folder-counts.ts
new file mode 100644
index 000000000..91beccb71
--- /dev/null
+++ b/lib/folder-counts.ts
@@ -0,0 +1,49 @@
+/**
+ * Per-folder counter override (#982). Without one a folder follows the global
+ * "show total count" setting.
+ * - unread: only the unread count
+ * - all: unread and total, even with the global total count off
+ * - none: no counter at all
+ */
+export type FolderCountMode = 'unread' | 'all' | 'none';
+
+export const FOLDER_COUNT_MODES: readonly FolderCountMode[] = ['unread', 'all', 'none'];
+
+export function isFolderCountMode(value: unknown): value is FolderCountMode {
+ return typeof value === 'string' && (FOLDER_COUNT_MODES as readonly string[]).includes(value);
+}
+
+/**
+ * Which counts a sidebar row shows. A zero means that count is not shown.
+ * Mountain View shows one number per row, so there at most one of the two is
+ * non-zero: the unread count, or the total for Drafts (a draft is never
+ * unread) and for folders set to "all".
+ */
+export function resolveFolderCounts({
+ unread,
+ total,
+ role,
+ mode: rawMode,
+ showTotalGlobally,
+ mountainView,
+}: {
+ unread?: number;
+ total?: number;
+ role?: string | null;
+ mode?: FolderCountMode | string;
+ showTotalGlobally: boolean;
+ mountainView: boolean;
+}): { unread: number; total: number } {
+ // Synced settings can carry values from other versions; treat those as unset.
+ const mode = isFolderCountMode(rawMode) ? rawMode : undefined;
+ const u = unread ?? 0;
+ const t = total ?? 0;
+ if (mode === 'none') return { unread: 0, total: 0 };
+ if (mountainView) {
+ const showTotal = mode === 'all' || (mode === undefined && role === 'drafts');
+ return showTotal ? { unread: 0, total: t } : { unread: u, total: 0 };
+ }
+ if (mode === 'unread') return { unread: u, total: 0 };
+ if (mode === 'all') return { unread: u, total: t };
+ return { unread: u, total: showTotalGlobally ? t : 0 };
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index b6333fac9..5f37b3696 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -2040,6 +2040,11 @@
"new_folder_name": "اسم المجلد",
"rename": "إعادة تسمية",
"change_icon": "تغيير الأيقونة",
+ "count_mode": "العداد لـ {name}",
+ "count_mode_default": "العداد: افتراضي",
+ "count_mode_unread": "غير المقروءة فقط",
+ "count_mode_all": "غير المقروءة والإجمالي",
+ "count_mode_none": "بدون عداد",
"delete": "حذف",
"confirm_delete": "هل أنت متأكد أنك تريد حذف \"{name}\"؟ ستُنقل الرسائل في هذا المجلد إلى المهملات.",
"create": "إنشاء",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 166314246..214dac12c 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -2040,6 +2040,11 @@
"new_folder_name": "Nom de la carpeta",
"rename": "Canvia el nom",
"change_icon": "Canvia la icona",
+ "count_mode": "Comptador per a {name}",
+ "count_mode_default": "Comptador: per defecte",
+ "count_mode_unread": "Només no llegits",
+ "count_mode_all": "No llegits i total",
+ "count_mode_none": "Sense comptador",
"delete": "Suprimeix",
"confirm_delete": "Segur que voleu suprimir «{name}»? Els correus d'aquesta carpeta es mouran a la paperera.",
"create": "Crea",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index be1175d69..ffdfc4d77 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Název složky",
"rename": "Přejmenovat",
"change_icon": "Změnit ikonu",
+ "count_mode": "Počítadlo pro {name}",
+ "count_mode_default": "Počítadlo: výchozí",
+ "count_mode_unread": "Jen nepřečtené",
+ "count_mode_all": "Nepřečtené a celkem",
+ "count_mode_none": "Bez počítadla",
"delete": "Odstranit",
"confirm_delete": "Opravdu chcete odstranit složku „{name}“? Zprávy v této složce budou přesunuty do Koše.",
"create": "Vytvořit",
diff --git a/locales/da/common.json b/locales/da/common.json
index 5e47ee71b..a35a306a3 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -2036,6 +2036,11 @@
"new_folder_name": "Mappenavn",
"rename": "Omdøb",
"change_icon": "Skift ikon",
+ "count_mode": "Tæller for {name}",
+ "count_mode_default": "Tæller: standard",
+ "count_mode_unread": "Kun ulæste",
+ "count_mode_all": "Ulæste og i alt",
+ "count_mode_none": "Ingen tæller",
"delete": "Slet",
"confirm_delete": "Er du sikker på, at du vil slette \"{name}\"? E-mails i denne mappe flyttes til papirkurven.",
"create": "Opret",
diff --git a/locales/de/common.json b/locales/de/common.json
index c0876d03b..0efab2a2c 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Ordnername",
"rename": "Umbenennen",
"change_icon": "Symbol ändern",
+ "count_mode": "Zähler für {name}",
+ "count_mode_default": "Zähler: Standard",
+ "count_mode_unread": "Nur ungelesene",
+ "count_mode_all": "Ungelesene und gesamt",
+ "count_mode_none": "Kein Zähler",
"delete": "Löschen",
"confirm_delete": "Möchten Sie \"{name}\" wirklich löschen? E-Mails in diesem Ordner werden in den Papierkorb verschoben.",
"create": "Erstellen",
diff --git a/locales/en/common.json b/locales/en/common.json
index 68028cbff..102ed0f14 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -2040,6 +2040,11 @@
"new_folder_name": "Folder name",
"rename": "Rename",
"change_icon": "Change icon",
+ "count_mode": "Counter for {name}",
+ "count_mode_default": "Counter: default",
+ "count_mode_unread": "Unread only",
+ "count_mode_all": "Unread and total",
+ "count_mode_none": "No counter",
"delete": "Delete",
"confirm_delete": "Are you sure you want to delete \"{name}\"? Emails in this folder will be moved to Trash.",
"create": "Create",
diff --git a/locales/es/common.json b/locales/es/common.json
index 9861f7bb3..c8ff4d650 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Nombre de la carpeta",
"rename": "Renombrar",
"change_icon": "Cambiar icono",
+ "count_mode": "Contador de {name}",
+ "count_mode_default": "Contador: predeterminado",
+ "count_mode_unread": "Solo no leídos",
+ "count_mode_all": "No leídos y total",
+ "count_mode_none": "Sin contador",
"delete": "Eliminar",
"confirm_delete": "¿Está seguro de que desea eliminar \"{name}\"? Los correos de esta carpeta se moverán a la Papelera.",
"create": "Crear",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 15f3c42ce..105cdc629 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -2040,6 +2040,11 @@
"new_folder_name": "نام پوشه",
"rename": "تغییر نام",
"change_icon": "تغییر آیکون",
+ "count_mode": "شمارنده برای {name}",
+ "count_mode_default": "شمارنده: پیشفرض",
+ "count_mode_unread": "فقط خواندهنشده",
+ "count_mode_all": "خواندهنشده و کل",
+ "count_mode_none": "بدون شمارنده",
"delete": "حذف",
"confirm_delete": "آیا از حذف \"{name}\" اطمینان دارید؟ ایمیلهای این پوشه به زبالهدان منتقل میشوند.",
"create": "ایجاد",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 7651271fe..4f57757e4 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Nom du dossier",
"rename": "Renommer",
"change_icon": "Changer l'icône",
+ "count_mode": "Compteur pour {name}",
+ "count_mode_default": "Compteur : par défaut",
+ "count_mode_unread": "Non lus uniquement",
+ "count_mode_all": "Non lus et total",
+ "count_mode_none": "Aucun compteur",
"delete": "Supprimer",
"confirm_delete": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Les e-mails de ce dossier seront déplacés dans la Corbeille.",
"create": "Créer",
diff --git a/locales/he/common.json b/locales/he/common.json
index 2a298f83c..a62813eb8 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -1997,6 +1997,11 @@
"new_folder_name": "שם התיקיה",
"rename": "שנה שם",
"change_icon": "שנה סמל",
+ "count_mode": "מונה עבור {name}",
+ "count_mode_default": "מונה: ברירת מחדל",
+ "count_mode_unread": "רק שלא נקראו",
+ "count_mode_all": "שלא נקראו וסך הכול",
+ "count_mode_none": "ללא מונה",
"delete": "לִמְחוֹק",
"confirm_delete": "האם אתה בטוח שברצונך למחוק את \"{name}\"? הודעות אימייל בתיקייה זו יועברו לאשפה.",
"create": "צור",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 6edde12a6..280aab4bf 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -2036,6 +2036,11 @@
"new_folder_name": "Mappa neve",
"rename": "Átnevezés",
"change_icon": "Ikon módosítása",
+ "count_mode": "Számláló: {name}",
+ "count_mode_default": "Számláló: alapértelmezett",
+ "count_mode_unread": "Csak olvasatlan",
+ "count_mode_all": "Olvasatlan és összes",
+ "count_mode_none": "Nincs számláló",
"delete": "Törlés",
"confirm_delete": "Biztosan törölni szeretnéd a \"{name}\" mappát? A mappában lévő e-mailek a Kukába kerülnek.",
"create": "Létrehozás",
diff --git a/locales/it/common.json b/locales/it/common.json
index b19f0a543..7e782f095 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Nome della cartella",
"rename": "Rinomina",
"change_icon": "Cambia icona",
+ "count_mode": "Contatore per {name}",
+ "count_mode_default": "Contatore: predefinito",
+ "count_mode_unread": "Solo non letti",
+ "count_mode_all": "Non letti e totale",
+ "count_mode_none": "Nessun contatore",
"delete": "Elimina",
"confirm_delete": "Sei sicuro di voler eliminare \"{name}\"? Le e-mail in questa cartella verranno spostate nel Cestino.",
"create": "Crea",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index bdd2cca20..eda9e4023 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "フォルダー名",
"rename": "名前を変更",
"change_icon": "アイコンを変更",
+ "count_mode": "{name} のカウンター",
+ "count_mode_default": "カウンター: 既定",
+ "count_mode_unread": "未読のみ",
+ "count_mode_all": "未読と合計",
+ "count_mode_none": "カウンターなし",
"delete": "削除",
"confirm_delete": "「{name}」を削除してもよろしいですか?このフォルダー内のメールはゴミ箱に移動されます。",
"create": "作成",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index aa75e5e85..dd681dfd2 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "폴더 이름",
"rename": "이름 바꾸기",
"change_icon": "아이콘 바꾸기",
+ "count_mode": "{name} 카운터",
+ "count_mode_default": "카운터: 기본값",
+ "count_mode_unread": "읽지 않음만",
+ "count_mode_all": "읽지 않음 및 전체",
+ "count_mode_none": "카운터 없음",
"delete": "삭제",
"confirm_delete": "정말 \"{name}\" 폴더를 삭제할까요? 폴더 안의 메일은 휴지통으로 이동돼요.",
"create": "만들기",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index e91ecde6f..32e7e55ed 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Mapes nosaukums",
"rename": "Pārdēvēt",
"change_icon": "Mainīt ikonu",
+ "count_mode": "Skaitītājs mapei {name}",
+ "count_mode_default": "Skaitītājs: noklusējums",
+ "count_mode_unread": "Tikai nelasītie",
+ "count_mode_all": "Nelasītie un kopā",
+ "count_mode_none": "Bez skaitītāja",
"delete": "Dzēst",
"confirm_delete": "Vai tiešām vēlaties dzēst \"{name}\"? Vēstules tiks pārvietotas uz atkritni.",
"create": "Izveidot",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index b4b7e0d85..ec0c04933 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -2040,6 +2040,11 @@
"new_folder_name": "Хавтасны нэр",
"rename": "Нэрээ өөрчлөх",
"change_icon": " Дүрсийг өөрчлөх",
+ "count_mode": "{name}-ийн тоолуур",
+ "count_mode_default": "Тоолуур: үндсэн",
+ "count_mode_unread": "Зөвхөн уншаагүй",
+ "count_mode_all": "Уншаагүй ба нийт",
+ "count_mode_none": "Тоолуургүй",
"delete": "Устгах",
"confirm_delete": "Та \"{name}\"-г устгахдаа итгэлтэй байна уу? Энэ хавтсанд байгаа имэйлийг Хогийн сав руу зөөх болно.",
"create": "Үүсгэх",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 6d35a0a9e..6726269d1 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -2040,6 +2040,11 @@
"new_folder_name": "Mappenavn",
"rename": "Gi nytt navn",
"change_icon": "Endre ikon",
+ "count_mode": "Teller for {name}",
+ "count_mode_default": "Teller: standard",
+ "count_mode_unread": "Bare uleste",
+ "count_mode_all": "Uleste og totalt",
+ "count_mode_none": "Ingen teller",
"delete": "Slett",
"confirm_delete": "Er du sikker på at du vil slette «{name}»? E-postene i denne mappen flyttes til papirkurven.",
"create": "Opprett",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 2845b20d2..12967b6f8 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Mapnaam",
"rename": "Hernoemen",
"change_icon": "Pictogram wijzigen",
+ "count_mode": "Teller voor {name}",
+ "count_mode_default": "Teller: standaard",
+ "count_mode_unread": "Alleen ongelezen",
+ "count_mode_all": "Ongelezen en totaal",
+ "count_mode_none": "Geen teller",
"delete": "Verwijderen",
"confirm_delete": "Weet u zeker dat u \"{name}\" wilt verwijderen? E-mails in deze map worden naar de Prullenbak verplaatst.",
"create": "Maken",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index e957fce90..7ec43d247 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Nazwa folderu",
"rename": "Zmień nazwę",
"change_icon": "Zmień ikonę",
+ "count_mode": "Licznik dla {name}",
+ "count_mode_default": "Licznik: domyślny",
+ "count_mode_unread": "Tylko nieprzeczytane",
+ "count_mode_all": "Nieprzeczytane i łącznie",
+ "count_mode_none": "Bez licznika",
"delete": "Usuń",
"confirm_delete": "Czy na pewno chcesz usunąć „{name}”? Wiadomości w tym folderze zostaną przeniesione do Kosza.",
"create": "Utwórz",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 6d92d4602..15e8e49b8 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Nome da pasta",
"rename": "Renomear",
"change_icon": "Alterar ícone",
+ "count_mode": "Contador de {name}",
+ "count_mode_default": "Contador: padrão",
+ "count_mode_unread": "Apenas não lidas",
+ "count_mode_all": "Não lidas e total",
+ "count_mode_none": "Sem contador",
"delete": "Excluir",
"confirm_delete": "Tem certeza de que deseja excluir \"{name}\"? Os e-mails nesta pasta serão movidos para a Lixeira.",
"create": "Criar",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index f0cf6b503..a281787df 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -2040,6 +2040,11 @@
"new_folder_name": "Numele folderului",
"rename": "Redenumire",
"change_icon": "Schimbă pictograma",
+ "count_mode": "Contor pentru {name}",
+ "count_mode_default": "Contor: implicit",
+ "count_mode_unread": "Doar necitite",
+ "count_mode_all": "Necitite și total",
+ "count_mode_none": "Fără contor",
"delete": "Șterge",
"confirm_delete": "Sunteți sigur că doriți să ștergeți „{name}”? E-mailurile din acest folder vor fi mutate în Coșul de gunoi.",
"create": "Creați",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 2c4b9c474..3941e248e 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Название папки",
"rename": "Переименовать",
"change_icon": "Изменить значок",
+ "count_mode": "Счётчик для {name}",
+ "count_mode_default": "Счётчик: по умолчанию",
+ "count_mode_unread": "Только непрочитанные",
+ "count_mode_all": "Непрочитанные и всего",
+ "count_mode_none": "Без счётчика",
"delete": "Удалить",
"confirm_delete": "Вы уверены, что хотите удалить \"{name}\"? Письма в этой папке будут перемещены в корзину.",
"create": "Создать",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 216b75f86..8a7730411 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -2040,6 +2040,11 @@
"new_folder_name": "Názov priečinka",
"rename": "Premenovať",
"change_icon": "Zmeniť ikonu",
+ "count_mode": "Počítadlo pre {name}",
+ "count_mode_default": "Počítadlo: predvolené",
+ "count_mode_unread": "Len neprečítané",
+ "count_mode_all": "Neprečítané a celkom",
+ "count_mode_none": "Bez počítadla",
"delete": "Odstrániť",
"confirm_delete": "Naozaj chcete odstrániť priečinok \"{name}\"? E-maily v tomto priečinku budú presunuté do Koša.",
"create": "Vytvoriť",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index b40d8206f..74c700a4e 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Klasör adı",
"rename": "Yeniden Adlandır",
"change_icon": "Simgeyi değiştir",
+ "count_mode": "{name} sayacı",
+ "count_mode_default": "Sayaç: varsayılan",
+ "count_mode_unread": "Yalnızca okunmamış",
+ "count_mode_all": "Okunmamış ve toplam",
+ "count_mode_none": "Sayaç yok",
"delete": "Sil",
"confirm_delete": "\"{name}\" klasörünü silmek istediğinizden emin misiniz? Bu klasördeki e-postalar Çöp Kutusu'na taşınacak.",
"create": "Oluştur",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 580e25b0f..af5af58f2 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "Назва папки",
"rename": "Перейменувати",
"change_icon": "Змінити значок",
+ "count_mode": "Лічильник для {name}",
+ "count_mode_default": "Лічильник: типово",
+ "count_mode_unread": "Лише непрочитані",
+ "count_mode_all": "Непрочитані й усього",
+ "count_mode_none": "Без лічильника",
"delete": "Видалити",
"confirm_delete": "Ви впевнені, що хочете видалити \"{name}\"? Електронні листи в цій папці буде переміщено в кошик.",
"create": "Створити",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 593b9cfec..dbbc098a1 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -2040,6 +2040,11 @@
"new_folder_name": "信件匣名稱",
"rename": "重新命名",
"change_icon": "變更圖示",
+ "count_mode": "{name} 的計數",
+ "count_mode_default": "計數:預設",
+ "count_mode_unread": "僅未讀",
+ "count_mode_all": "未讀與總數",
+ "count_mode_none": "不顯示計數",
"delete": "刪除",
"confirm_delete": "您確定要刪除「{name}」嗎?此信件匣中的郵件將移至垃圾桶。",
"create": "建立",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index b8b0ab9c1..1f3fc3546 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -2033,6 +2033,11 @@
"new_folder_name": "文件夹名称",
"rename": "重命名",
"change_icon": "更改图标",
+ "count_mode": "{name} 的计数",
+ "count_mode_default": "计数:默认",
+ "count_mode_unread": "仅未读",
+ "count_mode_all": "未读和总数",
+ "count_mode_none": "不显示计数",
"delete": "删除",
"confirm_delete": "您确定要删除\"{name}\"吗?此文件夹中的邮件将被移至\"已删除\"。",
"create": "创建",
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index f5e663ee5..badd12081 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -11,6 +11,7 @@ import { apiFetch } from '@/lib/browser-navigation';
import { settingsHooks } from '@/lib/plugin-hooks';
import { generateAccountId } from '@/lib/account-utils';
import { subscriptionOwnerFor } from '@/lib/calendar-subscription-sync';
+import type { FolderCountMode } from '@/lib/folder-counts';
import { orderForMailbox, sanitizeSortLevels, type MessageListOrderScope, type SortLevel } from '@/lib/message-list-order';
import {
DEFAULT_SUB_ADDRESS_DELIMITER,
@@ -654,6 +655,7 @@ interface SettingsState {
// Folders
folderIcons: Record; // mailboxId -> icon name
+ folderCountModes: Record; // mailboxId -> counter override (#982)
// Keywords (labels/tags)
emailKeywords: KeywordDefinition[];
@@ -912,6 +914,7 @@ const DEFAULT_SETTINGS = {
// Folders
folderIcons: {} as Record,
+ folderCountModes: {} as Record,
// Keywords
emailKeywords: USING_MOCK_SERVER ? DEV_KEYWORDS : DEFAULT_KEYWORDS,
@@ -1238,6 +1241,7 @@ export const useSettingsStore = create()(
showSharedMailAccounts: state.showSharedMailAccounts,
hiddenSharedMailAccounts: state.hiddenSharedMailAccounts,
folderIcons: state.folderIcons,
+ folderCountModes: state.folderCountModes,
emailKeywords: state.emailKeywords,
nestedTags: state.nestedTags,
attachmentReminderEnabled: state.attachmentReminderEnabled,
From 481665b435da6dc82774c96329c5bd0ccb5f3e87 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 02:16:35 +0200
Subject: [PATCH 040/238] test: pick the role dropdown in the folder order test
next to the counter menus #982
---
.../__tests__/folder-settings-role-order.test.tsx | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/components/settings/__tests__/folder-settings-role-order.test.tsx b/components/settings/__tests__/folder-settings-role-order.test.tsx
index 348c9b23e..29f681e05 100644
--- a/components/settings/__tests__/folder-settings-role-order.test.tsx
+++ b/components/settings/__tests__/folder-settings-role-order.test.tsx
@@ -60,9 +60,13 @@ describe('FolderSettings role dropdown order (#984)', () => {
it('lists options in folder-tree order, children under their parent', () => {
render( );
- const selects = screen.getAllByRole('combobox');
- expect(selects.length).toBeGreaterThan(0);
- const values = within(selects[0] as HTMLElement)
+ // The role dropdowns list folder ids; the per-folder counter dropdowns
+ // (#982) sit on the folder rows and list modes instead.
+ const roleSelect = screen
+ .getAllByRole('combobox')
+ .find((s) => within(s).queryAllByRole('option').some((o) => (o as HTMLOptionElement).value === 'inbox'));
+ expect(roleSelect).toBeDefined();
+ const values = within(roleSelect as HTMLElement)
.getAllByRole('option')
.map((o) => (o as HTMLOptionElement).value)
.filter((v) => v !== '');
From b611d9c028881036066f03eaaecc069a6e258c2d Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:38:52 +0200
Subject: [PATCH 041/238] feat: refresh sender icons that changed on the
sender's website #960
---
app/api/favicon/route.ts | 29 ++++++++-
components/settings/appearance-settings.tsx | 19 +++++-
components/ui/__tests__/avatar.test.tsx | 20 +++++-
components/ui/avatar.tsx | 12 ++--
lib/__tests__/favicon-route-refresh.test.ts | 68 +++++++++++++++++++++
locales/ar/common.json | 6 +-
locales/ca/common.json | 6 +-
locales/cs/common.json | 6 +-
locales/da/common.json | 6 +-
locales/de/common.json | 6 +-
locales/en/common.json | 6 +-
locales/es/common.json | 6 +-
locales/fa/common.json | 6 +-
locales/fr/common.json | 6 +-
locales/he/common.json | 6 +-
locales/hu/common.json | 6 +-
locales/it/common.json | 6 +-
locales/ja/common.json | 6 +-
locales/ko/common.json | 6 +-
locales/lv/common.json | 6 +-
locales/mn/common.json | 6 +-
locales/nb/common.json | 6 +-
locales/nl/common.json | 6 +-
locales/pl/common.json | 6 +-
locales/pt/common.json | 6 +-
locales/ro/common.json | 6 +-
locales/ru/common.json | 6 +-
locales/sk/common.json | 6 +-
locales/tr/common.json | 6 +-
locales/uk/common.json | 6 +-
locales/zh-TW/common.json | 6 +-
locales/zh/common.json | 6 +-
stores/favicon-refresh-store.ts | 29 +++++++++
33 files changed, 304 insertions(+), 35 deletions(-)
create mode 100644 lib/__tests__/favicon-route-refresh.test.ts
create mode 100644 stores/favicon-refresh-store.ts
diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts
index 41d292c1e..3c817c6f6 100644
--- a/app/api/favicon/route.ts
+++ b/app/api/favicon/route.ts
@@ -18,6 +18,9 @@ const cache = new Map();
const negativeCache = new Map();
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
const NEGATIVE_CACHE_MAX_SIZE = 2000;
+// A refresh request (?v=) refetches a domain at most this often, so the
+// unauthenticated route can't be used to hammer the upstream icon service.
+const MIN_REFRESH_INTERVAL_MS = 60 * 1000;
// 1x1 transparent PNG. Returned with HTTP 200 (instead of 404) when no
// favicon exists for a domain, so the browser's tag loads it cleanly
@@ -444,16 +447,17 @@ export async function GET(request: NextRequest) {
// Resolve to root domain so subdomains share the same favicon lookup
const normalizedDomain = getRootDomain(domain.toLowerCase());
+ const refreshAfter = parseRefreshAfter(request.nextUrl.searchParams.get('v'));
// Check negative cache (domains known to have no favicon)
const neg = negativeCache.get(normalizedDomain);
- if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
+ if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS && !isStale(neg.fetchedAt, refreshAfter)) {
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
}
// Check cache
const cached = cache.get(normalizedDomain);
- if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS && !isStale(cached.fetchedAt, refreshAfter)) {
return new NextResponse(cached.data, {
headers: faviconHeaders(cached.contentType),
});
@@ -490,6 +494,7 @@ export async function GET(request: NextRequest) {
}
// Cache the result
+ negativeCache.delete(normalizedDomain);
evictOldest();
cache.set(normalizedDomain, { data, contentType, fetchedAt: Date.now() });
@@ -497,6 +502,10 @@ export async function GET(request: NextRequest) {
headers: faviconHeaders(contentType),
});
} catch {
+ // A refresh that can't reach upstream keeps showing the icon we have.
+ if (cached) {
+ return new NextResponse(cached.data, { headers: faviconHeaders(cached.contentType) });
+ }
return new NextResponse(null, {
status: 502,
headers: { 'Cache-Control': 'public, max-age=300' }, // 5 min
@@ -504,6 +513,22 @@ export async function GET(request: NextRequest) {
}
}
+/**
+ * The time from a ?v= refresh stamp, or null. Avatars send the moment the
+ * user asked for fresh sender icons (#960); a stamp in the future is clamped
+ * to now so it can't keep a domain permanently uncached.
+ */
+function parseRefreshAfter(value: string | null): number | null {
+ if (!value || !/^\d{1,15}$/.test(value)) return null;
+ return Math.min(Number(value), Date.now());
+}
+
+/** Whether a cache entry predates the refresh the client asked for. */
+function isStale(fetchedAt: number, refreshAfter: number | null): boolean {
+ if (refreshAfter === null || fetchedAt >= refreshAfter) return false;
+ return Date.now() - fetchedAt >= MIN_REFRESH_INTERVAL_MS;
+}
+
const RASTER_FAVICON_TYPES = new Set([
'image/x-icon',
'image/vnd.microsoft.icon',
diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx
index 09cf63087..df3020751 100644
--- a/components/settings/appearance-settings.tsx
+++ b/components/settings/appearance-settings.tsx
@@ -7,10 +7,12 @@ import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settin
import { cn } from '@/lib/utils';
import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
-import { PlayCircle } from '@/components/icons';
+import { PlayCircle, RefreshCw } from '@/components/icons';
import { usePolicyStore } from '@/stores/policy-store';
import { IS_LITE } from '@/lib/lite';
import { MessageListOrderSettings } from './message-list-order-settings';
+import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store';
+import { toast } from '@/stores/toast-store';
const DENSITY_PREVIEW: Record = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
@@ -71,6 +73,7 @@ export function AppearanceSettings() {
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, showOnboardingOnNewDevices, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
+ const refreshFavicons = useFaviconRefreshStore((s) => s.refresh);
const { isSettingLocked, isSettingHidden } = usePolicyStore();
return (
@@ -137,6 +140,20 @@ export function AppearanceSettings() {
)}
+ {!IS_LITE && senderFavicons && (
+
+ { refreshFavicons(); toast.success(tAdvanced('sender_favicons.refresh_done')); }}
+ className="text-xs h-7"
+ >
+
+ {tAdvanced('sender_favicons.refresh_button')}
+
+
+ )}
+
updateSetting('showAvatarsInJunk', checked)} />
diff --git a/components/ui/__tests__/avatar.test.tsx b/components/ui/__tests__/avatar.test.tsx
index 14267b86c..63da44c33 100644
--- a/components/ui/__tests__/avatar.test.tsx
+++ b/components/ui/__tests__/avatar.test.tsx
@@ -1,6 +1,7 @@
-import { render } from '@testing-library/react';
+import { act, fireEvent, render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Avatar } from '../avatar';
+import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store';
describe('Avatar', () => {
it('renders initials from full name', () => {
@@ -30,4 +31,21 @@ describe('Avatar', () => {
const colorB = (b.firstChild as HTMLElement).style.backgroundColor;
expect(colorA).toBe(colorB);
});
+
+ // #960: a refresh must reach past the browser's two-week cache of the old
+ // icon and retry domains whose favicon failed before.
+ it('requests the sender favicon again after a refresh', () => {
+ useFaviconRefreshStore.setState({ refreshedAt: 0 });
+ const { container } = render( );
+ const img = () => container.querySelector('img');
+ expect(img()?.getAttribute('src')).toBe('/api/favicon?domain=example.cz');
+
+ fireEvent.error(img()!);
+ expect(img()).toBeNull();
+
+ act(() => useFaviconRefreshStore.getState().refresh());
+ const stamp = useFaviconRefreshStore.getState().refreshedAt;
+ expect(stamp).toBeGreaterThan(0);
+ expect(img()?.getAttribute('src')).toBe(`/api/favicon?domain=example.cz&v=${stamp}`);
+ });
});
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
index 2478c78a6..82f10828d 100644
--- a/components/ui/avatar.tsx
+++ b/components/ui/avatar.tsx
@@ -8,6 +8,7 @@ import { useConfig } from "@/hooks/use-config";
import { avatarHooks } from "@/lib/plugin-hooks";
import { withBasePath } from "@/lib/browser-navigation";
import { IS_LITE } from "@/lib/lite";
+import { useFaviconRefreshStore, failedFaviconDomains } from "@/stores/favicon-refresh-store";
const IS_DEV = process.env.NODE_ENV !== "production";
@@ -63,9 +64,6 @@ function getRootDomain(domain: string): string {
return parts.slice(-2).join(".");
}
-// Module-level cache of domains whose favicons failed to load.
-// Shared across all Avatar instances to avoid re-requesting known-bad domains.
-const failedFaviconDomains = new Set();
// Personal email domains where the favicon is the mail provider logo, not the sender
const PERSONAL_DOMAINS = new Set([
"gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com",
@@ -155,6 +153,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
const [pluginAvatarUrl, setPluginAvatarUrl] = useState(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
+ const faviconRefreshedAt = useFaviconRefreshStore((s) => s.refreshedAt);
const contacts = useContactStore((s) => s.contacts);
const { devMode } = useConfig();
@@ -171,6 +170,11 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
return () => { cancelled = true; };
}, [email, name]);
+ // A refresh of the sender icons gives a failed favicon another chance.
+ useEffect(() => {
+ setImgError(false);
+ }, [faviconRefreshedAt]);
+
// Look up contact photo by email from the contact store
const resolvedContactPhoto = useMemo(() => {
if (contactPhotoUri) return contactPhotoUri;
@@ -238,7 +242,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
- const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}`) : null;
+ const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}${faviconRefreshedAt ? `&v=${faviconRefreshedAt}` : ""}`) : null;
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
diff --git a/lib/__tests__/favicon-route-refresh.test.ts b/lib/__tests__/favicon-route-refresh.test.ts
new file mode 100644
index 000000000..d15ceba08
--- /dev/null
+++ b/lib/__tests__/favicon-route-refresh.test.ts
@@ -0,0 +1,68 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { NextRequest } from 'next/server';
+
+// #960: the route caches a domain's favicon for two weeks. A sender who
+// replaced their icon kept showing the old one; a ?v= refresh stamp newer
+// than the cached copy must fetch it again.
+
+const T0 = Date.UTC(2026, 9, 1);
+let now = T0;
+let icon = 1;
+const fetchMock = vi.fn(async () =>
+ new Response(new Uint8Array(64).fill(icon), { headers: { 'content-type': 'image/png' } }),
+);
+
+beforeEach(() => {
+ now = T0;
+ icon = 1;
+ fetchMock.mockClear();
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
+ vi.stubGlobal('fetch', fetchMock);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ vi.resetModules();
+});
+
+async function get(query: string) {
+ const { GET } = await import('@/app/api/favicon/route');
+ const res = await GET(new NextRequest(`https://webmail.example/api/favicon?${query}`));
+ return new Uint8Array(await res.arrayBuffer())[0];
+}
+
+describe('favicon route refresh', () => {
+ it('serves the cached icon until a newer refresh stamp arrives', async () => {
+ expect(await get('domain=example.cz')).toBe(1);
+
+ icon = 2; // the sender replaces their favicon
+ now = T0 + 60 * 60 * 1000;
+ expect(await get('domain=example.cz')).toBe(1);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ expect(await get(`domain=example.cz&v=${now}`)).toBe(2);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ // The fresh copy is cached again: same stamp, plain request, no refetch.
+ expect(await get(`domain=example.cz&v=${now}`)).toBe(2);
+ expect(await get('domain=example.cz')).toBe(2);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('refetches a domain at most once a minute, even with future stamps', async () => {
+ expect(await get('domain=example.cz')).toBe(1);
+ icon = 2;
+ now = T0 + 30 * 1000;
+ expect(await get(`domain=example.cz&v=${now + 10 ** 9}`)).toBe(1);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the cached icon when the refresh cannot reach upstream', async () => {
+ expect(await get('domain=example.cz')).toBe(1);
+ now = T0 + 60 * 60 * 1000;
+ fetchMock.mockRejectedValueOnce(new Error('offline'));
+ expect(await get(`domain=example.cz&v=${now}`)).toBe(1);
+ });
+});
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 5f37b3696..1789c06be 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "أيقونات مواقع المرسلين",
- "description": "إظهار أيقونات المواقع كصور شخصية للمرسلين من الشركات"
+ "description": "إظهار أيقونات المواقع كصور شخصية للمرسلين من الشركات",
+ "refresh_label": "تحديث أيقونات المرسلين",
+ "refresh_description": "جلب أيقونات المواقع مرة أخرى، مثلاً عندما يغيّر المرسل أيقونته",
+ "refresh_button": "تحديث",
+ "refresh_done": "سيتم جلب أيقونات المرسلين مرة أخرى"
},
"show_avatars_in_junk": {
"label": "إظهار الصور الشخصية في مجلد البريد المزعج",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 214dac12c..646530d1c 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "Icones dels remitents",
- "description": "Mostra icones de llocs web com a imatges de perfil per als remitents d'empresa"
+ "description": "Mostra icones de llocs web com a imatges de perfil per als remitents d'empresa",
+ "refresh_label": "Actualitza les icones dels remitents",
+ "refresh_description": "Torna a obtenir les icones dels llocs web, per exemple quan un remitent ha canviat la seva",
+ "refresh_button": "Actualitza",
+ "refresh_done": "Es tornaran a obtenir les icones dels remitents"
},
"show_avatars_in_junk": {
"label": "Mostra els avatars a la carpeta de brossa",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index ffdfc4d77..387d381f9 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Favikony odesílatelů",
- "description": "Zobrazovat ikony webových stránek jako profilové obrázky firemních odesílatelů"
+ "description": "Zobrazovat ikony webových stránek jako profilové obrázky firemních odesílatelů",
+ "refresh_label": "Obnovit ikony odesílatelů",
+ "refresh_description": "Znovu načíst ikony webů, například když odesílatel změnil svou ikonu",
+ "refresh_button": "Obnovit",
+ "refresh_done": "Ikony odesílatelů se načtou znovu"
},
"show_avatars_in_junk": {
"label": "Zobrazovat avatary ve spamu",
diff --git a/locales/da/common.json b/locales/da/common.json
index a35a306a3..24adbcaa3 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -2090,7 +2090,11 @@
},
"sender_favicons": {
"label": "Afsender-favicons",
- "description": "Vis hjemmesideikoner som profilbilleder for forretningsafsendere"
+ "description": "Vis hjemmesideikoner som profilbilleder for forretningsafsendere",
+ "refresh_label": "Opdater afsenderikoner",
+ "refresh_description": "Hent webstedsikonerne igen, f.eks. når en afsender har skiftet sit ikon",
+ "refresh_button": "Opdater",
+ "refresh_done": "Afsenderikonerne hentes igen"
},
"show_avatars_in_junk": {
"label": "Vis avatarer i spam-mappe",
diff --git a/locales/de/common.json b/locales/de/common.json
index 0efab2a2c..3832be9df 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Absender-Favicons",
- "description": "Website-Symbole als Profilbilder für geschäftliche Absender anzeigen"
+ "description": "Website-Symbole als Profilbilder für geschäftliche Absender anzeigen",
+ "refresh_label": "Absender-Symbole aktualisieren",
+ "refresh_description": "Website-Symbole neu laden, zum Beispiel wenn ein Absender sein Symbol geändert hat",
+ "refresh_button": "Aktualisieren",
+ "refresh_done": "Absender-Symbole werden neu geladen"
},
"show_avatars_in_junk": {
"label": "Avatare im Spam-Ordner anzeigen",
diff --git a/locales/en/common.json b/locales/en/common.json
index 102ed0f14..4b3d3e2ce 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "Sender Favicons",
- "description": "Show website icons as profile pictures for business senders"
+ "description": "Show website icons as profile pictures for business senders",
+ "refresh_label": "Refresh Sender Icons",
+ "refresh_description": "Fetch the website icons again, for example when a sender changed theirs",
+ "refresh_button": "Refresh",
+ "refresh_done": "Sender icons will be fetched again"
},
"show_avatars_in_junk": {
"label": "Show Avatars in Junk Folder",
diff --git a/locales/es/common.json b/locales/es/common.json
index c8ff4d650..ebc26351b 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Favicons de remitente",
- "description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales"
+ "description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales",
+ "refresh_label": "Actualizar iconos de remitentes",
+ "refresh_description": "Volver a obtener los iconos de los sitios web, por ejemplo cuando un remitente ha cambiado el suyo",
+ "refresh_button": "Actualizar",
+ "refresh_done": "Los iconos de los remitentes se volverán a obtener"
},
"show_avatars_in_junk": {
"label": "Mostrar avatares en la carpeta de spam",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 105cdc629..40782c9f8 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "Favicon فرستنده",
- "description": "نمایش آیکون وبسایت به عنوان تصویر پروفایل فرستندگان تجاری"
+ "description": "نمایش آیکون وبسایت به عنوان تصویر پروفایل فرستندگان تجاری",
+ "refresh_label": "بازخوانی آیکون فرستندگان",
+ "refresh_description": "آیکون وبسایتها را دوباره دریافت کن، مثلاً وقتی فرستنده آیکون خود را تغییر داده است",
+ "refresh_button": "بازخوانی",
+ "refresh_done": "آیکون فرستندگان دوباره دریافت میشود"
},
"show_avatars_in_junk": {
"label": "نمایش آواتار در پوشه هرزنامه",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 4f57757e4..4c9e05c1e 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Favicons des expéditeurs",
- "description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels"
+ "description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels",
+ "refresh_label": "Actualiser les icônes des expéditeurs",
+ "refresh_description": "Récupérer à nouveau les icônes des sites web, par exemple quand un expéditeur a changé la sienne",
+ "refresh_button": "Actualiser",
+ "refresh_done": "Les icônes des expéditeurs vont être récupérées à nouveau"
},
"show_avatars_in_junk": {
"label": "Afficher les avatars dans le dossier indésirable",
diff --git a/locales/he/common.json b/locales/he/common.json
index a62813eb8..bd8fd8b74 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -2052,7 +2052,11 @@
},
"sender_favicons": {
"label": "סמלים של השולח (ניסיוני)",
- "description": "הצג סמלי אתרים כתמונות פרופיל עבור שולחים עסקיים"
+ "description": "הצג סמלי אתרים כתמונות פרופיל עבור שולחים עסקיים",
+ "refresh_label": "רענון סמלי שולחים",
+ "refresh_description": "טעינה מחדש של סמלי האתרים, למשל כששולח החליף את הסמל שלו",
+ "refresh_button": "רענון",
+ "refresh_done": "סמלי השולחים ייטענו מחדש"
},
"sender_avatars": {},
"keyboard_shortcuts": {
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 280aab4bf..8fd6531a1 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -2090,7 +2090,11 @@
},
"sender_favicons": {
"label": "Feladó faviconok",
- "description": "Weboldal ikonok megjelenítése profilképekként az üzleti feladók számára"
+ "description": "Weboldal ikonok megjelenítése profilképekként az üzleti feladók számára",
+ "refresh_label": "Feladóikonok frissítése",
+ "refresh_description": "A webhelyikonok újbóli letöltése, például ha egy feladó lecserélte a sajátját",
+ "refresh_button": "Frissítés",
+ "refresh_done": "A feladóikonok újra le lesznek töltve"
},
"show_avatars_in_junk": {
"label": "Avatárok mutatása a Spam mappában",
diff --git a/locales/it/common.json b/locales/it/common.json
index 7e782f095..794d42c7d 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Favicon dei mittenti",
- "description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali"
+ "description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali",
+ "refresh_label": "Aggiorna icone dei mittenti",
+ "refresh_description": "Scarica di nuovo le icone dei siti web, ad esempio quando un mittente ha cambiato la sua",
+ "refresh_button": "Aggiorna",
+ "refresh_done": "Le icone dei mittenti verranno scaricate di nuovo"
},
"show_avatars_in_junk": {
"label": "Mostra avatar nella cartella spam",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index eda9e4023..ec1791639 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "送信者ファビコン",
- "description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
+ "description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示",
+ "refresh_label": "送信者アイコンを更新",
+ "refresh_description": "送信者がアイコンを変更した場合などに、ウェブサイトのアイコンを再取得します",
+ "refresh_button": "更新",
+ "refresh_done": "送信者アイコンを再取得します"
},
"show_avatars_in_junk": {
"label": "迷惑メールフォルダでアバターを表示",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index dd681dfd2..8db0c3a2c 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "보낸 사람 파비콘",
- "description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요"
+ "description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요",
+ "refresh_label": "발신자 아이콘 새로 고침",
+ "refresh_description": "발신자가 아이콘을 바꾼 경우 등에 웹사이트 아이콘을 다시 가져옵니다",
+ "refresh_button": "새로 고침",
+ "refresh_done": "발신자 아이콘을 다시 가져옵니다"
},
"show_avatars_in_junk": {
"label": "스팸함에서 아바타 표시",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 32e7e55ed..7680004f8 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -2068,7 +2068,11 @@
},
"sender_favicons": {
"label": "Sūtītāju ikonas",
- "description": "Rādīt vietņu ikonas kā sūtītāju avatarus"
+ "description": "Rādīt vietņu ikonas kā sūtītāju avatarus",
+ "refresh_label": "Atsvaidzināt sūtītāju ikonas",
+ "refresh_description": "Vēlreiz ielādēt vietņu ikonas, piemēram, ja sūtītājs ir nomainījis savu ikonu",
+ "refresh_button": "Atsvaidzināt",
+ "refresh_done": "Sūtītāju ikonas tiks ielādētas vēlreiz"
},
"show_avatars_in_junk": {
"label": "Rādīt avatarus mēstuļu mapē",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index ec0c04933..e6bdf1ce7 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "Илгээгчийн Favicons",
- "description": "Вэб сайтын дүрсийг бизнес илгээгчдэд профайл зураг болгон харуулах"
+ "description": "Вэб сайтын дүрсийг бизнес илгээгчдэд профайл зураг болгон харуулах",
+ "refresh_label": "Илгээгчийн дүрсийг шинэчлэх",
+ "refresh_description": "Вэбсайтын дүрсийг дахин татах, жишээ нь илгээгч дүрсээ сольсон үед",
+ "refresh_button": "Шинэчлэх",
+ "refresh_done": "Илгээгчийн дүрсийг дахин татна"
},
"show_avatars_in_junk": {
"label": "Хогийн хавтас дахь аватаруудыг харуулах",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 6726269d1..65b65dd22 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "Nettstedsikoner for avsendere",
- "description": "Vis nettstedsikoner som profilbilder for bedriftsavsendere"
+ "description": "Vis nettstedsikoner som profilbilder for bedriftsavsendere",
+ "refresh_label": "Oppdater avsenderikoner",
+ "refresh_description": "Hent nettstedikonene på nytt, for eksempel når en avsender har byttet ikon",
+ "refresh_button": "Oppdater",
+ "refresh_done": "Avsenderikonene hentes på nytt"
},
"show_avatars_in_junk": {
"label": "Vis profilbilder i søppelpostmappen",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 12967b6f8..653b92374 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Afzender-favicons",
- "description": "Toon websitepictogrammen als profielfoto's voor zakelijke afzenders"
+ "description": "Toon websitepictogrammen als profielfoto's voor zakelijke afzenders",
+ "refresh_label": "Afzenderpictogrammen vernieuwen",
+ "refresh_description": "De websitepictogrammen opnieuw ophalen, bijvoorbeeld als een afzender het zijne heeft gewijzigd",
+ "refresh_button": "Vernieuwen",
+ "refresh_done": "Afzenderpictogrammen worden opnieuw opgehaald"
},
"show_avatars_in_junk": {
"label": "Avatars tonen in de map ongewenst",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 7ec43d247..93918746b 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Favikony nadawców",
- "description": "Pokazuj ikony stron internetowych jako zdjęcia profilowe nadawców firmowych"
+ "description": "Pokazuj ikony stron internetowych jako zdjęcia profilowe nadawców firmowych",
+ "refresh_label": "Odśwież ikony nadawców",
+ "refresh_description": "Pobierz ponownie ikony stron, na przykład gdy nadawca zmienił swoją ikonę",
+ "refresh_button": "Odśwież",
+ "refresh_done": "Ikony nadawców zostaną pobrane ponownie"
},
"show_avatars_in_junk": {
"label": "Pokazuj awatary w folderze spam",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 15e8e49b8..da81c662a 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Favicons de remetente",
- "description": "Exibir ícones de sites como fotos de perfil para remetentes empresariais"
+ "description": "Exibir ícones de sites como fotos de perfil para remetentes empresariais",
+ "refresh_label": "Atualizar ícones dos remetentes",
+ "refresh_description": "Obter novamente os ícones dos sites, por exemplo quando um remetente alterou o seu",
+ "refresh_button": "Atualizar",
+ "refresh_done": "Os ícones dos remetentes serão obtidos novamente"
},
"show_avatars_in_junk": {
"label": "Mostrar avatares na pasta de spam",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index a281787df..d8ef29c9e 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "Favicon-uri ale expeditorului",
- "description": "Afișați pictogramele site-urilor web ca imagini de profil pentru expeditorii de afaceri"
+ "description": "Afișați pictogramele site-urilor web ca imagini de profil pentru expeditorii de afaceri",
+ "refresh_label": "Reîmprospătează pictogramele expeditorilor",
+ "refresh_description": "Descarcă din nou pictogramele site-urilor, de exemplu când un expeditor și-a schimbat pictograma",
+ "refresh_button": "Reîmprospătează",
+ "refresh_done": "Pictogramele expeditorilor vor fi descărcate din nou"
},
"show_avatars_in_junk": {
"label": "Afișați avatarurile din folderul „Junk”",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 3941e248e..49f609c7a 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Фавиконы отправителей",
- "description": "Показывать иконки сайтов как аватары для корпоративных отправителей"
+ "description": "Показывать иконки сайтов как аватары для корпоративных отправителей",
+ "refresh_label": "Обновить значки отправителей",
+ "refresh_description": "Заново загрузить значки сайтов, например если отправитель сменил свой",
+ "refresh_button": "Обновить",
+ "refresh_done": "Значки отправителей будут загружены заново"
},
"show_avatars_in_junk": {
"label": "Показывать аватары в папке «Спам»",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 8a7730411..967d8ccce 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "Favikony odosielateľov",
- "description": "Zobrazovať ikony webových stránok ako profilové obrázky firemných odosielateľov"
+ "description": "Zobrazovať ikony webových stránok ako profilové obrázky firemných odosielateľov",
+ "refresh_label": "Obnoviť ikony odosielateľov",
+ "refresh_description": "Znova načítať ikony webov, napríklad keď odosielateľ zmenil svoju ikonu",
+ "refresh_button": "Obnoviť",
+ "refresh_done": "Ikony odosielateľov sa načítajú znova"
},
"show_avatars_in_junk": {
"label": "Zobrazovať avatáry v spame",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 74c700a4e..e360c1795 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Gönderici Favorikonları",
- "description": "İş göndericileri için profil resmi olarak web sitesi simgelerini göster"
+ "description": "İş göndericileri için profil resmi olarak web sitesi simgelerini göster",
+ "refresh_label": "Gönderen simgelerini yenile",
+ "refresh_description": "Web sitesi simgelerini yeniden al, örneğin bir gönderen simgesini değiştirdiğinde",
+ "refresh_button": "Yenile",
+ "refresh_done": "Gönderen simgeleri yeniden alınacak"
},
"show_avatars_in_junk": {
"label": "İstem Dışı Klasöründe Avatarları Göster",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index af5af58f2..70ac98e37 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Favicons відправника",
- "description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників"
+ "description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників",
+ "refresh_label": "Оновити значки відправників",
+ "refresh_description": "Заново завантажити значки сайтів, наприклад якщо відправник змінив свій",
+ "refresh_button": "Оновити",
+ "refresh_done": "Значки відправників буде завантажено заново"
},
"show_avatars_in_junk": {
"label": "Показувати аватари в папці «Спам»",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index dbbc098a1..004f12c2d 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "寄件者圖示",
- "description": "使用企業寄件者的網站圖示作為頭像"
+ "description": "使用企業寄件者的網站圖示作為頭像",
+ "refresh_label": "重新整理寄件者圖示",
+ "refresh_description": "重新取得網站圖示,例如寄件者更換了圖示時",
+ "refresh_button": "重新整理",
+ "refresh_done": "將重新取得寄件者圖示"
},
"show_avatars_in_junk": {
"label": "在垃圾郵件信件匣顯示頭像",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 1f3fc3546..1b079b2f4 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "发件人图标",
- "description": "使用企业发件人的网站图标作为头像"
+ "description": "使用企业发件人的网站图标作为头像",
+ "refresh_label": "刷新发件人图标",
+ "refresh_description": "重新获取网站图标,例如发件人更换了图标时",
+ "refresh_button": "刷新",
+ "refresh_done": "将重新获取发件人图标"
},
"show_avatars_in_junk": {
"label": "在垃圾邮件文件夹中显示头像",
diff --git a/stores/favicon-refresh-store.ts b/stores/favicon-refresh-store.ts
new file mode 100644
index 000000000..f6087774b
--- /dev/null
+++ b/stores/favicon-refresh-store.ts
@@ -0,0 +1,29 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+// Domains whose favicon failed to load in this tab. Avatars skip them so a
+// missing icon isn't requested again on every render.
+export const failedFaviconDomains = new Set();
+
+interface FaviconRefreshState {
+ /** When the user last asked for fresh sender icons (ms), 0 if never. */
+ refreshedAt: number;
+ refresh: () => void;
+}
+
+// Sender favicons are cached for two weeks by the browser and by the
+// /api/favicon proxy. Refreshing stamps a new time that avatars append to the
+// favicon URL (so the browser and any proxy in between miss their cache) and
+// that the route compares against its own cache entry (issue #960).
+export const useFaviconRefreshStore = create()(
+ persist(
+ (set) => ({
+ refreshedAt: 0,
+ refresh: () => {
+ failedFaviconDomains.clear();
+ set({ refreshedAt: Date.now() });
+ },
+ }),
+ { name: 'favicon-refresh', partialize: (s) => ({ refreshedAt: s.refreshedAt }) },
+ ),
+);
From 03031fcb13430afe12b9e9d60c1cc0409ef4823f Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:41:18 +0200
Subject: [PATCH 042/238] feat: jump to the next unread message with n #514
---
components/mail/mail-app.tsx | 12 ++++++
.../__tests__/use-keyboard-shortcuts.test.ts | 42 +++++++++++++++++-
hooks/use-keyboard-shortcuts.ts | 11 +++++
lib/__tests__/next-unread-email.test.ts | 43 +++++++++++++++++++
lib/next-unread-email.ts | 19 ++++++++
locales/ar/common.json | 4 +-
locales/ca/common.json | 4 +-
locales/cs/common.json | 4 +-
locales/da/common.json | 4 +-
locales/de/common.json | 4 +-
locales/en/common.json | 4 +-
locales/es/common.json | 4 +-
locales/fa/common.json | 4 +-
locales/fr/common.json | 4 +-
locales/he/common.json | 4 +-
locales/hu/common.json | 4 +-
locales/it/common.json | 4 +-
locales/ja/common.json | 4 +-
locales/ko/common.json | 4 +-
locales/lv/common.json | 4 +-
locales/mn/common.json | 4 +-
locales/nb/common.json | 4 +-
locales/nl/common.json | 4 +-
locales/pl/common.json | 4 +-
locales/pt/common.json | 4 +-
locales/ro/common.json | 4 +-
locales/ru/common.json | 4 +-
locales/sk/common.json | 4 +-
locales/tr/common.json | 4 +-
locales/uk/common.json | 4 +-
locales/zh-TW/common.json | 4 +-
locales/zh/common.json | 4 +-
32 files changed, 207 insertions(+), 28 deletions(-)
create mode 100644 lib/__tests__/next-unread-email.test.ts
create mode 100644 lib/next-unread-email.ts
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 2672d322e..912964b73 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -53,6 +53,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePaneSize } from "@/hooks/use-pane-size";
import { usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
+import { findNextUnreadIndex } from "@/lib/next-unread-email";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
@@ -849,6 +850,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (mvShell) setCursorOnly(true);
}
},
+ // `n`: open the next unread message below the current one, in the order
+ // the list has loaded. Only the loaded rows count; say so when none is
+ // left instead of wrapping to the top. (#514)
+ onNextUnread: () => {
+ const nextIndex = findNextUnreadIndex(activeEmails, selectedEmail?.id);
+ if (nextIndex === -1) {
+ toast.info(t('shortcuts.navigation.no_more_unread'));
+ return;
+ }
+ void handleEmailSelect(activeEmails[nextIndex]);
+ },
onOpenEmail: () => {
// Outside Mountain View the email is already open by virtue of being
// selected; under it this is the step that promotes cursor to open.
diff --git a/hooks/__tests__/use-keyboard-shortcuts.test.ts b/hooks/__tests__/use-keyboard-shortcuts.test.ts
index 9d20d9e0b..4e75a0dc4 100644
--- a/hooks/__tests__/use-keyboard-shortcuts.test.ts
+++ b/hooks/__tests__/use-keyboard-shortcuts.test.ts
@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
-import { useKeyboardShortcuts } from '../use-keyboard-shortcuts';
+import { useKeyboardShortcuts, KEYBOARD_SHORTCUTS } from '../use-keyboard-shortcuts';
// #683: the help modal advertises `x` for thread expansion and the hook binds
// it; the handler must actually be invoked when a message is selected.
@@ -90,3 +90,43 @@ describe('useKeyboardShortcuts: Shift + arrow extends the selection', () => {
expect(onExtendSelection).not.toHaveBeenCalled();
});
});
+
+// #514: `n` jumps to the next unread message, with or without one open.
+describe('useKeyboardShortcuts: n opens the next unread message', () => {
+ function pressN(shiftKey = false) {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: shiftKey ? 'N' : 'n', code: 'KeyN', shiftKey, bubbles: true }));
+ }
+
+ it('calls onNextUnread with a message open', () => {
+ const onNextUnread = vi.fn();
+ renderHook(() =>
+ useKeyboardShortcuts({ enabled: true, emails: [], selectedEmailId: 'e1', handlers: { onNextUnread } }),
+ );
+
+ pressN();
+
+ expect(onNextUnread).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onNextUnread with nothing open', () => {
+ const onNextUnread = vi.fn();
+ renderHook(() => useKeyboardShortcuts({ enabled: true, emails: [], handlers: { onNextUnread } }));
+
+ pressN();
+
+ expect(onNextUnread).toHaveBeenCalledTimes(1);
+ });
+
+ it('ignores Shift + N', () => {
+ const onNextUnread = vi.fn();
+ renderHook(() => useKeyboardShortcuts({ enabled: true, emails: [], handlers: { onNextUnread } }));
+
+ pressN(true);
+
+ expect(onNextUnread).not.toHaveBeenCalled();
+ });
+
+ it('is listed in the help modal under navigation', () => {
+ expect(KEYBOARD_SHORTCUTS.navigation).toContainEqual({ key: 'n', description: 'shortcuts.navigation.next_unread' });
+ });
+});
diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts
index 30e9de777..06c81e3e7 100644
--- a/hooks/use-keyboard-shortcuts.ts
+++ b/hooks/use-keyboard-shortcuts.ts
@@ -12,6 +12,8 @@ export interface KeyboardShortcutHandlers {
onPreviousEmail?: () => void;
onOpenEmail?: () => void;
onCloseEmail?: () => void;
+ /** `n`: open the next unread message below the current one (#514). */
+ onNextUnread?: () => void;
// Email actions
onReply?: () => void;
@@ -319,6 +321,14 @@ export function useKeyboardShortcuts({
run("toggle-thread-expansion", h.onToggleThreadExpansion);
}
break;
+
+ // Navigation: next unread, as in Thunderbird (#514)
+ case "n":
+ if (!event.shiftKey) {
+ event.preventDefault();
+ run("next-unread", h.onNextUnread);
+ }
+ break;
}
},
[selectedEmailId, selectionCount, mountainViewKeys]
@@ -387,6 +397,7 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "k / ↑", description: "shortcuts.navigation.previous_email" },
{ key: "Enter / o", description: "shortcuts.navigation.open_email" },
{ key: "Esc", description: "shortcuts.navigation.close_email" },
+ { key: "n", description: "shortcuts.navigation.next_unread" },
{ key: "g → i / s / t / d / a", description: "shortcuts.navigation.go_to_folder", layout: "mountain-view" },
],
actions: [
diff --git a/lib/__tests__/next-unread-email.test.ts b/lib/__tests__/next-unread-email.test.ts
new file mode 100644
index 000000000..48b5c3f8e
--- /dev/null
+++ b/lib/__tests__/next-unread-email.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { findNextUnreadIndex } from '../next-unread-email';
+
+// #514: `n` opens the next unread message below the current one.
+
+const mail = (id: string, seen: boolean): { id: string; keywords: Record } =>
+ ({ id, keywords: seen ? { $seen: true } : {} });
+
+describe('findNextUnreadIndex', () => {
+ const list = [mail('a', false), mail('b', true), mail('c', true), mail('d', false), mail('e', true)];
+
+ it('finds the first unread message after the current one', () => {
+ expect(findNextUnreadIndex(list, 'b')).toBe(3);
+ });
+
+ it('skips the current message even when it is unread', () => {
+ expect(findNextUnreadIndex(list, 'a')).toBe(3);
+ });
+
+ it('starts at the top when nothing is open', () => {
+ expect(findNextUnreadIndex(list, undefined)).toBe(0);
+ expect(findNextUnreadIndex(list, null)).toBe(0);
+ });
+
+ it('starts at the top when the open message is not in the list', () => {
+ expect(findNextUnreadIndex(list, 'gone')).toBe(0);
+ });
+
+ it('does not wrap around to unread mail above', () => {
+ expect(findNextUnreadIndex(list, 'd')).toBe(-1);
+ expect(findNextUnreadIndex(list, 'e')).toBe(-1);
+ });
+
+ it('treats a message without keywords as unread', () => {
+ const rows = [mail('a', true), { id: 'b' } as { id: string; keywords: Record }];
+ expect(findNextUnreadIndex(rows, 'a')).toBe(1);
+ });
+
+ it('returns -1 for an empty or all-read list', () => {
+ expect(findNextUnreadIndex([], undefined)).toBe(-1);
+ expect(findNextUnreadIndex([mail('a', true), mail('b', true)], undefined)).toBe(-1);
+ });
+});
diff --git a/lib/next-unread-email.ts b/lib/next-unread-email.ts
new file mode 100644
index 000000000..58bbe268d
--- /dev/null
+++ b/lib/next-unread-email.ts
@@ -0,0 +1,19 @@
+import type { Email } from "@/lib/jmap/types";
+
+/**
+ * Index of the first unread message after `currentId` in the loaded list, or
+ * -1 when there is none below it. With nothing open (or the open message no
+ * longer in the list) the search starts at the top. It does not wrap around:
+ * unread mail above the current one is left for the user to scroll back to.
+ * (#514)
+ */
+export function findNextUnreadIndex(
+ emails: readonly Pick[],
+ currentId: string | null | undefined,
+): number {
+ const currentIndex = currentId ? emails.findIndex((e) => e.id === currentId) : -1;
+ for (let i = currentIndex + 1; i < emails.length; i++) {
+ if (!emails[i].keywords?.$seen) return i;
+ }
+ return -1;
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 1789c06be..52e7c4ae3 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "الرسالة السابقة",
"open_email": "فتح الرسالة",
"close_email": "إغلاق / إلغاء التحديد",
- "go_to_folder": "الانتقال إلى المجلد"
+ "go_to_folder": "الانتقال إلى المجلد",
+ "next_unread": "الرسالة غير المقروءة التالية",
+ "no_more_unread": "لا توجد رسائل غير مقروءة أسفل القائمة"
},
"actions": {
"reply": "رد",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 646530d1c..b66f0d338 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Correu anterior",
"open_email": "Obre el correu",
"close_email": "Tanca / Desselecciona",
- "go_to_folder": "Vés a la carpeta"
+ "go_to_folder": "Vés a la carpeta",
+ "next_unread": "Següent correu no llegit",
+ "no_more_unread": "No hi ha més correus no llegits més avall a la llista"
},
"actions": {
"reply": "Respon",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 387d381f9..d9f14d837 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Předchozí zpráva",
"open_email": "Otevřít zprávu",
"close_email": "Zavřít / Zrušit výběr",
- "go_to_folder": "Přejít do složky"
+ "go_to_folder": "Přejít do složky",
+ "next_unread": "Další nepřečtený e-mail",
+ "no_more_unread": "Níže v seznamu už nejsou žádné nepřečtené e-maily"
},
"actions": {
"reply": "Odpovědět",
diff --git a/locales/da/common.json b/locales/da/common.json
index 24adbcaa3..c406e216e 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Forrige e-mail",
"open_email": "Åbn e-mail",
"close_email": "Luk / Fravaælg",
- "go_to_folder": "Gå til mappe"
+ "go_to_folder": "Gå til mappe",
+ "next_unread": "Næste ulæste e-mail",
+ "no_more_unread": "Der er ikke flere ulæste e-mails længere nede på listen"
},
"actions": {
"reply": "Svar",
diff --git a/locales/de/common.json b/locales/de/common.json
index 3832be9df..df865c10d 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Vorherige E-Mail",
"open_email": "E-Mail öffnen",
"close_email": "Schließen / Auswahl aufheben",
- "go_to_folder": "Zum Ordner springen"
+ "go_to_folder": "Zum Ordner springen",
+ "next_unread": "Nächste ungelesene E-Mail",
+ "no_more_unread": "Weiter unten in der Liste gibt es keine ungelesenen E-Mails"
},
"actions": {
"reply": "Antworten",
diff --git a/locales/en/common.json b/locales/en/common.json
index 4b3d3e2ce..89021953b 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Previous email",
"open_email": "Open email",
"close_email": "Close / Deselect",
- "go_to_folder": "Go to folder"
+ "go_to_folder": "Go to folder",
+ "next_unread": "Next unread email",
+ "no_more_unread": "No unread email further down the list"
},
"actions": {
"reply": "Reply",
diff --git a/locales/es/common.json b/locales/es/common.json
index ebc26351b..451be606f 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Correo anterior",
"open_email": "Abrir correo",
"close_email": "Cerrar / Deseleccionar",
- "go_to_folder": "Ir a la carpeta"
+ "go_to_folder": "Ir a la carpeta",
+ "next_unread": "Siguiente correo no leído",
+ "no_more_unread": "No hay más correos no leídos más abajo en la lista"
},
"actions": {
"reply": "Responder",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 40782c9f8..2803e2e97 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "ایمیل قبلی",
"open_email": "باز کردن ایمیل",
"close_email": "بستن / لغو انتخاب",
- "go_to_folder": "رفتن به پوشه"
+ "go_to_folder": "رفتن به پوشه",
+ "next_unread": "ایمیل خواندهنشده بعدی",
+ "no_more_unread": "ایمیل خواندهنشدهای پایینتر در فهرست نیست"
},
"actions": {
"reply": "پاسخ",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 4c9e05c1e..ae4ea1299 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Email précédent",
"open_email": "Ouvrir l'email",
"close_email": "Fermer / Désélectionner",
- "go_to_folder": "Aller au dossier"
+ "go_to_folder": "Aller au dossier",
+ "next_unread": "E-mail non lu suivant",
+ "no_more_unread": "Aucun e-mail non lu plus bas dans la liste"
},
"actions": {
"reply": "Répondre",
diff --git a/locales/he/common.json b/locales/he/common.json
index bd8fd8b74..55f9c4739 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -2533,7 +2533,9 @@
"previous_email": "אימייל קודם",
"open_email": "פתח אימייל",
"close_email": "סגור / בטל בחירה",
- "go_to_folder": "מעבר לתיקייה"
+ "go_to_folder": "מעבר לתיקייה",
+ "next_unread": "ההודעה הבאה שלא נקראה",
+ "no_more_unread": "אין הודעות שלא נקראו בהמשך הרשימה"
},
"actions": {
"reply": "השב",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 8fd6531a1..5622e3d09 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Előző e-mail",
"open_email": "E-mail megnyitása",
"close_email": "Bezárás / Kijelölés megszüntetése",
- "go_to_folder": "Ugrás mappára"
+ "go_to_folder": "Ugrás mappára",
+ "next_unread": "Következő olvasatlan e-mail",
+ "no_more_unread": "Lejjebb a listában nincs több olvasatlan e-mail"
},
"actions": {
"reply": "Válasz",
diff --git a/locales/it/common.json b/locales/it/common.json
index 794d42c7d..0bfa4e586 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Messaggio precedente",
"open_email": "Apri messaggio",
"close_email": "Chiudi / Deseleziona",
- "go_to_folder": "Vai alla cartella"
+ "go_to_folder": "Vai alla cartella",
+ "next_unread": "Prossima email non letta",
+ "no_more_unread": "Nessuna email non letta più in basso nell'elenco"
},
"actions": {
"reply": "Rispondi",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index ec1791639..5285f6b64 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "前のメール",
"open_email": "メールを開く",
"close_email": "閉じる / 選択解除",
- "go_to_folder": "フォルダーに移動"
+ "go_to_folder": "フォルダーに移動",
+ "next_unread": "次の未読メール",
+ "no_more_unread": "リストのこれより下に未読メールはありません"
},
"actions": {
"reply": "返信",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 8db0c3a2c..4946d2e3f 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "이전 메일",
"open_email": "메일 열기",
"close_email": "닫기 / 선택 해제",
- "go_to_folder": "폴더로 이동"
+ "go_to_folder": "폴더로 이동",
+ "next_unread": "다음 읽지 않은 메일",
+ "no_more_unread": "목록 아래쪽에 읽지 않은 메일이 더 없습니다"
},
"actions": {
"reply": "답장",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 7680004f8..dcd00a33d 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Iepriekšējā vēstule",
"open_email": "Atvērt vēstuli",
"close_email": "Aizvērt / Noņemt atlasi",
- "go_to_folder": "Pāriet uz mapi"
+ "go_to_folder": "Pāriet uz mapi",
+ "next_unread": "Nākamais nelasītais e-pasts",
+ "no_more_unread": "Sarakstā zemāk vairs nav nelasītu e-pastu"
},
"actions": {
"reply": "Atbildēt",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index e6bdf1ce7..f90defdcc 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Өмнөх имэйл",
"open_email": "Имэйл нээх",
"close_email": "Хаах / Сонголтыг цуцлах",
- "go_to_folder": "Хавтас руу очих"
+ "go_to_folder": "Хавтас руу очих",
+ "next_unread": "Дараагийн уншаагүй имэйл",
+ "no_more_unread": "Жагсаалтын доод хэсэгт уншаагүй имэйл алга"
},
"actions": {
"reply": "Хариулах",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 65b65dd22..aaa8816da 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Forrige e-post",
"open_email": "Åpne e-post",
"close_email": "Lukk / fjern valget",
- "go_to_folder": "Gå til mappe"
+ "go_to_folder": "Gå til mappe",
+ "next_unread": "Neste uleste e-post",
+ "no_more_unread": "Det er ingen flere uleste e-poster lenger ned i listen"
},
"actions": {
"reply": "Svar",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 653b92374..bef09aa9b 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Vorige e-mail",
"open_email": "E-mail openen",
"close_email": "Sluiten / Deselecteren",
- "go_to_folder": "Ga naar map"
+ "go_to_folder": "Ga naar map",
+ "next_unread": "Volgende ongelezen e-mail",
+ "no_more_unread": "Verderop in de lijst staan geen ongelezen e-mails meer"
},
"actions": {
"reply": "Beantwoorden",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 93918746b..a88c5b2b6 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Poprzednia wiadomość",
"open_email": "Otwórz wiadomość",
"close_email": "Zamknij / Odznacz",
- "go_to_folder": "Przejdź do folderu"
+ "go_to_folder": "Przejdź do folderu",
+ "next_unread": "Następna nieprzeczytana wiadomość",
+ "no_more_unread": "Niżej na liście nie ma już nieprzeczytanych wiadomości"
},
"actions": {
"reply": "Odpowiedz",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index da81c662a..844ed196d 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "E-mail anterior",
"open_email": "Abrir e-mail",
"close_email": "Fechar / Desselecionar",
- "go_to_folder": "Ir para a pasta"
+ "go_to_folder": "Ir para a pasta",
+ "next_unread": "Próximo e-mail não lido",
+ "no_more_unread": "Não há mais e-mails não lidos mais abaixo na lista"
},
"actions": {
"reply": "Responder",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index d8ef29c9e..0d346fba7 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "E-mailul anterior",
"open_email": "Deschide e-mailul",
"close_email": "Închide / Deselectează",
- "go_to_folder": "Mergi la dosar"
+ "go_to_folder": "Mergi la dosar",
+ "next_unread": "Următorul e-mail necitit",
+ "no_more_unread": "Nu mai sunt e-mailuri necitite mai jos în listă"
},
"actions": {
"reply": "Răspunde",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 49f609c7a..9c5b9a018 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Предыдущее письмо",
"open_email": "Открыть письмо",
"close_email": "Закрыть / Отменить выбор",
- "go_to_folder": "Перейти к папке"
+ "go_to_folder": "Перейти к папке",
+ "next_unread": "Следующее непрочитанное письмо",
+ "no_more_unread": "Ниже в списке больше нет непрочитанных писем"
},
"actions": {
"reply": "Ответить",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 967d8ccce..7cc66a8f3 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Predchádzajúci e-mail",
"open_email": "Otvoriť e-mail",
"close_email": "Zavrieť / Zrušiť výber",
- "go_to_folder": "Prejsť do priečinka"
+ "go_to_folder": "Prejsť do priečinka",
+ "next_unread": "Ďalší neprečítaný e-mail",
+ "no_more_unread": "Nižšie v zozname už nie sú žiadne neprečítané e-maily"
},
"actions": {
"reply": "Odpovedať",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index e360c1795..e68ef8d08 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Önceki e-posta",
"open_email": "E-postayı aç",
"close_email": "Kapat / Seçimi kaldır",
- "go_to_folder": "Klasöre git"
+ "go_to_folder": "Klasöre git",
+ "next_unread": "Sonraki okunmamış e-posta",
+ "no_more_unread": "Listenin aşağısında okunmamış e-posta yok"
},
"actions": {
"reply": "Yanıtla",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 70ac98e37..d83acfc7e 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "Попередній електронний лист",
"open_email": "Відкрийте електронну пошту",
"close_email": "Закрити / Скасувати вибір",
- "go_to_folder": "Перейти до теки"
+ "go_to_folder": "Перейти до теки",
+ "next_unread": "Наступний непрочитаний лист",
+ "no_more_unread": "Нижче в списку більше немає непрочитаних листів"
},
"actions": {
"reply": "Відповісти",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 004f12c2d..5e5e562bd 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "上一封郵件",
"open_email": "開啟郵件",
"close_email": "關閉/取消選取",
- "go_to_folder": "跳至資料夾"
+ "go_to_folder": "跳至資料夾",
+ "next_unread": "下一封未讀郵件",
+ "no_more_unread": "清單下方沒有更多未讀郵件"
},
"actions": {
"reply": "回覆",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 1b079b2f4..deb193881 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -2615,7 +2615,9 @@
"previous_email": "上一封邮件",
"open_email": "打开邮件",
"close_email": "关闭/取消选择",
- "go_to_folder": "跳转到文件夹"
+ "go_to_folder": "跳转到文件夹",
+ "next_unread": "下一封未读邮件",
+ "no_more_unread": "列表下方没有更多未读邮件"
},
"actions": {
"reply": "回复",
From 8583c9efef7ec317e91c4a22aea554e7eacb2ca3 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:49:37 +0200
Subject: [PATCH 043/238] feat: hide the version from the connector probe when
the login page hides it #896
---
app/api/connector/capabilities/route.ts | 7 +++++--
lib/__tests__/instance-details-gates.test.ts | 18 +++++++++++++++++-
2 files changed, 22 insertions(+), 3 deletions(-)
diff --git a/app/api/connector/capabilities/route.ts b/app/api/connector/capabilities/route.ts
index 87de75fb9..4ff6fd1a1 100644
--- a/app/api/connector/capabilities/route.ts
+++ b/app/api/connector/capabilities/route.ts
@@ -15,7 +15,8 @@ import { TARGET_NAMES } from '@/lib/connector/registry';
* here, and the answer is narrower than the login page it sits next to. It
* says nothing about accounts, domains or the mail server - only the product,
* its version, the branded app name, and the target names, which are a
- * compile-time constant.
+ * compile-time constant. The version follows loginShowVersion, like the login
+ * page (#896); the connector only needs the target names to warn.
*
* `appName` can be a customer's own branding, so the whole endpoint is gated
* on `connectorEnabled` for deployments that would rather disclose nothing.
@@ -36,7 +37,9 @@ export async function GET() {
product: 'bulwark-webmail',
appName:
configManager.get('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Bulwark',
- version: process.env.NEXT_PUBLIC_APP_VERSION || null,
+ version: configManager.get('loginShowVersion', true)
+ ? process.env.NEXT_PUBLIC_APP_VERSION || null
+ : null,
connectorPath: '/connector',
targets: TARGET_NAMES,
},
diff --git a/lib/__tests__/instance-details-gates.test.ts b/lib/__tests__/instance-details-gates.test.ts
index d40383c2d..85b3b96ac 100644
--- a/lib/__tests__/instance-details-gates.test.ts
+++ b/lib/__tests__/instance-details-gates.test.ts
@@ -1,5 +1,5 @@
// @vitest-environment node
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// Endpoints answering anyone used to hand out what fingerprints an instance:
@@ -56,6 +56,7 @@ import { GET as health } from '@/app/api/health/route';
import { GET as updateStatus } from '@/app/api/system/update-status/route';
import { GET as plugins } from '@/app/api/plugins/route';
import { GET as policy } from '@/app/api/admin/policy/route';
+import { GET as connectorCapabilities } from '@/app/api/connector/capabilities/route';
const req = (path: string) => new NextRequest(`https://mail.example${path}`);
@@ -64,6 +65,10 @@ beforeEach(() => {
config.values = {};
});
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
describe('endpoints for visitors who are not signed in', () => {
it('health answers the liveness probe but no diagnostics', async () => {
const body = await (await health(req('/api/health?detailed=true'))).json();
@@ -87,6 +92,17 @@ describe('endpoints for visitors who are not signed in', () => {
expect((await (await updateStatus(req('/api/system/update-status'))).json()).status).toEqual(STATUS);
});
+ it('the connector probe follows loginShowVersion and still lists its targets', async () => {
+ vi.stubEnv('NEXT_PUBLIC_APP_VERSION', '1.9.0');
+ expect((await (await connectorCapabilities()).json()).version).toBe('1.9.0');
+
+ config.values.loginShowVersion = false;
+ const hidden = await (await connectorCapabilities()).json();
+ expect(hidden.version).toBeNull();
+ expect(hidden.product).toBe('bulwark-webmail');
+ expect(hidden.targets.length).toBeGreaterThan(0);
+ });
+
it('the plugin list needs a session', async () => {
expect((await plugins(req('/api/plugins'))).status).toBe(401);
gate.signedIn = true;
From edf675b2f435342bf3bdd57f554922e72720c299 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:50:49 +0200
Subject: [PATCH 044/238] feat: list your own folders in a separate section of
the sidebar #1205
---
.../__tests__/sidebar-your-folders.test.tsx | 150 ++++++++++++++++++
components/layout/sidebar.tsx | 124 ++++++++++++++-
locales/ar/common.json | 2 +
locales/ca/common.json | 2 +
locales/cs/common.json | 2 +
locales/da/common.json | 2 +
locales/de/common.json | 2 +
locales/en/common.json | 2 +
locales/es/common.json | 2 +
locales/fa/common.json | 2 +
locales/fr/common.json | 2 +
locales/he/common.json | 2 +
locales/hu/common.json | 2 +
locales/it/common.json | 2 +
locales/ja/common.json | 2 +
locales/ko/common.json | 2 +
locales/lv/common.json | 2 +
locales/mn/common.json | 2 +
locales/nb/common.json | 2 +
locales/nl/common.json | 2 +
locales/pl/common.json | 2 +
locales/pt/common.json | 2 +
locales/ro/common.json | 2 +
locales/ru/common.json | 2 +
locales/sk/common.json | 2 +
locales/tr/common.json | 2 +
locales/uk/common.json | 2 +
locales/zh-TW/common.json | 2 +
locales/zh/common.json | 2 +
29 files changed, 325 insertions(+), 3 deletions(-)
create mode 100644 components/layout/__tests__/sidebar-your-folders.test.tsx
diff --git a/components/layout/__tests__/sidebar-your-folders.test.tsx b/components/layout/__tests__/sidebar-your-folders.test.tsx
new file mode 100644
index 000000000..b53d31e78
--- /dev/null
+++ b/components/layout/__tests__/sidebar-your-folders.test.tsx
@@ -0,0 +1,150 @@
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import type { Mailbox } from '@/lib/jmap/types';
+
+// #1205: the folders the user made get a "Your folders" section of their own
+// below the system folders, instead of being mixed in with Inbox, Sent and
+// Trash. Users with no folders of their own see no change.
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+vi.mock('@/contexts/drag-drop-context', () => ({
+ useDragDropContext: () => ({ isDragging: false }),
+}));
+vi.mock('@/hooks/use-mailbox-drop', () => ({
+ useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
+}));
+vi.mock('@/hooks/use-tag-drop', () => ({
+ useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
+}));
+vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
+
+const { Sidebar } = await import('../sidebar');
+const { useSettingsStore } = await import('@/stores/settings-store');
+const { useUIStore } = await import('@/stores/ui-store');
+const { useAccountStore } = await import('@/stores/account-store');
+const { useAuthStore } = await import('@/stores/auth-store');
+
+const box = (id: string, name: string, role: string | null, sortOrder: number, parentId: string | null = null) =>
+ ({ id, name, role, parentId, totalEmails: 0, unreadEmails: 0, sortOrder, myRights: { mayAddItems: true } });
+
+const systemOnly = [
+ box('inbox', 'Inbox', 'inbox', 0),
+ box('drafts', 'Drafts', 'drafts', 1),
+ box('sent', 'Sent', 'sent', 2),
+ box('trash', 'Trash', 'trash', 3),
+];
+const withCustom = [
+ ...systemOnly,
+ box('receipts', 'Receipts', null, 4),
+ box('projects', 'Projects', null, 5),
+ box('inbox-sub', 'Newsletters', null, 6, 'inbox'),
+] as unknown as Mailbox[];
+
+const folderRow = (name: string) => document.querySelector(`[data-folder-name="${name}"]`);
+const inYourFolders = (name: string) =>
+ document.querySelector(`[data-testid="section-your-folders"] [data-folder-name="${name}"]`);
+
+describe('Sidebar "Your folders" section (#1205)', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ useUIStore.setState({ sidebarCollapsed: false } as never);
+ useAccountStore.setState({ accounts: [] } as never);
+ useSettingsStore.setState({
+ emailKeywords: [],
+ enableUnifiedMailbox: false,
+ hideAccountSwitcher: true,
+ } as never);
+ });
+
+ it('lists the folders you made under their own heading, apart from the system folders', () => {
+ render( );
+
+ expect(screen.getByText('your_folders')).toBeInTheDocument();
+ expect(inYourFolders('Receipts')).not.toBeNull();
+ expect(inYourFolders('Projects')).not.toBeNull();
+ for (const name of ['Inbox', 'Drafts', 'Sent', 'Trash']) {
+ expect(folderRow(name)).not.toBeNull();
+ expect(inYourFolders(name)).toBeNull();
+ }
+ // The system folders all come before the first folder of your own.
+ const rows = [...document.querySelectorAll('[data-folder-name]')].map((r) => r.getAttribute('data-folder-name'));
+ expect(rows.indexOf('Trash')).toBeLessThan(rows.indexOf('Receipts'));
+ });
+
+ it('keeps a folder nested under a system folder with its parent', () => {
+ localStorage.setItem('expandedMailboxes', JSON.stringify(['inbox']));
+ render( );
+
+ expect(folderRow('Newsletters')).not.toBeNull();
+ expect(inYourFolders('Newsletters')).toBeNull();
+ });
+
+ it('shows no section when there are no folders of your own', () => {
+ render( );
+
+ expect(screen.queryByText('your_folders')).toBeNull();
+ expect(document.querySelector('[data-testid="section-your-folders"]')).toBeNull();
+ expect(folderRow('Trash')).not.toBeNull();
+ });
+
+ it('collapses on its own and remembers it', () => {
+ render( );
+
+ fireEvent.click(screen.getByText('your_folders'));
+ expect(folderRow('Receipts')).toBeNull();
+ // The system folders are untouched.
+ expect(folderRow('Inbox')).not.toBeNull();
+
+ cleanup();
+ render( );
+ expect(screen.getByText('your_folders')).toBeInTheDocument();
+ expect(folderRow('Receipts')).toBeNull();
+
+ fireEvent.click(screen.getByText('your_folders'));
+ expect(folderRow('Receipts')).not.toBeNull();
+ });
+
+ it('creates a new folder from the heading', () => {
+ const onCreateFolder = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByLabelText('create_folder'));
+ expect(onCreateFolder).toHaveBeenCalledTimes(1);
+ // The click does not also fold the section away.
+ expect(folderRow('Receipts')).not.toBeNull();
+ });
+
+ it('leaves Mountain View with its Labels list', () => {
+ render( );
+
+ expect(screen.queryByText('your_folders')).toBeNull();
+ expect(document.querySelector('[data-mv-labels] [data-folder-name="Receipts"]')).not.toBeNull();
+ });
+
+ it('splits every account group in the Pro layout', () => {
+ const account = (id: string) => ({
+ id, label: `${id}@example.com`, email: `${id}@example.com`, username: `${id}@example.com`,
+ cookieSlot: id === 'a' ? 0 : 1, isConnected: true,
+ });
+ useAccountStore.setState({ accounts: [account('a'), account('b')] } as never);
+ useAuthStore.setState({ activeAccountId: 'a' } as never);
+ const other = [box('b-inbox', 'Inbox', 'inbox', 0), box('b-clients', 'Clients', null, 1)] as unknown as Mailbox[];
+
+ render(
+ ,
+ );
+
+ expect(screen.getAllByText('your_folders')).toHaveLength(2);
+ expect(inYourFolders('Receipts')).not.toBeNull();
+ expect(inYourFolders('Clients')).not.toBeNull();
+ expect(inYourFolders('Inbox')).toBeNull();
+
+ // Each account's section folds on its own.
+ fireEvent.click(screen.getByTestId('section-your-folders-header-b'));
+ expect(folderRow('Clients')).toBeNull();
+ expect(folderRow('Receipts')).not.toBeNull();
+ });
+});
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 4a11dc6fa..6991daabd 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -447,6 +447,11 @@ function SidebarRow({
);
}
+/** A folder the server gave a role (inbox, sent, trash...), as opposed to one the user made. */
+const isSystemFolder = (node: MailboxNode) => !!node.role;
+/** Key of the single-account "Your folders" section in the collapsed set. */
+const OWN_FOLDERS_KEY = '__own__';
+
function SidebarSectionHeader({
label,
expanded,
@@ -460,12 +465,16 @@ function SidebarSectionHeader({
testId,
onContextMenu,
status,
+ onAdd,
+ addTitle,
}: {
label: string;
expanded: boolean;
onToggle: () => void;
onSettings?: () => void;
settingsTitle?: string;
+ onAdd?: () => void;
+ addTitle?: string;
isCollapsed: boolean;
first?: boolean;
icon?: ReactNode;
@@ -508,6 +517,28 @@ function SidebarSectionHeader({
{label}
{status}
+ {onAdd && (
+ {
+ e.stopPropagation();
+ onAdd();
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ e.stopPropagation();
+ onAdd();
+ }
+ }}
+ className="ms-auto p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
+ title={addTitle}
+ aria-label={addTitle}
+ >
+
+
+ )}
{onSettings && (
@@ -966,6 +997,15 @@ function SidebarImpl({
} catch { /* fall through */ }
return new Set();
});
+ // "Your folders" sections the user has collapsed: OWN_FOLDERS_KEY for the
+ // single-account list, the accountId for each Pro account group.
+ const [collapsedCustomFolders, setCollapsedCustomFolders] = useState>(() => {
+ try {
+ const stored = localStorage.getItem('sidebarCollapsedCustomFolders');
+ if (stored !== null) return new Set(JSON.parse(stored) as string[]);
+ } catch { /* fall through */ }
+ return new Set();
+ });
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const nestedTags = useSettingsStore(s => s.nestedTags);
const isEmbedded = useIsEmbedded();
@@ -1102,7 +1142,12 @@ function SidebarImpl({
const mvFlat = mvShell && !isCollapsed;
const isPrimaryFolder = (node: MailboxNode) =>
node.role === 'inbox' || node.role === 'sent' || node.role === 'drafts';
- const primaryTree = mvFlat ? ownTree.filter(isPrimaryFolder) : ownTree;
+ // Everywhere else the folders the user made sit in a "Your folders" section
+ // of their own below the system folders (#1205). Folders nested under a
+ // system folder stay with their parent. With no folders of your own there is
+ // no section, so the list looks as it always did.
+ const primaryTree = mvFlat ? ownTree.filter(isPrimaryFolder) : ownTree.filter(isSystemFolder);
+ const customTree = mvFlat ? [] : ownTree.filter((node) => !isSystemFolder(node));
const secondaryTree = mvFlat ? ownTree.filter((node) => !isPrimaryFolder(node)) : [];
// Of those, the system folders (archive, spam, trash...) go behind "More",
// and the folders the user made are listed with the tags under "Labels",
@@ -1185,6 +1230,59 @@ function SidebarImpl({
);
};
+ // The "Your folders" section: the user's own top-level folders, under a
+ // heading of their own that collapses and offers to make a new one.
+ const renderCustomFolders = (
+ key: string,
+ tree: MailboxNode[],
+ {
+ sub = false,
+ selected,
+ onSelect,
+ active = true,
+ dragAccountId,
+ }: {
+ sub?: boolean;
+ selected: string;
+ onSelect?: (mailboxId: string) => void;
+ active?: boolean;
+ dragAccountId?: string | null;
+ },
+ ) => {
+ if (tree.length === 0) return null;
+ const expanded = !collapsedCustomFolders.has(key);
+ return (
+ // A Pro account group already carries the folders menu for the whole group.
+
+ toggleCustomFolders(key)}
+ onAdd={active && onCreateFolder ? () => onCreateFolder() : undefined}
+ addTitle={t("create_folder")}
+ isCollapsed={isCollapsed}
+ sub={sub}
+ testId={`section-your-folders-header-${key}`}
+ />
+ {((expanded && !isCollapsed) || isCollapsed) && tree.map((node) => (
+
+ ))}
+
+ );
+ };
+
const getUnifiedIcon = (role: UnifiedMailboxRole) => {
switch (role) {
case 'inbox': return Inbox;
@@ -1278,6 +1376,14 @@ function SidebarImpl({
return next;
});
};
+ const toggleCustomFolders = (key: string) => {
+ setCollapsedCustomFolders((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key); else next.add(key);
+ try { localStorage.setItem('sidebarCollapsedCustomFolders', JSON.stringify(Array.from(next))); } catch { /* */ }
+ return next;
+ });
+ };
// The tab now travels in the URL (#733) rather than through sessionStorage,
// so a gear click is a link the user can also bookmark or share. It still
@@ -1516,7 +1622,7 @@ function SidebarImpl({
) : (
<>
- {tree.map((node) => (
+ {tree.filter(isSystemFolder).map((node) => (
))}
{isActive && !tree.some((node) => node.role === 'drafts') && renderScheduledRow(`scheduled-${account.id}`)}
+ {renderCustomFolders(account.id, tree.filter((node) => !isSystemFolder(node)), {
+ sub: true,
+ selected: selectedKeyword || !isViewing ? "" : selectedMailbox,
+ onSelect: (mailboxId) => onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId),
+ active: isActive,
+ dragAccountId: isActive ? null : account.id,
+ })}
>
)}
>
@@ -1628,6 +1741,11 @@ function SidebarImpl({
)}
+ {!useMultiAccount && renderCustomFolders(OWN_FOLDERS_KEY, customTree, {
+ selected: selectedKeyword ? "" : selectedMailbox,
+ onSelect: onMailboxSelect,
+ })}
+
{mvFlat && !useMultiAccount && (
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 52e7c4ae3..6c101f061 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "إظهار الكل ({count})",
"show_fewer_tags": "إظهار أقل",
"folders": "المجلدات",
+ "your_folders": "مجلداتك",
+ "create_folder": "إنشاء مجلد",
"show_more": "المزيد",
"show_fewer": "أقل",
"shared": "مشترك",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index b66f0d338..886564d80 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Mostra-ho tot ({count})",
"show_fewer_tags": "Mostra'n menys",
"folders": "Carpetes",
+ "your_folders": "Les teves carpetes",
+ "create_folder": "Crea una carpeta",
"show_more": "Més",
"show_fewer": "Menys",
"shared": "Compartit",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index d9f14d837..b7f8ea9ba 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Zobrazit vše ({count})",
"show_fewer_tags": "Zobrazit méně",
"folders": "Složky",
+ "your_folders": "Vaše složky",
+ "create_folder": "Vytvořit složku",
"show_more": "Více",
"show_fewer": "Méně",
"shared": "Sdílené",
diff --git a/locales/da/common.json b/locales/da/common.json
index c406e216e..51c8ceec0 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Vis alle ({count})",
"show_fewer_tags": "Vis færre",
"folders": "Mapper",
+ "your_folders": "Dine mapper",
+ "create_folder": "Opret mappe",
"show_more": "Mere",
"show_fewer": "Mindre",
"shared": "Delt",
diff --git a/locales/de/common.json b/locales/de/common.json
index df865c10d..de27864a8 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Alle anzeigen ({count})",
"show_fewer_tags": "Weniger anzeigen",
"folders": "Ordner",
+ "your_folders": "Eigene Ordner",
+ "create_folder": "Ordner erstellen",
"show_more": "Mehr",
"show_fewer": "Weniger",
"mail": "E-Mail",
diff --git a/locales/en/common.json b/locales/en/common.json
index 89021953b..db28c520e 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Show all ({count})",
"show_fewer_tags": "Show less",
"folders": "Folders",
+ "your_folders": "Your folders",
+ "create_folder": "Create folder",
"show_more": "More",
"show_fewer": "Less",
"shared": "Shared",
diff --git a/locales/es/common.json b/locales/es/common.json
index 451be606f..2abec1cfc 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Mostrar todo ({count})",
"show_fewer_tags": "Mostrar menos",
"folders": "Carpetas",
+ "your_folders": "Tus carpetas",
+ "create_folder": "Crear carpeta",
"show_more": "Más",
"show_fewer": "Menos",
"mail": "Correo",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 2803e2e97..eae1ce922 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "نمایش همه ({count})",
"show_fewer_tags": "نمایش کمتر",
"folders": "پوشهها",
+ "your_folders": "پوشههای شما",
+ "create_folder": "ایجاد پوشه",
"show_more": "بیشتر",
"show_fewer": "کمتر",
"shared": "اشتراکی",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index ae4ea1299..87b1ff2ac 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Tout afficher ({count})",
"show_fewer_tags": "Afficher moins",
"folders": "Dossiers",
+ "your_folders": "Vos dossiers",
+ "create_folder": "Créer un dossier",
"show_more": "Plus",
"show_fewer": "Moins",
"mail": "Messagerie",
diff --git a/locales/he/common.json b/locales/he/common.json
index 55f9c4739..82dcdf9e0 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -133,6 +133,8 @@
"show_all_tags": "הצג הכל ({count})",
"show_fewer_tags": "הצג פחות",
"folders": "תיקיות",
+ "your_folders": "התיקיות שלך",
+ "create_folder": "יצירת תיקייה",
"show_more": "עוד",
"show_fewer": "פחות",
"mail": "דוֹאַר",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 5622e3d09..f74e81b4d 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Összes megjelenítése ({count})",
"show_fewer_tags": "Kevesebb megjelenítése",
"folders": "Mappák",
+ "your_folders": "Saját mappák",
+ "create_folder": "Mappa létrehozása",
"show_more": "Több",
"show_fewer": "Kevesebb",
"shared": "Megosztott",
diff --git a/locales/it/common.json b/locales/it/common.json
index 0bfa4e586..84510e66f 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Mostra tutto ({count})",
"show_fewer_tags": "Mostra meno",
"folders": "Cartelle",
+ "your_folders": "Le tue cartelle",
+ "create_folder": "Crea cartella",
"show_more": "Altro",
"show_fewer": "Riduci",
"mail": "Posta",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 5285f6b64..47dda8a18 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "すべて表示({count})",
"show_fewer_tags": "表示を減らす",
"folders": "フォルダ",
+ "your_folders": "マイフォルダ",
+ "create_folder": "フォルダを作成",
"show_more": "もっと見る",
"show_fewer": "折りたたむ",
"mail": "メール",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 4946d2e3f..da0157508 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "전체 보기({count})",
"show_fewer_tags": "간략히 보기",
"folders": "폴더",
+ "your_folders": "내 폴더",
+ "create_folder": "폴더 만들기",
"show_more": "더보기",
"show_fewer": "간략히",
"mail": "메일",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index dcd00a33d..4688d0c4a 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Rādīt visus ({count})",
"show_fewer_tags": "Rādīt mazāk",
"folders": "Mapes",
+ "your_folders": "Jūsu mapes",
+ "create_folder": "Izveidot mapi",
"show_more": "Vairāk",
"show_fewer": "Mazāk",
"mail": "Pasts",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index f90defdcc..d7d316662 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Бүгдийг нь харуулах ({count})",
"show_fewer_tags": "Бага харуулах",
"folders": "Хавтас",
+ "your_folders": "Таны хавтаснууд",
+ "create_folder": "Хавтас үүсгэх",
"show_more": "Илүү",
"show_fewer": "Бага",
"shared": "Хуваалцсан",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index aaa8816da..ba60ece05 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Vis alle ({count})",
"show_fewer_tags": "Vis færre",
"folders": "Mapper",
+ "your_folders": "Dine mapper",
+ "create_folder": "Opprett mappe",
"show_more": "Mer",
"show_fewer": "Mindre",
"shared": "Delt",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index bef09aa9b..c4665b4b8 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Alles tonen ({count})",
"show_fewer_tags": "Minder tonen",
"folders": "Mappen",
+ "your_folders": "Je mappen",
+ "create_folder": "Map maken",
"show_more": "Meer",
"show_fewer": "Minder",
"mail": "E-mail",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index a88c5b2b6..2f156f834 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Pokaż wszystkie ({count})",
"show_fewer_tags": "Pokaż mniej",
"folders": "Foldery",
+ "your_folders": "Twoje foldery",
+ "create_folder": "Utwórz folder",
"show_more": "Więcej",
"show_fewer": "Mniej",
"mail": "Poczta",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 844ed196d..484f0e9a1 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Mostrar tudo ({count})",
"show_fewer_tags": "Mostrar menos",
"folders": "Pastas",
+ "your_folders": "As suas pastas",
+ "create_folder": "Criar pasta",
"show_more": "Mais",
"show_fewer": "Menos",
"mail": "E-mail",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 0d346fba7..d7c130bf2 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Afișează tot ({count})",
"show_fewer_tags": "Afișează mai puține",
"folders": "Dosare",
+ "your_folders": "Dosarele tale",
+ "create_folder": "Creează dosar",
"show_more": "Mai multe",
"show_fewer": "Mai puține",
"shared": "Partajat",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 9c5b9a018..b01cc0775 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Показать все ({count})",
"show_fewer_tags": "Показать меньше",
"folders": "Папки",
+ "your_folders": "Ваши папки",
+ "create_folder": "Создать папку",
"show_more": "Ещё",
"show_fewer": "Свернуть",
"mail": "Почта",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 7cc66a8f3..a57c46912 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Zobraziť všetko ({count})",
"show_fewer_tags": "Zobraziť menej",
"folders": "Priečinky",
+ "your_folders": "Vaše priečinky",
+ "create_folder": "Vytvoriť priečinok",
"show_more": "Viac",
"show_fewer": "Menej",
"shared": "Zdieľané",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index e68ef8d08..e906053b8 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Tümünü göster ({count})",
"show_fewer_tags": "Daha az göster",
"folders": "Klasörler",
+ "your_folders": "Klasörleriniz",
+ "create_folder": "Klasör oluştur",
"show_more": "Diğer",
"show_fewer": "Daha az",
"shared": "Paylaşılan",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index d83acfc7e..296480cb6 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Показати всі ({count})",
"show_fewer_tags": "Показати менше",
"folders": "Папки",
+ "your_folders": "Ваші теки",
+ "create_folder": "Створити теку",
"show_more": "Більше",
"show_fewer": "Менше",
"mail": "Пошта",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 5e5e562bd..edb372a62 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "顯示全部({count})",
"show_fewer_tags": "顯示較少",
"folders": "信件匣",
+ "your_folders": "我的資料夾",
+ "create_folder": "建立資料夾",
"show_more": "更多",
"show_fewer": "收合",
"shared": "共用",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index deb193881..34fc9d1f0 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "显示全部({count})",
"show_fewer_tags": "收起",
"folders": "文件夹",
+ "your_folders": "我的文件夹",
+ "create_folder": "新建文件夹",
"show_more": "更多",
"show_fewer": "收起",
"mail": "邮件",
From 1ad1e31781b3fc3116e6d63315fbe0eac8df48f3 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:55:10 +0200
Subject: [PATCH 045/238] feat: set message priority in the composer #970
---
.../__tests__/composer-priority.test.tsx | 307 ++++++++++++++++++
components/email/composer-toolbar-menu.tsx | 32 ++
components/email/email-composer.tsx | 44 ++-
components/mail/mail-app.tsx | 5 +-
components/pro/pro-email-tab-body.tsx | 2 +
lib/__tests__/jmap-message-priority.test.ts | 123 +++++++
lib/jmap/client-interface.ts | 5 +-
lib/jmap/client.ts | 9 +
lib/message-priority.ts | 55 ++++
locales/ar/common.json | 4 +
locales/ca/common.json | 4 +
locales/cs/common.json | 4 +
locales/da/common.json | 4 +
locales/de/common.json | 4 +
locales/en/common.json | 4 +
locales/es/common.json | 4 +
locales/fa/common.json | 4 +
locales/fr/common.json | 4 +
locales/he/common.json | 4 +
locales/hu/common.json | 4 +
locales/it/common.json | 4 +
locales/ja/common.json | 4 +
locales/ko/common.json | 4 +
locales/lv/common.json | 4 +
locales/mn/common.json | 4 +
locales/nb/common.json | 4 +
locales/nl/common.json | 4 +
locales/pl/common.json | 4 +
locales/pt/common.json | 4 +
locales/ro/common.json | 4 +
locales/ru/common.json | 4 +
locales/sk/common.json | 4 +
locales/tr/common.json | 4 +
locales/uk/common.json | 4 +
locales/zh-TW/common.json | 4 +
locales/zh/common.json | 4 +
stores/email-store.ts | 3 +-
37 files changed, 687 insertions(+), 6 deletions(-)
create mode 100644 components/email/__tests__/composer-priority.test.tsx
create mode 100644 lib/__tests__/jmap-message-priority.test.ts
create mode 100644 lib/message-priority.ts
diff --git a/components/email/__tests__/composer-priority.test.tsx b/components/email/__tests__/composer-priority.test.tsx
new file mode 100644
index 000000000..c0b6c73fe
--- /dev/null
+++ b/components/email/__tests__/composer-priority.test.tsx
@@ -0,0 +1,307 @@
+import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import React from 'react';
+import { EmailComposer } from '../email-composer';
+import { useAuthStore } from '@/stores/auth-store';
+
+// #970: the priority picked in the Options menu goes out with the message and
+// with every draft save, and a re-opened draft comes back with it.
+
+// ─── Heavy component mocks (mirrors composer-close-guard.test.tsx) ──────────
+
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial
) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+const updateSetting = vi.fn();
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ sendDelaySeconds: 0,
+ signaturePosition: 'above_quote',
+ signatureSeparatorEnabled: false,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: (...args: unknown[]) => updateSetting(...args),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: { takeOver: async () => 'default' },
+ onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ sanitizeSignatureHtmlForDisplay: (v: string) => v,
+ sanitizePluginBodyHtml: (v: string) => v,
+ escapeHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/signature-utils', () => ({
+ appendPlainTextSignature: (body: string) => body,
+ getPlainTextSignature: () => '',
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/email/quoted-html', () => ({
+ buildQuotedHtmlBlock: () => '',
+ serializeEditorContent: () => '',
+}));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+const DRAFT = {
+ to: 'bob@example.com',
+ cc: '',
+ bcc: '',
+ subject: 'Plans',
+ body: 'Hello
',
+ showCc: false,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose' as const,
+ draftId: null,
+};
+
+const openOptions = () => {
+ if (!screen.queryByTestId('composer-priority')) fireEvent.click(screen.getByTestId('composer-options-menu'));
+};
+const pick = (level: 'high' | 'normal' | 'low') => {
+ openOptions();
+ fireEvent.click(screen.getByTestId(`composer-priority-${level}`));
+};
+const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement;
+
+async function send(onSend: ReturnType) {
+ fireEvent.click(sendButton());
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ return onSend.mock.calls[0][0] as { priority?: string };
+}
+
+function mockClient() {
+ const createDraft = vi.fn().mockResolvedValue('draft-8');
+ useAuthStore.setState({
+ client: {
+ createDraft,
+ getEmail: vi.fn().mockResolvedValue({ id: 'draft-8', attachments: [] }),
+ hasDelayedSend: () => false,
+ getMaxDelayedSend: () => 0,
+ } as never,
+ activeAccountId: 'acct-A' as never,
+ });
+ return createDraft;
+}
+
+async function saveViaCloseDialog(requestCloseRef: React.MutableRefObject<((afterClose?: () => void) => void) | null>) {
+ act(() => requestCloseRef.current!(vi.fn()));
+ const dialog = await screen.findByRole('alertdialog');
+ fireEvent.click(within(dialog).getByText('save'));
+}
+
+describe('composer message priority (#970)', () => {
+ afterEach(() => {
+ useAuthStore.setState({ client: null, activeAccountId: null });
+ vi.clearAllMocks();
+ });
+
+ it('offers high / normal / low in the Options menu with normal selected', () => {
+ render( );
+ openOptions();
+ expect(screen.getByTestId('composer-priority-normal')).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByTestId('composer-priority-high')).toHaveAttribute('aria-checked', 'false');
+ expect(screen.getByTestId('composer-priority-low')).toHaveAttribute('aria-checked', 'false');
+ });
+
+ it.each(['high', 'low'] as const)('sends with %s priority after picking it', async (level) => {
+ const onSend = vi.fn();
+ render( );
+ pick(level);
+ expect(screen.getByTestId(`composer-priority-${level}`)).toHaveAttribute('aria-checked', 'true');
+ expect((await send(onSend)).priority).toBe(level);
+ });
+
+ it('sends no priority when it stays normal', async () => {
+ const onSend = vi.fn();
+ render( );
+ expect((await send(onSend)).priority).toBeUndefined();
+ });
+
+ it('saves the draft with the picked priority', async () => {
+ const createDraft = mockClient();
+ const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
+ render( );
+ fireEvent.change(screen.getByDisplayValue('Plans'), { target: { value: 'Plans, edited' } });
+ pick('high');
+
+ await saveViaCloseDialog(requestCloseRef);
+ await waitFor(() => expect(createDraft).toHaveBeenCalled());
+ expect(createDraft.mock.calls[0][13]).toEqual({ priority: 'high' });
+ });
+
+ it('counts a priority change alone as an unsaved edit of a re-opened draft', async () => {
+ const createDraft = mockClient();
+ const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
+ render( );
+ pick('low');
+
+ await saveViaCloseDialog(requestCloseRef);
+ await waitFor(() => expect(createDraft).toHaveBeenCalled());
+ expect(createDraft.mock.calls[0][13]).toEqual({ priority: 'low' });
+ });
+
+ it('restores the priority of a re-opened draft and sends it again', async () => {
+ const onSend = vi.fn();
+ render( );
+ openOptions();
+ expect(screen.getByTestId('composer-priority-low')).toHaveAttribute('aria-checked', 'true');
+ expect((await send(onSend)).priority).toBe('low');
+ });
+
+ it('keeps the priority when the composer is stashed on unmount', () => {
+ const onSaveState = vi.fn();
+ const { unmount } = render( );
+ pick('high');
+ unmount();
+ expect(onSaveState).toHaveBeenCalledTimes(1);
+ expect(onSaveState.mock.calls[0][0]).toMatchObject({ priority: 'high' });
+ });
+});
diff --git a/components/email/composer-toolbar-menu.tsx b/components/email/composer-toolbar-menu.tsx
index 237691790..256a54a9d 100644
--- a/components/email/composer-toolbar-menu.tsx
+++ b/components/email/composer-toolbar-menu.tsx
@@ -142,6 +142,38 @@ export function ComposerToolbarMenuCheckItem({ checked, onToggle, description, t
);
}
+interface RadioItemProps {
+ checked: boolean;
+ onSelect: () => void;
+ testId?: string;
+ children: React.ReactNode;
+}
+
+/** One choice of a group (message priority); stays open like the check items. */
+export function ComposerToolbarMenuRadioItem({ checked, onSelect, testId, children }: RadioItemProps) {
+ return (
+
+
+ {checked && }
+
+ {children}
+
+ );
+}
+
interface PluginItemsProps {
/** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */
surface: string;
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 434544fd0..a65bc5986 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -36,6 +36,8 @@ import {
BookUser,
} from "@/components/icons";
import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
+import { ComposerToolbarMenuRadioItem } from "@/components/email/composer-toolbar-menu";
+import type { MessagePriority } from "@/lib/message-priority";
import { registerComposer } from "@/lib/plugin-sandbox/composer-registry";
import { plainOwnRange, readOwnContent, readSelection as readEditorSelection, replaceOwnContent, replaceSelection as replaceEditorSelection, replaceTextareaRange, textToEditorHtml, type OwnTextOptions } from "@/lib/plugin-sandbox/composer-edit";
import { actionsFor, runAction } from "@/lib/plugin-sandbox/actions";
@@ -205,6 +207,8 @@ export interface ComposerDraftData {
*/
inReplyTo?: string[];
references?: string[];
+ /** Priority of a re-opened draft, read back from its headers (#970). */
+ priority?: MessagePriority;
}
interface EmailComposerProps {
@@ -234,6 +238,8 @@ interface EmailComposerProps {
requestDsn?: boolean;
/** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */
requireTls?: boolean;
+ /** Importance / X-Priority; absent for normal (#970). */
+ priority?: MessagePriority;
}) => void | Promise;
onScheduledSendCreated?: () => void | Promise;
onClose?: () => void;
@@ -801,6 +807,10 @@ export function EmailComposer({
// extension (RFC 8621 §1.3 submissionExtensions).
const [requestDsn, setRequestDsn] = useState(false);
const [requireTls, setRequireTls] = useState(false);
+ // Message priority (#970): normal writes no header at all.
+ const [priority, setPriority] = useState(initialData?.priority ?? 'normal');
+ const priorityRef = useRef(priority);
+ priorityRef.current = priority;
const [draftId, setDraftId] = useState(initialData?.draftId ?? null);
// Mirror of draftId for synchronous reads inside chained saves; React's
// setDraftId is async, so a queued saveDraft would otherwise see the old
@@ -1471,6 +1481,17 @@ export function EmailComposer({
// `!==`, not `>`: a re-opened draft starts with hydrated attachments, and
// removing one must count as dirty or the removal never reaches the server.
body !== initialValuesRef.current.body || attachments.length !== initialValuesRef.current.attachmentCount;
+ // A priority change alone is an edit worth saving (#970).
+ const initialPriorityRef = useRef(priority);
+ if (priority !== initialPriorityRef.current) isDirtyRef.current = true;
+ const firstPriorityRunRef = useRef(true);
+ useEffect(() => {
+ if (firstPriorityRunRef.current) {
+ firstPriorityRunRef.current = false;
+ return;
+ }
+ changeVersionRef.current += 1;
+ }, [priority]);
// Ref to latest saveDraft for use in event handlers with stale closures
const saveDraftRef = useRef<() => Promise>(() => Promise.resolve(null));
@@ -1516,6 +1537,7 @@ export function EmailComposer({
...s,
mode,
replyTo,
+ priority: priorityRef.current,
});
}
};
@@ -2375,7 +2397,7 @@ export function EmailComposer({
// draft version (below), and hashing them would mark each save dirty
// again - an endless save loop. Name+type+size identifies an attachment
// for change detection.
- const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body: draftHtmlBody ?? draftTextBody, attachments: uploadedAttachments.map(({ name, type, size, cid }) => ({ name, type, size, cid })), identityId: selectedIdentityId, subAddressTag });
+ const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body: draftHtmlBody ?? draftTextBody, attachments: uploadedAttachments.map(({ name, type, size, cid }) => ({ name, type, size, cid })), identityId: selectedIdentityId, subAddressTag, priority });
// Only save if data has changed
if (currentData === lastSavedDataRef.current) {
@@ -2448,6 +2470,7 @@ export function EmailComposer({
savedDraft.htmlBody,
replyThreadingHeaders?.inReplyTo,
replyThreadingHeaders?.references,
+ { priority },
);
// Update the ref synchronously so a queued save sees the new id and
@@ -2726,7 +2749,7 @@ export function EmailComposer({
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up
- }, [toStr, ccStr, bccStr, subject, body, attachments]);
+ }, [toStr, ccStr, bccStr, subject, body, attachments, priority]);
useEffect(() => {
return () => {
@@ -3201,6 +3224,7 @@ export function EmailComposer({
requestReadReceipt,
requestDsn: requestDsn || undefined,
requireTls: requireTls || undefined,
+ priority: priority !== 'normal' ? priority : undefined,
delayedUntil: effectiveDelayedUntil,
});
@@ -4239,7 +4263,7 @@ export function EmailComposer({
{(close) => <>
@@ -4279,6 +4303,20 @@ export function EmailComposer({
{t('option_require_tls')}
)}
+
+
+
{t('option_priority')}
+ {(['high', 'normal', 'low'] as const).map((level) => (
+
setPriority(level)}
+ testId={`composer-priority-${level}`}
+ >
+ {t(`option_priority_${level}`)}
+
+ ))}
+
>}
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 912964b73..c20f9add1 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -32,6 +32,7 @@ import { useShallow } from "zustand/react/shallow";
import { usePolicyStore } from "@/stores/policy-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
+import { messagePriorityFromHeaders, type MessagePriority } from "@/lib/message-priority";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete } from "@/stores/email-store";
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
@@ -2050,6 +2051,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
requestReadReceipt?: boolean;
requestDsn?: boolean;
requireTls?: boolean;
+ priority?: MessagePriority;
}) => {
if (!client) return;
@@ -2072,7 +2074,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = composerSourceEmail?.id;
- const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt, requestDsn: data.requestDsn, requireTls: data.requireTls, localAccountId: data.localAccountId });
+ const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt, requestDsn: data.requestDsn, requireTls: data.requireTls, priority: data.priority, localAccountId: data.localAccountId });
submitted = true;
announceSent(result, data);
setShowComposer(false);
@@ -2342,6 +2344,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// come along or the reply leaves its thread.
inReplyTo: draft.inReplyTo ?? undefined,
references: draft.references ?? undefined,
+ priority: messagePriorityFromHeaders(draft.headers),
// Existing server-side attachments must ride along, or the composer
// starts empty and the next save/send silently rebuilds the draft
// without them (#849).
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index 46ae5496c..3c09ae111 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -22,6 +22,7 @@ import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
import { cn } from "@/lib/utils";
import { getDraftTextBody } from '@/lib/email-body-selection';
+import { messagePriorityFromHeaders } from '@/lib/message-priority';
interface ProEmailTabBodyProps {
tabId: string;
@@ -381,6 +382,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
// since #849; without this the Pro draft tab still opened the
// composer without its files and the next save stripped them.
attachments: email.attachments,
+ priority: messagePriorityFromHeaders(email.headers),
},
}, { to: addresses(email.to), cc: addresses(email.cc), subject: draftSubject, body: bodyText }, onClose);
}, [email, identities, multiAccountIdentities, openComposeTab, onClose, t]);
diff --git a/lib/__tests__/jmap-message-priority.test.ts b/lib/__tests__/jmap-message-priority.test.ts
new file mode 100644
index 000000000..7e700cf44
--- /dev/null
+++ b/lib/__tests__/jmap-message-priority.test.ts
@@ -0,0 +1,123 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+import { messagePriorityFromHeaders, messagePriorityHeaders, type MessagePriority } from '../message-priority';
+
+/**
+ * #970: the composer's priority goes out as Importance + X-Priority headers
+ * on the Email/set create, for sends and for drafts, and normal writes none.
+ */
+
+function createClient(): JMAPClient {
+ const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
+ Object.assign(client, {
+ apiUrl: 'https://jmap.example.com/api',
+ accountId: 'account-1',
+ username: 'user@example.com',
+ });
+ return client;
+}
+
+interface CapturedRequest { methodCalls: Array<[string, Record, string]> }
+
+function mockFlow() {
+ const captured: CapturedRequest[] = [];
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
+ const body = JSON.parse((init as { body: string }).body) as CapturedRequest;
+ captured.push(body);
+ const methodResponses: unknown[] = [];
+ for (const [method, args, callId] of body.methodCalls) {
+ if (method === 'Mailbox/get') {
+ methodResponses.push(['Mailbox/get', {
+ list: [
+ { id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
+ { id: 'mb-sent', name: 'Sent', role: 'sent' },
+ ],
+ }, callId]);
+ } else if (method === 'Identity/get') {
+ methodResponses.push(['Identity/get', { list: [{ id: 'identity-1', email: 'user@example.com' }] }, callId]);
+ } else if (method === 'Email/set') {
+ const key = Object.keys((args.create ?? {}) as Record)[0];
+ methodResponses.push(['Email/set', key ? { created: { [key]: { id: 'email-new-1' } } } : {}, callId]);
+ } else if (method === 'EmailSubmission/set') {
+ methodResponses.push(['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, callId]);
+ }
+ }
+ const payload = { methodResponses };
+ return {
+ ok: true,
+ status: 200,
+ text: () => Promise.resolve(JSON.stringify(payload)),
+ json: () => Promise.resolve(payload),
+ } as Response;
+ });
+ return captured;
+}
+
+function createdEmail(captured: CapturedRequest[]): Record {
+ const call = captured.flatMap(r => r.methodCalls).find(c => c[0] === 'Email/set' && c[1].create);
+ expect(call).toBeDefined();
+ return Object.values(call![1].create as Record>)[0];
+}
+
+function priorityProps(email: Record) {
+ return Object.fromEntries(Object.entries(email).filter(([k]) => /^header:(X-Priority|Importance|Priority):/i.test(k)));
+}
+
+const expected: Record> = {
+ high: { 'header:X-Priority:asText': '1 (Highest)', 'header:Importance:asText': 'high' },
+ normal: {},
+ low: { 'header:X-Priority:asText': '5 (Lowest)', 'header:Importance:asText': 'low' },
+};
+
+describe('message priority headers (#970)', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ it.each(['high', 'normal', 'low'] as const)('sendEmail writes the %s priority headers', async (priority) => {
+ const client = createClient();
+ const captured = mockFlow();
+ await client.sendEmail(
+ ['to@example.com'], 'Subject', 'body',
+ undefined, undefined, 'identity-1', 'user@example.com',
+ undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
+ { priority },
+ );
+ expect(priorityProps(createdEmail(captured))).toEqual(expected[priority]);
+ });
+
+ it('sendEmail without options writes no priority headers', async () => {
+ const client = createClient();
+ const captured = mockFlow();
+ await client.sendEmail(['to@example.com'], 'Subject', 'body', undefined, undefined, 'identity-1', 'user@example.com');
+ expect(priorityProps(createdEmail(captured))).toEqual({});
+ });
+
+ it.each(['high', 'normal', 'low'] as const)('createDraft writes the %s priority headers', async (priority) => {
+ const client = createClient();
+ const captured = mockFlow();
+ await client.createDraft(
+ ['to@example.com'], 'Subject', 'body',
+ undefined, undefined, 'identity-1', 'user@example.com',
+ undefined, undefined, undefined, undefined, undefined, undefined,
+ { priority },
+ );
+ expect(priorityProps(createdEmail(captured))).toEqual(expected[priority]);
+ });
+
+ it('reads the priority back from a draft\'s headers', () => {
+ // The draft round trip: what was written comes back as the same level.
+ for (const level of ['high', 'low'] as const) {
+ const headers = Object.fromEntries(
+ Object.entries(messagePriorityHeaders(level)).map(([k, v]) => [k.split(':')[1], ` ${v}`]),
+ );
+ expect(messagePriorityFromHeaders(headers)).toBe(level);
+ }
+ expect(messagePriorityFromHeaders({ 'x-priority': '2 (High)' })).toBe('high');
+ expect(messagePriorityFromHeaders({ 'X-Priority': '3 (Normal)', Importance: 'high' })).toBe('normal');
+ expect(messagePriorityFromHeaders({ Importance: 'Low' })).toBe('low');
+ expect(messagePriorityFromHeaders({ Subject: 'x' })).toBe('normal');
+ expect(messagePriorityFromHeaders(undefined)).toBe('normal');
+ });
+});
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index baab195cf..1bee2a5de 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -4,6 +4,7 @@ import type { FileNameRules } from "@/lib/file-name-rules";
import type { SortLevel } from "@/lib/message-list-order";
import type { SearchSnippetResult } from "@/lib/search-snippet";
import type { StorageQuota } from "@/lib/quota";
+import type { MessagePriority } from "@/lib/message-priority";
/** What `migrateKeyword` managed to do. */
export interface KeywordMigration {
@@ -338,6 +339,8 @@ export interface IJMAPClient {
htmlBody?: string,
inReplyTo?: string[],
references?: string[],
+ // Importance / X-Priority headers, so a re-opened draft keeps its priority (#970).
+ options?: { priority?: MessagePriority },
): Promise;
sendEmail(
@@ -362,7 +365,7 @@ export interface IJMAPClient {
// requestDsn / requireTls map to RFC 3461 / RFC 8689 envelope parameters
// and need the matching `submissionExtensions` entry (see
// supportsSubmissionExtension).
- options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean },
+ options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority },
): Promise;
/** Whether the submission account advertises an SMTP extension ("DSN", "REQUIRETLS", …). */
supportsSubmissionExtension?(extension: string, accountId?: string): boolean;
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 02258ff37..b772fa274 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -23,6 +23,7 @@ import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone";
import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order";
import { toInertBlob } from "@/lib/file-preview";
import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota";
+import { messagePriorityHeaders, type MessagePriority } from "@/lib/message-priority";
// Cap for the follow-up Email/get issued when a displayed body part comes
// back truncated at the normal 256000-byte limit (see refetchTruncatedBodyValues
@@ -958,6 +959,8 @@ export interface SubmissionSendOptions {
requestReadReceipt?: boolean;
requestDsn?: boolean;
requireTls?: boolean;
+ /** Importance / X-Priority headers (#970); see lib/message-priority.ts. */
+ priority?: MessagePriority;
}
/** The MAIL FROM / RCPT TO parameters the options translate to. */
@@ -3881,6 +3884,7 @@ export class JMAPClient implements IJMAPClient {
htmlBody?: string,
inReplyTo?: string[],
references?: string[],
+ options?: { priority?: MessagePriority },
): Promise {
const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
@@ -3904,6 +3908,7 @@ export class JMAPClient implements IJMAPClient {
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
inReplyTo?: string[];
references?: string[];
+ [header: `header:${string}`]: string;
}
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
@@ -3931,6 +3936,8 @@ export class JMAPClient implements IJMAPClient {
...(htmlBody ? { htmlBody: [{ partId: "html", type: "text/html" }] } : {}),
...(draftInReplyTo?.length ? { inReplyTo: draftInReplyTo } : {}),
...(draftReferences?.length ? { references: draftReferences } : {}),
+ // A re-opened draft reads its priority back from these (#970).
+ ...messagePriorityHeaders(options?.priority),
};
if (attachments?.length) {
@@ -4101,6 +4108,8 @@ export class JMAPClient implements IJMAPClient {
emailCreate["header:Disposition-Notification-To:asText"] = fromEmail || this.username;
}
+ Object.assign(emailCreate, messagePriorityHeaders(options?.priority));
+
if (htmlBody) {
// Send as multipart/alternative with both text and HTML
emailCreate.bodyValues = {
diff --git a/lib/message-priority.ts b/lib/message-priority.ts
new file mode 100644
index 000000000..d78ad28de
--- /dev/null
+++ b/lib/message-priority.ts
@@ -0,0 +1,55 @@
+/**
+ * Message priority set in the composer (#970).
+ *
+ * Clients disagree on which header carries it: Outlook reads Importance
+ * (RFC 4021), Thunderbird and most others read the de-facto X-Priority
+ * (1 highest ... 5 lowest). Both are written so the hint survives either
+ * way. Priority (RFC 4021, MIXER) is left out: it is meant to steer
+ * transmission, not display. Normal writes nothing, so ordinary mail is
+ * unchanged.
+ */
+export type MessagePriority = 'high' | 'normal' | 'low';
+
+/** JMAP Email/set create properties (RFC 8621 §4.1.3) for a priority. */
+export function messagePriorityHeaders(priority: MessagePriority | undefined): Record {
+ if (priority === 'high') {
+ return {
+ 'header:X-Priority:asText': '1 (Highest)',
+ 'header:Importance:asText': 'high',
+ };
+ }
+ if (priority === 'low') {
+ return {
+ 'header:X-Priority:asText': '5 (Lowest)',
+ 'header:Importance:asText': 'low',
+ };
+ }
+ return {};
+}
+
+function headerValue(headers: Record, name: string): string | undefined {
+ const lower = name.toLowerCase();
+ for (const [key, value] of Object.entries(headers)) {
+ if (key.toLowerCase() !== lower) continue;
+ const first = Array.isArray(value) ? value[0] : value;
+ if (typeof first === 'string' && first.trim()) return first.trim();
+ }
+ return undefined;
+}
+
+/**
+ * The priority a message's headers carry, for restoring a re-opened draft.
+ * X-Priority wins over Importance; 1-2 is high, 4-5 low, anything else normal.
+ */
+export function messagePriorityFromHeaders(headers: Record | undefined | null): MessagePriority {
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return 'normal';
+ const xPriority = headerValue(headers, 'X-Priority');
+ const level = xPriority ? Number.parseInt(xPriority, 10) : NaN;
+ if (level === 1 || level === 2) return 'high';
+ if (level === 4 || level === 5) return 'low';
+ if (!Number.isNaN(level)) return 'normal';
+ const importance = headerValue(headers, 'Importance')?.toLowerCase();
+ if (importance === 'high') return 'high';
+ if (importance === 'low') return 'low';
+ return 'normal';
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 6c101f061..8b332a17d 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -714,6 +714,10 @@
"option_dsn_hint": "احصل على تقرير عندما يسلّمها الخادم",
"option_require_tls": "اشتراط التسليم المشفّر",
"option_require_tls_hint": "التسليم عبر TLS فقط، وإلا تُرفض",
+ "option_priority": "الأهمية",
+ "option_priority_high": "عالية",
+ "option_priority_normal": "عادية",
+ "option_priority_low": "منخفضة",
"validation": {
"recipient_required": "أضف مستلمًا للإرسال",
"body_required": "اكتب رسالة أو أرفق ملفًا",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 886564d80..7da2ae79a 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -714,6 +714,10 @@
"option_dsn_hint": "Rep un informe quan el servidor el lliuri",
"option_require_tls": "Exigeix lliurament xifrat",
"option_require_tls_hint": "Lliura només per TLS; si no, retorna'l",
+ "option_priority": "Prioritat",
+ "option_priority_high": "Alta",
+ "option_priority_normal": "Normal",
+ "option_priority_low": "Baixa",
"validation": {
"recipient_required": "Afegiu un destinatari per enviar",
"body_required": "Escriviu un missatge o adjunteu un fitxer",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index b7f8ea9ba..c4a737b8d 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "Dostat zprávu, až ji server doručí",
"option_require_tls": "Vyžadovat šifrované doručení",
"option_require_tls_hint": "Doručit jen přes TLS, jinak vrátit",
+ "option_priority": "Priorita",
+ "option_priority_high": "Vysoká",
+ "option_priority_normal": "Normální",
+ "option_priority_low": "Nízká",
"validation": {
"recipient_required": "Chcete-li zprávu odeslat, přidejte příjemce",
"body_required": "Napište zprávu nebo připojte soubor",
diff --git a/locales/da/common.json b/locales/da/common.json
index 51c8ceec0..9c6f3e524 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -713,6 +713,10 @@
"option_dsn_hint": "Få en rapport, når serveren leverer den",
"option_require_tls": "Kræv krypteret levering",
"option_require_tls_hint": "Lever kun over TLS, ellers afvis",
+ "option_priority": "Prioritet",
+ "option_priority_high": "Høj",
+ "option_priority_normal": "Normal",
+ "option_priority_low": "Lav",
"validation": {
"recipient_required": "Tilføj en modtager for at sende",
"body_required": "Skriv en besked eller vedhæft en fil",
diff --git a/locales/de/common.json b/locales/de/common.json
index de27864a8..384a54252 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "Bericht erhalten, sobald der Server zustellt",
"option_require_tls": "Verschlüsselte Zustellung erzwingen",
"option_require_tls_hint": "Nur über TLS zustellen, sonst zurückweisen",
+ "option_priority": "Wichtigkeit",
+ "option_priority_high": "Hoch",
+ "option_priority_normal": "Normal",
+ "option_priority_low": "Niedrig",
"validation": {
"recipient_required": "Empfänger hinzufügen zum Senden",
"body_required": "Nachricht schreiben oder Datei anhängen",
diff --git a/locales/en/common.json b/locales/en/common.json
index db28c520e..6956458e6 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -715,6 +715,10 @@
"option_dsn_hint": "Get a report when the server delivers it",
"option_require_tls": "Require encrypted delivery",
"option_require_tls_hint": "Only deliver over TLS, otherwise bounce",
+ "option_priority": "Priority",
+ "option_priority_high": "High",
+ "option_priority_normal": "Normal",
+ "option_priority_low": "Low",
"validation": {
"recipient_required": "Add a recipient to send",
"body_required": "Write a message or attach a file",
diff --git a/locales/es/common.json b/locales/es/common.json
index 2abec1cfc..a36de1fcf 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "Recibir un informe cuando el servidor lo entregue",
"option_require_tls": "Exigir entrega cifrada",
"option_require_tls_hint": "Entregar solo por TLS; si no, devolver",
+ "option_priority": "Prioridad",
+ "option_priority_high": "Alta",
+ "option_priority_normal": "Normal",
+ "option_priority_low": "Baja",
"validation": {
"recipient_required": "Agregue un destinatario para enviar",
"body_required": "Escriba un mensaje o adjunte un archivo",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index eae1ce922..f758332e0 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -714,6 +714,10 @@
"option_dsn_hint": "دریافت گزارش وقتی سرور آن را تحویل داد",
"option_require_tls": "الزام تحویل رمزگذاریشده",
"option_require_tls_hint": "فقط از طریق TLS تحویل شود، وگرنه برگشت بخورد",
+ "option_priority": "اولویت",
+ "option_priority_high": "بالا",
+ "option_priority_normal": "عادی",
+ "option_priority_low": "پایین",
"validation": {
"recipient_required": "یک گیرنده اضافه کنید",
"body_required": "پیامی بنویسید یا فایلی پیوست کنید",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 87b1ff2ac..360344280 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "Recevoir un rapport quand le serveur le remet",
"option_require_tls": "Exiger une remise chiffrée",
"option_require_tls_hint": "Remettre uniquement via TLS, sinon rejeter",
+ "option_priority": "Priorité",
+ "option_priority_high": "Haute",
+ "option_priority_normal": "Normale",
+ "option_priority_low": "Basse",
"validation": {
"recipient_required": "Ajoutez un destinataire pour envoyer",
"body_required": "Rédigez un message ou joignez un fichier",
diff --git a/locales/he/common.json b/locales/he/common.json
index 82dcdf9e0..af81ffcfe 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -658,6 +658,10 @@
"option_dsn_hint": "לקבל דוח כשהשרת מוסר את ההודעה",
"option_require_tls": "חיוב מסירה מוצפנת",
"option_require_tls_hint": "למסור רק דרך TLS, אחרת להחזיר",
+ "option_priority": "עדיפות",
+ "option_priority_high": "גבוהה",
+ "option_priority_normal": "רגילה",
+ "option_priority_low": "נמוכה",
"validation": {
"recipient_required": "הוסף נמען לשליחה",
"body_required": "כתוב הודעה או צרף קובץ",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index f74e81b4d..9daea5636 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -714,6 +714,10 @@
"option_dsn_hint": "Jelentés, amikor a szerver kézbesíti",
"option_require_tls": "Titkosított kézbesítés megkövetelése",
"option_require_tls_hint": "Csak TLS-en kézbesítse, különben utasítsa vissza",
+ "option_priority": "Fontosság",
+ "option_priority_high": "Magas",
+ "option_priority_normal": "Normál",
+ "option_priority_low": "Alacsony",
"validation": {
"recipient_required": "Adj meg egy címzettet a küldéshez",
"body_required": "Írj üzenetet vagy csatolj fájlt",
diff --git a/locales/it/common.json b/locales/it/common.json
index 84510e66f..98455bd0b 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "Ricevi un rapporto quando il server lo consegna",
"option_require_tls": "Richiedi consegna cifrata",
"option_require_tls_hint": "Consegna solo tramite TLS, altrimenti respingi",
+ "option_priority": "Priorità",
+ "option_priority_high": "Alta",
+ "option_priority_normal": "Normale",
+ "option_priority_low": "Bassa",
"validation": {
"recipient_required": "Aggiungi un destinatario per inviare",
"body_required": "Scrivi un messaggio o allega un file",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 47dda8a18..c5d491222 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "サーバーが配信したときにレポートを受け取ります",
"option_require_tls": "暗号化された配信を必須にする",
"option_require_tls_hint": "TLS でのみ配信し、できなければ返送します",
+ "option_priority": "重要度",
+ "option_priority_high": "高",
+ "option_priority_normal": "標準",
+ "option_priority_low": "低",
"validation": {
"recipient_required": "送信するには宛先を追加してください",
"body_required": "メッセージを入力するかファイルを添付してください",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index da0157508..90b6c76c3 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "서버가 배달하면 보고서를 받습니다",
"option_require_tls": "암호화된 배달 필수",
"option_require_tls_hint": "TLS로만 배달하고, 불가능하면 반송합니다",
+ "option_priority": "중요도",
+ "option_priority_high": "높음",
+ "option_priority_normal": "보통",
+ "option_priority_low": "낮음",
"validation": {
"recipient_required": "보내려면 받는 사람을 추가해 주세요",
"body_required": "메시지를 작성하거나 파일을 첨부해 주세요",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 4688d0c4a..e2a92d4fe 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "Saņemt atskaiti, kad serveris to piegādā",
"option_require_tls": "Pieprasīt šifrētu piegādi",
"option_require_tls_hint": "Piegādāt tikai caur TLS, citādi atgriezt",
+ "option_priority": "Prioritāte",
+ "option_priority_high": "Augsta",
+ "option_priority_normal": "Parasta",
+ "option_priority_low": "Zema",
"validation": {
"recipient_required": "Norādiet vismaz vienu saņēmēju",
"body_required": "Ierakstiet ziņojumu vai pievienojiet failu",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index d7d316662..e70511be7 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -714,6 +714,10 @@
"option_dsn_hint": "Сервер хүргэх үед тайлан авах",
"option_require_tls": "Шифрлэгдсэн хүргэлт шаардах",
"option_require_tls_hint": "Зөвхөн TLS-ээр хүргэх, үгүй бол буцаах",
+ "option_priority": "Чухал байдал",
+ "option_priority_high": "Өндөр",
+ "option_priority_normal": "Энгийн",
+ "option_priority_low": "Бага",
"validation": {
"recipient_required": "Илгээхийн тулд хүлээн авагч нэмэх",
"body_required": "Мессеж бичих эсвэл файл хавсаргах",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index ba60ece05..89774040d 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -714,6 +714,10 @@
"option_dsn_hint": "Få en rapport når serveren leverer den",
"option_require_tls": "Krev kryptert levering",
"option_require_tls_hint": "Lever bare over TLS, ellers avvis",
+ "option_priority": "Prioritet",
+ "option_priority_high": "Høy",
+ "option_priority_normal": "Normal",
+ "option_priority_low": "Lav",
"validation": {
"recipient_required": "Legg til minst én mottaker før du sender",
"body_required": "Skriv en melding eller legg ved en fil",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index c4665b4b8..9de3969c1 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "Een rapport krijgen zodra de server het bezorgt",
"option_require_tls": "Versleutelde bezorging vereisen",
"option_require_tls_hint": "Alleen via TLS bezorgen, anders terugsturen",
+ "option_priority": "Prioriteit",
+ "option_priority_high": "Hoog",
+ "option_priority_normal": "Normaal",
+ "option_priority_low": "Laag",
"validation": {
"recipient_required": "Voeg een ontvanger toe om te verzenden",
"body_required": "Schrijf een bericht of voeg een bestand toe",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 2f156f834..0a906c944 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "Otrzymaj raport, gdy serwer ją doręczy",
"option_require_tls": "Wymagaj szyfrowanego doręczenia",
"option_require_tls_hint": "Doręcz tylko przez TLS, inaczej odrzuć",
+ "option_priority": "Priorytet",
+ "option_priority_high": "Wysoki",
+ "option_priority_normal": "Normalny",
+ "option_priority_low": "Niski",
"validation": {
"recipient_required": "Dodaj odbiorcę, aby wysłać",
"body_required": "Napisz wiadomość lub załącz plik",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 484f0e9a1..81320ee8c 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "Receber um relatório quando o servidor entregar",
"option_require_tls": "Exigir entrega cifrada",
"option_require_tls_hint": "Entregar só via TLS; caso contrário, devolver",
+ "option_priority": "Prioridade",
+ "option_priority_high": "Alta",
+ "option_priority_normal": "Normal",
+ "option_priority_low": "Baixa",
"validation": {
"recipient_required": "Adicione um destinatário para enviar",
"body_required": "Escreva uma mensagem ou anexe um arquivo",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index d7c130bf2..9c7327de4 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -714,6 +714,10 @@
"option_dsn_hint": "Primește un raport când serverul îl livrează",
"option_require_tls": "Cere livrare criptată",
"option_require_tls_hint": "Livrează doar prin TLS, altfel respinge",
+ "option_priority": "Prioritate",
+ "option_priority_high": "Ridicată",
+ "option_priority_normal": "Normală",
+ "option_priority_low": "Scăzută",
"validation": {
"recipient_required": "Adăugați un destinatar căruia să îi trimiteți mesajul",
"body_required": "Scrieți un mesaj sau atașați un fișier",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index b01cc0775..f631ac4cd 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "Получить отчёт, когда сервер доставит письмо",
"option_require_tls": "Требовать шифрованную доставку",
"option_require_tls_hint": "Доставлять только через TLS, иначе вернуть",
+ "option_priority": "Важность",
+ "option_priority_high": "Высокая",
+ "option_priority_normal": "Обычная",
+ "option_priority_low": "Низкая",
"validation": {
"recipient_required": "Укажите получателя для отправки",
"body_required": "Напишите сообщение или прикрепите файл",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index a57c46912..8407f6fd3 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -714,6 +714,10 @@
"option_dsn_hint": "Dostať správu, keď ju server doručí",
"option_require_tls": "Vyžadovať šifrované doručenie",
"option_require_tls_hint": "Doručiť len cez TLS, inak vrátiť",
+ "option_priority": "Priorita",
+ "option_priority_high": "Vysoká",
+ "option_priority_normal": "Normálna",
+ "option_priority_low": "Nízka",
"validation": {
"recipient_required": "Pridajte príjemcu na odoslanie",
"body_required": "Napíšte správu alebo priložte súbor",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index e906053b8..c16b8f142 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -714,6 +714,10 @@
"option_dsn_hint": "Sunucu teslim ettiğinde rapor al",
"option_require_tls": "Şifreli teslimat zorunlu",
"option_require_tls_hint": "Yalnızca TLS ile teslim et, yoksa geri çevir",
+ "option_priority": "Öncelik",
+ "option_priority_high": "Yüksek",
+ "option_priority_normal": "Normal",
+ "option_priority_low": "Düşük",
"validation": {
"recipient_required": "Göndermek için bir alıcı ekleyin",
"body_required": "Bir ileti yazın veya dosya ekleyin",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 296480cb6..68f4dde96 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "Отримати звіт, коли сервер доставить лист",
"option_require_tls": "Вимагати шифровану доставку",
"option_require_tls_hint": "Доставляти лише через TLS, інакше повернути",
+ "option_priority": "Важливість",
+ "option_priority_high": "Висока",
+ "option_priority_normal": "Звичайна",
+ "option_priority_low": "Низька",
"validation": {
"recipient_required": "Додайте одержувача для надсилання",
"body_required": "Напишіть повідомлення або прикріпіть файл",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index edb372a62..9806f83b1 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -714,6 +714,10 @@
"option_dsn_hint": "伺服器投遞後收到報告",
"option_require_tls": "要求加密投遞",
"option_require_tls_hint": "僅透過 TLS 投遞,否則退回",
+ "option_priority": "優先順序",
+ "option_priority_high": "高",
+ "option_priority_normal": "一般",
+ "option_priority_low": "低",
"validation": {
"recipient_required": "請新增收件者",
"body_required": "請輸入郵件內容或新增附件",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 34fc9d1f0..8240f4d49 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -711,6 +711,10 @@
"option_dsn_hint": "服务器投递后收到报告",
"option_require_tls": "要求加密投递",
"option_require_tls_hint": "仅通过 TLS 投递,否则退回",
+ "option_priority": "优先级",
+ "option_priority_high": "高",
+ "option_priority_normal": "普通",
+ "option_priority_low": "低",
"validation": {
"recipient_required": "请添加收件人",
"body_required": "请输入邮件内容或添加附件",
diff --git a/stores/email-store.ts b/stores/email-store.ts
index e508ca6b3..6a33a21f6 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types";
import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
import type { IJMAPClient, SearchEmailsResult } from "@/lib/jmap/client-interface";
+import type { MessagePriority } from "@/lib/message-priority";
import { withSearchSnippets, type DeferredSnippetBatch } from "@/lib/search-snippet";
import { afterRequestDispatch } from "@/lib/after-request-dispatch";
import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store";
@@ -315,7 +316,7 @@ interface EmailStore {
loadMoreEmails: (client: IJMAPClient) => Promise;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise;
fetchQuota: (client: IJMAPClient) => Promise;
- sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; localAccountId?: string }) => Promise;
+ sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; localAccountId?: string }) => Promise;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise;
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean, emailKey?: string) => Promise;
From 107d731af77d674017b879f577a100996752511a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 02:12:24 +0200
Subject: [PATCH 046/238] feat: remove a message from just the open folder when
it is in several #615
---
...l-context-menu-remove-from-folder.test.tsx | 117 +++++++++++
components/email/email-context-menu.tsx | 27 +++
components/email/email-list.tsx | 45 +++-
components/email/email-viewer.tsx | 28 +++
components/email/mv-list-toolbar.tsx | 18 +-
components/mail/mail-app.tsx | 12 +-
hooks/use-remove-from-folder.ts | 32 +++
lib/demo/demo-client.ts | 11 +
lib/jmap/client-interface.ts | 6 +
lib/jmap/client.ts | 17 +-
lib/plugin-hook-catalog.ts | 4 +-
lib/plugin-mail-hooks.ts | 3 +-
locales/ar/common.json | 3 +
locales/ca/common.json | 3 +
locales/cs/common.json | 3 +
locales/da/common.json | 3 +
locales/de/common.json | 3 +
locales/en/common.json | 3 +
locales/es/common.json | 3 +
locales/fa/common.json | 3 +
locales/fr/common.json | 3 +
locales/he/common.json | 3 +
locales/hu/common.json | 3 +
locales/it/common.json | 3 +
locales/ja/common.json | 3 +
locales/ko/common.json | 3 +
locales/lv/common.json | 3 +
locales/mn/common.json | 3 +
locales/nb/common.json | 3 +
locales/nl/common.json | 3 +
locales/pl/common.json | 3 +
locales/pt/common.json | 3 +
locales/ro/common.json | 3 +
locales/ru/common.json | 3 +
locales/sk/common.json | 3 +
locales/tr/common.json | 3 +
locales/uk/common.json | 3 +
locales/zh-TW/common.json | 3 +
locales/zh/common.json | 3 +
.../email-store-remove-from-folder.test.ts | 194 ++++++++++++++++++
stores/email-store.ts | 118 +++++++++++
41 files changed, 705 insertions(+), 8 deletions(-)
create mode 100644 components/email/__tests__/email-context-menu-remove-from-folder.test.tsx
create mode 100644 hooks/use-remove-from-folder.ts
create mode 100644 stores/__tests__/email-store-remove-from-folder.test.ts
diff --git a/components/email/__tests__/email-context-menu-remove-from-folder.test.tsx b/components/email/__tests__/email-context-menu-remove-from-folder.test.tsx
new file mode 100644
index 000000000..1b84b48c9
--- /dev/null
+++ b/components/email/__tests__/email-context-menu-remove-from-folder.test.tsx
@@ -0,0 +1,117 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { beforeEach, describe, it, expect, vi } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { useEmailStore } from '@/stores/email-store';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('../rules-menu', () => ({ RulesContextSubMenu: () => null }));
+vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() }));
+
+const { EmailContextMenu } = await import('../email-context-menu');
+
+// "Remove from this folder" (#615): only for a message that is in the open
+// folder and in at least one other, and only while one folder is open.
+
+function mailbox(id: string, extra: Partial = {}): Mailbox {
+ return {
+ id, name: id, role: undefined, parentId: undefined, totalEmails: 0, unreadEmails: 0,
+ myRights: { mayAddItems: true }, ...extra,
+ } as unknown as Mailbox;
+}
+
+const mailboxes = [mailbox('inbox', { role: 'inbox', name: 'Inbox' }), mailbox('work', { name: 'Work' })];
+const inBoth = { id: 'e1', keywords: { $seen: true }, mailboxIds: { inbox: true, work: true } } as unknown as Email;
+const inboxOnly = { id: 'e2', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email;
+
+function openFolder(id: string, extra: Partial> = {}) {
+ useEmailStore.setState({
+ mailboxes,
+ accountMailboxes: {},
+ viewingAccountId: null,
+ selectedMailbox: id,
+ selectedKeyword: null,
+ isUnifiedView: false,
+ crossView: null,
+ isScheduledView: false,
+ searchQuery: '',
+ searchFilters: DEFAULT_SEARCH_FILTERS,
+ searchMailboxId: '',
+ ...extra,
+ });
+}
+
+function renderMenu(props: Partial> = {}) {
+ return render(
+ {}}
+ menuRef={{ current: null }}
+ mailboxes={mailboxes}
+ selectedMailbox="inbox"
+ onMoveToMailbox={vi.fn()}
+ onRemoveFromFolder={vi.fn()}
+ {...props}
+ />,
+ );
+}
+
+beforeEach(() => openFolder('inbox'));
+
+describe('Remove from this folder in the message menu', () => {
+ it('is offered for a message that is also in another folder, and runs', () => {
+ const onRemoveFromFolder = vi.fn();
+ renderMenu({ onRemoveFromFolder });
+ fireEvent.click(screen.getByTestId('ctx-remove-from-folder'));
+ expect(onRemoveFromFolder).toHaveBeenCalledTimes(1);
+ });
+
+ it('is hidden for a message that is only in the open folder', () => {
+ renderMenu({ email: inboxOnly });
+ expect(screen.queryByTestId('ctx-remove-from-folder')).toBeNull();
+ });
+
+ it('is hidden for a message that is not in the open folder', () => {
+ openFolder('work');
+ renderMenu({ email: { ...inboxOnly, mailboxIds: { inbox: true, other: true } } as unknown as Email, selectedMailbox: 'work' });
+ expect(screen.queryByTestId('ctx-remove-from-folder')).toBeNull();
+ });
+
+ it('is hidden where no single folder is open', () => {
+ openFolder('inbox', { isUnifiedView: true });
+ const { unmount } = renderMenu();
+ expect(screen.queryByTestId('ctx-remove-from-folder')).toBeNull();
+ unmount();
+
+ openFolder('inbox', { selectedKeyword: '$label:red' });
+ const second = renderMenu();
+ expect(screen.queryByTestId('ctx-remove-from-folder')).toBeNull();
+ second.unmount();
+
+ // A search across all folders has no folder to leave either.
+ openFolder('inbox', { searchQuery: 'invoice', searchMailboxId: '' });
+ renderMenu();
+ expect(screen.queryByTestId('ctx-remove-from-folder')).toBeNull();
+ });
+
+ it('is offered in a search scoped to the open folder', () => {
+ openFolder('inbox', { searchQuery: 'invoice', searchMailboxId: 'inbox' });
+ renderMenu();
+ expect(screen.getByTestId('ctx-remove-from-folder')).toBeTruthy();
+ });
+
+ it('offers it for a selection only when every message is in another folder too', () => {
+ const onBatchRemoveFromFolder = vi.fn();
+ const batch = { isMultiSelect: true, selectedCount: 2, onBatchRemoveFromFolder };
+ const { unmount } = renderMenu({ ...batch, batchEmails: [inBoth, inboxOnly] });
+ expect(screen.queryByTestId('ctx-remove-from-folder')).toBeNull();
+ unmount();
+
+ const both = { ...inBoth, id: 'e3' } as Email;
+ renderMenu({ ...batch, batchEmails: [inBoth, both] });
+ fireEvent.click(screen.getByTestId('ctx-remove-from-folder'));
+ expect(onBatchRemoveFromFolder).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx
index 9ab7ed72c..378c80786 100644
--- a/components/email/email-context-menu.tsx
+++ b/components/email/email-context-menu.tsx
@@ -3,6 +3,7 @@
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
+import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store";
import { Email, Mailbox } from "@/lib/jmap/types";
import {
ContextMenu,
@@ -25,6 +26,7 @@ import {
Trash2,
Archive,
FolderInput,
+ FolderX,
Tag,
Inbox,
Send,
@@ -94,6 +96,11 @@ interface EmailContextMenuProps {
onArchive?: () => void;
onSetTag?: (tagId: string | null) => void;
onMoveToMailbox?: (mailboxId: string) => void;
+ /**
+ * Take the message out of the open folder only (#615). Offered when the
+ * message is also in another folder and the list shows a single folder.
+ */
+ onRemoveFromFolder?: () => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onEditDraft?: () => void;
@@ -109,6 +116,9 @@ interface EmailContextMenuProps {
onBatchDelete?: () => void;
onBatchArchive?: () => void;
onBatchMoveToMailbox?: (mailboxId: string) => void;
+ /** The selected messages, for the batch "Remove from this folder". */
+ batchEmails?: Email[];
+ onBatchRemoveFromFolder?: () => void;
onBatchMarkAsSpam?: () => void;
onBatchUndoSpam?: () => void;
/**
@@ -162,6 +172,7 @@ export function EmailContextMenu({
onArchive,
onSetTag,
onMoveToMailbox,
+ onRemoveFromFolder,
onMarkAsSpam,
onUndoSpam,
onBatchMarkAsRead,
@@ -171,6 +182,8 @@ export function EmailContextMenu({
onBatchDelete,
onBatchArchive,
onBatchMoveToMailbox,
+ batchEmails,
+ onBatchRemoveFromFolder,
onBatchMarkAsSpam,
onBatchUndoSpam,
onBatchForwardAsAttachment,
@@ -198,6 +211,12 @@ export function EmailContextMenu({
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
+ // "Remove from this folder" needs one open folder and messages that are
+ // also in another one; for a selection, every selected message must be.
+ const folderToLeave = useEmailStore(removalFolder);
+ const canRemove = showBatchActions
+ ? !!onBatchRemoveFromFolder && !!batchEmails?.length && batchEmails.every((e) => canRemoveFromFolder(e, folderToLeave))
+ : !!onRemoveFromFolder && canRemoveFromFolder(email, folderToLeave);
// Move-to submenu: the message's own account first (#1149)
const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, {
@@ -406,6 +425,14 @@ export function EmailContextMenu({
)}
)}
+ {canRemove && (
+ handleAction(showBatchActions ? onBatchRemoveFromFolder! : onRemoveFromFolder!)}
+ />
+ )}
>
);
const copyItem = (
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index 42914a057..ea9e32b55 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -10,11 +10,12 @@ import { listVerificationCode } from "@/lib/verification-code";
import { EmailContextMenu, type CopyTargetAccount } from "./email-context-menu";
import { BatchTagButton } from "./batch-tag-button";
import { cn, cleanPreview } from "@/lib/utils";
-import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, X } from "@/components/icons";
+import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, X, FolderX } from "@/components/icons";
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
-import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, type ListRow } from "@/stores/email-store";
+import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, type ListRow } from "@/stores/email-store";
+import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { useShallow } from "zustand/react/shallow";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
@@ -451,6 +452,25 @@ export function EmailList({
[emails, threadEmailsCache, selectedEmailKeys],
);
const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]);
+ // "Remove from this folder" (#615): offered for a selection only when every
+ // selected message is also in another folder.
+ const removeFromFolder = useRemoveFromFolder();
+ const folderToLeave = useEmailStore(removalFolder);
+ const selectedEmailsForRemoval = useMemo(
+ () => (hasSelection ? resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }) : []),
+ [hasSelection, emails, threadEmailsCache, selectedEmailKeys],
+ );
+ const selectionCanLeaveFolder = selectedEmailsForRemoval.length > 0
+ && selectedEmailsForRemoval.every((email) => canRemoveFromFolder(email, folderToLeave));
+ const handleBatchRemoveFromFolder = async () => {
+ if (isProcessing) return;
+ setIsProcessing(true);
+ try {
+ await removeFromFolder(resolveSelectedEmails(useEmailStore.getState()));
+ } finally {
+ setTimeout(() => setIsProcessing(false), 500);
+ }
+ };
const listSlotProps = useMemo(
() => ({ mailboxId: selectedMailbox || null, mailboxRole: effectiveMailboxRole ?? null }),
[selectedMailbox, effectiveMailboxRole],
@@ -769,6 +789,24 @@ export function EmailList({
)}
)}
+ {selectionCanLeaveFolder && (
+
+ {isProcessing ? (
+
+ ) : (
+
+ )}
+
+ )}
onArchive?.(contextMenuEmail!)}
onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)}
+ onRemoveFromFolder={() => void removeFromFolder([contextMenuEmail!])}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
@@ -1025,6 +1064,8 @@ export function EmailList({
: undefined,
});
}}
+ batchEmails={selectedEmailsForRemoval}
+ onBatchRemoveFromFolder={() => void handleBatchRemoveFromFolder()}
onBatchMoveToMailbox={async (mailboxId) => {
if (!client) return;
const count = selectedEmailKeys.size;
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index dd0cc525a..be5fd5cc1 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -75,6 +75,7 @@ import {
PanelRightOpen,
Send,
FolderInput,
+ FolderX,
Inbox,
Folder,
Sun,
@@ -163,6 +164,11 @@ interface EmailViewerProps {
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onMoveToMailbox?: (mailboxId: string) => void;
+ /**
+ * Take the message out of the open folder only (#615). Passed only when
+ * the message is also in another folder.
+ */
+ onRemoveFromFolder?: () => void;
onBack?: () => void;
onNavigateNext?: () => void;
onNavigatePrev?: () => void;
@@ -1009,6 +1015,7 @@ export function EmailViewer({
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
+ onRemoveFromFolder,
onBack,
onNavigateNext,
onNavigatePrev,
@@ -4039,6 +4046,17 @@ export function EmailViewer({
)}
)}
+ {onRemoveFromFolder && (
+
{ onRemoveFromFolder(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
+ >
+
+ {tContextMenu('remove_from_folder')}
+
+ )}
{/* Overflow: tag - submenu */}
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
)}
+ {onRemoveFromFolder && (
+
{ onRemoveFromFolder(); setMoreMenuOpen(false); }}
+ className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
+ >
+
+ {tContextMenu('remove_from_folder')}
+
+ )}
{/* Tag (opens sub-view) */}
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
0
+ && selectedEmails.every((email) => canRemoveFromFolder(email, folderToLeave));
// The same targets the row's own context menu offers, with the account of
// the selection leading (#1149).
@@ -512,6 +520,14 @@ export function MvListToolbar({
disabled={isProcessing}
/>
)}
+ {selectionCanLeaveFolder && (
+ run(() => removeFromFolder(resolveSelectedEmails(useEmailStore.getState())))}
+ disabled={isProcessing}
+ />
+ )}
{
if (!client) return;
const destinationList = [mailboxes, ...Object.values(accountMailboxes)]
@@ -5288,6 +5295,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMoveToMailbox={async (mailboxId) => {
if (selectedEmail) await handleMoveToMailbox(selectedEmail.id, mailboxId);
}}
+ onRemoveFromFolder={openEmailCanLeaveFolder ? () => {
+ if (selectedEmail) void removeFromFolder([selectedEmail]);
+ } : undefined}
className={isMobile ? "flex-1" : undefined}
/>
diff --git a/hooks/use-remove-from-folder.ts b/hooks/use-remove-from-folder.ts
new file mode 100644
index 000000000..3f7921e4a
--- /dev/null
+++ b/hooks/use-remove-from-folder.ts
@@ -0,0 +1,32 @@
+"use client";
+
+import { useCallback } from "react";
+import { useTranslations } from "next-intl";
+import { useAuthStore } from "@/stores/auth-store";
+import { useEmailStore, removalFolder } from "@/stores/email-store";
+import { runBatchEmailAction } from "@/lib/email-action-toast";
+import { localizeMailboxName } from "@/lib/mailbox-label";
+import type { Email } from "@/lib/jmap/types";
+
+/**
+ * "Remove from this folder" (#615): takes the messages out of the open folder
+ * only, keeps them in their other folders, and toasts the outcome.
+ */
+export function useRemoveFromFolder(): (emails: Email[]) => Promise {
+ const t = useTranslations("notifications");
+ const tSidebar = useTranslations("sidebar");
+ const client = useAuthStore((state) => state.client);
+ const removeFromMailbox = useEmailStore((state) => state.removeFromMailbox);
+
+ return useCallback(async (emails: Email[]) => {
+ if (!client || emails.length === 0) return;
+ const folder = removalFolder(useEmailStore.getState());
+ const mailbox = folder
+ ? localizeMailboxName(folder.role, folder.name, (key) => tSidebar(`mailboxes.${key}`))
+ : "";
+ await runBatchEmailAction(() => removeFromMailbox(client, emails), {
+ success: t("removed_from_folder", { mailbox }),
+ error: t("remove_from_folder_failed"),
+ });
+ }, [client, removeFromMailbox, t, tSidebar]);
+}
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index 4b0c3755b..70adb7021 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -528,6 +528,17 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
+ async removeEmailsFromMailbox(emailIds: string[], mailboxId: string): Promise {
+ for (const email of this.data.emails) {
+ if (!emailIds.includes(email.id) || !email.mailboxIds[mailboxId]) continue;
+ // A message has to stay in at least one mailbox.
+ if (Object.keys(email.mailboxIds).filter(id => email.mailboxIds[id]).length < 2) continue;
+ const { [mailboxId]: _removed, ...rest } = email.mailboxIds;
+ email.mailboxIds = rest;
+ }
+ this.recalcMailboxCounts();
+ }
+
async emptyMailbox(mailboxId: string, _accountId?: string): Promise {
const before = this.data.emails.length;
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 1bee2a5de..88178c184 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -312,6 +312,12 @@ export interface IJMAPClient {
accountId?: string,
): Promise;
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise;
+ /**
+ * Take messages out of one mailbox and leave every other mailbox they are
+ * in alone (a `mailboxIds/: null` patch). A message must stay in at
+ * least one mailbox, so the server refuses this for a message only in it.
+ */
+ removeEmailsFromMailbox(emailIds: string[], mailboxId: string, accountId?: string): Promise;
emptyMailbox(mailboxId: string, accountId?: string): Promise;
moveMailboxContents(fromMailboxId: string, toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise;
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index b772fa274..4d692c51c 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -4,7 +4,7 @@ import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { CalendarEventUpdateOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions } from "./client-interface";
import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet";
import { batched, itemsPerRequest } from "./request-limits";
-import { keywordPointer } from "./patch-pointer";
+import { keywordPointer, pointerToken } from "./patch-pointer";
import { FirstTouchGate } from "./first-touch-gate";
import { debug } from "@/lib/debug";
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
@@ -2937,6 +2937,21 @@ export class JMAPClient implements IJMAPClient {
}
}
+ async removeEmailsFromMailbox(emailIds: string[], mailboxId: string, accountId?: string): Promise {
+ if (emailIds.length === 0) return;
+ // A pointer patch, not a full mailboxIds replacement: the other mailboxes
+ // the message is in stay as the server has them, even when the copy the
+ // client holds is out of date.
+ const patch = { [`mailboxIds/${pointerToken(mailboxId)}`]: null };
+ for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
+ const update = Object.fromEntries(batch.map(id => [id, { ...patch }]));
+ const response = await this.request([
+ ["Email/set", { accountId: accountId || this.accountId, update }, "0"],
+ ]);
+ assertSetSucceeded(response, 'Failed to remove from folder');
+ }
+ }
+
async emptyMailbox(mailboxId: string, accountId?: string): Promise {
const targetAccountId = accountId || this.accountId;
const batchSize = Math.min(500, this.getMaxObjectsInSet());
diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts
index de80235bf..2598fc23c 100644
--- a/lib/plugin-hook-catalog.ts
+++ b/lib/plugin-hook-catalog.ts
@@ -85,8 +85,8 @@ export const HOOK_CATALOG = {
// ─── Mail: organizing ──────────────────────────────────────
onBeforeEmailDelete: H('mail-organize', 'intercept', 'email:write', '{ emailIds, permanent }', 'before messages are deleted or moved to Trash'),
onAfterEmailDelete: H('mail-organize', 'observer', 'email:read', '{ emailIds, permanent }', 'after messages were deleted'),
- onBeforeEmailMove: H('mail-organize', 'intercept', 'email:write', '{ emailIds, fromMailboxId, toMailboxId }', 'before messages are moved'),
- onAfterEmailMove: H('mail-organize', 'observer', 'email:read', '{ emailIds, fromMailboxId, toMailboxId }', 'after messages were moved'),
+ onBeforeEmailMove: H('mail-organize', 'intercept', 'email:write', '{ emailIds, fromMailboxId, toMailboxId }', 'before messages are moved (toMailboxId null: removed from fromMailboxId only, still in other folders)'),
+ onAfterEmailMove: H('mail-organize', 'observer', 'email:read', '{ emailIds, fromMailboxId, toMailboxId }', 'after messages were moved (toMailboxId null: removed from fromMailboxId only, still in other folders)'),
onBeforeEmailArchive: H('mail-organize', 'intercept', 'email:write', '{ emailIds }', 'before messages are archived'),
onEmailArchive: H('mail-organize', 'observer', 'email:read', '{ emailIds }', 'after messages were archived'),
onEmailUnarchive: H('mail-organize', 'observer', 'email:read', '{ emailIds, toMailboxId }', 'after messages were moved out of the archive'),
diff --git a/lib/plugin-mail-hooks.ts b/lib/plugin-mail-hooks.ts
index d222ddf13..da278fe04 100644
--- a/lib/plugin-mail-hooks.ts
+++ b/lib/plugin-mail-hooks.ts
@@ -62,7 +62,8 @@ export function isArchiveFolder(mailboxId: string | null | undefined, mailboxes:
export interface EmailMove {
emailIds: string[];
fromMailboxId: string | null;
- toMailboxId: string;
+ /** Null when the messages only leave fromMailboxId and stay in their other folders. */
+ toMailboxId: string | null;
}
/**
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 8b332a17d..4089fc848 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -882,6 +882,8 @@
"emails_moved": "تم نقل {count} رسالة",
"moved_to_mailbox": "تم النقل إلى {mailbox}",
"move_failed": "فشل النقل",
+ "removed_from_folder": "تمت الإزالة من {mailbox}",
+ "remove_from_folder_failed": "تعذرت الإزالة من هذا المجلد",
"emails_copied": "تم نسخ {count} رسالة",
"copy_failed": "فشل النسخ",
"move_error": "تعذّر نقل الرسائل إلى المجلد المحدد",
@@ -2524,6 +2526,7 @@
"unpin": "إلغاء التثبيت",
"move_to": "نقل إلى...",
"copy_to_account": "نسخ إلى حساب...",
+ "remove_from_folder": "إزالة من هذا المجلد",
"archive": "أرشفة",
"delete": "حذف",
"mark_as_spam": "الإبلاغ عن بريد مزعج",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 7da2ae79a..b64d5c60d 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} correus moguts",
"moved_to_mailbox": "Mogut a {mailbox}",
"move_failed": "No s'ha pogut moure",
+ "removed_from_folder": "S'ha tret de {mailbox}",
+ "remove_from_folder_failed": "No s'ha pogut treure d'aquesta carpeta",
"emails_copied": "{count} correus copiats",
"copy_failed": "No s'ha pogut copiar",
"move_error": "No s'han pogut moure els correus a la carpeta seleccionada",
@@ -2524,6 +2526,7 @@
"unpin": "Desfixa",
"move_to": "Mou a...",
"copy_to_account": "Copia a un compte...",
+ "remove_from_folder": "Treu d'aquesta carpeta",
"archive": "Arxiva",
"delete": "Suprimeix",
"mark_as_spam": "Denuncia com a brossa",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index c4a737b8d..a8cf7a0a7 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} zpráv bylo přesunuto",
"moved_to_mailbox": "Přesunuto do složky {mailbox}",
"move_failed": "Přesun selhal",
+ "removed_from_folder": "Odebráno ze složky {mailbox}",
+ "remove_from_folder_failed": "Odebrání z této složky se nezdařilo",
"emails_copied": "{count} zpráv bylo zkopírováno",
"copy_failed": "Kopírování selhalo",
"move_error": "Zprávu nelze přesunout do vybrané složky",
@@ -2524,6 +2526,7 @@
"unpin": "Odepnout",
"move_to": "Přesunout do...",
"copy_to_account": "Kopírovat do účtu...",
+ "remove_from_folder": "Odebrat z této složky",
"archive": "Archivovat",
"delete": "Odstranit",
"mark_as_spam": "Nahlásit spam",
diff --git a/locales/da/common.json b/locales/da/common.json
index 9c6f3e524..cdf5b2fc7 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} e-mails flyttet",
"moved_to_mailbox": "Flyttet til {mailbox}",
"move_failed": "Flytning mislykkedes",
+ "removed_from_folder": "Fjernet fra {mailbox}",
+ "remove_from_folder_failed": "Kunne ikke fjerne fra denne mappe",
"emails_copied": "{count} e-mails kopieret",
"copy_failed": "Kopiering mislykkedes",
"move_error": "Kunne ikke flytte e-mails til den valgte mappe",
@@ -2524,6 +2526,7 @@
"unpin": "Frigør",
"move_to": "Flyt til...",
"copy_to_account": "Kopiér til konto...",
+ "remove_from_folder": "Fjern fra denne mappe",
"archive": "Arkivér",
"delete": "Slet",
"mark_as_spam": "Rapportér spam",
diff --git a/locales/de/common.json b/locales/de/common.json
index 384a54252..e77c5d5b6 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} E-Mails verschoben",
"moved_to_mailbox": "Nach {mailbox} verschoben",
"move_failed": "Verschieben fehlgeschlagen",
+ "removed_from_folder": "Aus {mailbox} entfernt",
+ "remove_from_folder_failed": "Konnte nicht aus diesem Ordner entfernt werden",
"emails_copied": "{count} E-Mails kopiert",
"copy_failed": "Kopieren fehlgeschlagen",
"move_error": "E-Mails konnten nicht in den ausgewählten Ordner verschoben werden",
@@ -2524,6 +2526,7 @@
"unpin": "Lösen",
"move_to": "Verschieben nach...",
"copy_to_account": "In Konto kopieren...",
+ "remove_from_folder": "Aus diesem Ordner entfernen",
"archive": "Archivieren",
"delete": "Löschen",
"mark_as_spam": "Spam melden",
diff --git a/locales/en/common.json b/locales/en/common.json
index 6956458e6..5202c8878 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} emails moved",
"moved_to_mailbox": "Moved to {mailbox}",
"move_failed": "Move failed",
+ "removed_from_folder": "Removed from {mailbox}",
+ "remove_from_folder_failed": "Could not remove from this folder",
"emails_copied": "{count} emails copied",
"copy_failed": "Copy failed",
"move_error": "Could not move emails to the selected folder",
@@ -2524,6 +2526,7 @@
"unpin": "Unpin",
"move_to": "Move to...",
"copy_to_account": "Copy to account...",
+ "remove_from_folder": "Remove from this folder",
"archive": "Archive",
"delete": "Delete",
"mark_as_spam": "Report spam",
diff --git a/locales/es/common.json b/locales/es/common.json
index a36de1fcf..c119e7947 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} correos movidos",
"moved_to_mailbox": "Movido a {mailbox}",
"move_failed": "Error al mover",
+ "removed_from_folder": "Quitado de {mailbox}",
+ "remove_from_folder_failed": "No se pudo quitar de esta carpeta",
"emails_copied": "{count} correos copiados",
"copy_failed": "Error al copiar",
"move_error": "No se pudieron mover los correos a la carpeta seleccionada",
@@ -2524,6 +2526,7 @@
"unpin": "Desanclar",
"move_to": "Mover a...",
"copy_to_account": "Copiar a cuenta...",
+ "remove_from_folder": "Quitar de esta carpeta",
"archive": "Archivar",
"delete": "Eliminar",
"mark_as_spam": "Reportar spam",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index f758332e0..b75c3728f 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} ایمیل منتقل شد",
"moved_to_mailbox": "منتقل شد به {mailbox}",
"move_failed": "انتقال ناموفق بود",
+ "removed_from_folder": "از {mailbox} حذف شد",
+ "remove_from_folder_failed": "حذف از این پوشه انجام نشد",
"emails_copied": "{count} ایمیل کپی شد",
"copy_failed": "کپی ناموفق بود",
"move_error": "انتقال ایمیلها به پوشه انتخاب شده ممکن نیست",
@@ -2524,6 +2526,7 @@
"unpin": "برداشتن سنجاق",
"move_to": "انتقال به...",
"copy_to_account": "کپی به حساب...",
+ "remove_from_folder": "حذف از این پوشه",
"archive": "بایگانی",
"delete": "حذف",
"mark_as_spam": "گزارش هرزنامه",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 360344280..fd93dc0c7 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} emails déplacés",
"moved_to_mailbox": "Déplacé vers {mailbox}",
"move_failed": "Échec du déplacement",
+ "removed_from_folder": "Retiré de {mailbox}",
+ "remove_from_folder_failed": "Impossible de retirer de ce dossier",
"emails_copied": "{count} emails copiés",
"copy_failed": "Échec de la copie",
"move_error": "Impossible de déplacer les emails vers le dossier sélectionné",
@@ -2524,6 +2526,7 @@
"unpin": "Désépingler",
"move_to": "Déplacer vers...",
"copy_to_account": "Copier vers un compte...",
+ "remove_from_folder": "Retirer de ce dossier",
"archive": "Archiver",
"delete": "Supprimer",
"mark_as_spam": "Signaler comme spam",
diff --git a/locales/he/common.json b/locales/he/common.json
index af81ffcfe..13e845790 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -847,6 +847,8 @@
"emails_moved": "דוא\"ל{count}הועברו",
"moved_to_mailbox": "הועבר אל{mailbox}",
"move_failed": "המהלך נכשל",
+ "removed_from_folder": "הוסר מ-{mailbox}",
+ "remove_from_folder_failed": "לא ניתן להסיר מתיקייה זו",
"emails_copied": "{count} הודעות הועתקו",
"copy_failed": "ההעתקה נכשלה",
"move_error": "לא ניתן היה להעביר הודעות דוא\"ל לתיקיה שנבחרה",
@@ -2490,6 +2492,7 @@
"unpin": "בטל הצמדה",
"move_to": "העבר ל...",
"copy_to_account": "העתק לחשבון...",
+ "remove_from_folder": "הסרה מתיקייה זו",
"archive": "ארכיון",
"delete": "לִמְחוֹק",
"mark_as_spam": "דווח על ספאם",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 9daea5636..0c0c176c0 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} e-mail áthelyezve",
"moved_to_mailbox": "Áthelyezve ide: {mailbox}",
"move_failed": "Áthelyezés sikertelen",
+ "removed_from_folder": "Eltávolítva innen: {mailbox}",
+ "remove_from_folder_failed": "Nem sikerült eltávolítani ebből a mappából",
"emails_copied": "{count} e-mail másolva",
"copy_failed": "Másolás sikertelen",
"move_error": "Nem sikerült áthelyezni az e-maileket a kiválasztott mappába",
@@ -2524,6 +2526,7 @@
"unpin": "Rögzítés feloldása",
"move_to": "Áthelyezés ide...",
"copy_to_account": "Másolás fiókba...",
+ "remove_from_folder": "Eltávolítás ebből a mappából",
"archive": "Archiválás",
"delete": "Törlés",
"mark_as_spam": "Spam jelentése",
diff --git a/locales/it/common.json b/locales/it/common.json
index 98455bd0b..788a0df6a 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} messaggi spostati",
"moved_to_mailbox": "Spostato in {mailbox}",
"move_failed": "Spostamento non riuscito",
+ "removed_from_folder": "Rimosso da {mailbox}",
+ "remove_from_folder_failed": "Impossibile rimuovere da questa cartella",
"emails_copied": "{count} messaggi copiati",
"copy_failed": "Copia non riuscita",
"move_error": "Impossibile spostare i messaggi nella cartella selezionata",
@@ -2524,6 +2526,7 @@
"unpin": "Non fissare più",
"move_to": "Sposta in...",
"copy_to_account": "Copia in account...",
+ "remove_from_folder": "Rimuovi da questa cartella",
"archive": "Archivia",
"delete": "Elimina",
"mark_as_spam": "Segnala come spam",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index c5d491222..6c4370fc5 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count}件のメールを移動しました",
"moved_to_mailbox": "{mailbox}に移動しました",
"move_failed": "移動に失敗しました",
+ "removed_from_folder": "{mailbox} から削除しました",
+ "remove_from_folder_failed": "このフォルダから削除できませんでした",
"emails_copied": "{count}件のメールをコピーしました",
"copy_failed": "コピーに失敗しました",
"move_error": "選択したフォルダにメールを移動できませんでした",
@@ -2524,6 +2526,7 @@
"unpin": "ピン留めを外す",
"move_to": "移動...",
"copy_to_account": "アカウントにコピー...",
+ "remove_from_folder": "このフォルダから削除",
"archive": "アーカイブ",
"delete": "削除",
"mark_as_spam": "迷惑メールを報告",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 90b6c76c3..0aff63e54 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count}개의 메일이 이동되었어요",
"moved_to_mailbox": "{mailbox}함으로 이동되었어요",
"move_failed": "이동에 실패했어요",
+ "removed_from_folder": "{mailbox}에서 제거됨",
+ "remove_from_folder_failed": "이 폴더에서 제거하지 못했습니다",
"emails_copied": "{count}개의 메일이 복사되었어요",
"copy_failed": "복사에 실패했어요",
"move_error": "선택한 폴더로 메일을 이동할 수 없어요",
@@ -2524,6 +2526,7 @@
"unpin": "고정 해제",
"move_to": "이동...",
"copy_to_account": "계정으로 복사...",
+ "remove_from_folder": "이 폴더에서 제거",
"archive": "보관",
"delete": "삭제",
"mark_as_spam": "스팸 신고",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index e2a92d4fe..ba600a13a 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} vēstules pārvietotas",
"moved_to_mailbox": "Pārvietots uz {mailbox}",
"move_failed": "Pārvietošanas kļūda",
+ "removed_from_folder": "Noņemts no {mailbox}",
+ "remove_from_folder_failed": "Neizdevās noņemt no šīs mapes",
"emails_copied": "{count} vēstules nokopētas",
"copy_failed": "Kopēšanas kļūda",
"move_error": "Neizdevās pārvietot vēstules uz izvēlēto mapi",
@@ -2524,6 +2526,7 @@
"unpin": "Atspraust",
"move_to": "Pārvietot uz...",
"copy_to_account": "Kopēt uz kontu...",
+ "remove_from_folder": "Noņemt no šīs mapes",
"archive": "Arhivēt",
"delete": "Dzēst",
"mark_as_spam": "Atzīmēt kā mēstuli",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index e70511be7..57a0740e7 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} имэйлийг зөөсөн",
"moved_to_mailbox": "{mailbox}",
"move_failed": "Зөөж чадсангүй",
+ "removed_from_folder": "{mailbox}-с хасагдлаа",
+ "remove_from_folder_failed": "Энэ хавтаснаас хасаж чадсангүй",
"emails_copied": "{count} имэйлийг хуулсан",
"copy_failed": "Хуулж чадсангүй",
"move_error": "Имэйлүүдийг сонгосон хавтас руу зөөж чадсангүй",
@@ -2524,6 +2526,7 @@
"unpin": "Буулгах",
"move_to": "...",
"copy_to_account": "Бүртгэл рүү хуулах...",
+ "remove_from_folder": "Энэ хавтаснаас хасах",
"archive": "Архив",
"delete": "Устгах",
"mark_as_spam": "Спам мэдээлэх",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 89774040d..18cf5471e 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} e-poster er flyttet",
"moved_to_mailbox": "Flyttet til {mailbox}",
"move_failed": "Kunne ikke flytte e-posten",
+ "removed_from_folder": "Fjernet fra {mailbox}",
+ "remove_from_folder_failed": "Kunne ikke fjerne fra denne mappen",
"emails_copied": "{count} e-poster er kopiert",
"copy_failed": "Kunne ikke kopiere",
"move_error": "Kunne ikke flytte e-poster til den valgte mappen",
@@ -2524,6 +2526,7 @@
"unpin": "Løsne",
"move_to": "Flytt til...",
"copy_to_account": "Kopier til konto...",
+ "remove_from_folder": "Fjern fra denne mappen",
"archive": "Arkiver",
"delete": "Slett",
"mark_as_spam": "Rapporter som søppelpost",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 9de3969c1..a2615a9f0 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} e-mails verplaatst",
"moved_to_mailbox": "Verplaatst naar {mailbox}",
"move_failed": "Verplaatsen mislukt",
+ "removed_from_folder": "Verwijderd uit {mailbox}",
+ "remove_from_folder_failed": "Kon niet uit deze map verwijderen",
"emails_copied": "{count} e-mails gekopieerd",
"copy_failed": "Kopiëren mislukt",
"move_error": "Kan e-mails niet verplaatsen naar de geselecteerde map",
@@ -2524,6 +2526,7 @@
"unpin": "Losmaken",
"move_to": "Verplaatsen naar...",
"copy_to_account": "Kopiëren naar account...",
+ "remove_from_folder": "Uit deze map verwijderen",
"archive": "Archiveren",
"delete": "Verwijderen",
"mark_as_spam": "Spam melden",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 0a906c944..1c381b5c1 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} wiadomości przeniesionych",
"moved_to_mailbox": "Przeniesiono do {mailbox}",
"move_failed": "Przenoszenie nie powiodło się",
+ "removed_from_folder": "Usunięto z folderu {mailbox}",
+ "remove_from_folder_failed": "Nie udało się usunąć z tego folderu",
"emails_copied": "{count} wiadomości skopiowanych",
"copy_failed": "Kopiowanie nie powiodło się",
"move_error": "Nie można przenieść wiadomości do wybranego folderu",
@@ -2524,6 +2526,7 @@
"unpin": "Odepnij",
"move_to": "Przenieś do...",
"copy_to_account": "Kopiuj do konta...",
+ "remove_from_folder": "Usuń z tego folderu",
"archive": "Archiwizuj",
"delete": "Usuń",
"mark_as_spam": "Zgłoś spam",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 81320ee8c..e5778af2b 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} e-mails movidos",
"moved_to_mailbox": "Movido para {mailbox}",
"move_failed": "Falha ao mover",
+ "removed_from_folder": "Removido de {mailbox}",
+ "remove_from_folder_failed": "Não foi possível remover desta pasta",
"emails_copied": "{count} e-mails copiados",
"copy_failed": "Falha ao copiar",
"move_error": "Não foi possível mover os e-mails para a pasta selecionada",
@@ -2524,6 +2526,7 @@
"unpin": "Desafixar",
"move_to": "Mover para...",
"copy_to_account": "Copiar para conta...",
+ "remove_from_folder": "Remover desta pasta",
"archive": "Arquivar",
"delete": "Excluir",
"mark_as_spam": "Reportar spam",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 9c7327de4..cc8a2ccb9 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} e-mailuri mutate",
"moved_to_mailbox": "Mutată la {mailbox}",
"move_failed": "Mutarea a eșuat",
+ "removed_from_folder": "Eliminat din {mailbox}",
+ "remove_from_folder_failed": "Nu s-a putut elimina din acest dosar",
"emails_copied": "{count} e-mailuri copiate",
"copy_failed": "Copierea a eșuat",
"move_error": "Nu s-au putut muta e-mailurile în folderul selectat",
@@ -2524,6 +2526,7 @@
"unpin": "Anulează fixarea",
"move_to": "Mergi la...",
"copy_to_account": "Copiază în cont...",
+ "remove_from_folder": "Elimină din acest dosar",
"archive": "Arhivează",
"delete": "Șterge",
"mark_as_spam": "Raportează spamul",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index f631ac4cd..1dcc14dee 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} писем перемещено",
"moved_to_mailbox": "Перемещено в {mailbox}",
"move_failed": "Ошибка перемещения",
+ "removed_from_folder": "Убрано из папки {mailbox}",
+ "remove_from_folder_failed": "Не удалось убрать из этой папки",
"emails_copied": "{count} писем скопировано",
"copy_failed": "Ошибка копирования",
"move_error": "Не удалось переместить письма в выбранную папку",
@@ -2524,6 +2526,7 @@
"unpin": "Открепить",
"move_to": "Переместить в...",
"copy_to_account": "Копировать в аккаунт...",
+ "remove_from_folder": "Убрать из этой папки",
"archive": "В архив",
"delete": "Удалить",
"mark_as_spam": "Отметить как спам",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 8407f6fd3..c35244b42 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} e-mailov bolo presunutých",
"moved_to_mailbox": "Presunuté do {mailbox}",
"move_failed": "Presun zlyhal",
+ "removed_from_folder": "Odstránené z priečinka {mailbox}",
+ "remove_from_folder_failed": "Odstránenie z tohto priečinka zlyhalo",
"emails_copied": "{count} e-mailov bolo skopírovaných",
"copy_failed": "Kopírovanie zlyhalo",
"move_error": "E-maily sa nedá presunúť do vybratej priečinky",
@@ -2524,6 +2526,7 @@
"unpin": "Odopnúť",
"move_to": "Presunúť do...",
"copy_to_account": "Kopírovať do účtu...",
+ "remove_from_folder": "Odstrániť z tohto priečinka",
"archive": "Archivovať",
"delete": "Odstrániť",
"mark_as_spam": "Nahlásiť spam",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index c16b8f142..689c4b24f 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} e-posta taşındı",
"moved_to_mailbox": "{mailbox} klasörüne taşındı",
"move_failed": "Taşıma başarısız",
+ "removed_from_folder": "{mailbox} klasöründen kaldırıldı",
+ "remove_from_folder_failed": "Bu klasörden kaldırılamadı",
"emails_copied": "{count} e-posta kopyalandı",
"copy_failed": "Kopyalama başarısız",
"move_error": "E-postalar seçilen klasöre taşınamadı",
@@ -2524,6 +2526,7 @@
"unpin": "Sabitlemeyi kaldır",
"move_to": "Şuraya taşı...",
"copy_to_account": "Hesaba kopyala...",
+ "remove_from_folder": "Bu klasörden kaldır",
"archive": "Arşivle",
"delete": "Sil",
"mark_as_spam": "Spam bildir",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 68f4dde96..d5a992996 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} електронних листів переміщено",
"moved_to_mailbox": "Переміщено до {mailbox}",
"move_failed": "Не вдалося перемістити",
+ "removed_from_folder": "Прибрано з папки {mailbox}",
+ "remove_from_folder_failed": "Не вдалося прибрати з цієї папки",
"emails_copied": "{count} електронних листів скопійовано",
"copy_failed": "Не вдалося скопіювати",
"move_error": "Не вдалося перемістити електронні листи до вибраної папки",
@@ -2524,6 +2526,7 @@
"unpin": "Відкріпити",
"move_to": "Перейти до...",
"copy_to_account": "Копіювати в обліковий запис...",
+ "remove_from_folder": "Прибрати з цієї папки",
"archive": "Архів",
"delete": "Видалити",
"mark_as_spam": "Повідомити про спам",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 9806f83b1..7da62998d 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} 封郵件已移動",
"moved_to_mailbox": "已移至 {mailbox}",
"move_failed": "移動郵件失敗",
+ "removed_from_folder": "已從 {mailbox} 移除",
+ "remove_from_folder_failed": "無法從此資料夾移除",
"emails_copied": "{count} 封郵件已複製",
"copy_failed": "複製失敗",
"move_error": "無法將郵件移至選取的信件匣",
@@ -2524,6 +2526,7 @@
"unpin": "取消固定",
"move_to": "移至…",
"copy_to_account": "複製到帳戶…",
+ "remove_from_folder": "從此資料夾移除",
"archive": "封存",
"delete": "刪除",
"mark_as_spam": "回報垃圾郵件",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 8240f4d49..f0d94539d 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -882,6 +882,8 @@
"emails_moved": "{count} 封邮件已移动",
"moved_to_mailbox": "已移至 {mailbox}",
"move_failed": "移动失败",
+ "removed_from_folder": "已从 {mailbox} 中移除",
+ "remove_from_folder_failed": "无法从此文件夹中移除",
"emails_copied": "{count} 封邮件已复制",
"copy_failed": "复制失败",
"move_error": "无法将邮件移至所选文件夹",
@@ -2524,6 +2526,7 @@
"unpin": "取消固定",
"move_to": "移动到…",
"copy_to_account": "复制到账户…",
+ "remove_from_folder": "从此文件夹中移除",
"archive": "归档",
"delete": "删除",
"mark_as_spam": "举报垃圾邮件",
diff --git a/stores/__tests__/email-store-remove-from-folder.test.ts b/stores/__tests__/email-store-remove-from-folder.test.ts
new file mode 100644
index 000000000..f50c3008a
--- /dev/null
+++ b/stores/__tests__/email-store-remove-from-folder.test.ts
@@ -0,0 +1,194 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useEmailStore } from '../email-store';
+import { useAuthStore } from '../auth-store';
+import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+import { emailKeyFor } from '@/lib/thread-utils';
+import { JMAPClient } from '@/lib/jmap/client';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+// "Remove from this folder" (#615): the message leaves the open folder and
+// stays in its other folders.
+
+function makeMailbox(id: string, overrides: Partial = {}): Mailbox {
+ return {
+ id, name: id, sortOrder: 0, totalEmails: 10, unreadEmails: 3, totalThreads: 10, unreadThreads: 3,
+ isSubscribed: true, isShared: false, ...overrides,
+ } as Mailbox;
+}
+
+function makeEmail(id: string, overrides: Partial = {}): Email {
+ return {
+ id, threadId: `t-${id}`, subject: id, receivedAt: '2026-01-01T00:00:00Z',
+ keywords: {}, mailboxIds: { inbox: true, work: true }, ...overrides,
+ } as Email;
+}
+
+const mailboxes = [makeMailbox('inbox', { role: 'inbox' }), makeMailbox('work')];
+
+function makeClient(): IJMAPClient {
+ return {
+ getAccountId: () => 'me',
+ removeEmailsFromMailbox: vi.fn().mockResolvedValue(undefined),
+ moveEmail: vi.fn().mockResolvedValue(undefined),
+ } as unknown as IJMAPClient;
+}
+
+let client: IJMAPClient;
+
+function openFolder(folderId: string, emails: Email[], extra: Record = {}) {
+ useEmailStore.setState({
+ mailboxes,
+ accountMailboxes: {},
+ viewingAccountId: null,
+ selectedMailbox: folderId,
+ selectedKeyword: null,
+ isUnifiedView: false,
+ crossView: null,
+ isScheduledView: false,
+ searchQuery: '',
+ searchFilters: DEFAULT_SEARCH_FILTERS,
+ searchMailboxId: '',
+ emails,
+ selectedEmail: null,
+ selectedEmailKeys: new Set(),
+ threadEmailsCache: new Map(),
+ error: null,
+ ...extra,
+ });
+}
+
+beforeEach(() => {
+ clearAllHooks();
+ client = makeClient();
+ useAuthStore.setState({ activeAccountId: 'acct', client } as never);
+});
+
+afterEach(() => clearAllHooks());
+
+describe('removeFromMailbox', () => {
+ it('takes the message out of the open folder only and drops its row', async () => {
+ const e1 = makeEmail('e1');
+ const e2 = makeEmail('e2', { keywords: { $seen: true } });
+ openFolder('inbox', [e1, e2], { selectedEmail: e1, selectedEmailKeys: new Set([emailKeyFor(e1)]) });
+
+ expect(await useEmailStore.getState().removeFromMailbox(client, [e1])).toBe(true);
+
+ expect(client.removeEmailsFromMailbox).toHaveBeenCalledWith(['e1'], 'inbox', undefined);
+ expect(client.moveEmail).not.toHaveBeenCalled();
+ const state = useEmailStore.getState();
+ expect(state.emails.map(e => e.id)).toEqual(['e2']);
+ expect(state.selectedEmail?.id).toBe('e2');
+ expect(state.selectedEmailKeys.size).toBe(0);
+ // e1 was unread: the open folder loses one message and one unread; the
+ // other folder still has it.
+ const inbox = state.mailboxes.find(m => m.id === 'inbox')!;
+ const work = state.mailboxes.find(m => m.id === 'work')!;
+ expect([inbox.totalEmails, inbox.unreadEmails]).toEqual([9, 2]);
+ expect([work.totalEmails, work.unreadEmails]).toEqual([10, 3]);
+ });
+
+ it('sends the bare id of a shared folder to its owner account', async () => {
+ const shared = [
+ makeMailbox('S:inbox', { role: 'inbox', isShared: true, accountId: 'S', originalId: 'inbox' }),
+ makeMailbox('S:work', { isShared: true, accountId: 'S', originalId: 'work' }),
+ ];
+ const e1 = makeEmail('e1', { mailboxIds: { 'S:inbox': true, 'S:work': true } });
+ openFolder('S:inbox', [e1], { mailboxes: shared });
+
+ expect(await useEmailStore.getState().removeFromMailbox(client, [e1])).toBe(true);
+ expect(client.removeEmailsFromMailbox).toHaveBeenCalledWith(['e1'], 'inbox', 'S');
+ });
+
+ it('leaves a message alone that is only in the open folder', async () => {
+ const only = makeEmail('e1', { mailboxIds: { inbox: true } });
+ openFolder('inbox', [only]);
+
+ expect(await useEmailStore.getState().removeFromMailbox(client, [only])).toBe(false);
+ expect(client.removeEmailsFromMailbox).not.toHaveBeenCalled();
+ expect(useEmailStore.getState().emails).toHaveLength(1);
+ });
+
+ it('does nothing in a view without one open folder', async () => {
+ const e1 = makeEmail('e1');
+ openFolder('inbox', [e1], { selectedKeyword: '$label:red' });
+
+ expect(await useEmailStore.getState().removeFromMailbox(client, [e1])).toBe(false);
+ expect(client.removeEmailsFromMailbox).not.toHaveBeenCalled();
+ });
+
+ it('matches rows by key, so a namesake from another account stays', async () => {
+ const mine = makeEmail('e1');
+ const namesake = makeEmail('e1', { sourceAccountId: 'other', sourceClientAccountId: 'other-login' });
+ openFolder('inbox', [mine, namesake]);
+
+ await useEmailStore.getState().removeFromMailbox(client, [mine]);
+ expect(useEmailStore.getState().emails).toEqual([namesake]);
+ });
+
+ it('asks the move hooks first and reports the removal', async () => {
+ const e1 = makeEmail('e1');
+ openFolder('inbox', [e1]);
+ const before = vi.fn(() => true);
+ const after = vi.fn();
+ emailHooks.onBeforeEmailMove.register('t', before);
+ emailHooks.onAfterEmailMove.register('t', after);
+
+ await useEmailStore.getState().removeFromMailbox(client, [e1]);
+
+ const payload = { emailIds: ['e1'], fromMailboxId: 'inbox', toMailboxId: null };
+ expect(before).toHaveBeenCalledWith(payload);
+ expect(after).toHaveBeenCalledWith(payload);
+ });
+
+ it('stops when a plugin refuses', async () => {
+ const e1 = makeEmail('e1');
+ openFolder('inbox', [e1]);
+ emailHooks.onBeforeEmailMove.register('t', () => false);
+
+ expect(await useEmailStore.getState().removeFromMailbox(client, [e1])).toBe(false);
+ expect(client.removeEmailsFromMailbox).not.toHaveBeenCalled();
+ expect(useEmailStore.getState().emails).toHaveLength(1);
+ });
+
+ it('keeps the row when the server refuses', async () => {
+ const e1 = makeEmail('e1');
+ openFolder('inbox', [e1]);
+ vi.mocked(client.removeEmailsFromMailbox).mockRejectedValue(new Error('invalidProperties'));
+
+ await expect(useEmailStore.getState().removeFromMailbox(client, [e1])).rejects.toThrow('invalidProperties');
+ expect(useEmailStore.getState().emails).toHaveLength(1);
+ expect(useEmailStore.getState().mailboxes.find(m => m.id === 'inbox')!.totalEmails).toBe(10);
+ });
+});
+
+describe('JMAPClient.removeEmailsFromMailbox', () => {
+ type Call = [string, Record, string];
+
+ it('patches only the one mailbox away', async () => {
+ const jmap = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
+ Object.assign(jmap, { accountId: 'me' });
+ const request = vi.spyOn(jmap as unknown as { request: (c: Call[]) => Promise }, 'request')
+ .mockResolvedValue({ methodResponses: [['Email/set', { updated: { e1: null, e2: null } }, '0']] });
+
+ await jmap.removeEmailsFromMailbox(['e1', 'e2'], 'a/b', 'shared');
+
+ expect(request).toHaveBeenCalledTimes(1);
+ expect(request.mock.calls[0][0]).toEqual([
+ ['Email/set', {
+ accountId: 'shared',
+ update: { e1: { 'mailboxIds/a~1b': null }, e2: { 'mailboxIds/a~1b': null } },
+ }, '0'],
+ ]);
+ });
+
+ it('fails when the server refuses a message', async () => {
+ const jmap = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
+ Object.assign(jmap, { accountId: 'me' });
+ vi.spyOn(jmap as unknown as { request: (c: Call[]) => Promise }, 'request')
+ .mockResolvedValue({ methodResponses: [['Email/set', { notUpdated: { e1: { type: 'invalidProperties' } } }, '0']] });
+
+ await expect(jmap.removeEmailsFromMailbox(['e1'], 'inbox')).rejects.toThrow();
+ });
+});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 6a33a21f6..47bd5d9f4 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -333,6 +333,13 @@ interface EmailStore {
moveToMailboxCrossAware: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise;
moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise;
moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise;
+ /**
+ * Take messages out of the open folder only, leaving them in their other
+ * folders (#615). Acts on those of `emails` that removalFolder and
+ * canRemoveFromFolder allow; resolves false when none qualify or a plugin
+ * said no.
+ */
+ removeFromMailbox: (client: IJMAPClient, emails: Email[]) => Promise;
/**
* Move emails across JMAP accounts. JMAP has no native cross-account move,
* so for each email we fetch the source's raw RFC822 blob, import it into
@@ -1704,6 +1711,40 @@ function emailInMailbox(
return false;
}
+/**
+ * The folder "Remove from this folder" takes messages out of (#615): the
+ * folder the list shows, when it shows exactly one. A search scoped to one
+ * folder counts as that folder. Null in the unified, cross-account, tag and
+ * scheduled views and in a search across folders, where no single folder is
+ * open.
+ */
+export function removalFolder(
+ state: Pick,
+): Mailbox | null {
+ if (state.isUnifiedView || state.crossView || state.selectedKeyword || state.isScheduledView) return null;
+ const searching = !!state.searchQuery || !isFilterEmpty(state.searchFilters);
+ if (searching && isAllFoldersSearchScope(state.searchMailboxId)) return null;
+ const folderId = searching ? state.searchMailboxId : state.selectedMailbox;
+ if (!folderId) return null;
+ return mailboxesInView(state).find(mb => mb.id === folderId) ?? null;
+}
+
+/**
+ * Whether a message can leave `folder` and still be somewhere: it is in that
+ * folder and in at least one other. JMAP lets a message sit in several
+ * mailboxes at once, but never in none.
+ */
+export function canRemoveFromFolder(
+ email: Pick,
+ folder: Mailbox | null,
+): boolean {
+ if (!folder || !emailInMailbox(email, folder)) return false;
+ return Object.values(email.mailboxIds ?? {}).filter(Boolean).length > 1;
+}
+
// Apply a per-mailbox counter adjustment to the mailbox list that actually holds
// the email's folders, and return the matching `set()` partial.
//
@@ -3624,6 +3665,83 @@ export const useEmailStore = create((set, get) => ({
return true;
},
+ removeFromMailbox: async (client, targets): Promise => {
+ const folder = removalFolder(get());
+ if (!folder) return false;
+ // Rows are matched by key, not id: ids of different accounts can collide.
+ const removable = new Map();
+ for (const email of targets) {
+ if (canRemoveFromFolder(email, folder)) removable.set(emailKeyFor(email), email);
+ }
+ if (removable.size === 0) return false;
+
+ const mailboxes = resolveActionMailboxes();
+ const leaving = [...removable.values()];
+ // Plugins see it as a move out of the folder that lands nowhere new.
+ const move = { emailIds: [...new Set(leaving.map(e => e.id))], fromMailboxId: folder.id, toMailboxId: null };
+ if (!(await allowEmailMove(move, mailboxes))) return false;
+
+ try {
+ await resolveActionClient(client).removeEmailsFromMailbox(
+ move.emailIds,
+ folder.originalId || folder.id,
+ folder.isShared ? folder.accountId : undefined,
+ );
+ } catch (error) {
+ set({ error: error instanceof Error ? error.message : 'Failed to remove from folder' });
+ throw error;
+ }
+
+ const isLeaving = (e: Email) => removable.has(emailKeyFor(e));
+ const unread = leaving.filter(e => !e.keywords?.$seen).length;
+ const withoutFolder = (e: Email): Email => {
+ const mailboxIds = { ...e.mailboxIds };
+ delete mailboxIds[folder.id];
+ if (folder.originalId) delete mailboxIds[folder.originalId];
+ return { ...e, mailboxIds };
+ };
+
+ set((state) => {
+ // Only the open folder loses them; their other folders keep them.
+ const adjust = (mb: Mailbox): Mailbox => mb.id !== folder.id ? mb : {
+ ...mb,
+ totalEmails: Math.max(0, mb.totalEmails - leaving.length),
+ unreadEmails: Math.max(0, mb.unreadEmails - unread),
+ };
+ const inViewingList = !!state.viewingAccountId && !!state.accountMailboxes[state.viewingAccountId];
+ const counters = inViewingList
+ ? { accountMailboxes: { ...state.accountMailboxes, [state.viewingAccountId!]: state.accountMailboxes[state.viewingAccountId!].map(adjust) } }
+ : { mailboxes: state.mailboxes.map(adjust) };
+
+ // The open message moves on to its neighbour, as after a move.
+ let selectedEmail = state.selectedEmail;
+ if (selectedEmail && isLeaving(selectedEmail)) {
+ const index = state.emails.findIndex(e => emailKeyFor(e) === emailKeyFor(selectedEmail!));
+ const after = state.emails.slice(index + 1).find(e => !isLeaving(e));
+ const before = state.emails.slice(0, Math.max(0, index)).reverse().find(e => !isLeaving(e));
+ selectedEmail = index === -1 ? null : (removable.size === 1 ? after ?? before ?? null : null);
+ }
+
+ // Loaded thread members keep their other folders.
+ let threadEmailsCache = state.threadEmailsCache;
+ for (const [key, members] of state.threadEmailsCache) {
+ if (!members.some(isLeaving)) continue;
+ if (threadEmailsCache === state.threadEmailsCache) threadEmailsCache = new Map(state.threadEmailsCache);
+ threadEmailsCache.set(key, members.map(e => (isLeaving(e) ? withoutFolder(e) : e)));
+ }
+
+ return {
+ emails: state.emails.filter(e => !isLeaving(e)),
+ selectedEmail,
+ selectedEmailKeys: dropFromSelection(state, isLeaving),
+ threadEmailsCache,
+ ...counters,
+ };
+ });
+ emitEmailMove(move, mailboxes);
+ return true;
+ },
+
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId, sourceJmapAccountId, options): Promise => {
if (emailIdsBySource.size === 0) return true;
const keepOriginal = options?.keepOriginal === true;
From 76dbaf532a1b76d1d157ee4ba6878578a0bbc048 Mon Sep 17 00:00:00 2001
From: Viktor Shulika MacBook Air M5
Date: Sat, 17 Oct 2026 19:47:39 +0200
Subject: [PATCH 047/238] fix: reply to a message from another connected
account goes to that account
With several accounts connected, the composer treated every account's
identities as "own" when deciding whether a message was self-sent (#703).
A message that account B sent to account A therefore looked self-sent
when replying from A, so the reply was addressed to the original To -
account A itself - and From was switched to B.
Self-sent now means "sent by the account holding the original":
buildReplyRecipients takes an optional selfSentEmails list (defaulting to
ownEmails, so single-account behaviour is unchanged), and the composer
passes the source account's identities there and to the From selection.
Reply-all still drops every connected account's addresses.
Co-Authored-By: Claude Opus 5.5
---
components/email/email-composer.tsx | 22 +++++++++++------
lib/__tests__/reply-recipients.test.ts | 34 ++++++++++++++++++++++++++
lib/reply-recipients.ts | 10 +++++++-
3 files changed, 58 insertions(+), 8 deletions(-)
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index a65bc5986..8353eb3d3 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -538,17 +538,25 @@ export function EmailComposer({
identities.map(i => i.email),
[composerAccount?.email, composerAccount?.username, replyAccount?.email, replyAccount?.username],
);
- const ownIdentityEmailsKey = ownIdentityEmails.map((e) => e.toLowerCase()).join(',');
+ // "Self-sent" means sent by the account holding the original. A message
+ // another connected account sent to this one was received here, and its
+ // reply must go back to that sender, not to this account's own address.
+ const sourceAccount = replyAccount ?? composerAccount;
+ const sourceAccountEmails = ownReplyAddresses(
+ sourceAccountIdentities.map(i => i.email),
+ [sourceAccount?.email, sourceAccount?.username],
+ );
+ const ownIdentityEmailsKey = [...ownIdentityEmails, '|', ...sourceAccountEmails].map((e) => e.toLowerCase()).join(',');
// Initialize with reply/forward data if provided
const getInitialTo = (): Recipient[] => {
if (mode !== 'reply' && mode !== 'replyAll') return [];
- return buildReplyRecipients(replyTo, mode, ownIdentityEmails).to.map(toRecipient);
+ return buildReplyRecipients(replyTo, mode, ownIdentityEmails, sourceAccountEmails).to.map(toRecipient);
};
const getInitialCc = (): Recipient[] => {
if (mode !== 'replyAll') return [];
- return buildReplyRecipients(replyTo, mode, ownIdentityEmails).cc.map(toRecipient);
+ return buildReplyRecipients(replyTo, mode, ownIdentityEmails, sourceAccountEmails).cc.map(toRecipient);
};
const getInitialSubject = () => {
@@ -1194,10 +1202,10 @@ export function EmailComposer({
// identity that sent it. Resolving from the recipients here would pick the
// *other* party's address - and on a catch-all domain it would even set a
// From override to their address. An identity on the message's own account
- // wins when two accounts share the address.
- if (isSelfSent({ from: replyTo?.from }, identities.map(i => i.email).filter(Boolean))) {
- const senderIdentityId = findDraftIdentityId(sourceAccountIdentities, replyTo?.from?.[0])
- ?? findDraftIdentityId(identities, replyTo?.from?.[0]);
+ // wins when two accounts share the address. Only the message's own account
+ // counts: mail another connected account sent here is a received message.
+ if (isSelfSent({ from: replyTo?.from }, sourceAccountIdentities.map(i => i.email).filter(Boolean))) {
+ const senderIdentityId = findDraftIdentityId(sourceAccountIdentities, replyTo?.from?.[0]);
if (senderIdentityId) {
setSelectedIdentityId(senderIdentityId);
return;
diff --git a/lib/__tests__/reply-recipients.test.ts b/lib/__tests__/reply-recipients.test.ts
index c3966e514..14ef07ec8 100644
--- a/lib/__tests__/reply-recipients.test.ts
+++ b/lib/__tests__/reply-recipients.test.ts
@@ -124,6 +124,40 @@ describe('buildReplyRecipients', () => {
});
});
+ describe('message from another connected account', () => {
+ // Two accounts connected: book@ sent this to mail@, and we reply from mail@.
+ const ALL = ['mail@hotel.example', 'book@hotel.example'];
+ const THIS_ACCOUNT = ['mail@hotel.example'];
+ const fromOtherAccount = {
+ from: [{ email: 'book@hotel.example', name: 'Booking' }],
+ to: [{ email: 'mail@hotel.example' }, { email: 'guest@other.com' }],
+ cc: [{ email: 'book@hotel.example' }],
+ };
+
+ it('replies to the other account, not back to ourselves', () => {
+ const { to, cc } = buildReplyRecipients(fromOtherAccount, 'reply', ALL, THIS_ACCOUNT);
+ expect(emails(to)).toEqual(['book@hotel.example']);
+ expect(cc).toEqual([]);
+ });
+
+ it('reply-all still drops every connected account from the other recipients', () => {
+ const { to, cc } = buildReplyRecipients(fromOtherAccount, 'replyAll', ALL, THIS_ACCOUNT);
+ expect(emails(to)).toEqual(['book@hotel.example', 'guest@other.com']);
+ expect(cc).toEqual([]);
+ });
+
+ it('still continues the thread for a message this account sent', () => {
+ const ownSent = { ...fromOtherAccount, from: [{ email: 'mail@hotel.example' }], to: [{ email: 'guest@other.com' }] };
+ const { to } = buildReplyRecipients(ownSent, 'reply', ALL, THIS_ACCOUNT);
+ expect(emails(to)).toEqual(['guest@other.com']);
+ });
+
+ it('without selfSentEmails every connected account counts as self (single-account behaviour)', () => {
+ const { to } = buildReplyRecipients(fromOtherAccount, 'reply', ALL);
+ expect(emails(to)).toEqual(['mail@hotel.example', 'guest@other.com']);
+ });
+ });
+
it('returns nothing without a source message', () => {
expect(buildReplyRecipients(undefined, 'replyAll', OWN)).toEqual({ to: [], cc: [] });
});
diff --git a/lib/reply-recipients.ts b/lib/reply-recipients.ts
index fdf260825..380980e00 100644
--- a/lib/reply-recipients.ts
+++ b/lib/reply-recipients.ts
@@ -95,11 +95,19 @@ export function isSelfSent(source: ReplySource | undefined, ownEmails: string[])
*
* A self-sent message with no visible recipients (Bcc-only) has nothing to
* continue to, so it falls back to the normal behaviour.
+ *
+ * `selfSentEmails` decides what counts as "self-sent" and defaults to
+ * `ownEmails`. With several connected accounts, `ownEmails` holds every
+ * account's addresses (so reply-all still drops all of them), but a message
+ * one of those accounts sent to another is a received message for the
+ * account it landed in: pass only that account's addresses here, or the
+ * reply goes back to the original To - the account itself.
*/
export function buildReplyRecipients(
source: ReplySource | undefined,
mode: 'reply' | 'replyAll',
ownEmails: string[],
+ selfSentEmails: string[] = ownEmails,
): ReplyRecipientsResult {
if (!source) return { to: [], cc: [] };
@@ -108,7 +116,7 @@ export function buildReplyRecipients(
const replyToList = withEmail(source.replyToAddresses);
const externalReplyTo = replyToList.filter((r) => !isOwnAddress(r.email, ownEmails));
- if (!externalReplyTo.length && isSelfSent(source, ownEmails)) {
+ if (!externalReplyTo.length && isSelfSent(source, selfSentEmails)) {
const originalTo = withEmail(source.to);
if (originalTo.length > 0) {
return {
From 2c6914ae62f6ecfaff234357f908f43e287e7ba1 Mon Sep 17 00:00:00 2001
From: Peterka35 <43301641+Peterka35@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:01:33 +0200
Subject: [PATCH 048/238] fix: pass the owning accountId when marking a thread
read
markThreadAsRead resolved each message through resolveEmailActionContext
but kept only the client and dropped the accountId, so batchMarkAsRead
always wrote to the reaching client's own account.
That hit two cases where the client and the owning account differ:
- a shared mailbox opened directly from the "Shared" sidebar section,
whose owner comes from the selected shared folder;
- a shared source in the unified view, whose messages are stamped with
sourceAccountId but reached through the login client.
The Email/set then went to the login's own account. The shared messages
stayed unread, and since JMAP ids are only unique within an account, a
message in the user's own account with the same id could be marked read
instead.
Group the ids by client and owning account, and pass the accountId to
batchMarkAsRead, as the batch and single-message paths already do.
---
.../email-store-shared-folder-actions.test.ts | 16 ++++++++++++++
.../__tests__/email-store-thread-key.test.ts | 15 ++++++++++++-
stores/email-store.ts | 21 ++++++++++++-------
3 files changed, 44 insertions(+), 8 deletions(-)
diff --git a/stores/__tests__/email-store-shared-folder-actions.test.ts b/stores/__tests__/email-store-shared-folder-actions.test.ts
index dd3e55830..2b606e8d0 100644
--- a/stores/__tests__/email-store-shared-folder-actions.test.ts
+++ b/stores/__tests__/email-store-shared-folder-actions.test.ts
@@ -142,6 +142,22 @@ describe('non-unified shared-folder batch action routing', () => {
expect(activeClient.batchMarkAsRead).toHaveBeenCalledWith(['e1', 'e2'], true, 'owner-x');
});
+ it('markThreadAsRead marks a shared conversation read on the owner account', async () => {
+ // Opening a conversation in the shared inbox. Its ids belong to owner-x;
+ // the login's own account may hold unrelated messages with the same ids.
+ useEmailStore.setState({
+ emails: [
+ makeEmail({ id: 'e1', threadId: 'thread-1', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }),
+ makeEmail({ id: 'e2', threadId: 'thread-1', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }),
+ ],
+ });
+
+ await useEmailStore.getState().markThreadAsRead(activeClient, 'thread-1');
+
+ expect(activeClient.batchMarkAsRead).toHaveBeenCalledTimes(1);
+ expect(activeClient.batchMarkAsRead).toHaveBeenCalledWith(['e1', 'e2'], true, 'owner-x');
+ });
+
it('batchArchive archives shared emails into the SHARED archive on the owner account', async () => {
await useEmailStore.getState().batchArchive(activeClient);
diff --git a/stores/__tests__/email-store-thread-key.test.ts b/stores/__tests__/email-store-thread-key.test.ts
index 76e6ad336..35ff056dd 100644
--- a/stores/__tests__/email-store-thread-key.test.ts
+++ b/stores/__tests__/email-store-thread-key.test.ts
@@ -134,13 +134,26 @@ describe('thread identity across accounts (#1012)', () => {
it('marks only the owning account\'s messages read when a thread is expanded', async () => {
await useEmailStore.getState().markThreadAsRead(clientA, threadKeyFor(a1));
- expect(clientA.batchMarkAsRead).toHaveBeenCalledWith(['a1'], true);
+ expect(clientA.batchMarkAsRead).toHaveBeenCalledWith(['a1'], true, 'acct-a');
expect(clientB.batchMarkAsRead).not.toHaveBeenCalled();
const byId = new Map(useEmailStore.getState().emails.map((e) => [e.id, e]));
expect(byId.get('a1')?.keywords?.$seen).toBe(true);
expect(byId.get('b1')?.keywords?.$seen).toBeUndefined();
});
+ it('marks a shared source\'s conversation read on the owner account, not the login\'s own', async () => {
+ // A shared (group) account in the unified view is reached through login-a
+ // but owns its ids; login-a's own account may hold a different "s1".
+ const shared = inAccount('a', 's1', { sourceAccountId: 'acct-shared' });
+ useEmailStore.setState({ emails: [a1, b1, shared] });
+
+ await useEmailStore.getState().markThreadAsRead(clientA, threadKeyFor(shared));
+
+ expect(clientA.batchMarkAsRead).toHaveBeenCalledTimes(1);
+ expect(clientA.batchMarkAsRead).toHaveBeenCalledWith(['s1'], true, 'acct-shared');
+ expect(clientB.batchMarkAsRead).not.toHaveBeenCalled();
+ });
+
it('keeps expansion state apart for same-id threads of different accounts', () => {
useEmailStore.getState().toggleThreadExpansion(threadKeyFor(a1));
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 47bd5d9f4..f568bb9b7 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -5742,20 +5742,27 @@ export const useEmailStore = create((set, get) => ({
const emailsById = new Map();
for (const e of allEmailMap.values()) emailsById.set(e.id, e);
- // Group unread IDs by account client
- const groups = new Map();
+ // Group unread IDs by account client AND owning JMAP account: a shared
+ // folder is reached through the login client, but its ids belong to the
+ // owner's account. Without the accountId the write lands in the login's
+ // own account, which may hold a different message with the same id.
+ const groups = new Map>();
for (const id of unreadIds) {
const email = emailsById.get(id)!;
- const { client: actionClient } = resolveEmailActionContext(email, client);
- if (!groups.has(actionClient)) groups.set(actionClient, []);
- groups.get(actionClient)!.push(id);
+ const { client: actionClient, accountId } = resolveEmailActionContext(email, client);
+ if (!groups.has(actionClient)) groups.set(actionClient, new Map());
+ const byAccount = groups.get(actionClient)!;
+ if (!byAccount.has(accountId)) byAccount.set(accountId, []);
+ byAccount.get(accountId)!.push(id);
}
// Mark all as read on the server
try {
await Promise.all(
- Array.from(groups.entries()).map(([actionClient, emailIds]) =>
- actionClient.batchMarkAsRead(emailIds, true)
+ Array.from(groups.entries()).flatMap(([actionClient, byAccount]) =>
+ Array.from(byAccount.entries()).map(([accountId, emailIds]) =>
+ actionClient.batchMarkAsRead(emailIds, true, accountId)
+ )
)
);
} catch (error) {
From bbc3c2123e87be5d078728185cf617ef7c97002b Mon Sep 17 00:00:00 2001
From: Peterka35 <43301641+Peterka35@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:01:34 +0200
Subject: [PATCH 049/238] fix: follow markAsReadDelay when expanding a
conversation or opening a Pro folder row
Expanding a conversation in the message list (row click, expand chevron,
or the `x` shortcut) and clicking a row in a Pro folder tab marked the
messages read straight away. They ignored "Mark as read: Never" and any
delay, which the reading pane and the conversation view follow.
Route them through one helper, scheduleAutoMarkAsRead: -1 never marks,
0 marks at once, any other value marks after that many milliseconds and
returns a cancel function.
- New store action autoMarkExpandedThreadAsRead keeps one pending timer
per thread key. Collapsing the thread, or collapsing all threads, drops
it. Both expansion paths (EmailList and the `x` handler in MailApp) use
it.
- MailApp's handleEmailSelect drops the pending mark of every expanded
thread other than the one the newly opened message belongs to, through
cancelAutoMarkThreadAsRead. Moving on with `j`/`k` or a click no longer
lets the delayed mark land on a conversation the user has left. The
thread stays expanded.
- ProFolderTabBody schedules the row mark through the helper and drops a
pending one when another row is opened or the tab unmounts.
---
...mail-list-thread-expand-mark-read.test.tsx | 114 +++++++++++++
components/email/email-list.tsx | 10 +-
.../mail-app-thread-expand-mark-read.test.tsx | 160 ++++++++++++++++++
components/mail/mail-app.tsx | 18 +-
.../pro-folder-tab-mark-read.test.tsx | 112 ++++++++++++
components/pro/pro-folder-tab-body.tsx | 13 +-
lib/__tests__/mark-as-read-delay.test.ts | 43 +++++
lib/mark-as-read-delay.ts | 24 +++
...mail-store-thread-expand-mark-read.test.ts | 142 ++++++++++++++++
stores/email-store.ts | 44 ++++-
10 files changed, 671 insertions(+), 9 deletions(-)
create mode 100644 components/email/__tests__/email-list-thread-expand-mark-read.test.tsx
create mode 100644 components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx
create mode 100644 components/pro/__tests__/pro-folder-tab-mark-read.test.tsx
create mode 100644 lib/__tests__/mark-as-read-delay.test.ts
create mode 100644 lib/mark-as-read-delay.ts
create mode 100644 stores/__tests__/email-store-thread-expand-mark-read.test.ts
diff --git a/components/email/__tests__/email-list-thread-expand-mark-read.test.tsx b/components/email/__tests__/email-list-thread-expand-mark-read.test.tsx
new file mode 100644
index 000000000..c605a2e03
--- /dev/null
+++ b/components/email/__tests__/email-list-thread-expand-mark-read.test.tsx
@@ -0,0 +1,114 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { EmailList } from '../email-list';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
+import type { Email } from '@/lib/jmap/types';
+
+/**
+ * Expanding a conversation from the list must go through markAsReadDelay:
+ * with "Never" nothing is marked read. Guards the list's wiring, not only the
+ * store action.
+ */
+
+const client = vi.hoisted(() => ({
+ getAccountId: () => 'acct-1',
+ getThreadEmails: vi.fn(),
+ batchMarkAsRead: vi.fn(async () => {}),
+}));
+
+vi.mock('@/hooks/use-email-drag', () => ({
+ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
+}));
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+// jsdom has no layout, so the real virtualizer draws no rows. Draw them all.
+vi.mock('@tanstack/react-virtual', () => ({
+ useVirtualizer: ({ count }: { count: number }) => ({
+ getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, key: index, start: index * 80, size: 80 })),
+ getTotalSize: () => count * 80,
+ measureElement: () => {},
+ measure: () => {},
+ scrollToOffset: () => {},
+ scrollToIndex: () => {},
+ measurementsCache: [],
+ scrollOffset: 0,
+ scrollRect: null,
+ }),
+}));
+vi.mock('@/stores/auth-store', async () => {
+ const { create } = await import('zustand');
+ return {
+ useAuthStore: create(() => ({
+ client, identities: [], username: null, activeAccountId: 'acct-1',
+ getClientForAccount: () => client,
+ })),
+ };
+});
+vi.mock('@/stores/account-store', () => {
+ const state = { getAccountById: () => undefined };
+ return {
+ useAccountStore: Object.assign(
+ (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
+ { getState: () => state },
+ ),
+ };
+});
+
+const mail = (id: string, time: string, seen: boolean): Email => ({
+ id,
+ threadId: 'thread-t',
+ mailboxIds: { inbox: true },
+ keywords: seen ? { $seen: true } : {},
+ size: 1000,
+ receivedAt: `2026-09-30T${time}:00Z`,
+ from: [{ name: 'Alice', email: 'alice@example.com' }],
+ subject: 'Conversation',
+ hasAttachment: false,
+});
+
+const THREAD = [mail('t2', '16:00', false), mail('t1', '15:00', true)];
+
+const initialEmailState = useEmailStore.getState();
+const initialTabsState = useMessageListTabsStore.getState();
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ client.batchMarkAsRead.mockClear();
+ client.getThreadEmails.mockResolvedValue(THREAD);
+ useEmailStore.setState(initialEmailState, true);
+ useEmailStore.setState({ selectedMailbox: 'inbox', emails: THREAD });
+ useMessageListTabsStore.setState(initialTabsState, true);
+});
+
+afterEach(() => {
+ useEmailStore.getState().collapseAllThreads();
+ vi.useRealTimers();
+});
+
+async function expandFromList() {
+ render( );
+ fireEvent.click(screen.getAllByText('Conversation')[0]);
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+ expect(useEmailStore.getState().expandedThreadIds.has('thread-t')).toBe(true);
+}
+
+describe('email list conversation expansion follows markAsReadDelay', () => {
+ it('marks nothing read with "Never" (-1)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: -1 });
+ await expandFromList();
+ await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ });
+
+ it('marks the unread message read after the delay', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ await expandFromList();
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+
+ await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
+ expect(client.batchMarkAsRead).toHaveBeenCalledWith(['t2'], true, undefined);
+ });
+});
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index ea9e32b55..e81379bd3 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -172,7 +172,7 @@ export function EmailList({
isLoadingThread,
toggleThreadExpansion,
fetchThreadEmails,
- markThreadAsRead,
+ autoMarkExpandedThreadAsRead,
collapseAllThreads,
threadEmailCounts,
searchFilters,
@@ -204,7 +204,7 @@ export function EmailList({
isLoadingThread: state.isLoadingThread,
toggleThreadExpansion: state.toggleThreadExpansion,
fetchThreadEmails: state.fetchThreadEmails,
- markThreadAsRead: state.markThreadAsRead,
+ autoMarkExpandedThreadAsRead: state.autoMarkExpandedThreadAsRead,
collapseAllThreads: state.collapseAllThreads,
threadEmailCounts: state.threadEmailCounts,
searchFilters: state.searchFilters,
@@ -607,13 +607,13 @@ export function EmailList({
toggleThreadExpansion(threadKey);
await fetchThreadEmails(client, threadKey);
emitThreadExpand(threadKey, threadMembers(threadKey), true);
- // Mark all unread emails in this thread as read
- void markThreadAsRead(client, threadKey);
+ // Mark the thread's unread emails read, following markAsReadDelay.
+ autoMarkExpandedThreadAsRead(client, threadKey);
} else {
toggleThreadExpansion(threadKey);
emitThreadExpand(threadKey, threadMembers(threadKey), !isExpanded);
}
- }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead, threadMembers]);
+ }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, autoMarkExpandedThreadAsRead, threadMembers]);
const collapseAllThreadsAndNotify = useCallback(() => {
const wereExpanded = [...useEmailStore.getState().expandedThreadIds];
diff --git a/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx b/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx
new file mode 100644
index 000000000..8f636c660
--- /dev/null
+++ b/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx
@@ -0,0 +1,160 @@
+import React from 'react';
+import { act, fireEvent, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePluginStore } from '@/stores/plugin-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+
+// The `x` shortcut expands the selected conversation and marks it read. It has
+// to follow markAsReadDelay like the reading pane, and a delayed mark must not
+// land once the user has moved on (`j`/`k`) to a message outside it.
+
+vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
+vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => null }));
+vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
+vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
+vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
+vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
+vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
+vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+ useIsDesktop: () => true,
+ useIsMobile: () => false,
+}));
+vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
+vi.mock('@/components/email/email-list', () => ({ EmailList: () => message list
}));
+
+const { MailApp } = await import('../mail-app');
+
+const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
+const mail = (id: string, threadId: string, seen: boolean, time: string): Email => ({
+ id, threadId, mailboxIds: { inbox: true }, keywords: seen ? { $seen: true } : {}, size: 1,
+ receivedAt: `2026-10-01T${time}:00Z`, from: [{ email: 'sender@example.com' }], subject: id,
+ preview: '', hasAttachment: false,
+}) as Email;
+
+// Newest first, as the list shows it: the conversation, then a single message.
+const conversation = [mail('c2', 'conv', false, '12:00'), mail('c1', 'conv', true, '11:00')];
+const single = mail('s1', 'single', true, '10:00');
+
+let client: {
+ getAccountId: () => string; getUsername: () => string; getSieveAccountId: () => null;
+ hasDelayedSend: () => boolean; getSession: () => null;
+ getEmail: ReturnType; getThreadEmails: ReturnType;
+ batchMarkAsRead: ReturnType; markAsRead: ReturnType;
+};
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ client = {
+ getAccountId: () => 'acc', getUsername: () => 'me@example.com',
+ getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
+ getEmail: vi.fn(async (id: string) => [...conversation, single].find((e) => e.id === id) ?? null),
+ getThreadEmails: vi.fn(async () => conversation),
+ batchMarkAsRead: vi.fn(async () => {}),
+ markAsRead: vi.fn(async () => {}),
+ };
+ useAuthStore.setState({
+ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [],
+ getClientForAccount: () => client as never,
+ getAllConnectedClients: () => new Map([['login', client]]) as never,
+ });
+ useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', interfaceLayout: 'default' } as never);
+ useEmailStore.setState({
+ // The conversation's newest message is open (selected with `j`).
+ emails: [...conversation, single], selectedEmail: conversation[0], selectedMailbox: inbox.id, mailboxes: [inbox],
+ viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
+ isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null,
+ selectedEmailKeys: new Set(), expandedThreadIds: new Set(), threadEmailsCache: new Map(),
+ processingReadStatus: new Set(),
+ fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
+ fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
+ refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+ } as never);
+});
+afterEach(() => {
+ useEmailStore.getState().collapseAllThreads();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+const press = async (code: string) => {
+ fireEvent.keyDown(window, { key: code.slice(3).toLowerCase(), code });
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+};
+
+describe('`x` expansion follows markAsReadDelay', () => {
+ it('marks nothing read with "Never" (-1)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: -1 });
+ vi.useFakeTimers();
+ render( );
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+
+ await press('KeyX');
+ expect(useEmailStore.getState().expandedThreadIds.has('conv')).toBe(true);
+ await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ expect(client.markAsRead).not.toHaveBeenCalled();
+ });
+
+ it('drops the delayed conversation mark once `j` opens a message outside it', async () => {
+ // The reading pane already shows c2 marked read here: only the thread
+ // expansion is under test.
+ useEmailStore.setState({ selectedEmail: { ...conversation[0], keywords: { $seen: true } } });
+ useSettingsStore.setState({ markAsReadDelay: 5000 });
+ vi.useFakeTimers();
+ render( );
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+
+ await press('KeyX');
+ expect(useEmailStore.getState().expandedThreadIds.has('conv')).toBe(true);
+ await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
+
+ // Move past the conversation to the single message.
+ await press('KeyJ');
+ await press('KeyJ');
+ expect(useEmailStore.getState().selectedEmail?.id).toBe('s1');
+ await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ // Moving on does not collapse the conversation.
+ expect(useEmailStore.getState().expandedThreadIds.has('conv')).toBe(true);
+ });
+
+ it('still marks the conversation read after the delay while the user stays in it', async () => {
+ useEmailStore.setState({ selectedEmail: { ...conversation[0], keywords: { $seen: true } } });
+ useSettingsStore.setState({ markAsReadDelay: 5000 });
+ vi.useFakeTimers();
+ render( );
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+
+ await press('KeyX');
+ await act(async () => { await vi.advanceTimersByTimeAsync(4999); });
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ await act(async () => { await vi.advanceTimersByTimeAsync(1); });
+
+ expect(client.batchMarkAsRead).toHaveBeenCalledWith(['c2'], true, undefined);
+ });
+});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 1b100d4e1..5fa720acf 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -1061,7 +1061,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
// `x` in the help modal: expand/collapse the selected email's thread. Same
// steps as EmailList.handleToggleThreadExpansion - expanding pulls the
- // thread's messages and marks them read, collapsing only toggles. (#683)
+ // thread's messages and marks them read (following markAsReadDelay),
+ // collapsing only toggles. (#683)
onToggleThreadExpansion: () => {
if (isScheduledView || !client) return;
if (!selectedEmail?.threadId) return;
@@ -1074,7 +1075,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!wasExpanded) {
void store.fetchThreadEmails(client, threadKey).then(() => {
emitThreadExpand(threadKey, members(), true);
- void useEmailStore.getState().markThreadAsRead(client, threadKey);
+ useEmailStore.getState().autoMarkExpandedThreadAsRead(client, threadKey);
});
} else {
emitThreadExpand(threadKey, members(), false);
@@ -4093,6 +4094,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
&& (email.sourceAccountId === undefined || e.sourceAccountId === email.sourceAccountId))
?? (email.sourceClientAccountId ? email : undefined);
+ // Expanding a conversation may have scheduled a delayed "mark read" for
+ // it (markAsReadDelay). Moving on to a message outside that conversation
+ // (j/k, a click) drops it, as the reading pane drops its own pending
+ // mark; the conversation stays expanded.
+ const openRef = (listEmail ?? email) as Partial;
+ if (openRef.threadId) {
+ const openThreadKey = threadKeyFor(openRef as Email);
+ const emailStore = useEmailStore.getState();
+ for (const key of emailStore.expandedThreadIds) {
+ if (key !== openThreadKey) emailStore.cancelAutoMarkThreadAsRead(key);
+ }
+ }
+
// Only the newest open may touch the viewer: a slow fetch for a message
// the user has already clicked away from must neither replace the one
// they are looking at nor end its loading state.
diff --git a/components/pro/__tests__/pro-folder-tab-mark-read.test.tsx b/components/pro/__tests__/pro-folder-tab-mark-read.test.tsx
new file mode 100644
index 000000000..486b47a10
--- /dev/null
+++ b/components/pro/__tests__/pro-folder-tab-mark-read.test.tsx
@@ -0,0 +1,112 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import type { Email } from '@/lib/jmap/types';
+import { useSettingsStore } from '@/stores/settings-store';
+
+/**
+ * A Pro folder tab marked a message read the moment its row was clicked,
+ * ignoring "Mark as read: Never" / a delay. It must follow markAsReadDelay
+ * like the reading pane does.
+ */
+
+vi.mock('@/components/pro/pro-email-tab-body', () => ({
+ ProEmailView: ({ emailId }: { emailId: string }) => React.createElement('div', { 'data-testid': 'reader' }, emailId),
+}));
+vi.mock('@/components/email/email-viewer', () => ({
+ EmailViewer: () => null,
+}));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+}));
+
+const client = vi.hoisted(() => ({
+ getEmails: vi.fn(),
+ markAsRead: vi.fn(async () => {}),
+}));
+
+vi.mock('@/stores/auth-store', () => {
+ const state = { client, getClientForAccount: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state);
+ hook.getState = () => state;
+ return { useAuthStore: hook };
+});
+
+const { ProFolderTabBody } = await import('../pro-folder-tab-body');
+
+const unread = (id: string, subject: string): Email =>
+ ({
+ id,
+ threadId: id,
+ mailboxIds: { inbox: true },
+ keywords: {},
+ from: [{ name: 'Sender', email: 'sender@example.com' }],
+ subject,
+ receivedAt: '2026-10-01T10:00:00Z',
+ preview: '',
+ hasAttachment: false,
+ size: 1,
+ }) as unknown as Email;
+
+async function renderTab() {
+ render(
+ ,
+ );
+ await act(async () => {});
+}
+
+describe('Pro folder tab row click follows markAsReadDelay', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ client.markAsRead.mockClear();
+ client.getEmails.mockResolvedValue({
+ emails: [unread('e1', 'First'), unread('e2', 'Second')],
+ total: 2,
+ hasMore: false,
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('does not mark the message read with "Never" (-1)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: -1 });
+ await renderTab();
+
+ fireEvent.click(screen.getByText('First'));
+ await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
+
+ expect(screen.getByTestId('reader')).toHaveTextContent('e1');
+ expect(client.markAsRead).not.toHaveBeenCalled();
+ });
+
+ it('marks the message read at once with "Instantly" (0)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 0 });
+ await renderTab();
+
+ fireEvent.click(screen.getByText('First'));
+ await act(async () => {});
+
+ expect(client.markAsRead).toHaveBeenCalledWith('e1', true, undefined);
+ });
+
+ it('marks the message read after the delay, and only the row still open', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ await renderTab();
+
+ fireEvent.click(screen.getByText('First'));
+ await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
+ // Moving on to another row before the delay ran out drops the first mark.
+ fireEvent.click(screen.getByText('Second'));
+ await act(async () => { await vi.advanceTimersByTimeAsync(2999); });
+ expect(client.markAsRead).not.toHaveBeenCalled();
+
+ await act(async () => { await vi.advanceTimersByTimeAsync(1); });
+ expect(client.markAsRead).toHaveBeenCalledTimes(1);
+ expect(client.markAsRead).toHaveBeenCalledWith('e2', true, undefined);
+ });
+});
diff --git a/components/pro/pro-folder-tab-body.tsx b/components/pro/pro-folder-tab-body.tsx
index 4479ebbd9..0746b47c6 100644
--- a/components/pro/pro-folder-tab-body.tsx
+++ b/components/pro/pro-folder-tab-body.tsx
@@ -16,6 +16,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePaneId } from "@/hooks/use-pane-context";
import type { Email, Mailbox } from "@/lib/jmap/types";
import { emailHooks } from "@/lib/plugin-hooks";
+import { scheduleAutoMarkAsRead } from "@/lib/mark-as-read-delay";
interface ProFolderTabBodyProps {
tabId: string;
@@ -152,8 +153,18 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
);
}, [data.mailboxId, paneId, t]);
+ // Opening a row marks it read on the user's markAsReadDelay, like the
+ // reading pane does; a pending delayed mark is dropped when another row is
+ // opened or the tab goes away.
+ const cancelRowReadRef = useRef<(() => void) | null>(null);
+ useEffect(() => () => cancelRowReadRef.current?.(), []);
+
const handleRowClick = useCallback((email: Email) => {
- void markRowAsRead(email);
+ cancelRowReadRef.current?.();
+ cancelRowReadRef.current = scheduleAutoMarkAsRead(
+ useSettingsStore.getState().markAsReadDelay,
+ () => void markRowAsRead(email),
+ );
if (isMobile) {
// No room for a reading pane - promote to a dedicated email tab.
openAsEmailTab(email, true);
diff --git a/lib/__tests__/mark-as-read-delay.test.ts b/lib/__tests__/mark-as-read-delay.test.ts
new file mode 100644
index 000000000..fff1fab58
--- /dev/null
+++ b/lib/__tests__/mark-as-read-delay.test.ts
@@ -0,0 +1,43 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { scheduleAutoMarkAsRead } from '../mark-as-read-delay';
+
+describe('scheduleAutoMarkAsRead', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('never marks with "Never" (-1)', () => {
+ const mark = vi.fn();
+ scheduleAutoMarkAsRead(-1, mark);
+ vi.advanceTimersByTime(60_000);
+ expect(mark).not.toHaveBeenCalled();
+ });
+
+ it('marks synchronously with "Instantly" (0)', () => {
+ const mark = vi.fn();
+ scheduleAutoMarkAsRead(0, mark);
+ expect(mark).toHaveBeenCalledTimes(1);
+ });
+
+ it('marks once the delay has passed', () => {
+ const mark = vi.fn();
+ scheduleAutoMarkAsRead(3000, mark);
+ vi.advanceTimersByTime(2999);
+ expect(mark).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(1);
+ expect(mark).toHaveBeenCalledTimes(1);
+ });
+
+ it('drops a pending delayed mark when cancelled', () => {
+ const mark = vi.fn();
+ const cancel = scheduleAutoMarkAsRead(3000, mark);
+ vi.advanceTimersByTime(1000);
+ cancel();
+ vi.advanceTimersByTime(10_000);
+ expect(mark).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/mark-as-read-delay.ts b/lib/mark-as-read-delay.ts
new file mode 100644
index 000000000..f1a10a359
--- /dev/null
+++ b/lib/mark-as-read-delay.ts
@@ -0,0 +1,24 @@
+// Automatic "mark as read" timing for surfaces that mark a message read
+// because it was shown. Used by thread expansion and Pro folder rows; same
+// rules as the reading pane (`mail-app.tsx`) and `useMarkReadWhenShown`.
+//
+// `markAsReadDelay` is the user's setting in milliseconds: -1 = never,
+// 0 = instantly, anything else = after that many milliseconds. Surfaces that
+// skipped the setting marked mail read even with "Never" selected.
+
+const noop = () => {};
+
+/**
+ * Runs `mark` according to `markAsReadDelay` and returns a function that
+ * cancels a still-pending delayed mark. "Never" (any negative value) does
+ * nothing; 0 runs `mark` synchronously.
+ */
+export function scheduleAutoMarkAsRead(markAsReadDelay: number, mark: () => void): () => void {
+ if (markAsReadDelay < 0) return noop;
+ if (markAsReadDelay === 0) {
+ mark();
+ return noop;
+ }
+ const timeout = setTimeout(mark, markAsReadDelay);
+ return () => clearTimeout(timeout);
+}
diff --git a/stores/__tests__/email-store-thread-expand-mark-read.test.ts b/stores/__tests__/email-store-thread-expand-mark-read.test.ts
new file mode 100644
index 000000000..f75d68498
--- /dev/null
+++ b/stores/__tests__/email-store-thread-expand-mark-read.test.ts
@@ -0,0 +1,142 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useEmailStore } from '../email-store';
+import { useAuthStore } from '../auth-store';
+import { useSettingsStore } from '../settings-store';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+/**
+ * Expanding a conversation in the message list (row click, expand chevron or
+ * the `x` shortcut) marked its unread messages read straight away, even with
+ * "Mark as read: Never". It must follow markAsReadDelay like the reading pane.
+ */
+
+const makeMailbox = (overrides: Partial = {}): Mailbox => ({
+ id: 'inbox',
+ name: 'Inbox',
+ role: 'inbox',
+ sortOrder: 0,
+ totalEmails: 2,
+ unreadEmails: 1,
+ totalThreads: 1,
+ unreadThreads: 1,
+ myRights: {
+ mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
+ maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true,
+ },
+ isSubscribed: true,
+ isShared: false,
+ ...overrides,
+});
+
+const makeEmail = (id: string, seen: boolean): Email =>
+ ({
+ id,
+ threadId: 't1',
+ mailboxIds: { inbox: true },
+ keywords: seen ? { $seen: true } : {},
+ size: 1,
+ receivedAt: '2026-10-01T10:00:00Z',
+ subject: id,
+ preview: '',
+ hasAttachment: false,
+ }) as unknown as Email;
+
+function makeClient() {
+ return {
+ getAccountId: () => 'acct-a',
+ batchMarkAsRead: vi.fn(async () => {}),
+ } as unknown as IJMAPClient & { batchMarkAsRead: ReturnType };
+}
+
+describe('autoMarkExpandedThreadAsRead follows markAsReadDelay', () => {
+ let client: ReturnType;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ client = makeClient();
+ useAuthStore.setState({
+ activeAccountId: 'login-a',
+ getClientForAccount: () => client as never,
+ } as never);
+ useEmailStore.setState({
+ isUnifiedView: false,
+ unifiedRole: null,
+ viewingAccountId: null,
+ selectedMailbox: 'inbox',
+ mailboxes: [makeMailbox()],
+ accountMailboxes: {},
+ emails: [makeEmail('m1', true), makeEmail('m2', false)],
+ expandedThreadIds: new Set(),
+ threadEmailsCache: new Map(),
+ processingReadStatus: new Set(),
+ });
+ });
+
+ afterEach(() => {
+ useEmailStore.getState().collapseAllThreads();
+ vi.useRealTimers();
+ });
+
+ const expand = () => {
+ useEmailStore.getState().toggleThreadExpansion('t1');
+ useEmailStore.getState().autoMarkExpandedThreadAsRead(client, 't1');
+ };
+
+ it('leaves the conversation unread with "Never" (-1)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: -1 });
+ expand();
+ await vi.advanceTimersByTimeAsync(60_000);
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ expect(useEmailStore.getState().emails.find((e) => e.id === 'm2')?.keywords?.$seen).toBeFalsy();
+ });
+
+ it('marks the unread messages read at once with "Instantly" (0)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 0 });
+ expand();
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(client.batchMarkAsRead).toHaveBeenCalledWith(['m2'], true, undefined);
+ });
+
+ it('marks the unread messages read after the configured delay', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ expand();
+ await vi.advanceTimersByTimeAsync(2999);
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(1);
+ expect(client.batchMarkAsRead).toHaveBeenCalledWith(['m2'], true, undefined);
+ });
+
+ it('drops the delayed mark when the conversation is collapsed first', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ expand();
+ await vi.advanceTimersByTimeAsync(1000);
+ useEmailStore.getState().toggleThreadExpansion('t1');
+ await vi.advanceTimersByTimeAsync(10_000);
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ });
+
+ it('drops the delayed mark when cancelled, and leaves the thread expanded', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ expand();
+ await vi.advanceTimersByTimeAsync(1000);
+ useEmailStore.getState().cancelAutoMarkThreadAsRead('t1');
+ await vi.advanceTimersByTimeAsync(10_000);
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ expect(useEmailStore.getState().expandedThreadIds.has('t1')).toBe(true);
+ });
+
+ it('drops the delayed mark when every thread is collapsed', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ expand();
+ useEmailStore.getState().collapseAllThreads();
+ await vi.advanceTimersByTimeAsync(10_000);
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ });
+});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index f568bb9b7..16139b1b0 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -11,6 +11,7 @@ import { useCalendarStore } from "@/stores/calendar-store";
import { orderKeywords, placeHeldRows, sanitizeSortLevels, type HeldKeywords, type SortLevel } from "@/lib/message-list-order";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
+import { scheduleAutoMarkAsRead } from "@/lib/mark-as-read-delay";
import { allowEmailMove, createMailboxWithHooks, emitEmailMove, idsFromSelectionKeys, keywordChangeLists, mailboxToView, quotaToView } from "@/lib/plugin-mail-hooks";
import { emailSummary, emailToReadView } from "@/lib/plugin-projection";
import { resolveThreadRoute } from "@/lib/thread-routing";
@@ -453,6 +454,17 @@ interface EmailStore {
updateThreadCache: (threadId: string, emails: Email[]) => void;
fetchThreadEmailCounts: (client: IJMAPClient) => Promise;
markThreadAsRead: (client: IJMAPClient, threadKey: string) => Promise;
+ /**
+ * Marks an expanded thread read the way the reading pane marks a message:
+ * following `markAsReadDelay` (never / instantly / after a delay). A delayed
+ * mark is dropped when the thread is collapsed first.
+ */
+ autoMarkExpandedThreadAsRead: (client: IJMAPClient, threadKey: string) => void;
+ /**
+ * Drops a still-pending delayed mark for the thread, without collapsing it.
+ * Used when the user moves on to a message outside that thread.
+ */
+ cancelAutoMarkThreadAsRead: (threadKey: string) => void;
// Mailbox management
createMailbox: (client: IJMAPClient, name: string, parentId?: string, accountId?: string) => Promise;
@@ -1082,6 +1094,18 @@ function folderListState(s: EmailStore): FolderList {
/** The list row beginOpenEmail put in the viewer, until its message arrives. */
let openingRow: Email | null = null;
+// Pending delayed "mark thread read" timers, by thread key. Collapsing the
+// thread, or opening a message outside it, cancels its timer.
+const pendingThreadReads = new Map void>();
+
+function cancelPendingThreadRead(threadKey?: string): void {
+ const keys = threadKey === undefined ? [...pendingThreadReads.keys()] : [threadKey];
+ for (const key of keys) {
+ pendingThreadReads.get(key)?.();
+ pendingThreadReads.delete(key);
+ }
+}
+
/**
* The per-thread message counts to store once a page arrived: the counts that
* page brought along (Thread/get chained onto its query) under the keys the
@@ -5646,9 +5670,10 @@ export const useEmailStore = create((set, get) => ({
if (newExpandedThreadIds.has(threadKey)) {
newExpandedThreadIds.delete(threadKey);
+ cancelPendingThreadRead(threadKey);
} else {
newExpandedThreadIds.add(threadKey);
- // Expanding marks the thread read; hold it before that lands.
+ // Expanding may mark the thread read; hold it before that lands.
get().holdListRow(threadKey);
}
@@ -5718,6 +5743,22 @@ export const useEmailStore = create((set, get) => ({
}
},
+ autoMarkExpandedThreadAsRead: (client, threadKey) => {
+ cancelPendingThreadRead(threadKey);
+ const { markAsReadDelay } = useSettingsStore.getState();
+ const cancel = scheduleAutoMarkAsRead(markAsReadDelay, () => {
+ pendingThreadReads.delete(threadKey);
+ // Collapsed (or the list was reset) before the delay ran out.
+ if (!get().expandedThreadIds.has(threadKey)) return;
+ void get().markThreadAsRead(client, threadKey);
+ });
+ if (markAsReadDelay > 0) pendingThreadReads.set(threadKey, cancel);
+ },
+
+ cancelAutoMarkThreadAsRead: (threadKey) => {
+ cancelPendingThreadRead(threadKey);
+ },
+
markThreadAsRead: async (client, threadKey) => {
const state = get();
const threadEmails = state.threadEmailsCache.get(threadKey) ?? [];
@@ -5815,6 +5856,7 @@ export const useEmailStore = create((set, get) => ({
},
collapseAllThreads: () => {
+ cancelPendingThreadRead();
set({
expandedThreadIds: new Set(),
isLoadingThread: null
From 6fe9f8f7f15f63de11962c9b0ea271ff20344903 Mon Sep 17 00:00:00 2001
From: Ali Yousuf
Date: Sat, 17 Oct 2026 18:41:31 -0400
Subject: [PATCH 050/238] fix: let api.email.get read a listed message of a
shared account
doEmailGet asked the signed-in account for every id, so a message in a
folder shared from another account came back as null even though it was
in the list. A message in the loaded list is now read from the account
that owns it, through resolveEmailBlobContext like attachment downloads.
getAttachment and getRaw keep reading from the signed-in account only.
Fixes #1273
---
lib/__tests__/plugin-host-mail-read.test.ts | 10 +++++++++-
lib/plugin-sandbox/host-api-mail.ts | 16 ++++++++++++----
2 files changed, 21 insertions(+), 5 deletions(-)
diff --git a/lib/__tests__/plugin-host-mail-read.test.ts b/lib/__tests__/plugin-host-mail-read.test.ts
index 9db3e13b1..a7ed397c4 100644
--- a/lib/__tests__/plugin-host-mail-read.test.ts
+++ b/lib/__tests__/plugin-host-mail-read.test.ts
@@ -54,7 +54,7 @@ beforeEach(() => {
afterEach(() => {
useAuthStore.setState({ client: null });
- useEmailStore.setState({ mailboxes: [], selectedEmail: null });
+ useEmailStore.setState({ mailboxes: [], selectedEmail: null, selectedMailbox: '', emails: [] });
});
describe('api.email.get', () => {
@@ -67,6 +67,14 @@ describe('api.email.get', () => {
expect(await call(READER, 'email.get', 'gone')).toBeNull();
});
+ it('asks the owning account for a listed message of a shared folder', async () => {
+ client.getEmail.mockImplementation(async (id: string, accountId?: string) => (
+ id === 's1' && accountId === 'acc2' ? mail('s1', { subject: 'Shared' }) : null));
+ useEmailStore.setState({ selectedMailbox: 'acc2:box', emails: [mail('s1', { mailboxIds: { 'acc2:box': true } })] });
+ expect(await call(READER, 'email.get', 's1')).toEqual(expect.objectContaining({ id: 's1', subject: 'Shared' }));
+ expect(client.getEmail).toHaveBeenCalledWith('s1', 'acc2');
+ });
+
it('uses the open message when its body is loaded', async () => {
useEmailStore.setState({ selectedEmail: mail('open', { bodyValues: { 1: { value: 'x' } }, textBody: [{ partId: '1' }] } as unknown as Partial) });
expect(await call(READER, 'email.get', 'open')).toEqual(expect.objectContaining({ id: 'open', text: 'x' }));
diff --git a/lib/plugin-sandbox/host-api-mail.ts b/lib/plugin-sandbox/host-api-mail.ts
index 4c2fb4ee8..33d4d7389 100644
--- a/lib/plugin-sandbox/host-api-mail.ts
+++ b/lib/plugin-sandbox/host-api-mail.ts
@@ -3,13 +3,14 @@
// (api.email.getAttachment / getRaw). Reads go to the server through the
// JMAP client and never change what the user has open.
//
-// Messages are read from the signed-in account itself; mail in folders shared
-// from other accounts is not reached by id here.
+// api.email.get also reads a message of a shared account when it is in the
+// loaded list, from the account that owns it. The bytes of attachments and
+// sources are read from the signed-in account only.
import type { EmailReadView, EmailSummary, MailboxView } from '../plugin-types';
import type { Email, Mailbox } from '../jmap/types';
import type { IJMAPClient } from '../jmap/client-interface';
-import { useEmailStore } from '@/stores/email-store';
+import { resolveEmailBlobContext, useEmailStore } from '@/stores/email-store';
import { emailSummary, emailToReadView } from '../plugin-projection';
import {
MAX_PLUGIN_FETCH_BYTES, readId, readInteger, readObject, sessionClient, withinRate,
@@ -30,7 +31,14 @@ async function loadEmail(client: IJMAPClient, emailId: string): Promise {
const method = 'email.get';
const emailId = readId(id, method, 'emailId');
- const email = await loadEmail(sessionClient(method), emailId);
+ const client = sessionClient(method);
+ // A listed message of a shared account is asked of the account that owns
+ // it; the signed-in account doesn't know its id.
+ const listed = useEmailStore.getState().emails.find((e) => e.id === emailId);
+ const owner = listed ? resolveEmailBlobContext(listed, client) : null;
+ const email = owner?.client && owner.accountId
+ ? await owner.client.getEmail(emailId, owner.accountId)
+ : await loadEmail(client, emailId);
return email ? emailToReadView(email) : null;
}
From b811ad93cbc3e50d609aab99b7ecbb6fb4056251 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 13:46:47 +0200
Subject: [PATCH 051/238] fix: use the theme's sans font for the whole app
#1278
---
app/globals.css | 29 +++++++++++-------
lib/__tests__/font-sans.test.ts | 54 +++++++++++++++++++++++++++++++++
2 files changed, 72 insertions(+), 11 deletions(-)
create mode 100644 lib/__tests__/font-sans.test.ts
diff --git a/app/globals.css b/app/globals.css
index 3ed4e0f32..dfb768ca0 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -3,6 +3,23 @@
@import "./theme-inline.css";
+/* The app's font stack replaces Tailwind's default --font-sans, so the
+ font-sans utility and body use the same stack, and a theme's
+ typography.fontSans (compiled to --font-sans on :root) restyles both. */
+@theme {
+ --font-sans:
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ "Helvetica Neue",
+ Arial,
+ "Noto Sans Thai",
+ "Leelawadee UI",
+ Tahoma,
+ sans-serif;
+}
+
:root {
font-size: var(--font-size-base);
@@ -142,17 +159,7 @@
body {
background-color: var(--color-background);
color: var(--color-foreground);
- font-family:
- -apple-system,
- BlinkMacSystemFont,
- "Segoe UI",
- Roboto,
- "Helvetica Neue",
- Arial,
- "Noto Sans Thai",
- "Leelawadee UI",
- Tahoma,
- sans-serif;
+ font-family: var(--font-sans);
font-feature-settings:
"rlig" 1,
"calt" 1;
diff --git a/lib/__tests__/font-sans.test.ts b/lib/__tests__/font-sans.test.ts
new file mode 100644
index 000000000..6a9ddfe63
--- /dev/null
+++ b/lib/__tests__/font-sans.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect, beforeAll } from 'vitest';
+import { mkdtempSync, readFileSync, rmSync } from 'fs';
+import os from 'os';
+import path from 'path';
+import type { AtRule, Root } from 'postcss';
+
+/**
+ * A theme's typography.fontSans compiles to --font-sans on :root (#1278).
+ * Body has to read that variable, and the variable's default has to be the
+ * app's own stack, not Tailwind's, so nothing changes without a theme.
+ * Build globals.css with Tailwind to see the default it actually emits.
+ */
+let root: Root;
+
+beforeAll(async () => {
+ const postcss = (await import('postcss')).default;
+ const tailwind = (await import('@tailwindcss/postcss')).default;
+ const file = path.join(process.cwd(), 'app', 'globals.css');
+ // Scan an empty folder: only the stylesheet's own rules matter here.
+ const base = mkdtempSync(path.join(os.tmpdir(), 'font-sans-'));
+ try {
+ root = (await postcss([tailwind({ base })]).process(readFileSync(file, 'utf8'), { from: file })).root;
+ } finally {
+ rmSync(base, { recursive: true, force: true });
+ }
+}, 60_000);
+
+/**
+ * The value of `prop` in the `selector` rule whose parent is `wrapper`
+ * (`@layer theme`), or that sits at the top level when `wrapper` is null.
+ */
+function declValue(selector: string, prop: string, wrapper: string | null): string | undefined {
+ let value: string | undefined;
+ root.walkRules(selector, (rule) => {
+ const at = rule.parent?.type === 'atrule' ? (rule.parent as AtRule) : null;
+ if (value !== undefined || (at ? `@${at.name} ${at.params}` : null) !== wrapper) return;
+ rule.walkDecls(prop, (decl) => {
+ value ??= decl.value.replace(/\s+/g, ' ');
+ });
+ });
+ return value;
+}
+
+describe('theme font (#1278)', () => {
+ it('sets body in --font-sans', () => {
+ expect(declValue('body', 'font-family', null)).toBe('var(--font-sans)');
+ });
+
+ it('defaults --font-sans to the app stack, Thai fallbacks included', () => {
+ expect(declValue(':root, :host', '--font-sans', '@layer theme')).toBe(
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans Thai", "Leelawadee UI", Tahoma, sans-serif',
+ );
+ });
+});
From a11a89d6895728a3c472148df50a31c126e0e6ac Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 13:46:47 +0200
Subject: [PATCH 052/238] fix: skip vacation filter rules on shared accounts
without sieve #1263
---
lib/demo/demo-client.ts | 1 +
lib/filters/__tests__/sieve-mock.ts | 1 +
lib/jmap/client-interface.ts | 2 +
.../__tests__/vacation-shared-account.test.ts | 60 +++++++++++++++++++
stores/vacation-store.ts | 13 +++-
5 files changed, 75 insertions(+), 2 deletions(-)
create mode 100644 stores/__tests__/vacation-shared-account.test.ts
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index 70adb7021..e0c13be0b 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -97,6 +97,7 @@ export class DemoJMAPClient implements IJMAPClient {
supportsEmailSubmission(): boolean { return true; }
supportsQuota(): boolean { return true; }
supportsVacationResponse(): boolean { return true; }
+ accountSupports(): boolean { return true; }
supportsContacts(): boolean { return true; }
supportsCalendars(): boolean { return true; }
supportsSieve(): boolean { return true; }
diff --git a/lib/filters/__tests__/sieve-mock.ts b/lib/filters/__tests__/sieve-mock.ts
index bac77da20..eda883106 100644
--- a/lib/filters/__tests__/sieve-mock.ts
+++ b/lib/filters/__tests__/sieve-mock.ts
@@ -109,6 +109,7 @@ export function mockStalwartAccount(
const maxNumberRedirects = 'maxNumberRedirects' in options ? options.maxNumberRedirects : 1;
const client = Object.assign(account.client, {
supportsSieve: () => true,
+ accountSupports: () => true,
hasAccountCapability: () => false,
getSieveAccounts: () => [{ id: accountId, name: 'Me', isPrimary: true }],
getSieveCapabilities: () => ({ sieveExtensions: extensions, maxNumberRedirects }) as never,
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 88178c184..f25aabe06 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -105,6 +105,8 @@ export interface IJMAPClient {
supportsEmailSubmission(): boolean;
supportsQuota(): boolean;
supportsVacationResponse(): boolean;
+ /** Whether the user may use a capability's methods with an account (its accountCapabilities). */
+ accountSupports(accountId: string, capability: string): boolean;
supportsContacts(): boolean;
supportsCalendars(): boolean;
supportsSieve(): boolean;
diff --git a/stores/__tests__/vacation-shared-account.test.ts b/stores/__tests__/vacation-shared-account.test.ts
new file mode 100644
index 000000000..1a9a7288d
--- /dev/null
+++ b/stores/__tests__/vacation-shared-account.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { generateScript } from '@/lib/sieve/generator';
+import { STALWART_VACATION_SCRIPT, mockSieveAccount } from '@/lib/filters/__tests__/sieve-mock';
+import { useVacationStore } from '../vacation-store';
+
+const SIEVE = 'urn:ietf:params:jmap:sieve';
+
+/**
+ * The session lists Sieve, and account "b" runs its auto-reply from the
+ * filters script. `sieveInAccount` is whether b's own accountCapabilities
+ * list Sieve too.
+ */
+function accountB(sieveInAccount: boolean) {
+ const account = mockSieveAccount('b', [
+ { name: 'filters', content: generateScript([], undefined, { extensions: ['include'], includeVacation: true }), isActive: true },
+ { name: 'vacation', content: STALWART_VACATION_SCRIPT, isActive: false },
+ ]);
+ const client = Object.assign(account.client, {
+ supportsSieve: () => true,
+ accountSupports: (_accountId: string, capability: string) => capability !== SIEVE || sieveInAccount,
+ getVacationResponse: vi.fn(async () => ({
+ id: 'singleton', isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: 'away', htmlBody: null,
+ })),
+ setVacationResponse: vi.fn(async () => {}),
+ });
+ return { ...account, client };
+}
+
+const initial = useVacationStore.getState();
+afterEach(() => {
+ useVacationStore.setState(initial, true);
+});
+
+describe('vacation on a shared account (#1263)', () => {
+ it('leaves Sieve alone when the account does not list it', async () => {
+ const server = accountB(false);
+ await useVacationStore.getState().fetchVacationResponse(server.client, 'b');
+ expect(useVacationStore.getState()).toMatchObject({ isEnabled: false, textBody: 'away', error: null });
+ expect(server.client.getSieveScripts).not.toHaveBeenCalled();
+
+ await useVacationStore.getState().updateVacationResponse(server.client, { isEnabled: true }, 'b');
+ expect(server.client.setVacationResponse).toHaveBeenCalledWith({ isEnabled: true }, 'b');
+ expect(server.client.getSieveScripts).not.toHaveBeenCalled();
+ expect(server.writes()).toBe(0);
+ });
+
+ it('reads the filters script when the account lists Sieve', async () => {
+ const server = accountB(true);
+ await useVacationStore.getState().fetchVacationResponse(server.client, 'b');
+ expect(server.client.getSieveScripts).toHaveBeenCalledWith('b');
+ expect(useVacationStore.getState().isEnabled).toBe(true);
+ });
+
+ it('goes by the session for the own account', async () => {
+ const server = accountB(false);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ expect(server.client.getSieveScripts).toHaveBeenCalledWith('b');
+ expect(useVacationStore.getState().isEnabled).toBe(true);
+ });
+});
diff --git a/stores/vacation-store.ts b/stores/vacation-store.ts
index 9fd8de1c6..14e8c841a 100644
--- a/stores/vacation-store.ts
+++ b/stores/vacation-store.ts
@@ -81,6 +81,15 @@ function fromFilters(filters: VacationFilters) {
};
}
+/**
+ * Whether the account has a filters script to read and sync. A shared
+ * account goes by its own capabilities, which can lack Sieve where the
+ * session has it (#1263); the own account by the session.
+ */
+function hasSieve(client: IJMAPClient, accountId?: string): boolean {
+ return accountId ? client.accountSupports(accountId, 'urn:ietf:params:jmap:sieve') : client.supportsSieve();
+}
+
// Each load, and each account switch (the store epoch), outdates the answers
// of the loads before it: one for another account must not land in the card
// on screen, where saving would write it to this account.
@@ -113,7 +122,7 @@ export const useVacationStore = create()((set, get) => ({
const vacation = await client.getVacationResponse(accountId);
let isEnabled = vacation.isEnabled;
let filters: VacationFilters | null = null;
- if (client.supportsSieve()) {
+ if (hasSieve(client, accountId)) {
filters = await readVacationFilters(client, accountId).catch(() => null);
// Running from the filters script leaves the vacation script itself
// inactive, so VacationResponse reports it as off.
@@ -186,7 +195,7 @@ export const useVacationStore = create()((set, get) => ({
let filtersError: VacationFiltersError | null = null;
let filtersState: Partial = {};
const needsSync = vacationUpdates.isEnabled !== undefined || forwardSettings !== undefined || audience !== undefined;
- if (needsSync && client.supportsSieve()) {
+ if (needsSync && hasSieve(client, accountId)) {
let synced = false;
try {
await syncVacationWithFilters(
From 86d6c78ce7161d0129483ab6975300596ff8d838 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 13:46:47 +0200
Subject: [PATCH 053/238] fix: keep a plugin slot's callbacks working when its
props change while it loads #1266
---
.../plugin-host-bridge-origin.test.ts | 23 ++++++
.../plugin-sandbox-runtime-slot-props.test.ts | 75 +++++++++++++++++++
lib/plugin-sandbox/host-bridge.ts | 15 +++-
lib/plugin-sandbox/runtime.tsx | 3 +
4 files changed, 114 insertions(+), 2 deletions(-)
create mode 100644 lib/__tests__/plugin-sandbox-runtime-slot-props.test.ts
diff --git a/lib/__tests__/plugin-host-bridge-origin.test.ts b/lib/__tests__/plugin-host-bridge-origin.test.ts
index e51fb49fd..3c8ad1a5c 100644
--- a/lib/__tests__/plugin-host-bridge-origin.test.ts
+++ b/lib/__tests__/plugin-host-bridge-origin.test.ts
@@ -147,6 +147,29 @@ describe('host bookkeeping', () => {
expect(table.size).toBe(1);
});
+
+ it('boots a slot with the props updated before init, whose callbacks still answer', async () => {
+ // The update used to drop the callbacks init was still going to carry,
+ // so the slot's first call got "unknown callback" (#1266).
+ const inst = createSlotInstance({
+ plugin: plugin(), slot: 'calendar-event-actions', code: '', locale: 'en', tier: 'untrusted',
+ extraProps: { setVirtualLocation: () => 'old' }, hostContainer: document.body, onResize: () => {},
+ });
+ instances.push(inst);
+ const sent = vi.spyOn(inst.iframe.contentWindow!, 'postMessage');
+ inst.updateProps({ setVirtualLocation: () => 'new' });
+
+ post(inst.iframe.contentWindow, 'null', { type: 'sandbox-ready' });
+ await new Promise((r) => setTimeout(r, 0));
+ type Msg = { type: string; payload?: { extraProps: { setVirtualLocation: { __pluginCallback: string } } } };
+ const messages = () => sent.mock.calls.map(([m]) => m as Msg);
+ const callbackId = messages().find((m) => m.type === 'init')!.payload!.extraProps.setVirtualLocation.__pluginCallback;
+ post(inst.iframe.contentWindow, 'null', { type: 'callback-invoke', id: 'c1', callbackId, args: [] });
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(messages().find((m) => m.type === 'callback-response')).toEqual({ type: 'callback-response', id: 'c1', ok: true, result: 'new' });
+ expect(messages().map((m) => m.type)).not.toContain('props-update');
+ });
});
describe('plugin frame permissions', () => {
diff --git a/lib/__tests__/plugin-sandbox-runtime-slot-props.test.ts b/lib/__tests__/plugin-sandbox-runtime-slot-props.test.ts
new file mode 100644
index 000000000..f8d873710
--- /dev/null
+++ b/lib/__tests__/plugin-sandbox-runtime-slot-props.test.ts
@@ -0,0 +1,75 @@
+/**
+ * A slot frame renders the props the host sent with init and follows every
+ * props-update after that. An update that landed after the slot's first
+ * render but before it subscribed was dropped, so the slot kept the replaced
+ * props and their callbacks, which the host had already forgotten:
+ * "unknown callback" (#1266).
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { render, cleanup, screen, waitFor } from '@testing-library/react';
+
+const HOST_ORIGIN = window.location.origin;
+
+// The slot component shows its label, and tells the test when it renders.
+const BUNDLE = `module.exports = { slots: { 'email-banner': { component: function (props) {
+ globalThis.__slotRendered && globalThis.__slotRendered(props);
+ return React.createElement('span', null, props.label);
+} } } };`;
+const hooks = globalThis as { __slotRendered?: (props: unknown) => void };
+
+function post(source: unknown, origin: string, data: unknown): void {
+ const ev = new MessageEvent('message', { data, origin });
+ Object.defineProperty(ev, 'source', { value: source });
+ window.dispatchEvent(ev);
+}
+
+let hostWindow: { postMessage: ReturnType };
+const realParent = Object.getOwnPropertyDescriptor(window, 'parent');
+
+beforeEach(() => {
+ // The runtime keeps its host binding and boot flag at module scope.
+ vi.resetModules();
+ hostWindow = { postMessage: vi.fn() };
+ Object.defineProperty(window, 'parent', { configurable: true, get: () => hostWindow as unknown as Window });
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ delete hooks.__slotRendered;
+ if (realParent) Object.defineProperty(window, 'parent', realParent);
+});
+
+describe('slot props in the sandbox runtime', () => {
+ it('shows an update that arrives before the slot has subscribed to updates', async () => {
+ const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime');
+ render(React.createElement(SandboxRuntime));
+
+ // The first render has read the init props; its effects have not run yet.
+ let posted = false;
+ hooks.__slotRendered = () => {
+ if (posted) return;
+ posted = true;
+ post(hostWindow, HOST_ORIGIN, { type: 'props-update', props: { label: 'new' } });
+ };
+ post(hostWindow, HOST_ORIGIN, {
+ type: 'init',
+ payload: {
+ mode: 'slot',
+ pluginId: 'x',
+ tier: 'privileged',
+ slot: 'email-banner',
+ locale: 'en',
+ manifest: { id: 'x', version: '1.0.0', permissions: [], settings: {} },
+ code: BUNDLE,
+ extraProps: { label: 'old' },
+ theme: { dark: false, fontFamily: 'sans-serif', vars: {} },
+ },
+ });
+
+ await waitFor(() => expect(posted).toBe(true));
+ await waitFor(() => expect(screen.getByText('new')).toBeInTheDocument());
+ }, 30_000);
+});
diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts
index af22c0832..c66da9000 100644
--- a/lib/plugin-sandbox/host-bridge.ts
+++ b/lib/plugin-sandbox/host-bridge.ts
@@ -164,6 +164,8 @@ export class SandboxInstance {
private callbackTable = new Map unknown>();
/** Ids in callbackTable that came from the current props. */
private propCallbackIds = new Set();
+ /** The init payload until it has been sent; props updates before that go into it. */
+ private pendingInit: InitPayload | null;
constructor(
private plugin: InstalledPlugin,
@@ -183,6 +185,7 @@ export class SandboxInstance {
initPayload.extraProps = encodeCallbacks(initPayload.extraProps, this.callbackTable) as Record;
this.propCallbackIds = new Set([...this.callbackTable.keys()].filter((k) => !before.has(k)));
}
+ this.pendingInit = initPayload;
this.readyPromise = new Promise((res) => { this.resolveReady = res; });
this.initPromise = new Promise((res, rej) => {
@@ -249,8 +252,9 @@ export class SandboxInstance {
// Send init after the iframe runtime signals it's ready.
this.readyPromise.then(() => {
- if (this.destroyed) return;
- const msg: InitMsg = { type: 'init', payload: initPayload };
+ if (this.destroyed || !this.pendingInit) return;
+ const msg: InitMsg = { type: 'init', payload: this.pendingInit };
+ this.pendingInit = null;
this.send(msg);
});
}
@@ -442,6 +446,13 @@ export class SandboxInstance {
for (const id of this.propCallbackIds) this.callbackTable.delete(id);
this.propCallbackIds = new Set(fresh.keys());
for (const [id, fn] of fresh) this.callbackTable.set(id, fn);
+ // Before init has gone out the frame has no slot to update, and init
+ // would boot it with the callbacks just dropped: send the new props
+ // with init instead.
+ if (this.pendingInit?.mode === 'slot') {
+ this.pendingInit.extraProps = encoded;
+ return;
+ }
this.send({ type: 'props-update', props: encoded });
}
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
index 80d1641d6..16d0591c5 100644
--- a/lib/plugin-sandbox/runtime.tsx
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -974,6 +974,9 @@ function bootSlot(payload: SlotInit): void {
const [, setSettingsVersion] = React.useState(0);
React.useEffect(() => {
propsListeners.add(setProps);
+ // An update can land between the first render and this effect; the
+ // props it replaced carry callbacks the host has already dropped.
+ setProps(currentProps);
return () => { propsListeners.delete(setProps); };
}, []);
// Re-render the slot when its settings change, so a component that reads
From 89001ededa579f6cfe726fa1177642a814d95b4c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 13:46:48 +0200
Subject: [PATCH 054/238] fix: open the reader's Move, Tag and Rules flyouts on
the side with room #1261
---
.../viewer-more-menu-flyouts.test.tsx | 105 ++++++++++++++++
components/email/email-viewer.tsx | 22 ++--
components/ui/context-menu.tsx | 116 ++++++++++--------
3 files changed, 183 insertions(+), 60 deletions(-)
create mode 100644 components/email/__tests__/viewer-more-menu-flyouts.test.tsx
diff --git a/components/email/__tests__/viewer-more-menu-flyouts.test.tsx b/components/email/__tests__/viewer-more-menu-flyouts.test.tsx
new file mode 100644
index 000000000..4ff0aff31
--- /dev/null
+++ b/components/email/__tests__/viewer-more-menu-flyouts.test.tsx
@@ -0,0 +1,105 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+// #1261: the Move, Tag and Rules flyouts of the reader's More menu always
+// opened on the menu's start side and ran past the reading pane when there
+// was no room there. They now pick the side with room, like a context
+// submenu does.
+
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+}));
+
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({
+ PluginSlot: () => null,
+}));
+
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({
+ usePluginSlotOffers: () => [],
+}));
+
+vi.mock('../rules-menu', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useRulesAvailability: () => ({ availability: 'available', target: {} }),
+ RulesPanel: () => rules panel
,
+}));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+
+const email = {
+ id: 'e1',
+ blobId: 'b1',
+ threadId: 't1',
+ mailboxIds: { inbox: true },
+ keywords: { $seen: true },
+ from: [{ name: 'Alice', email: 'alice@example.com' }],
+ to: [{ name: 'Bob', email: 'bob@example.com' }],
+ subject: 'Hello',
+ receivedAt: '2026-01-01T00:00:00Z',
+ preview: 'Hello',
+ hasAttachment: false,
+ size: 100,
+ textBody: [],
+ htmlBody: [],
+ attachments: [],
+ bodyValues: {},
+} as unknown as Email;
+
+const mailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+
+function rect(left: number, top: number, width: number, height: number): DOMRect {
+ return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+/** Opens Rules with its entry at `entryLeft` (192 px wide) in a 1280 px window; returns the flyout. */
+function openRules(entryLeft: number, direction = 'ltr'): HTMLElement {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
+ const entry = screen.getByTestId('viewer-rules').parentElement!;
+ // jsdom doesn't inherit `direction` from , so set it where it is read.
+ entry.style.direction = direction;
+ vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280);
+ vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800);
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+ if (this === entry) return rect(entryLeft, 120, 192, 32);
+ if (this.getAttribute('aria-label') === 'rules.title') return rect(0, 0, 288, 200);
+ return rect(0, 0, 0, 0);
+ });
+ fireEvent.click(screen.getByTestId('viewer-rules'));
+ return screen.getByRole('menu', { name: 'rules.title' });
+}
+
+describe('reader More menu flyouts (#1261)', () => {
+ it('opens on the right when there is no room on the left', () => {
+ const flyout = openRules(100);
+ expect(flyout.style.left).toBe('292px');
+ expect(flyout.style.top).toBe('120px');
+ });
+
+ it('still opens on the left when both sides have room', () => {
+ const flyout = openRules(600);
+ expect(flyout.style.left).toBe('312px');
+ });
+
+ it('opens on the right when both sides have room in a right-to-left layout', () => {
+ const flyout = openRules(600, 'rtl');
+ expect(flyout.style.left).toBe('792px');
+ });
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index be5fd5cc1..5a061399e 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -15,6 +15,7 @@ import { buildContactsPath, buildMailPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
+import { SubMenuPanel } from "@/components/ui/context-menu";
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
import { itemsToHide } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
@@ -4004,10 +4005,11 @@ export function EmailViewer({
{moreMenuSub === 'move' && (
-
{(() => {
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
@@ -4042,7 +4044,7 @@ export function EmailViewer({
};
return renderMobileNodes(moveTree);
})()}
-
+
)}
)}
@@ -4076,16 +4078,17 @@ export function EmailViewer({
{moreMenuSub === 'tag' && (
-
{ if (email) onSetTag?.(email.id, tagId); }}
/>
-
+
)}
)}
@@ -4109,13 +4112,14 @@ export function EmailViewer({
{moreMenuSub === 'rules' && (
-
-
+
)}
)}
diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx
index a8dc532a8..a9c5ab387 100644
--- a/components/ui/context-menu.tsx
+++ b/components/ui/context-menu.tsx
@@ -202,49 +202,8 @@ export function ContextMenuSubMenu({
hint,
}: ContextMenuSubMenuProps) {
const [isOpen, setIsOpen] = useState(false);
- const [subMenuPos, setSubMenuPos] = useState(null);
- const itemRef = useRef(null);
- const subMenuRef = useRef(null);
const closeTimerRef = useRef | null>(null);
- useLayoutEffect(() => {
- if (!isOpen) {
- setSubMenuPos(null);
- return;
- }
- const itemEl = itemRef.current;
- const subEl = subMenuRef.current;
- if (!itemEl || !subEl) return;
-
- const itemRect = itemEl.getBoundingClientRect();
- const subRect = subEl.getBoundingClientRect();
- const vw = window.innerWidth;
- const vh = window.innerHeight;
-
- let left: number;
- if (itemRect.right + subRect.width <= vw - VIEWPORT_MARGIN) {
- left = itemRect.right;
- } else if (itemRect.left - subRect.width >= VIEWPORT_MARGIN) {
- left = itemRect.left - subRect.width;
- } else {
- left = Math.max(VIEWPORT_MARGIN, vw - subRect.width - VIEWPORT_MARGIN);
- }
-
- let top = itemRect.top;
- if (top + subRect.height > vh - VIEWPORT_MARGIN) {
- top = vh - subRect.height - VIEWPORT_MARGIN;
- }
- top = Math.max(VIEWPORT_MARGIN, top);
-
- // The submenu is measured while it still sits at left/top 0, so its rect
- // is the origin `position: fixed` resolves against. That is the viewport,
- // unless a theme gives the parent menu a backdrop-filter, filter or
- // transform: then the parent is the containing block and viewport
- // coordinates land offset by the parent's own position, far from the
- // entry (#1149).
- setSubMenuPos({ x: left - subRect.left, y: top - subRect.top });
- }, [isOpen]);
-
useEffect(() => {
return () => clearTimeout(closeTimerRef.current ?? undefined);
}, []);
@@ -266,7 +225,6 @@ export function ContextMenuSubMenu({
return (
{isOpen && !disabled && (
-
+
)}
);
}
+interface SubMenuPanelProps extends React.HTMLAttributes {
+ /** The side of the entry to open on when it fits; flips with the text direction. */
+ side?: "start" | "end";
+}
+
+/**
+ * A submenu's panel, placed beside the entry it is rendered in (its parent
+ * element): on `side` when it fits in the window, else on the other side,
+ * else clamped inside the window. It is `position: fixed`, so a pane that
+ * clips its overflow can't cut it off.
+ */
+export function SubMenuPanel({ side = "end", className, style, ...rest }: SubMenuPanelProps) {
+ const [pos, setPos] = useState(null);
+ const subMenuRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const subEl = subMenuRef.current;
+ const itemEl = subEl?.parentElement;
+ if (!subEl || !itemEl) return;
+
+ const itemRect = itemEl.getBoundingClientRect();
+ const subRect = subEl.getBoundingClientRect();
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+
+ // Where the panel's left edge goes on either side, if it fits there.
+ const onRight = itemRect.right + subRect.width <= vw - VIEWPORT_MARGIN ? itemRect.right : null;
+ const onLeft = itemRect.left - subRect.width >= VIEWPORT_MARGIN ? itemRect.left - subRect.width : null;
+ const rightFirst = (side === "end") !== (getComputedStyle(itemEl).direction === "rtl");
+ const x = (rightFirst ? (onRight ?? onLeft) : (onLeft ?? onRight))
+ ?? Math.max(VIEWPORT_MARGIN, vw - subRect.width - VIEWPORT_MARGIN);
+
+ let top = itemRect.top;
+ if (top + subRect.height > vh - VIEWPORT_MARGIN) {
+ top = vh - subRect.height - VIEWPORT_MARGIN;
+ }
+ top = Math.max(VIEWPORT_MARGIN, top);
+
+ // The submenu is measured while it still sits at left/top 0, so its rect
+ // is the origin `position: fixed` resolves against. That is the viewport,
+ // unless a theme gives the parent menu a backdrop-filter, filter or
+ // transform: then the parent is the containing block and viewport
+ // coordinates land offset by the parent's own position, far from the
+ // entry (#1149).
+ setPos({ x: x - subRect.left, y: top - subRect.top });
+ }, [side]);
+
+ return (
+
+ );
+}
+
interface ContextMenuHeaderProps {
children: React.ReactNode;
}
From 4adbad25f6150f213fb4ed352afaaca1efcd450d Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 13:46:48 +0200
Subject: [PATCH 055/238] feat: end the provider session through a configured
logout URL #1279
---
.env.example | 16 +++++--
.../admin/_tabs/_jmap-servers-section.tsx | 22 ++++++++-
app/(main)/admin/_tabs/auth.tsx | 3 +-
app/api/auth/token/route.ts | 19 ++++++--
lib/__tests__/oauth-issuer-precedence.test.ts | 34 ++++++++++++++
lib/__tests__/oauth-token-route.test.ts | 46 +++++++++++++++++++
lib/admin/jmap-servers.ts | 7 ++-
lib/admin/types.ts | 5 ++
lib/oauth/end-session.ts | 6 ++-
lib/oauth/token-exchange.ts | 7 ++-
10 files changed, 150 insertions(+), 15 deletions(-)
diff --git a/.env.example b/.env.example
index 96245b1a0..f5f46bb39 100644
--- a/.env.example
+++ b/.env.example
@@ -30,7 +30,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Prefer configuring this from the admin dashboard - the env form exists for
# stateless deployments. An "oauth" block with both "clientId" and
# "buttonLabel" gives that server its own sign-in button on the login page,
-# even while OAuth is off globally.
+# even while OAuth is off globally. "endSessionUrl" in the "oauth" block is
+# that server's OAUTH_END_SESSION_URL (see below).
# JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com","oauth":{"clientId":"webmail-us"}}]
# Pick the server automatically from the domain of the address the user types,
@@ -80,10 +81,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
# Signing out of an SSO account also signs out of the identity provider,
-# through the end_session_endpoint it advertises (RP-initiated logout). Set to
-# false when the provider is shared with apps that should stay signed in.
+# through the end_session_endpoint it advertises or OAUTH_END_SESSION_URL
+# (RP-initiated logout). Set to false when the provider is shared with apps
+# that should stay signed in.
# OAUTH_END_SESSION=false
+# The provider's logout URL, for providers whose discovery document has no
+# end_session_endpoint (Stalwart's built-in provider, for example). When set,
+# it is used instead of the discovered endpoint and gets the same parameters:
+# client_id, id_token_hint and post_logout_redirect_uri. Must be https.
+# Applies to OAUTH_ISSUER_URL's provider only; JMAP_SERVERS entries set their
+# own "endSessionUrl" in their "oauth" block.
+# OAUTH_END_SESSION_URL=https://your-idp.example.com/logout
+
# Where the identity provider sends the browser after signing the user out.
# Register it with the provider as a post-logout redirect URI first, or it
# will refuse the logout. Unset: the provider shows its own signed-out page.
diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx
index 2f0c531e0..3dab7f75d 100644
--- a/app/(main)/admin/_tabs/_jmap-servers-section.tsx
+++ b/app/(main)/admin/_tabs/_jmap-servers-section.tsx
@@ -20,6 +20,7 @@ interface RowDraft {
oauthIssuerUrl: string;
oauthClientSecret: string;
oauthButtonLabel: string;
+ oauthEndSessionUrl: string;
oauthExpanded: boolean;
}
@@ -33,7 +34,8 @@ function entryToDraft(e: JmapServerEntry): RowDraft {
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
oauthClientSecret: e.oauth?.clientSecret ?? '',
oauthButtonLabel: e.oauth?.buttonLabel ?? '',
- oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
+ oauthEndSessionUrl: e.oauth?.endSessionUrl ?? '',
+ oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret || e.oauth.endSessionUrl)),
};
}
@@ -49,12 +51,14 @@ function draftToEntry(d: RowDraft): JmapServerEntry | null {
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
const clientSecret = d.oauthClientSecret;
const buttonLabel = d.oauthButtonLabel.trim();
- const oauth = clientId || issuerUrl || clientSecret
+ const endSessionUrl = d.oauthEndSessionUrl.trim();
+ const oauth = clientId || issuerUrl || clientSecret || endSessionUrl
? {
...(clientId ? { clientId } : {}),
...(issuerUrl ? { issuerUrl } : {}),
...(clientSecret ? { clientSecret } : {}),
...(buttonLabel ? { buttonLabel } : {}),
+ ...(endSessionUrl ? { endSessionUrl } : {}),
}
: undefined;
return {
@@ -76,6 +80,7 @@ function emptyDraft(): RowDraft {
oauthIssuerUrl: '',
oauthClientSecret: '',
oauthButtonLabel: '',
+ oauthEndSessionUrl: '',
oauthExpanded: false,
};
}
@@ -268,6 +273,19 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
With a client ID, this label gives the server its own sign-in button on the login page, whichever server is selected and even while OAuth is off globally.
+
+
Provider logout URL
+
update(i, { oauthEndSessionUrl: e.target.value })}
+ placeholder="https://auth.example.com/logout"
+ className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ />
+
+ Where signing out sends the browser to end the provider's session, instead of the end_session_endpoint from discovery. Set it for providers that advertise none, such as Stalwart. Must be https.
+
+
)}
diff --git a/app/(main)/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx
index 7d28e969b..730111def 100644
--- a/app/(main)/admin/_tabs/auth.tsx
+++ b/app/(main)/admin/_tabs/auth.tsx
@@ -296,7 +296,8 @@ export function AuthTab() {
diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts
index 963179dc8..0631a5399 100644
--- a/app/api/auth/token/route.ts
+++ b/app/api/auth/token/route.ts
@@ -70,17 +70,26 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada
}
}
-/** The provider logout URL for a slot being signed out, or null when there is none to visit. */
+/**
+ * The provider logout URL for a slot being signed out, or null when there is
+ * none to visit. A logout URL the admin configured (#1279) goes before the
+ * discovered one: it is there for providers that advertise none, such as
+ * Stalwart, and for a logout page shared with other apps, which must keep
+ * working once the provider starts advertising its own.
+ */
function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined): string | null {
- if (!metadata?.end_session_endpoint || !isEndSessionEnabled()) return null;
+ if (!isEndSessionEnabled()) return null;
let clientId: string;
+ let configuredEndpoint: string | undefined;
try {
- ({ clientId } = getRequiredConfig(serverId));
+ ({ clientId, endSessionUrl: configuredEndpoint } = getRequiredConfig(serverId));
} catch {
return null;
}
+ const endpoint = configuredEndpoint || metadata?.end_session_endpoint;
+ if (!endpoint) return null;
return buildEndSessionUrl({
- endpoint: metadata.end_session_endpoint,
+ endpoint,
clientId,
idToken,
postLogoutRedirectUri: getPostLogoutRedirectUri(),
@@ -265,7 +274,7 @@ export async function PUT(request: NextRequest) {
/**
* Revoke a slot's refresh token and clear its token cookies. With
* `endSession`, also returns the URL that ends the provider's session, or null
- * when the provider has no usable end_session_endpoint.
+ * when there is no usable logout URL.
*/
async function signOutSlot(
cookieStore: CookieStore,
diff --git a/lib/__tests__/oauth-issuer-precedence.test.ts b/lib/__tests__/oauth-issuer-precedence.test.ts
index 58d8ad9a9..5858d3bf3 100644
--- a/lib/__tests__/oauth-issuer-precedence.test.ts
+++ b/lib/__tests__/oauth-issuer-precedence.test.ts
@@ -25,6 +25,13 @@ const SERVERS = [
url: 'https://mx3.example.com',
oauth: { issuerUrl: 'https://idp3.example.com' },
},
+ {
+ id: 'mx4',
+ label: 'Server 4',
+ url: 'https://mx4.example.com',
+ oauth: { endSessionUrl: ' https://mx4.example.com/logout/ ' },
+ },
+ { id: 'mx5', label: 'Server 5', url: 'https://mx5.example.com', oauth: { endSessionUrl: 'logout' } },
];
vi.mock('@/lib/admin/config-manager', () => ({
@@ -64,3 +71,30 @@ describe('SSO discovery issuer precedence (#952)', () => {
expect(getRequiredConfig('unknown').discoveryUrl).toBe('https://idp1.example.com');
});
});
+
+describe('configured end-session URL precedence (#1279)', () => {
+ beforeEach(() => {
+ vi.stubEnv('JMAP_SERVER_URL', 'https://mx1.example.com');
+ vi.stubEnv('OAUTH_CLIENT_ID', 'client');
+ vi.stubEnv('OAUTH_END_SESSION_URL', 'https://idp1.example.com/logout');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('uses the selected server\'s own logout URL, as written', () => {
+ // A trailing slash is part of some providers' logout path.
+ expect(getRequiredConfig('mx4').endSessionUrl).toBe('https://mx4.example.com/logout/');
+ });
+
+ it('does not give a server entry the global logout URL', () => {
+ expect(getRequiredConfig('mx2').endSessionUrl).toBeUndefined();
+ expect(getRequiredConfig('mx5').endSessionUrl).toBeUndefined();
+ });
+
+ it('uses the global logout URL when no server entry matched', () => {
+ expect(getRequiredConfig(null).endSessionUrl).toBe('https://idp1.example.com/logout');
+ expect(getRequiredConfig('unknown').endSessionUrl).toBe('https://idp1.example.com/logout');
+ });
+});
diff --git a/lib/__tests__/oauth-token-route.test.ts b/lib/__tests__/oauth-token-route.test.ts
index b915acb54..d220afc01 100644
--- a/lib/__tests__/oauth-token-route.test.ts
+++ b/lib/__tests__/oauth-token-route.test.ts
@@ -611,6 +611,52 @@ describe('oauth token route - sign-out (#905)', () => {
expect(body.end_session_url).toBeUndefined();
});
+ // Stalwart's built-in provider advertises no end_session_endpoint (#1279).
+ it('builds the provider logout from the configured URL when discovery has none', async () => {
+ seedSlot(0);
+ getMetadata.mockResolvedValue({ ...KEYCLOAK, end_session_endpoint: undefined });
+ getRequiredConfig.mockReturnValue({ clientId: 'webmail', endSessionUrl: 'https://mail.example.org/logout/' });
+ configValues.oauthPostLogoutRedirectUri = 'https://mail.example.com/en/login';
+
+ const { body } = await callRoute('DELETE', { slot: '0', end_session: 'true' });
+
+ const url = new URL(body.end_session_url as string);
+ expect(`${url.origin}${url.pathname}`).toBe('https://mail.example.org/logout/');
+ expect(url.searchParams.get('client_id')).toBe('webmail');
+ expect(url.searchParams.get('id_token_hint')).toBe(ID_TOKEN);
+ expect(url.searchParams.get('post_logout_redirect_uri')).toBe('https://mail.example.com/en/login');
+ });
+
+ it('prefers the configured logout URL over the discovered one', async () => {
+ seedSlot(0);
+ getRequiredConfig.mockReturnValue({ clientId: 'webmail', endSessionUrl: 'https://sso.example.com/logout' });
+
+ const { body } = await callRoute('DELETE', { slot: '0', end_session: 'true' });
+
+ const url = new URL(body.end_session_url as string);
+ expect(`${url.origin}${url.pathname}`).toBe('https://sso.example.com/logout');
+ });
+
+ it('does not use the configured logout URL when the admin turned provider logout off', async () => {
+ seedSlot(0);
+ getRequiredConfig.mockReturnValue({ clientId: 'webmail', endSessionUrl: 'https://sso.example.com/logout' });
+ configValues.oauthEndSession = false;
+
+ const { body } = await callRoute('DELETE', { slot: '0', end_session: 'true' });
+
+ expect(body.end_session_url).toBeUndefined();
+ });
+
+ it('does not offer a configured logout URL over plain http', async () => {
+ seedSlot(0);
+ getMetadata.mockResolvedValue({ ...KEYCLOAK, end_session_endpoint: undefined });
+ getRequiredConfig.mockReturnValue({ clientId: 'webmail', endSessionUrl: 'http://mail.example.org/logout' });
+
+ const { body } = await callRoute('DELETE', { slot: '0', end_session: 'true' });
+
+ expect(body.end_session_url).toBeUndefined();
+ });
+
it.each([
['non-https', 'http://id.example.com/logout'],
['malformed', 'not a url'],
diff --git a/lib/admin/jmap-servers.ts b/lib/admin/jmap-servers.ts
index aa79b727a..d78f05d60 100644
--- a/lib/admin/jmap-servers.ts
+++ b/lib/admin/jmap-servers.ts
@@ -8,6 +8,8 @@ export interface JmapServerOAuthConfig {
clientSecret?: string;
/** Text of this server's sign-in button, e.g. "Sign in with Google"; the generic SSO label otherwise. */
buttonLabel?: string;
+ /** The provider's logout URL, used instead of a discovered end_session_endpoint (#1279). Never sent to the browser. */
+ endSessionUrl?: string;
}
export interface JmapServerEntry {
@@ -82,12 +84,15 @@ export function parseJmapServers(raw: unknown): JmapServerEntry[] {
const issuerUrl = typeof o.issuerUrl === 'string' ? trimUrl(o.issuerUrl) : '';
const clientSecret = typeof o.clientSecret === 'string' ? o.clientSecret : '';
const buttonLabel = typeof o.buttonLabel === 'string' ? o.buttonLabel.trim().slice(0, 64) : '';
- if (clientId || issuerUrl || clientSecret) {
+ // Kept as written: some providers' logout path ends in a slash.
+ const endSessionUrl = typeof o.endSessionUrl === 'string' ? o.endSessionUrl.trim() : '';
+ if (clientId || issuerUrl || clientSecret || endSessionUrl) {
oauth = {};
if (clientId) oauth.clientId = clientId;
if (issuerUrl && isHttpUrl(issuerUrl)) oauth.issuerUrl = issuerUrl;
if (clientSecret) oauth.clientSecret = clientSecret;
if (buttonLabel) oauth.buttonLabel = buttonLabel;
+ if (endSessionUrl && isHttpUrl(endSessionUrl)) oauth.endSessionUrl = endSessionUrl;
}
}
out.push({
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 8e17ce6f0..758f00125 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -266,6 +266,11 @@ export const CONFIG_ENV_MAP: Record
('oauthEndSessionUrl', '') || process.env.OAUTH_END_SESSION_URL)?.trim() || undefined;
+ return { clientId, serverUrl, discoveryUrl, serverId: entry?.id, endSessionUrl };
}
export function getClientSecret(serverId?: string | null): string {
From 8e7245b26c3608632d15b234ec18e41d4389302a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 13:46:49 +0200
Subject: [PATCH 056/238] feat: let the theme policy set and lock the color
mode #1280
---
app/(main)/[locale]/login/page.tsx | 10 +-
app/(main)/admin/_tabs/themes.tsx | 44 ++++++-
.../__tests__/command-palette.test.tsx | 11 ++
.../command-palette/command-palette.tsx | 14 ++-
.../appearance-settings-color-mode.test.tsx | 38 ++++++
components/settings/appearance-settings.tsx | 4 +-
lib/admin/types.ts | 8 ++
.../theme-store-color-mode-policy.test.ts | 112 ++++++++++++++++++
stores/policy-store.ts | 18 ++-
stores/settings-store.ts | 7 +-
stores/theme-store.ts | 50 +++++++-
11 files changed, 298 insertions(+), 18 deletions(-)
create mode 100644 components/settings/__tests__/appearance-settings-color-mode.test.tsx
create mode 100644 stores/__tests__/theme-store-color-mode-policy.test.ts
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx
index a4ee53ec8..f42a930b0 100644
--- a/app/(main)/[locale]/login/page.tsx
+++ b/app/(main)/[locale]/login/page.tsx
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { useAuthStore, consumeSignedOut } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store";
+import { usePolicyStore } from "@/stores/policy-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
@@ -161,6 +162,7 @@ function LoginPageContent() {
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
+ const colorModeLocked = usePolicyStore((s) => s.getColorModePolicy().locked);
// Login logo sizing: when a max height/width is configured, drop the fixed
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
@@ -956,8 +958,8 @@ function LoginPageContent() {
if (demoMode && !isAddAccountMode) {
return (
- {/* Theme toggle */}
-
+ {/* Theme toggle, hidden while the theme policy locks the mode (#1280) */}
+
- {/* Theme toggle - top right, dropdown style */}
-
+ {/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}
+
>) {
+ setPolicy(prev => ({
+ ...prev,
+ themePolicy: {
+ ...DEFAULT_THEME_POLICY,
+ ...prev.themePolicy,
+ ...patch,
+ },
+ }));
+ setPolicyDirty(true);
+ setMessage(null);
+ }
+
async function handleSavePolicy() {
setSavingPolicy(true);
setMessage(null);
@@ -306,6 +319,7 @@ export function ThemesTab() {
const themesEnabled = policy.features.themesEnabled ?? true;
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
+ const lockColorMode = policy.themePolicy?.lockColorMode === true;
return (
@@ -437,6 +451,34 @@ export function ThemesTab() {
+
+
+
Default Color Mode
+
Light, dark or system for users who have not picked one
+
+
setColorModePolicy({ defaultColorMode: e.target.value === 'system' ? null : e.target.value as ColorMode })}
+ className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
+ >
+ System
+ Light
+ Dark
+
+
+
+
+
+
Lock Color Mode
+
Everyone gets the default color mode and cannot switch it in settings, on the login page or in the command palette
+
+
setColorModePolicy({ lockColorMode: !lockColorMode })}
+ className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${lockColorMode ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
+
+
+
+
Built-in Themes
diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx
index b7869b0bb..cc87ef4b1 100644
--- a/components/command-palette/__tests__/command-palette.test.tsx
+++ b/components/command-palette/__tests__/command-palette.test.tsx
@@ -22,6 +22,8 @@ vi.mock('@/components/keyboard-shortcuts-modal', () => ({
const { CommandPaletteHost } = await import('../command-palette');
const { useAuthStore } = await import('@/stores/auth-store');
const { useThemeStore } = await import('@/stores/theme-store');
+const { usePolicyStore } = await import('@/stores/policy-store');
+const { DEFAULT_POLICY, DEFAULT_THEME_POLICY } = await import('@/lib/admin/types');
const appCommands = await import('@/lib/app-commands');
const dispatch = vi.fn().mockResolvedValue(undefined);
@@ -35,6 +37,7 @@ beforeEach(() => {
dispatch.mockClear();
setActionDispatcher(dispatch);
useAuthStore.setState({ isAuthenticated: true, client: null } as never);
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY } });
});
afterEach(() => {
appCommands.parkAppCommand(null as never);
@@ -171,4 +174,12 @@ describe('command palette', () => {
act(() => { fireEvent.click(screen.getByRole('option', { name: 'shortcuts' })); });
expect(screen.getByText('shortcuts help')).toBeInTheDocument();
});
+
+ it('offers no theme switch while the theme policy locks the color mode (#1280)', () => {
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, themePolicy: { ...DEFAULT_THEME_POLICY, lockColorMode: true } } });
+ render(
);
+ pressCtrlK();
+ expect(screen.queryByRole('option', { name: /^theme_/ })).toBeNull();
+ expect(screen.getByRole('option', { name: 'shortcuts' })).toBeInTheDocument();
+ });
});
diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx
index d3c4b8d4d..da8eb004d 100644
--- a/components/command-palette/command-palette.tsx
+++ b/components/command-palette/command-palette.tsx
@@ -103,6 +103,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
const client = useAuthStore((s) => s.client);
const supportsCalendar = useCalendarStore((s) => s.supportsCalendar);
const isFeatureEnabled = usePolicyStore((s) => s.isFeatureEnabled);
+ const colorModeLocked = usePolicyStore((s) => s.getColorModePolicy().locked);
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const pluginActions = usePluginActions(surface);
@@ -132,15 +133,18 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
{ id: "go-settings", group: "navigation", icon: Settings, label: t("go_to", { name: tSidebar("settings") }), run: go("/settings") },
{ id: "compose", group: "actions", icon: PenLine, label: t("compose"), run: onMail("compose") },
{ id: "search", group: "actions", icon: Search, label: t("search"), run: onMail("search") },
- {
+ );
+ // The theme policy can pin the color mode (#1280).
+ if (!colorModeLocked) {
+ list.push({
id: "theme",
group: "actions",
icon: resolvedTheme === "dark" ? Sun : Moon,
label: resolvedTheme === "dark" ? t("theme_light") : t("theme_dark"),
run: () => useThemeStore.getState().setTheme(resolvedTheme === "dark" ? "light" : "dark"),
- },
- { id: "shortcuts", group: "actions", icon: Keyboard, label: t("shortcuts"), run: onShowShortcuts },
- );
+ });
+ }
+ list.push({ id: "shortcuts", group: "actions", icon: Keyboard, label: t("shortcuts"), run: onShowShortcuts });
for (const action of pluginActions) {
list.push({
id: `plugin:${action.pluginId}:${action.id}`,
@@ -152,7 +156,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
});
}
return list;
- }, [client, supportsCalendar, isFeatureEnabled, resolvedTheme, pluginActions, router, t, tSidebar, onShowShortcuts]);
+ }, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, router, t, tSidebar, onShowShortcuts]);
const needle = query.trim().toLowerCase();
const visible = needle
diff --git a/components/settings/__tests__/appearance-settings-color-mode.test.tsx b/components/settings/__tests__/appearance-settings-color-mode.test.tsx
new file mode 100644
index 000000000..a3a71ee52
--- /dev/null
+++ b/components/settings/__tests__/appearance-settings-color-mode.test.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { render, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// #1280: the theme policy can lock the light/dark/system mode.
+
+vi.mock('next-intl', () => ({
+ useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
+}));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn() }),
+}));
+vi.mock('../message-list-order-settings', () => ({ MessageListOrderSettings: () => null }));
+
+const { AppearanceSettings } = await import('../appearance-settings');
+const { usePolicyStore } = await import('@/stores/policy-store');
+const { DEFAULT_POLICY, DEFAULT_THEME_POLICY } = await import('@/lib/admin/types');
+
+function renderModeItem() {
+ const { container } = render(
);
+ return container.querySelector('[data-search-label="settings.appearance.theme.label"]') as HTMLElement;
+}
+
+beforeEach(() => {
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY } });
+});
+
+describe('appearance settings color mode (#1280)', () => {
+ it('shows the mode as managed while the theme policy locks it', () => {
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, themePolicy: { ...DEFAULT_THEME_POLICY, lockColorMode: true } } });
+ expect(within(renderModeItem()).getByLabelText('Managed by administrator')).toBeInTheDocument();
+ });
+
+ it('leaves the mode to the user otherwise, even with a default', () => {
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, themePolicy: { ...DEFAULT_THEME_POLICY, defaultColorMode: 'light' } } });
+ expect(within(renderModeItem()).queryByLabelText('Managed by administrator')).toBeNull();
+ });
+});
diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx
index df3020751..344574152 100644
--- a/components/settings/appearance-settings.tsx
+++ b/components/settings/appearance-settings.tsx
@@ -74,11 +74,11 @@ export function AppearanceSettings() {
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, showOnboardingOnNewDevices, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
const refreshFavicons = useFaviconRefreshStore((s) => s.refresh);
- const { isSettingLocked, isSettingHidden } = usePolicyStore();
+ const { isSettingLocked, isSettingHidden, getColorModePolicy } = usePolicyStore();
return (
-
+
setTheme(value as 'light' | 'dark' | 'system')}
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 758f00125..2307b5978 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -96,6 +96,8 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
customFromEnabled: true,
};
+export type ColorMode = 'light' | 'dark' | 'system';
+
export interface ThemePolicy {
/** Built-in theme IDs that are disabled (hidden from users) */
disabledBuiltinThemes: string[];
@@ -103,12 +105,18 @@ export interface ThemePolicy {
disabledThemes: string[];
/** Default theme ID for new users (null = system default) */
defaultThemeId: string | null;
+ /** Color mode for users who have not picked one (null = system) */
+ defaultColorMode: ColorMode | null;
+ /** Every user gets defaultColorMode (system when unset) and cannot switch it */
+ lockColorMode: boolean;
}
export const DEFAULT_THEME_POLICY: ThemePolicy = {
disabledBuiltinThemes: [],
disabledThemes: [],
defaultThemeId: null,
+ defaultColorMode: null,
+ lockColorMode: false,
};
/** One entry of the Web Push relay list users can pick from. */
diff --git a/stores/__tests__/theme-store-color-mode-policy.test.ts b/stores/__tests__/theme-store-color-mode-policy.test.ts
new file mode 100644
index 000000000..47827a3e4
--- /dev/null
+++ b/stores/__tests__/theme-store-color-mode-policy.test.ts
@@ -0,0 +1,112 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const apiFetch = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/browser-navigation', () => ({ apiFetch }));
+
+import { useThemeStore } from '../theme-store';
+import { useSettingsStore } from '../settings-store';
+import { usePolicyStore } from '../policy-store';
+import { DEFAULT_POLICY, DEFAULT_THEME_POLICY, type ThemePolicy } from '@/lib/admin/types';
+
+// #1280: themePolicy could enforce a theme, but not light, dark or system.
+
+function loadThemePolicy(patch: Partial) {
+ usePolicyStore.setState({
+ policy: { ...DEFAULT_POLICY, themePolicy: { ...DEFAULT_THEME_POLICY, ...patch } },
+ loaded: true,
+ });
+}
+
+const importBlob = (blob: Record) =>
+ useSettingsStore.getState().importSettings(JSON.stringify(blob));
+
+const theme = () => useThemeStore.getState();
+
+beforeEach(() => {
+ localStorage.clear();
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, loaded: false });
+ useThemeStore.setState({ theme: 'system', themeChosen: false });
+});
+
+describe('color mode policy (#1280)', () => {
+ it('applies the default to a user who never picked a mode', () => {
+ loadThemePolicy({ defaultColorMode: 'light' });
+ expect(theme().theme).toBe('light');
+ expect(theme().themeChosen).toBe(false);
+ });
+
+ it('keeps a mode the user picked, even system', () => {
+ theme().setTheme('system');
+ loadThemePolicy({ defaultColorMode: 'light' });
+ expect(theme().theme).toBe('system');
+ });
+
+ it('follows the operator changing a default the user never touched', () => {
+ loadThemePolicy({ defaultColorMode: 'light' });
+ loadThemePolicy({ defaultColorMode: 'dark' });
+ expect(theme().theme).toBe('dark');
+ });
+
+ it('enforces a locked mode over the user\'s pick and ignores picking another', () => {
+ theme().setTheme('dark');
+ loadThemePolicy({ defaultColorMode: 'light', lockColorMode: true });
+ expect(theme().theme).toBe('light');
+
+ theme().setTheme('dark');
+ expect(theme().theme).toBe('light');
+ expect(theme().resolvedTheme).toBe('light');
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
+ });
+
+ it('locks to system when no default mode is set', () => {
+ theme().setTheme('dark');
+ loadThemePolicy({ lockColorMode: true });
+ expect(theme().theme).toBe('system');
+ });
+
+ it('ignores a default that is not a color mode', () => {
+ loadThemePolicy({ defaultColorMode: 'Dark' as never });
+ expect(theme().theme).toBe('system');
+ });
+});
+
+describe('color mode policy and synced settings (#1280)', () => {
+ it('applies the default over a synced mode the user never chose', () => {
+ loadThemePolicy({ defaultColorMode: 'light' });
+ importBlob({ theme: 'system', themeChosen: false });
+ expect(theme().theme).toBe('light');
+ });
+
+ it('keeps a synced mode the user chose', () => {
+ loadThemePolicy({ defaultColorMode: 'light' });
+ importBlob({ theme: 'system', themeChosen: true });
+ expect(theme().theme).toBe('system');
+ expect(theme().themeChosen).toBe(true);
+ });
+
+ it('counts a mode other than system in an older blob as chosen', () => {
+ loadThemePolicy({ defaultColorMode: 'light' });
+ importBlob({ theme: 'dark' });
+ expect(theme().theme).toBe('dark');
+ importBlob({ theme: 'system' });
+ expect(theme().theme).toBe('light');
+ });
+
+ it('syncs whether the user chose the mode', () => {
+ expect(JSON.parse(useSettingsStore.getState().exportSettings()).themeChosen).toBe(false);
+ theme().setTheme('dark');
+ expect(JSON.parse(useSettingsStore.getState().exportSettings()).themeChosen).toBe(true);
+ });
+});
+
+describe('theme settings stored before #1280', () => {
+ it.each([
+ ['dark', true],
+ ['system', false],
+ ])('takes a stored %s mode as chosen: %s', async (stored, chosen) => {
+ localStorage.setItem('theme-storage', JSON.stringify({ state: { theme: stored, activeThemeId: null, installedThemes: [] }, version: 0 }));
+ await useThemeStore.persist.rehydrate();
+ expect(theme().theme).toBe(stored);
+ expect(theme().themeChosen).toBe(chosen);
+ });
+});
diff --git a/stores/policy-store.ts b/stores/policy-store.ts
index d3870f969..5d0003492 100644
--- a/stores/policy-store.ts
+++ b/stores/policy-store.ts
@@ -1,5 +1,5 @@
import { create } from 'zustand';
-import type { SettingsPolicy, FeatureGates, SettingRestriction, ThemePolicy } from '@/lib/admin/types';
+import type { SettingsPolicy, FeatureGates, SettingRestriction, ThemePolicy, ColorMode } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY, POLICY_SCOPE_HEADER } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { IS_LITE, IS_LITE_STALWART, LITE_POLICY_PATH, withLiteBuildId } from '@/lib/lite';
@@ -22,6 +22,12 @@ interface PolicyState {
getRestriction: (key: string) => SettingRestriction | undefined;
getEffectiveDefault: (key: string) => unknown;
getThemePolicy: () => ThemePolicy;
+ /**
+ * The theme policy's color mode (#1280): `mode` for users who have not
+ * picked one, and for everyone while `locked`. A lock without a valid
+ * defaultColorMode pins the built-in default, system.
+ */
+ getColorModePolicy: () => { mode: ColorMode | null; locked: boolean };
getForcedThemeId: (availableThemeIds?: string[]) => string | null;
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
isPluginForceEnabled: (pluginId: string) => boolean;
@@ -30,6 +36,8 @@ interface PolicyState {
let policyRequest = 0;
+const COLOR_MODES: readonly ColorMode[] = ['light', 'dark', 'system'];
+
export const usePolicyStore = create()((set, get) => ({
policy: { ...DEFAULT_POLICY },
loaded: false,
@@ -93,6 +101,14 @@ export const usePolicyStore = create()((set, get) => ({
return get().policy.themePolicy || { ...DEFAULT_THEME_POLICY };
},
+ getColorModePolicy: () => {
+ const tp = get().policy.themePolicy || DEFAULT_THEME_POLICY;
+ // policy.json is written by hand: anything but the three modes is no default.
+ const mode = COLOR_MODES.find((m) => m === tp.defaultColorMode) ?? null;
+ const locked = tp.lockColorMode === true;
+ return { mode: locked ? mode ?? 'system' : mode, locked };
+ },
+
getForcedThemeId: (availableThemeIds) => {
const forceEnabledThemes = get().policy.forceEnabledThemes || [];
if (!availableThemeIds || availableThemeIds.length === 0) {
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index badd12081..91fb9621c 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -1261,6 +1261,7 @@ export const useSettingsStore = create()(
explicitSettings: state.explicitSettings,
// Cross-store settings
theme: useThemeStore.getState().theme,
+ themeChosen: useThemeStore.getState().themeChosen,
locale: useLocaleStore.getState().locale,
// Omitted entirely (rather than emitted empty) if the bridge has
// not registered, so an importing device leaves its templates alone.
@@ -1445,7 +1446,11 @@ export const useSettingsStore = create()(
// Apply cross-store settings
if (settings.theme) {
- useThemeStore.getState().setTheme(settings.theme);
+ // A mode the user never chose leaves room for the operator's
+ // default (#1280). Blobs from before themeChosen count any mode
+ // but the built-in default as chosen.
+ const themeChosen = typeof settings.themeChosen === 'boolean' ? settings.themeChosen : settings.theme !== 'system';
+ useThemeStore.getState().setTheme(settings.theme, { chosen: themeChosen });
}
if (settings.locale) {
useLocaleStore.getState().setLocale(settings.locale);
diff --git a/stores/theme-store.ts b/stores/theme-store.ts
index 5e31ac3fa..23bbc9b9b 100644
--- a/stores/theme-store.ts
+++ b/stores/theme-store.ts
@@ -32,8 +32,22 @@ function getForcedThemeId(installedThemes: InstalledTheme[]): string | null {
return installedThemes.find((theme) => theme.forceEnabled)?.id ?? null;
}
+/**
+ * The mode to use for `theme` (#1280): the theme policy's locked mode
+ * whatever the user picked, its default while they have not picked one.
+ */
+function policyColorMode(theme: Theme, chosen: boolean): Theme {
+ const { mode, locked } = usePolicyStore.getState().getColorModePolicy();
+ return mode && (locked || !chosen) ? mode : theme;
+}
+
interface ThemeState {
theme: Theme;
+ /**
+ * The user picked `theme` themselves (#1280). Until they do, the theme
+ * policy's defaultColorMode applies. Synced with the settings.
+ */
+ themeChosen: boolean;
resolvedTheme: 'light' | 'dark';
hydrated: boolean;
@@ -41,9 +55,12 @@ interface ThemeState {
installedThemes: InstalledTheme[];
activeThemeId: string | null; // null = built-in default
- setTheme: (theme: Theme) => void;
+ /** `chosen: false` sets a mode the user did not pick (a synced one they never chose). */
+ setTheme: (theme: Theme, opts?: { chosen?: boolean }) => void;
toggleTheme: () => void;
initializeTheme: () => void;
+ /** Applies the theme policy's color mode; runs whenever the policy changes. */
+ applyColorModePolicy: () => void;
// Custom theme management
installTheme: (file: File) => Promise<{ success: boolean; error?: string; warnings?: string[] }>;
@@ -52,6 +69,8 @@ interface ThemeState {
syncServerThemes: () => Promise;
}
+type PersistedThemeState = Pick;
+
const getSystemTheme = (): 'light' | 'dark' => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
@@ -85,15 +104,18 @@ export const useThemeStore = create()(
persist(
(set, get) => ({
theme: 'system',
+ themeChosen: false,
resolvedTheme: 'light',
hydrated: false,
installedThemes: [...BUILTIN_THEMES],
activeThemeId: null,
- setTheme: (theme) => {
+ setTheme: (requested, opts) => {
+ const themeChosen = opts?.chosen ?? true;
+ const theme = policyColorMode(requested, themeChosen);
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
applyTheme(resolvedTheme);
- set({ theme, resolvedTheme });
+ set({ theme, resolvedTheme, themeChosen });
// Re-apply active custom theme for new mode
const { activeThemeId, installedThemes } = get();
if (activeThemeId) {
@@ -110,6 +132,11 @@ export const useThemeStore = create()(
get().setTheme(nextTheme);
},
+ applyColorModePolicy: () => {
+ const { theme, themeChosen } = get();
+ if (policyColorMode(theme, themeChosen) !== theme) get().setTheme(theme, { chosen: themeChosen });
+ },
+
initializeTheme: () => {
const { theme, activeThemeId, installedThemes } = get();
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
@@ -485,8 +512,10 @@ export const useThemeStore = create()(
}),
{
name: 'theme-storage',
- partialize: (state) => ({
+ version: 1,
+ partialize: (state): PersistedThemeState => ({
theme: state.theme,
+ themeChosen: state.themeChosen,
activeThemeId: state.activeThemeId,
// Store theme metadata but NOT full CSS / skin (those go in IndexedDB)
installedThemes: state.installedThemes.map(t => ({
@@ -496,6 +525,13 @@ export const useThemeStore = create()(
preview: undefined, // previews also in IndexedDB
})),
}),
+ // v1: themeChosen (#1280). Earlier builds did not record it, so a mode
+ // other than the built-in default is taken as chosen.
+ migrate: (persisted, version) => {
+ const state = persisted as PersistedThemeState;
+ if (version < 1) state.themeChosen = state.theme !== 'system';
+ return state;
+ },
onRehydrateStorage: () => {
return (state) => {
if (state) {
@@ -645,6 +681,12 @@ async function downloadThemeAsset(theme: ServerThemeInfo, kind: 'css' | 'skin'):
return null;
}
}
+// The policy arrives after this store has rehydrated (and again in full once
+// signed in), so its color mode applies whenever it changes (#1280).
+usePolicyStore.subscribe((state, prev) => {
+ if (state.policy !== prev.policy) useThemeStore.getState().applyColorModePolicy();
+});
+
// Plugins hear about every change of scheme or theme, whichever path made it
// (settings, the system switching to dark, a forced admin theme).
useThemeStore.subscribe((state, prev) => {
From 7163a5dcc1f29fed013b0f9216c1f44b95086566 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 13:47:35 +0200
Subject: [PATCH 057/238] fix: translate contact field types and more French
strings
Co-authored-by: French-Asgeir <109287366+French-Asgeir@users.noreply.github.com>
---
.../__tests__/contact-detail.test.tsx | 15 ++++
components/contacts/contact-detail.tsx | 39 ++++++++---
locales/fr/common.json | 70 +++++++++----------
3 files changed, 79 insertions(+), 45 deletions(-)
diff --git a/components/contacts/__tests__/contact-detail.test.tsx b/components/contacts/__tests__/contact-detail.test.tsx
index a908f69f9..386778b4f 100644
--- a/components/contacts/__tests__/contact-detail.test.tsx
+++ b/components/contacts/__tests__/contact-detail.test.tsx
@@ -45,6 +45,21 @@ describe('ContactDetail', () => {
expect(screen.getByText('+33612345678')).toBeInTheDocument();
});
+ it('labels phones, emails and addresses with translated contexts and phone types', () => {
+ const typed: ContactCard = {
+ ...contact,
+ emails: { e0: { address: 'alice@work.example', contexts: { work: true } } },
+ phones: { p0: { number: '+4930123', contexts: { private: true }, features: { cell: true, 'x-sat': true } } },
+ addresses: { a0: { street: '1 Office Rd', contexts: { work: true, 'x-hq': true } } },
+ };
+ render( );
+ // The test translator returns the key, so a translated label shows up as its key.
+ expect(screen.getByText('form.context_work')).toBeInTheDocument();
+ expect(screen.getByText('form.context_private · form.phone_cell, x-sat')).toBeInTheDocument();
+ // Custom vCard types are shown as stored.
+ expect(screen.getByText('form.context_work, x-hq')).toBeInTheDocument();
+ });
+
it('displays organization name', () => {
render( );
const matches = screen.getAllByText('Acme Corp');
diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx
index 14c66479c..478fe85bb 100644
--- a/components/contacts/contact-detail.tsx
+++ b/components/contacts/contact-detail.tsx
@@ -41,9 +41,26 @@ interface ContactDetailProps {
className?: string;
}
-function formatPhoneFeatures(features?: Record): string {
+// Contexts and phone features the contact form offers, shown with the same
+// labels here. Anything else (custom vCard types) is shown as stored.
+const CONTEXT_LABEL_KEYS: Record = {
+ work: "form.context_work",
+ private: "form.context_private",
+};
+
+const PHONE_FEATURE_LABEL_KEYS: Record = {
+ voice: "form.phone_voice",
+ cell: "form.phone_cell",
+ mobile: "form.phone_cell",
+ fax: "form.phone_fax",
+ pager: "form.phone_pager",
+ video: "form.phone_video",
+ text: "form.phone_text",
+};
+
+function formatPhoneFeatures(features: Record | undefined, label: (key: string) => string): string {
if (!features) return "";
- return Object.keys(features).filter(k => features[k]).join(", ");
+ return Object.keys(features).filter(k => features[k]).map(label).join(", ");
}
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
@@ -122,6 +139,8 @@ function formatDate(dateInput: AnniversaryDate): string {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
+ const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key);
+ const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key);
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
@@ -248,7 +267,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{emails.map((e, i) => (
-
+
{e.address}
@@ -269,8 +288,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
))}
{phones.map((p, i) => {
- const features = formatPhoneFeatures(p.features);
- const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[];
+ const features = formatPhoneFeatures(p.features, featureLabel);
+ const labelParts = [p.label, formatContexts(p.contexts, contextLabel), features].filter(Boolean) as string[];
return (
@@ -299,7 +318,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
lines.push(...parts);
}
return (
-
+
{lines.map((line, idx) => (
{line}
@@ -316,7 +335,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
@@ -398,7 +417,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{lang.language}
@@ -527,9 +546,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
);
}
-function formatContexts(contexts?: Record
): string {
+function formatContexts(contexts: Record | undefined, label: (key: string) => string): string {
if (!contexts) return "";
- return Object.keys(contexts).filter(k => contexts[k]).join(", ");
+ return Object.keys(contexts).filter(k => contexts[k]).map(label).join(", ");
}
export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
diff --git a/locales/fr/common.json b/locales/fr/common.json
index fd93dc0c7..990185589 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -1184,8 +1184,8 @@
"description": "Colore les icônes de dossiers et d'étiquettes par type (Boîte de réception en bleu, Indésirable en rouge, Envoyés en vert, etc.). Désactivez pour une barre latérale monochrome."
},
"tint_list_rows": {
- "label": "Tint List Rows by Tag Color",
- "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color."
+ "label": "Lignes de liste colorées selon la couleur d'étiquette",
+ "description": "Appliquez une couleur de fond à chaque ligne de message en fonction de la couleur de sa première étiquette. Désactivez cette option pour conserver des lignes unies. Les points et les pastilles d'étiquette continueront d'afficher la couleur."
},
"show_account_dot": {
"label": "Show Account Dot",
@@ -1200,8 +1200,8 @@
"description": "Affiche le nombre total de messages à côté des dossiers et des étiquettes, en plus du nombre de messages non lus. Désactivez pour n'afficher que les messages non lus."
},
"favicon_unread_badge": {
- "label": "Unread Count on Tab Icon",
- "description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
+ "label": "Nombre de non-lu sur l'icône de l'onglet",
+ "description": "Afficher le nombre de messages non lus de la boîte de réception sous forme de badge sur l’icône de l’onglet du navigateur, afin que les nouveaux messages restent visibles lorsque l’onglet n’est pas actif. Désactiver cette option pour ne pas afficher de badge sur l’icône."
},
"show_shared_mail_accounts": {
"label": "Afficher les comptes de messagerie partagés",
@@ -1375,12 +1375,12 @@
"preview_older": "Plus ancien :"
},
"date_locale": {
- "label": "Date format region",
- "description": "How numeric dates are ordered (day, month, year)",
+ "label": "Format de date",
+ "description": "Comment sont ordonnées des dates (jour, mois, année)",
"auto": "Automatic",
"iso": "ISO 8601",
- "dmy": "Day/Month/Year",
- "mdy": "Month/Day/Year"
+ "dmy": "Jour/Mois/Année",
+ "mdy": "Mois/Jour/Année"
},
"time_format": {
"label": "Format d'heure",
@@ -1395,10 +1395,10 @@
"monday": "Lundi"
},
"time_zone": {
- "label": "Time zone",
- "description": "Show email and calendar times in this time zone instead of the one reported by your browser",
- "auto": "Automatic ({zone})",
- "preview_now": "Now:"
+ "label": "Fuseau horaire",
+ "description": "Afficher les heures des e-mails et du calendrier dans ce fuseau horaire plutôt que dans celui indiqué par votre navigateur",
+ "auto": "Automatique ({zone})",
+ "preview_now": "Maintenant :"
}
},
"email_behavior": {
@@ -2111,9 +2111,9 @@
"button": "Voir les raccourcis"
},
"refresh_cache": {
- "label": "Refresh cached data",
- "description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
- "button": "Refresh"
+ "label": "Actualiser les données en cache",
+ "description": "Actualiser les contacts, calendriers et dossiers depuis le serveur. Vos comptes et sessions sont conservés. Corrige un affichage obsolète ou incorrect sans vous déconnecter.",
+ "button": "Actualiser"
},
"reset_settings": {
"label": "Réinitialiser les paramètres",
@@ -2915,16 +2915,16 @@
"cert_already_imported": "Certificat déjà importé",
"cert_imported": "Certificat importé",
"cert_import_failed": "Échec de l'import du certificat",
- "section_contact": "Contact details",
- "section_work": "Work",
- "section_personal": "Personal",
- "email_default_label": "Email",
- "phone_default_label": "Phone",
- "address_default_label": "Address",
- "online_service_default_label": "Online",
- "organization_label": "Organization",
- "title_label": "Title",
- "role_label": "Role",
+ "section_contact": "Détails du contact",
+ "section_work": "Travail",
+ "section_personal": "Personnel",
+ "email_default_label": "E-mail",
+ "phone_default_label": "Téléphone",
+ "address_default_label": "Adresse",
+ "online_service_default_label": "En ligne",
+ "organization_label": "Organisation",
+ "title_label": "Titre",
+ "role_label": "Rôle",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Plus d'actions",
@@ -2932,15 +2932,15 @@
"years_since": "{count, plural, one {il y a 1 an} other {il y a # ans}}"
},
"activity": {
- "recent_emails": "Recent Emails",
- "upcoming_events": "Upcoming Events",
- "no_emails": "No recent emails",
- "no_events": "No upcoming events",
- "no_subject": "(No subject)",
- "no_title": "(No title)",
- "load_failed": "Failed to load",
+ "recent_emails": "Courriels récents",
+ "upcoming_events": "Évènements à venir",
+ "no_emails": "Aucun courriel récent",
+ "no_events": "Aucun évènement à venir",
+ "no_subject": "(Aucun objet)",
+ "no_title": "(Aucun titre)",
+ "load_failed": "Échec du chargement",
"unknown_sender": "Unknown sender",
- "all_day": "All day"
+ "all_day": "Toute la journée"
},
"form": {
"create_title": "Nouveau contact",
@@ -3062,9 +3062,9 @@
"no_members": "Aucun membre dans ce groupe",
"member_count": "{count, plural, =0 {Aucun membre} one {1 membre} other {# membres}}",
"send_email": "Send email to group",
- "send_email_to": "To",
+ "send_email_to": "À",
"send_email_cc": "Cc",
- "send_email_bcc": "Bcc",
+ "send_email_bcc": "Cci",
"no_member_emails": "This group has no members with an email address."
},
"import": {
From 710e32cc10feeac1ae7f40ecf6c3c2a7bd0aaed6 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 14:19:07 +0200
Subject: [PATCH 058/238] fix: ignore Nextcloud filter markers inside rule text
---
lib/sieve/__tests__/external-rules.test.ts | 28 ++++++++++++++++
lib/sieve/parser.ts | 38 ++++++++++++++++++----
2 files changed, 59 insertions(+), 7 deletions(-)
diff --git a/lib/sieve/__tests__/external-rules.test.ts b/lib/sieve/__tests__/external-rules.test.ts
index ae7c0733c..d734af1fd 100644
--- a/lib/sieve/__tests__/external-rules.test.ts
+++ b/lib/sieve/__tests__/external-rules.test.ts
@@ -377,5 +377,33 @@ describe('external rule preservation (issue #201)', () => {
const headerCount = (script.match(/# --- External rules \(managed outside Bulwark\) ---/g) || []).length;
expect(headerCount).toBe(1);
});
+
+ it('ignores marker lines inside a rule value, so the value never turns into live Sieve', () => {
+ const value = `x\n${NEXTCLOUD_MARKER}\nredirect "attacker@example.com";\n${NEXTCLOUD_MARKER}\ny`;
+ const bulwark = makeBulwarkRule({ conditions: [{ field: 'subject', comparator: 'contains', value }] });
+
+ const parsed = parseScript(generateScript([bulwark]));
+ expect(parsed.rules.filter(r => r.originLabel === 'Nextcloud')).toHaveLength(0);
+ expect(parsed.rules).toHaveLength(1);
+ expect(parsed.rules[0].conditions[0].value).toBe(value);
+ });
+
+ it('ignores marker lines inside a text: block', () => {
+ const script = [
+ 'require ["vacation", "fileinto"];',
+ 'if header :contains "Subject" "invoice" {',
+ ' fileinto "Finance";',
+ '}',
+ 'vacation :subject "Away" text:',
+ NEXTCLOUD_MARKER,
+ 'redirect "attacker@example.com";',
+ NEXTCLOUD_MARKER,
+ '.',
+ ';',
+ '',
+ ].join('\n');
+ const result = parseScript(script);
+ expect(result.rules.filter(r => r.originLabel === 'Nextcloud')).toHaveLength(0);
+ });
});
});
diff --git a/lib/sieve/parser.ts b/lib/sieve/parser.ts
index 847f57dcc..67641deff 100644
--- a/lib/sieve/parser.ts
+++ b/lib/sieve/parser.ts
@@ -144,6 +144,19 @@ function skipBlockComment(s: string, i: number): number {
return end === -1 ? s.length : end + 2;
}
+// Past a `text:` string: every line up to and including the lone ".".
+function skipMultiLineString(s: string, i: number): number {
+ let line = s.indexOf('\n', i) + 1;
+ if (line === 0) return s.length;
+ while (line < s.length) {
+ const nl = s.indexOf('\n', line);
+ const next = nl === -1 ? s.length : nl + 1;
+ if (s.slice(line, next).replace(/\r?\n$/, '') === '.') return next;
+ line = next;
+ }
+ return s.length;
+}
+
function skipStatement(s: string, i: number): number {
while (i < s.length) {
const c = s[i];
@@ -700,14 +713,25 @@ function extractNextcloudRegions(content: string): {
requires: string[];
} {
const marker = NEXTCLOUD_BLOCK_MARKER;
+ // Only a comment line counts. The same text inside a string (a rule's value,
+ // an auto-reply) is data, and taken for a region it would be written back
+ // as live Sieve.
const positions: number[] = [];
- let searchFrom = 0;
- while (true) {
- const idx = content.indexOf(marker, searchFrom);
- if (idx === -1) break;
- const atLineStart = idx === 0 || content[idx - 1] === '\n';
- if (atLineStart) positions.push(idx);
- searchFrom = idx + marker.length;
+ let i = 0;
+ while (i < content.length) {
+ const c = content[i];
+ if (c === '"') { i = skipStringLit(content, i); continue; }
+ if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; }
+ if (c === '#') {
+ if ((i === 0 || content[i - 1] === '\n') && content.startsWith(marker, i)) positions.push(i);
+ i = skipHashComment(content, i);
+ continue;
+ }
+ if (content.slice(i, i + 5).toLowerCase() === 'text:' && !/\w/.test(content[i - 1] ?? '')) {
+ i = skipMultiLineString(content, i);
+ continue;
+ }
+ i++;
}
if (positions.length < 2) {
From 2f7873e75690bbfb10cab5e443d2b52a9d271fb1 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 14:39:07 +0200
Subject: [PATCH 059/238] test: update the inline image reply test for the
current composer mocks
---
.../__tests__/composer-inline-image-cid.test.tsx | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/components/email/__tests__/composer-inline-image-cid.test.tsx b/components/email/__tests__/composer-inline-image-cid.test.tsx
index 52d4b62ee..5896ea943 100644
--- a/components/email/__tests__/composer-inline-image-cid.test.tsx
+++ b/components/email/__tests__/composer-inline-image-cid.test.tsx
@@ -82,6 +82,8 @@ vi.mock('@/stores/settings-store', () => {
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
+ externalContentPolicy: 'block',
+ isSenderTrusted: () => false,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
@@ -123,6 +125,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -130,8 +140,8 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
- onComposeSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
@@ -182,6 +192,7 @@ describe('reply/forward inline image cid normalization', () => {
client: {
fetchBlobArrayBuffer,
hasDelayedSend: () => false,
+ supportsPrincipals: () => false,
getMaxDelayedSend: () => 0,
} as never,
});
From 610df64463129cd7e59d330779ea268fbfdd5801 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 14:39:27 +0200
Subject: [PATCH 060/238] fix: keep the whole folder after a restart when a
category tab was open #1264
---
.../email-store-refresh-category-tab.test.ts | 23 +++++++++++++++++++
stores/email-store.ts | 5 ++--
2 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/stores/__tests__/email-store-refresh-category-tab.test.ts b/stores/__tests__/email-store-refresh-category-tab.test.ts
index 5dc7b467d..f91c72188 100644
--- a/stores/__tests__/email-store-refresh-category-tab.test.ts
+++ b/stores/__tests__/email-store-refresh-category-tab.test.ts
@@ -78,4 +78,27 @@ describe('refreshing a folder with a category tab open (#1264)', () => {
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['receipt']);
expect(useEmailStore.getState().newEmailNotification).toBeNull();
});
+
+ it('leaves no delta baseline for the tab page, as the first load does', async () => {
+ useMessageListTabsStore.getState().setActiveTab('receipts', 'inbox');
+ const receipt = email('receipt', '2026-10-01T00:00:00Z');
+ const newsletter = email('newsletter', '2026-10-15T00:00:00Z');
+ const c = {
+ ...client([]),
+ getEmails: vi.fn(async (...args: unknown[]) => {
+ const rows = args[6] ? [receipt] : [newsletter, receipt];
+ return { emails: rows, total: rows.length, hasMore: false, state: 's' };
+ }),
+ getEmailChanges: vi.fn(async () => ({ created: [], updated: [], destroyed: [], newState: 's', hasMoreChanges: false })),
+ } as unknown as IJMAPClient;
+ await useEmailStore.getState().refreshCurrentMailbox(c);
+ expect(useEmailStore.getState().emailListSync).toBeNull();
+
+ // The baseline would be saved with the rows (boot snapshot). On the next
+ // boot, before the plugin has registered its tabs again, the folder loads
+ // behind those rows and must not take the tab's page for the whole folder.
+ useMessageListTabsStore.getState().clearTabs('receipts-plugin');
+ await useEmailStore.getState().fetchEmails(c, 'inbox', { background: true });
+ expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['newsletter', 'receipt']);
+ });
});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 74a52e63f..afd049c22 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -5634,8 +5634,9 @@ export const useEmailStore = create((set, get) => ({
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
result = await effectiveClient.getEmails(jmapMailboxId, accountId, windowSize, 0, undefined, true, categoryFilter ?? undefined, order);
// This page is the new delta-sync baseline for the folder; the
- // search / unified paths above cannot be delta-synced.
- syncAfterRefresh = result.state
+ // search / unified paths above cannot be delta-synced, and neither
+ // can a category tab's page (see fetchEmails).
+ syncAfterRefresh = result.state && !categoryFilter
? { state: result.state, accountId: accountId ?? effectiveClient.getAccountId(), mailboxId: selectedMailbox }
: null;
}
From 811e175cd040a00f5fee61b10fe4c0524c254601 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 14:39:58 +0200
Subject: [PATCH 061/238] fix: tell today's mail by the time zone setting in
the sender line #1259
---
.../__tests__/mv-sender-date-zone.test.tsx | 106 ++++++++++++++++++
components/email/email-viewer.tsx | 8 +-
lib/__tests__/format-relative-ago.test.ts | 6 +
lib/utils.ts | 5 +-
4 files changed, 122 insertions(+), 3 deletions(-)
create mode 100644 components/email/__tests__/mv-sender-date-zone.test.tsx
diff --git a/components/email/__tests__/mv-sender-date-zone.test.tsx b/components/email/__tests__/mv-sender-date-zone.test.tsx
new file mode 100644
index 000000000..e1dfef7aa
--- /dev/null
+++ b/components/email/__tests__/mv-sender-date-zone.test.tsx
@@ -0,0 +1,106 @@
+import { render } from '@testing-library/react';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { useSettingsStore } from '@/stores/settings-store';
+
+// #1259: the Mountain View sender line shows only the time for today's mail
+// and the date as well before that. "Today" is the day in the time zone
+// setting, the zone the time and the "(… ago)" after it are shown in, not the
+// browser's.
+
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+}));
+
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({
+ PluginSlot: () => null,
+}));
+
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({
+ usePluginSlotOffers: () => [],
+}));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+
+const mailboxes: Mailbox[] = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+
+function mail(n: number, receivedAt: string, overrides: Partial = {}): Email {
+ return {
+ id: `m${n}`,
+ blobId: `b${n}`,
+ threadId: 't1',
+ mailboxIds: { inbox: true },
+ keywords: { $seen: true },
+ from: [{ name: `Sender ${n}`, email: `s${n}@example.com` }],
+ to: [{ name: 'Me', email: 'me@example.com' }],
+ subject: 'Subject',
+ receivedAt,
+ preview: `Preview ${n}`,
+ hasAttachment: false,
+ size: 100,
+ ...overrides,
+ } as unknown as Email;
+}
+
+let originalTZ: string | undefined;
+
+describe('Mountain View sender date (#1259)', () => {
+ beforeAll(() => {
+ // The browser's zone, a day ahead of Denver in the evening.
+ originalTZ = process.env.TZ;
+ process.env.TZ = 'UTC';
+ });
+
+ afterAll(() => {
+ process.env.TZ = originalTZ;
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ useSettingsStore.setState({ timeZone: 'auto' });
+ });
+
+ it("shows the date for yesterday's mail in the time zone setting", () => {
+ vi.useFakeTimers({ toFake: ['Date'] });
+ // 16 Oct, 10:00 in Denver.
+ vi.setSystemTime(new Date('2026-10-16T16:00:00Z'));
+ useSettingsStore.setState({
+ interfaceLayout: 'mountain-view', markAsReadDelay: 0, timeZone: 'America/Denver', timeFormat: '24h',
+ });
+ // 15 Oct, 21:00 in Denver, which is already 16 Oct in UTC.
+ const yesterday = mail(1, '2026-10-16T03:00:00Z');
+ const open = {
+ ...mail(2, '2026-10-16T15:00:00Z'),
+ textBody: [{ partId: '1', type: 'text/plain' }],
+ htmlBody: [],
+ attachments: [],
+ bodyValues: { '1': { value: 'Open body', isTruncated: false } },
+ } as unknown as Email;
+ const { container } = render(
+ ,
+ );
+ const card = container.querySelector('[data-mv-conversation-card][data-email-id="m1"]');
+ expect(card?.textContent).toContain('21:00');
+ expect(card?.textContent).toContain('2026');
+ });
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index dba3d4ac4..2427a5e86 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { SubMenuPanel } from "@/components/ui/context-menu";
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
+import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
@@ -689,7 +690,12 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl
*/
function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string {
const d = typeof date === 'string' ? new Date(date) : date;
- const today = new Date().toDateString() === d.toDateString();
+ // "Today" in the time zone setting, the zone the time and the relative
+ // part are shown in, not the browser's (#1259).
+ const zone = getEffectiveTimeZone();
+ const day = Number.isNaN(d.getTime()) ? null : getWallClock(d, zone);
+ const now = getWallClock(new Date(), zone);
+ const today = !!day && day.year === now.year && day.month === now.month && day.day === now.day;
const when = today
? formatDateTime(d, timeFormat)
: formatDateTime(d, timeFormat, { year: 'numeric', month: 'short', day: 'numeric' });
diff --git a/lib/__tests__/format-relative-ago.test.ts b/lib/__tests__/format-relative-ago.test.ts
index befcfba80..73e446b1c 100644
--- a/lib/__tests__/format-relative-ago.test.ts
+++ b/lib/__tests__/format-relative-ago.test.ts
@@ -32,4 +32,10 @@ describe('formatRelativeAgo', () => {
// Under a day still reads in hours, even across midnight.
expect(formatRelativeAgo(new Date('2026-10-15T23:00:00Z'), denverNow)).toBe('17 hours ago');
});
+
+ it('does not call the same day yesterday on the 25-hour day the clocks go back', () => {
+ useSettingsStore.setState({ timeZone: 'America/Denver' });
+ // 1 Nov 2026 in Denver: 00:10 MDT, and 24.5 hours later 23:40 MST.
+ expect(formatRelativeAgo(new Date('2026-11-01T06:10:00Z'), new Date('2026-11-02T06:40:00Z'))).toBe('24 hours ago');
+ });
});
diff --git a/lib/utils.ts b/lib/utils.ts
index 1af71cfcd..584aff124 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -274,13 +274,14 @@ export function formatRelativeAgo(date: Date | string, now: Date = new Date()):
const w = getWallClock(x, timeZone);
return Date.UTC(w.year, w.month - 1, w.day) / 86400000;
};
- const days = Math.max(1, dayNumber(now) - dayNumber(d));
+ const days = dayNumber(now) - dayNumber(d);
if (days >= 30) return '';
const locale = getEffectiveLocale();
const rtf = new Intl.RelativeTimeFormat(locale === 'en' ? 'en-US' : locale, { numeric: 'auto' });
if (minutes < 1) return rtf.format(0, 'second');
if (minutes < 60) return rtf.format(-minutes, 'minute');
- if (hours < 24) return rtf.format(-hours, 'hour');
+ // The day the clocks go back has 25 hours: 24 of them can still be today.
+ if (hours < 24 || days < 1) return rtf.format(-hours, 'hour');
return rtf.format(-days, 'day');
}
From 7573471544b82369fd64abaf29f3762baf5a5a1e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 14:39:58 +0200
Subject: [PATCH 062/238] fix: keep plugins from reading mail of other
connected logins #1273
---
lib/__tests__/plugin-host-mail-read.test.ts | 16 +++++++++++++++-
lib/plugin-sandbox/host-api-mail.ts | 14 ++++++++++----
2 files changed, 25 insertions(+), 5 deletions(-)
diff --git a/lib/__tests__/plugin-host-mail-read.test.ts b/lib/__tests__/plugin-host-mail-read.test.ts
index a7ed397c4..c5fcc1fb5 100644
--- a/lib/__tests__/plugin-host-mail-read.test.ts
+++ b/lib/__tests__/plugin-host-mail-read.test.ts
@@ -39,6 +39,7 @@ const shared = {
} as unknown as Mailbox;
let client: Record>;
+const realGetClientForAccount = useAuthStore.getState().getClientForAccount;
beforeEach(() => {
client = {
@@ -53,7 +54,7 @@ beforeEach(() => {
});
afterEach(() => {
- useAuthStore.setState({ client: null });
+ useAuthStore.setState({ client: null, getClientForAccount: realGetClientForAccount });
useEmailStore.setState({ mailboxes: [], selectedEmail: null, selectedMailbox: '', emails: [] });
});
@@ -75,6 +76,19 @@ describe('api.email.get', () => {
expect(client.getEmail).toHaveBeenCalledWith('s1', 'acc2');
});
+ it('reads no listed message of another connected login', async () => {
+ // Rows can be put in the list by a plugin (onEmailsFetched), stamps included.
+ const other = { getEmail: vi.fn(async () => mail('x1', { subject: 'Other login' })) };
+ useAuthStore.setState({
+ getClientForAccount: ((id: string) => (id === 'login2' ? other : undefined)) as never,
+ });
+ useEmailStore.setState({ emails: [mail('x1', { sourceClientAccountId: 'login2', sourceAccountId: 'acc9' })] });
+ expect(await call(READER, 'email.get', 'x1')).toBeNull();
+ expect(other.getEmail).not.toHaveBeenCalled();
+ // Nor is the signed-in login asked for an id that belongs to another one.
+ expect(client.getEmail).not.toHaveBeenCalled();
+ });
+
it('uses the open message when its body is loaded', async () => {
useEmailStore.setState({ selectedEmail: mail('open', { bodyValues: { 1: { value: 'x' } }, textBody: [{ partId: '1' }] } as unknown as Partial) });
expect(await call(READER, 'email.get', 'open')).toEqual(expect.objectContaining({ id: 'open', text: 'x' }));
diff --git a/lib/plugin-sandbox/host-api-mail.ts b/lib/plugin-sandbox/host-api-mail.ts
index 33d4d7389..9c27e41f7 100644
--- a/lib/plugin-sandbox/host-api-mail.ts
+++ b/lib/plugin-sandbox/host-api-mail.ts
@@ -4,8 +4,9 @@
// JMAP client and never change what the user has open.
//
// api.email.get also reads a message of a shared account when it is in the
-// loaded list, from the account that owns it. The bytes of attachments and
-// sources are read from the signed-in account only.
+// loaded list, from the account that owns it, always through the signed-in
+// login. The bytes of attachments and sources are read from the signed-in
+// account only.
import type { EmailReadView, EmailSummary, MailboxView } from '../plugin-types';
import type { Email, Mailbox } from '../jmap/types';
@@ -36,8 +37,13 @@ export async function doEmailGet(id: unknown): Promise {
// it; the signed-in account doesn't know its id.
const listed = useEmailStore.getState().emails.find((e) => e.id === emailId);
const owner = listed ? resolveEmailBlobContext(listed, client) : null;
- const email = owner?.client && owner.accountId
- ? await owner.client.getEmail(emailId, owner.accountId)
+ // Only through the signed-in login. A row of another connected login is
+ // not read here, nor asked of this login under an id that isn't its own.
+ // The rows can come from a plugin (onEmailsFetched), so their stamps must
+ // not pick which login is asked.
+ if (owner && owner.client !== client) return null;
+ const email = owner?.accountId
+ ? await client.getEmail(emailId, owner.accountId)
: await loadEmail(client, emailId);
return email ? emailToReadView(email) : null;
}
From be069563985a65bc85a0d05d72cbd2bd95b9e56e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 14:39:59 +0200
Subject: [PATCH 063/238] test: cover the From pick when replying to another
connected account
---
.../email/__tests__/reply-addressing.test.tsx | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/components/email/__tests__/reply-addressing.test.tsx b/components/email/__tests__/reply-addressing.test.tsx
index 8a489d537..93dd0866d 100644
--- a/components/email/__tests__/reply-addressing.test.tsx
+++ b/components/email/__tests__/reply-addressing.test.tsx
@@ -489,6 +489,24 @@ describe('composer reply addressing', () => {
render( );
expect(identitySelect().value).toBe('local-1::id-info');
});
+
+ // Mail one connected account sent to another was received by the second:
+ // the reply goes back to the sender, from the address that received it.
+ it('answers mail another connected account sent here like received mail', () => {
+ render(
+ ,
+ );
+ expect(toChips()).toEqual(['Login (login@elsewhere.net)']);
+ expect(identitySelect().value).toBe('local-1::id-info');
+ });
});
});
From 1e992e6c7fc0026afcb89d0363d18e75b69e6445 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 16 Oct 2026 23:42:39 +0200
Subject: [PATCH 064/238] fix: check plugin dropdown messages before the host
draws them
---
components/plugins/plugin-iframe-slot.tsx | 4 +-
.../plugin-floating-dropdown-host.test.ts | 80 ++++++++++++++++
lib/plugin-sandbox/host-bridge.ts | 91 ++++++++++++++++++-
3 files changed, 172 insertions(+), 3 deletions(-)
create mode 100644 lib/__tests__/plugin-floating-dropdown-host.test.ts
diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx
index c571f9a93..42ad79a0d 100644
--- a/components/plugins/plugin-iframe-slot.tsx
+++ b/components/plugins/plugin-iframe-slot.tsx
@@ -258,7 +258,7 @@ function FloatingDropdownPortal({
return createPortal(
{ optionRefs.current[i] = el; }}
- id={`${dropdown.id}-opt-${i}`}
+ id={`plugin-dropdown-${dropdown.id}-opt-${i}`}
role="option"
aria-selected={isSelected}
aria-disabled={opt.disabled}
diff --git a/lib/__tests__/plugin-floating-dropdown-host.test.ts b/lib/__tests__/plugin-floating-dropdown-host.test.ts
new file mode 100644
index 000000000..f2e570992
--- /dev/null
+++ b/lib/__tests__/plugin-floating-dropdown-host.test.ts
@@ -0,0 +1,80 @@
+import { afterEach, describe, it, expect, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+
+vi.mock('../plugin-sandbox/host-api', () => ({
+ dispatchApiCall: vi.fn(async () => 'ok'),
+}));
+
+import { createSlotInstance } from '../plugin-sandbox/host-bridge';
+
+function plugin(): InstalledPlugin {
+ return {
+ id: 'p1', name: 'P', version: '1.0.0', author: '', description: '', type: 'hook',
+ permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
+ };
+}
+
+const instances: Array<{ destroy(): void }> = [];
+afterEach(() => {
+ for (const i of instances.splice(0)) i.destroy();
+});
+
+// A slot iframe of 300x100 px; messages come from its own (null-origin) window.
+function slot() {
+ const onFloatingDropdown = vi.fn();
+ const inst = createSlotInstance({
+ plugin: plugin(), slot: 'plugin-dialog', code: '', locale: 'en', tier: 'untrusted', extraProps: {},
+ hostContainer: document.body, onResize: vi.fn(), onFloatingDropdown,
+ });
+ instances.push(inst);
+ inst.iframe.getBoundingClientRect = () => ({ left: 0, top: 0, right: 300, bottom: 100, width: 300, height: 100, x: 0, y: 0, toJSON: () => ({}) });
+ const post = (data: unknown) =>
+ window.dispatchEvent(new MessageEvent('message', { data, origin: 'null', source: inst.iframe.contentWindow }));
+ return { onFloatingDropdown, post };
+}
+
+const goodRect = { left: 10, top: 20, bottom: 50, width: 200, height: 30 };
+
+describe('floating dropdown messages from a plugin', () => {
+ it('passes a well-formed open message through', () => {
+ const { onFloatingDropdown, post } = slot();
+ post({
+ type: 'floating-dropdown-open', id: 'x-listbox', rect: goodRect,
+ options: [{ value: 'a', label: 'A' }], selectedValues: [], highlightedIndex: 0,
+ });
+ expect(onFloatingDropdown).toHaveBeenCalledTimes(1);
+ expect(onFloatingDropdown.mock.calls[0][0]).toMatchObject({ id: 'x-listbox', rect: goodRect, options: [{ value: 'a', label: 'A' }] });
+ });
+
+ it('drops an open message whose options are not a list, so the host never renders it', () => {
+ const { onFloatingDropdown, post } = slot();
+ post({ type: 'floating-dropdown-open', id: 'x', rect: goodRect, options: 'boom', selectedValues: [], highlightedIndex: 0 });
+ expect(onFloatingDropdown).not.toHaveBeenCalled();
+ });
+
+ it('keeps only options with string labels and fills in missing selectedValues', () => {
+ const { onFloatingDropdown, post } = slot();
+ post({
+ type: 'floating-dropdown-open', id: 'x', rect: goodRect, highlightedIndex: 0,
+ options: [{ value: 'a', label: { $$typeof: 'x' } }, { value: 'b', label: 'B' }, null],
+ });
+ const msg = onFloatingDropdown.mock.calls[0][0];
+ expect(msg.options).toEqual([{ value: 'b', label: 'B' }]);
+ expect(msg.selectedValues).toEqual([]);
+ });
+
+ it('clamps the anchor rect to the plugin frame', () => {
+ const { onFloatingDropdown, post } = slot();
+ post({
+ type: 'floating-dropdown-open', id: 'x', options: [], selectedValues: [], highlightedIndex: -1,
+ rect: { left: -900, top: 5000, bottom: 6000, width: 99999, height: 1000 },
+ });
+ expect(onFloatingDropdown.mock.calls[0][0].rect).toEqual({ left: 0, top: 100, bottom: 100, width: 300, height: 0 });
+ });
+
+ it('ignores a bad rect in an update but keeps the rest', () => {
+ const { onFloatingDropdown, post } = slot();
+ post({ type: 'floating-dropdown-update', id: 'x', rect: { left: 'a' }, highlightedIndex: 2 });
+ expect(onFloatingDropdown.mock.calls[0][0]).toEqual({ type: 'floating-dropdown-update', id: 'x', highlightedIndex: 2 });
+ });
+});
diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts
index deb34af95..6076898fc 100644
--- a/lib/plugin-sandbox/host-bridge.ts
+++ b/lib/plugin-sandbox/host-bridge.ts
@@ -18,10 +18,95 @@ import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload, StreamChunkMsg, StreamEndMsg, StreamProgressMsg,
FloatingDropdownOpenMsg, FloatingDropdownUpdateMsg, FloatingDropdownCloseMsg,
FloatingDropdownSelectMsg, FloatingDropdownHighlightMsg, FloatingDropdownDismissMsg,
+ FloatingDropdownOption, FloatingDropdownRect,
} from './protocol';
export type FloatingDropdownHostMsg = FloatingDropdownOpenMsg | FloatingDropdownUpdateMsg | FloatingDropdownCloseMsg;
+// ─── Floating dropdown messages ──────────────────────────────
+
+const MAX_DROPDOWN_OPTIONS = 500;
+const MAX_DROPDOWN_TEXT = 500;
+
+function str(v: unknown, max = MAX_DROPDOWN_TEXT): string | undefined {
+ return typeof v === 'string' ? v.slice(0, max) : undefined;
+}
+
+function clamp(v: unknown, min: number, max: number): number | null {
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null;
+ return Math.min(Math.max(v, min), Math.max(min, max));
+}
+
+function cleanRect(r: unknown, frameW: number, frameH: number): FloatingDropdownRect | null {
+ if (!r || typeof r !== 'object') return null;
+ const o = r as Record
;
+ const left = clamp(o.left, 0, frameW);
+ const top = clamp(o.top, 0, frameH);
+ if (left === null || top === null) return null;
+ const bottom = clamp(o.bottom, top, frameH);
+ const width = clamp(o.width, 0, frameW - left);
+ if (bottom === null || width === null) return null;
+ return { left, top, bottom, width, height: bottom - top };
+}
+
+function cleanOptions(v: unknown): FloatingDropdownOption[] | null {
+ if (!Array.isArray(v)) return null;
+ const out: FloatingDropdownOption[] = [];
+ for (const item of v.slice(0, MAX_DROPDOWN_OPTIONS)) {
+ if (!item || typeof item !== 'object') continue;
+ const o = item as Record;
+ const value = str(o.value);
+ const label = str(o.label);
+ if (value === undefined || label === undefined) continue;
+ out.push(o.disabled === true ? { value, label, disabled: true } : { value, label });
+ }
+ return out;
+}
+
+function cleanValues(v: unknown): string[] | null {
+ if (!Array.isArray(v)) return null;
+ return v.slice(0, MAX_DROPDOWN_OPTIONS).filter((x): x is string => typeof x === 'string');
+}
+
+/**
+ * Check a floating-dropdown message from a plugin before the host renders
+ * it in its own DOM: drop messages with a bad id or shape, cap sizes, keep
+ * only string labels, and clamp the anchor rect (iframe coordinates) to the
+ * frame so a plugin can't put a host-drawn popup anywhere on the page.
+ */
+export function sanitizeFloatingDropdownMsg(
+ msg: FloatingDropdownHostMsg,
+ frameW: number,
+ frameH: number,
+): FloatingDropdownHostMsg | null {
+ const id = str(msg.id, 200);
+ if (!id) return null;
+ if (msg.type === 'floating-dropdown-close') return { type: msg.type, id };
+ const highlighted = typeof msg.highlightedIndex === 'number' && Number.isInteger(msg.highlightedIndex)
+ ? msg.highlightedIndex : undefined;
+ if (msg.type === 'floating-dropdown-open') {
+ const rect = cleanRect(msg.rect, frameW, frameH);
+ const options = cleanOptions(msg.options);
+ if (!rect || !options) return null;
+ return {
+ type: msg.type, id, rect, options,
+ multiple: msg.multiple === true,
+ selectedValues: cleanValues(msg.selectedValues) ?? [],
+ singleValue: str(msg.singleValue),
+ highlightedIndex: highlighted ?? -1,
+ ariaLabel: str(msg.ariaLabel),
+ };
+ }
+ const out: FloatingDropdownUpdateMsg = { type: 'floating-dropdown-update', id };
+ if (msg.rect !== undefined) { const r = cleanRect(msg.rect, frameW, frameH); if (r) out.rect = r; }
+ if (msg.options !== undefined) { const o = cleanOptions(msg.options); if (o) out.options = o; }
+ if (msg.selectedValues !== undefined) { const v = cleanValues(msg.selectedValues); if (v) out.selectedValues = v; }
+ if (typeof msg.multiple === 'boolean') out.multiple = msg.multiple;
+ if (typeof msg.singleValue === 'string') out.singleValue = str(msg.singleValue);
+ if (highlighted !== undefined) out.highlightedIndex = highlighted;
+ return out;
+}
+
// ─── Callback marshalling ────────────────────────────────────
/**
@@ -375,7 +460,11 @@ export class SandboxInstance {
case 'floating-dropdown-open':
case 'floating-dropdown-update':
case 'floating-dropdown-close': {
- this.slotFloatingDropdownCb?.(msg);
+ // The host draws this dropdown in its own DOM, so a malformed message
+ // must not crash the app, and the anchor must stay inside the frame.
+ const frame = this.iframe.getBoundingClientRect();
+ const clean = sanitizeFloatingDropdownMsg(msg, frame.width, frame.height);
+ if (clean) this.slotFloatingDropdownCb?.(clean);
return;
}
}
From f3482ce0206d38d937fd1f975fafd91dfeabbf19 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:13:46 +0200
Subject: [PATCH 065/238] fix: remember restored sign-ins only when the
restoring account is remembered
---
app/(main)/admin/_tabs/policy.tsx | 2 +-
lib/__tests__/account-archive-client.test.ts | 18 +++++++++++++++++-
lib/account-archive-client.ts | 9 ++++++++-
3 files changed, 26 insertions(+), 3 deletions(-)
diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx
index 4b6e94eb1..c5b410022 100644
--- a/app/(main)/admin/_tabs/policy.tsx
+++ b/app/(main)/admin/_tabs/policy.tsx
@@ -39,7 +39,7 @@ const FEATURE_GATE_LABELS: Partial {
});
const result = await restoreArchive([entry('a@example.com', 'right'), entry('b@example.com', 'stale'), entry('c@example.com')], true);
expect(auth.signIn).toHaveBeenCalledTimes(2);
- expect(auth.signIn).toHaveBeenCalledWith(generateAccountId('a@example.com', server), 'right', true);
+ expect(auth.signIn).toHaveBeenCalledWith(generateAccountId('a@example.com', server), 'right', false);
expect(result).toMatchObject({ added: 3, signedIn: 1, signInFailed: 1, waiting: 2 });
});
+ it('remembers the restored sign-ins only when the restoring account is remembered', async () => {
+ auth.signIn.mockResolvedValue(true);
+ const owner = add('owner@example.com', 'basic');
+ useAccountStore.getState().updateAccount(owner, { rememberMe: false });
+ useAccountStore.setState({ activeAccountId: owner });
+ await restoreArchive([entry('a@example.com', 'right')], true);
+ expect(auth.signIn).toHaveBeenLastCalledWith(generateAccountId('a@example.com', server), 'right', false);
+
+ useAccountStore.getState().updateAccount(owner, { rememberMe: true });
+ await restoreArchive([entry('b@example.com', 'right')], true);
+ expect(auth.signIn).toHaveBeenLastCalledWith(generateAccountId('b@example.com', server), 'right', true);
+
+ await restoreArchive([entry('c@example.com', 'right')], false);
+ expect(auth.signIn).toHaveBeenLastCalledWith(generateAccountId('c@example.com', server), 'right', false);
+ });
+
it('does not sign in again to an account already connected here', async () => {
const id = add('a@example.com', 'basic');
auth.clients.set(id, { getBasicPassword: () => 'right' });
diff --git a/lib/account-archive-client.ts b/lib/account-archive-client.ts
index 3eb83c826..c17a14b15 100644
--- a/lib/account-archive-client.ts
+++ b/lib/account-archive-client.ts
@@ -75,8 +75,15 @@ export function restoreAccounts(archived: ArchivedAccount[]): { added: number; u
* Restores the list, then signs in one by one to the accounts saved with a
* password that are not signed in here yet. An account whose password no
* longer works keeps waiting for a sign-in by hand.
+ *
+ * A restored sign-in is remembered only when the server allows it and the
+ * account doing the restore was itself signed in with "Remember me": on a
+ * shared computer, restoring must not leave long-lived sessions behind.
*/
-export async function restoreArchive(archived: ArchivedAccount[], rememberMe: boolean) {
+export async function restoreArchive(archived: ArchivedAccount[], rememberMeAllowed: boolean) {
+ const accounts = useAccountStore.getState();
+ const active = accounts.accounts.find(a => a.id === accounts.activeAccountId);
+ const rememberMe = rememberMeAllowed && !!active?.rememberMe;
const result = restoreAccounts(archived);
let signedIn = 0, signInFailed = 0;
for (const entry of archived) {
From dca10ba9aa431f87425da59a7d3f8f93600b519c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 16 Oct 2026 23:35:01 +0200
Subject: [PATCH 066/238] feat: translate the company directory into Estonian
---
locales/et/common.json | 43 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
diff --git a/locales/et/common.json b/locales/et/common.json
index c0e06d1b8..68216b69e 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -626,6 +626,7 @@
"plugin_panel_hide": "Peida pistikprogrammi paneel"
},
"email_composer": {
+ "select_from_directory": "Vali kataloogist",
"minimize": "Minimeeri",
"maximize": "Täisekraan",
"restore": "Taasta",
@@ -2739,6 +2740,48 @@
}
},
"contacts": {
+ "directory": {
+ "title": "Kataloog",
+ "search_placeholder": "Otsi nime või e-posti aadressi",
+ "refresh": "Värskenda kataloogi",
+ "retry": "Proovi uuesti",
+ "loading": "Kataloogi laaditakse…",
+ "unavailable_title": "Kataloog puudub",
+ "unavailable_hint": "Sinu meiliserver ei paku kontode kataloogi.",
+ "forbidden_title": "Kataloog pole saadaval",
+ "forbidden_hint": "Sinu meiliserver ei luba selle kontoga kataloogi sirvida. Küsi oma administraatorilt.",
+ "error_title": "Kataloogi laadimine ebaõnnestus",
+ "error_hint": "Kontrolli ühendust ja proovi uuesti.",
+ "empty_title": "Kataloog on tühi",
+ "empty_hint": "Sinu meiliserver ei näita ühtegi e-posti aadressiga kontot.",
+ "no_match": "Kataloogis pole kedagi, kes sinu otsingule vastaks",
+ "refresh_failed": "Kataloogi värskendamine ebaõnnestus. Näidatakse viimati laaditud nimekirja.",
+ "select_page": "Vali kõik sellel lehel",
+ "deselect_page": "Tühista valik sellel lehel",
+ "select_entry": "Vali {name}",
+ "range": "{from}–{to} / {total}",
+ "page": "Leht {page} / {count}",
+ "previous_page": "Eelmine leht",
+ "next_page": "Järgmine leht",
+ "picker_title": "Vali kataloogist",
+ "cancel": "Tühista",
+ "selected_count": "{count} valitud",
+ "add_to": "Lisa saajaks",
+ "add_cc": "Lisa CC-sse",
+ "add_bcc": "Lisa BCC-sse",
+ "select_hint": "Vali keegi, et näha tema andmeid",
+ "write_email": "Kirjuta kiri",
+ "add_to_contacts": "Lisa kontaktidesse",
+ "email": "E-post",
+ "read_only_note": "Kataloogi kirjed tulevad sinu meiliserverist ja neid ei saa siin muuta.",
+ "type": {
+ "individual": "Isik",
+ "group": "Grupp",
+ "resource": "Ressurss",
+ "location": "Asukoht",
+ "other": "Muu"
+ }
+ },
"title": "Kontaktid",
"search_placeholder": "Otsi kontakte...",
"create_new": "Uus kontakt",
From eb100c6aac6052db1107b6c0ea9975442d6496db Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 16 Oct 2026 23:35:30 +0200
Subject: [PATCH 067/238] fix: draw the Estonian flag without a shared clip
path id
---
components/ui/flag-icons.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/components/ui/flag-icons.tsx b/components/ui/flag-icons.tsx
index 7c956a1ff..254f6eadd 100644
--- a/components/ui/flag-icons.tsx
+++ b/components/ui/flag-icons.tsx
@@ -128,7 +128,7 @@ function FlagTW(props: FlagProps) {
// Estonia is not in Twemoji's set used above; drawn here in the same style.
function FlagEE(props: FlagProps) {
- return ;
+ return ;
}
export const flagComponents: Record ReactElement> = {
From b23735fb6b28f8547e3b808003fe9c9f8017dc74 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 14:41:32 +0200
Subject: [PATCH 068/238] feat: translate the strings added since the Estonian
locale was made
---
locales/et/common.json | 38 +++++++++++++++++++++++++++++++++++---
1 file changed, 35 insertions(+), 3 deletions(-)
diff --git a/locales/et/common.json b/locales/et/common.json
index 68216b69e..09d85edef 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -142,6 +142,8 @@
"show_all_tags": "Näita kõiki ({count})",
"show_fewer_tags": "Näita vähem",
"folders": "Kaustad",
+ "your_folders": "Sinu kaustad",
+ "create_folder": "Loo kaust",
"show_more": "Rohkem",
"show_fewer": "Vähem",
"shared": "Jagatud",
@@ -713,6 +715,10 @@
"option_dsn_hint": "Saa aruanne, kui server kirja kohale toimetab",
"option_require_tls": "Nõua krüpteeritud kohaletoimetamist",
"option_require_tls_hint": "Toimeta kohale ainult TLS-iga, muidu lükka tagasi",
+ "option_priority": "Prioriteet",
+ "option_priority_high": "Kõrge",
+ "option_priority_normal": "Tavaline",
+ "option_priority_low": "Madal",
"validation": {
"recipient_required": "Saatmiseks lisa saaja",
"body_required": "Kirjuta sõnum või lisa fail",
@@ -876,6 +882,8 @@
"emails_moved": "{count} e-kirja liigutati",
"moved_to_mailbox": "Liigutatud kausta {mailbox}",
"move_failed": "Liigutamine ebaõnnestus",
+ "removed_from_folder": "Eemaldatud kaustast {mailbox}",
+ "remove_from_folder_failed": "Sellest kaustast ei õnnestunud eemaldada",
"emails_copied": "{count} kirja kopeeritud",
"copy_failed": "Kopeerimine ebaõnnestus",
"move_error": "E-kirju ei õnnestunud valitud kausta liigutada",
@@ -1182,6 +1190,10 @@
"label": "Värvi loendirida sildi värviga",
"description": "Tooni iga kirja rida selle esimese sildi värviga. Lülita välja, et read jääksid tavalised; sildipunktid ja -märgised näitavad värvi ikkagi."
},
+ "show_account_dot": {
+ "label": "Näita konto täppi",
+ "description": "Ühendatud vaates näidatakse saatja kõrval konto värvi täppi, et oleks näha, millisest kontost kiri pärineb. Ei mõju, kui read on konto värviga toonitud."
+ },
"tint_list_rows_by_account": {
"label": "Toonita nimekirja read konto värviga",
"description": "Ühendatud vaates varjuta iga rida konto värviga konto punkti näitamise asemel. Sellel on eelis sildi värvi ees; sildid säilitavad oma värvi oma märgistel."
@@ -1736,6 +1748,7 @@
"backup": {
"title": "Kontode nimekirja varukoopia",
"description": "Hoia selle brauseri kontode nimekirja serveris, krüpteeritult parooliga, mida tead ainult sina, ja too see tagasi teises seadmes. Postkastide paroole ei salvestata: igasse taastatud kontosse logid ise sisse.",
+ "description_passwords": "Hoia selle brauseri kontode nimekirja serveris, krüpteeritult parooliga, mida tead ainult sina, ja too see tagasi teises seadmes. Soovi korral saad lisada ka postkastide paroolid, nii et taastatud kontod logivad ise sisse.",
"status_none": "Kontole {account} pole nimekirja salvestatud.",
"status_saved": "Kontole {account} on nimekiri salvestatud.",
"save": "Salvesta nimekiri",
@@ -1747,9 +1760,15 @@
"password_hint": "Vähemalt 10 märki. Seda ei saa taastada: kui unustad, salvesta nimekiri uuesti uue parooliga.",
"restore_hint": "Parool, mille valisid nimekirja salvestamisel.",
"confirm": "Kinnita parool",
+ "include_passwords": "Lisa postkastide paroolid",
+ "include_passwords_hint": "Siin parooliga sisse logitud kontod logivad pärast taastamist ise sisse. Igaüks, kes teab varukoopia parooli, pääseb ligi kõigile nimekirja postkastidele.",
"saved": "Nimekiri salvestatud ({count, plural, one {# konto} other {# kontot}}).",
"restored": "Lisatud kontosid: {added}. Juba olemasolevaid ja uuendatud kontosid: {updated}. Sisselogimiseks ava iga uus konto kontode nimekirjast.",
+ "restored_signed_in": "Lisatud kontosid: {added}. Juba olemasolevaid ja uuendatud kontosid: {updated}.",
"skipped": "{count, plural, one {# kontot} other {# kontot}} ei lisatud: kontode piir on täis.",
+ "saved_passwords": "Paroolid lisatud {count, plural, one {# kontole} other {# kontole}}.",
+ "signed_in": "Salvestatud parooliga logiti sisse {count, plural, one {# kontosse} other {# kontosse}}.",
+ "sign_in_failed": "{count, plural, one {# salvestatud parool lükati tagasi} other {# salvestatud parooli lükati tagasi}}: logi neisse kontodesse kontode nimekirjast sisse.",
"deleted": "Salvestatud nimekiri kustutatud.",
"errors": {
"mismatch": "Paroolid ei kattu.",
@@ -2036,6 +2055,11 @@
"new_folder_name": "Kausta nimi",
"rename": "Nimeta ümber",
"change_icon": "Muuda ikooni",
+ "count_mode": "Kausta {name} loendur",
+ "count_mode_default": "Loendur: vaikimisi",
+ "count_mode_unread": "Ainult lugemata",
+ "count_mode_all": "Lugemata ja kokku",
+ "count_mode_none": "Loendurit pole",
"delete": "Kustuta",
"confirm_delete": "Kas oled kindel, et soovid kustutada kausta \"{name}\"? Selles kaustas olevad kirjad liigutatakse prügikasti.",
"create": "Loo",
@@ -2085,7 +2109,11 @@
},
"sender_favicons": {
"label": "Saatja favicon'id",
- "description": "Näita äriliste saatjate profiilipiltidena veebisaitide ikoone"
+ "description": "Näita äriliste saatjate profiilipiltidena veebisaitide ikoone",
+ "refresh_label": "Värskenda saatjate ikoone",
+ "refresh_description": "Laadi veebisaitide ikoonid uuesti, näiteks kui saatja on oma ikooni muutnud",
+ "refresh_button": "Värskenda",
+ "refresh_done": "Saatjate ikoonid laaditakse uuesti"
},
"show_avatars_in_junk": {
"label": "Näita avatare rämpsposti kaustas",
@@ -2505,6 +2533,7 @@
"unpin": "Eemalda kinnitus",
"move_to": "Liiguta kausta...",
"copy_to_account": "Kopeeri kontole...",
+ "remove_from_folder": "Eemalda sellest kaustast",
"archive": "Arhiveeri",
"delete": "Kustuta",
"mark_as_spam": "Märgi rämpspostiks",
@@ -2602,7 +2631,9 @@
"previous_email": "Eelmine e-kiri",
"open_email": "Ava e-kiri",
"close_email": "Sulge / tühista valik",
- "go_to_folder": "Mine kausta"
+ "go_to_folder": "Mine kausta",
+ "next_unread": "Järgmine lugemata e-kiri",
+ "no_more_unread": "Allpool loendis pole rohkem lugemata e-kirju"
},
"actions": {
"reply": "Vasta",
@@ -2621,7 +2652,8 @@
"help": "Näita kiirklahve",
"refresh": "Värskenda e-kirjad",
"select_all": "Vali kõik",
- "global_search": "Otsi kõike"
+ "global_search": "Otsi kõike",
+ "extend_selection": "Laienda valikut alla / üles"
},
"threads": {
"expand_collapse": "Ava/sulge vestlus",
From 99cf78a2803cf48472464dc5f43283ffc9e4367c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:10:22 +0200
Subject: [PATCH 069/238] fix: keep shared folders from other servers when they
reuse an account id #1108
---
lib/__tests__/unified-mailbox-dedupe.test.ts | 20 ++++++++++++++++++++
lib/unified-mailbox.ts | 19 ++++++++++++++-----
stores/email-store.ts | 6 +++---
3 files changed, 37 insertions(+), 8 deletions(-)
diff --git a/lib/__tests__/unified-mailbox-dedupe.test.ts b/lib/__tests__/unified-mailbox-dedupe.test.ts
index 4499a0b96..4eeb42bcc 100644
--- a/lib/__tests__/unified-mailbox-dedupe.test.ts
+++ b/lib/__tests__/unified-mailbox-dedupe.test.ts
@@ -54,4 +54,24 @@ describe('dedupeUnifiedAccounts', () => {
const group = sharedEntry('G', 'login-a', [mb('G:inbox', 'inbox', 1, 'inbox')]);
expect(dedupeUnifiedAccounts([a, b, group])).toEqual([a, b, group]);
});
+ it('does not merge equal JMAP ids that live on different servers', () => {
+ // Stalwart hands out the same short account and mailbox ids on every
+ // server, so login A's own folder and a folder shared to login B on
+ // another server can both be account "c", mailbox "f1".
+ const server = (url: string) => ({ getServerUrl: () => url } as unknown as IJMAPClient);
+ const own = makeAccount({ accountId: 'login-a', jmapAccountId: 'c', client: server('https://one.example'), mailboxes: [mb('f1', undefined, 1)] });
+ const sharedElsewhere = sharedEntry('c', 'login-b', [mb('c:f1', undefined, 2, 'f1')]);
+ sharedElsewhere.client = server('https://two.example');
+ const out = dedupeUnifiedAccounts([own, sharedElsewhere]);
+ expect(out).toEqual([own, sharedElsewhere]);
+ expect(getCrossUnreadTotal(out)).toBe(3);
+ });
+
+ it('still merges the same folder reached through two logins of one server', () => {
+ const server = (url: string) => ({ getServerUrl: () => url } as unknown as IJMAPClient);
+ const own = makeAccount({ accountId: 'login-a', jmapAccountId: 'c', client: server('https://one.example'), mailboxes: [mb('f1', undefined, 1)] });
+ const viaGrantee = sharedEntry('c', 'login-b', [mb('c:f1', undefined, 1, 'f1')]);
+ viaGrantee.client = server('https://ONE.example/');
+ expect(dedupeUnifiedAccounts([viaGrantee, own])).toEqual([own]);
+ });
});
diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts
index 2be114442..203b70940 100644
--- a/lib/unified-mailbox.ts
+++ b/lib/unified-mailbox.ts
@@ -768,6 +768,11 @@ export function connectedAccountsGrew(previous: string | null, current: string):
return current.split(',').some((id) => id !== '' && !before.has(id));
}
+/** The server a client talks to, compared without case or a trailing slash. */
+export function unifiedServerKey(client: Pick | undefined): string {
+ return (client?.getServerUrl?.() ?? '').trim().replace(/\/+$/, '').toLowerCase();
+}
+
/**
* How far the unified scope has got while logins are still reconnecting:
* `{ loaded, total }` while some logins expected in it are not there yet,
@@ -795,18 +800,22 @@ export function unifiedLoadProgress(input: {
* shared with two logins at once, otherwise yields one entry per reaching
* login: its messages list twice and its unread count is summed twice.
*
- * A mailbox is identified by its owning JMAP account + server-side id (a shared
- * entry's `originalId`). Personal entries always win and are never trimmed, so
+ * A mailbox is identified by its server, its owning JMAP account and its
+ * server-side id (a shared entry's `originalId`). JMAP account and mailbox ids
+ * are only unique on one server (Stalwart hands out short ids like "b" or "c"
+ * everywhere), so logins on different servers never cover each other's
+ * folders. Personal entries always win and are never trimmed, so
* an owner's folder is read through the owner's own login and honors their
* folder selection. Shared entries keep only mailboxes not already covered and
* are dropped when none remain. Order is preserved.
*/
export function dedupeUnifiedAccounts(accounts: UnifiedAccountClient[]): UnifiedAccountClient[] {
const seen = new Set();
- const key = (jmapAccountId: string, mailboxId: string) => `${jmapAccountId}\u0000${mailboxId}`;
+ const key = (account: UnifiedAccountClient, mailboxId: string) =>
+ `${unifiedServerKey(account.client)}\u0000${account.jmapAccountId}\u0000${mailboxId}`;
for (const account of accounts) {
if (account.isShared) continue;
- for (const m of account.mailboxes) seen.add(key(account.jmapAccountId, m.id));
+ for (const m of account.mailboxes) seen.add(key(account, m.id));
}
const out: UnifiedAccountClient[] = [];
for (const account of accounts) {
@@ -815,7 +824,7 @@ export function dedupeUnifiedAccounts(accounts: UnifiedAccountClient[]): Unified
continue;
}
const mailboxes = account.mailboxes.filter((m) => {
- const k = key(account.jmapAccountId, m.originalId ?? m.id);
+ const k = key(account, m.originalId ?? m.id);
if (seen.has(k)) return false;
seen.add(k);
return true;
diff --git a/stores/email-store.ts b/stores/email-store.ts
index c5ec41781..67d765405 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -18,7 +18,7 @@ import { emailSummary, emailToReadView } from "@/lib/plugin-projection";
import { resolveThreadRoute } from "@/lib/thread-routing";
import { emailKeyFor, threadKeyFor, threadIdFromKey, KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
import type { ExternalSearchResult } from "@/lib/plugin-types";
-import { positionsByAccount, fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, trashAndJunkIds, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, dedupeUnifiedAccounts, type AcrossAccountsSearchOptions, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
+import { positionsByAccount, fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, trashAndJunkIds, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, dedupeUnifiedAccounts, unifiedServerKey, type AcrossAccountsSearchOptions, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { defaultSearchScopeFor, isAllFoldersSearchScope, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
import { useAuthStore } from "@/stores/auth-store";
import { pathNamesMailFolder } from "@/lib/deep-links";
@@ -1492,7 +1492,7 @@ export async function buildUnifiedAccountClients(
const ownLoginJmapIds = new Set();
for (const a of authAccounts) {
const c = allClients.get(a.id);
- if (c) ownLoginJmapIds.add(c.getAccountId());
+ if (c) ownLoginJmapIds.add(`${unifiedServerKey(c)} ${c.getAccountId()}`);
}
// Every login at once rather than one after the other: the slowest login,
// not the sum of all of them, bounds how long the scope takes.
@@ -1548,7 +1548,7 @@ export async function buildUnifiedAccountClients(
// in the owner account instead of falling back to the active account.
// When the owner is logged in too, its own (un-namespaced) list wins:
// dedupeUnifiedAccounts routes that folder through the owner's login.
- if (!ownLoginJmapIds.has(ownerId)) publish(ownerId, ownerMailboxes);
+ if (!ownLoginJmapIds.has(`${unifiedServerKey(c)} ${ownerId}`)) publish(ownerId, ownerMailboxes);
}
}
} catch {
From 4fa22200e726260434fc00aaa9b681efd76b75fd Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:31:34 +0200
Subject: [PATCH 070/238] fix: keep vacation forwarding and plugin vetoes when
a tag rename rewrites the filters #854
---
components/settings/keyword-settings.tsx | 9 +--
.../filter-store-keyword-rename.test.ts | 24 ++++++++
stores/filter-store.ts | 56 +++++++------------
3 files changed, 46 insertions(+), 43 deletions(-)
diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx
index 7468d8be9..d735d2f4e 100644
--- a/components/settings/keyword-settings.tsx
+++ b/components/settings/keyword-settings.tsx
@@ -602,13 +602,11 @@ export function KeywordSettings() {
const outcome = await renameKeywordInFilters(client, oldId, keyword.id);
rewroteFilters = outcome.changed > 0;
if (outcome.unhandled > 0) {
- const toastModule = await import('sonner');
- toastModule.toast.warning(t("filters_migration_error"));
+ toast.warning(t("filters_migration_error"));
}
} catch (error) {
console.error("Failed to migrate keyword in filters:", error);
- const toastModule = await import('sonner');
- toastModule.toast.warning(t("filters_migration_error"));
+ toast.warning(t("filters_migration_error"));
}
if (rewroteFilters) {
@@ -621,8 +619,7 @@ export function KeywordSettings() {
fetchTagCounts(client);
} catch (error) {
console.error("Failed to sweep up mail tagged during the rename:", error);
- const toastModule = await import('sonner');
- toastModule.toast.error(t("migration_error"));
+ toast.error(t("migration_error"));
}
}
diff --git a/stores/__tests__/filter-store-keyword-rename.test.ts b/stores/__tests__/filter-store-keyword-rename.test.ts
index ff936c301..89f7c4a9d 100644
--- a/stores/__tests__/filter-store-keyword-rename.test.ts
+++ b/stores/__tests__/filter-store-keyword-rename.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useFilterStore } from '../filter-store';
import { generateScript } from '@/lib/sieve/generator';
+import { parseScript } from '@/lib/sieve/parser';
import { filterHooks } from '@/lib/plugin-hooks';
import type { FilterRule } from '@/lib/jmap/sieve-types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -114,6 +115,7 @@ describe('filter-store renameKeywordInFilters', () => {
const client = {
supportsSieve: () => true,
getSieveAccountId: () => 'primary',
+ getSieveCapabilities: () => null,
getSieveScripts: async () => [],
} as unknown as IJMAPClient;
@@ -199,4 +201,26 @@ describe('filter-store renameKeywordInFilters', () => {
useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso'),
).rejects.toThrow('over quota');
});
+ it('keeps the forwarding set up in the vacation card', async () => {
+ const forward = { enabled: true, to: 'kollege@example.com', keepCopy: false };
+ const { client, updateSieveScript } = makeClient(generateScript([makeRule()], undefined, { vacationForward: forward }));
+ expect(parseScript(generateScript([makeRule()], undefined, { vacationForward: forward })).vacationForward).toEqual(forward);
+
+ await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
+
+ const uploaded = updateSieveScript.mock.calls[0][1];
+ expect(uploaded).toContain('$label:iso');
+ expect(parseScript(uploaded).vacationForward).toEqual(forward);
+ });
+
+ it('writes nothing when a plugin stops filter saves', async () => {
+ filterHooks.onBeforeFiltersSave.register('test-veto', () => false);
+ try {
+ const { client, updateSieveScript } = makeClient(generateScript([makeRule()]));
+ await expect(useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso')).rejects.toThrow();
+ expect(updateSieveScript).not.toHaveBeenCalled();
+ } finally {
+ filterHooks.onBeforeFiltersSave.clear();
+ }
+ });
});
diff --git a/stores/filter-store.ts b/stores/filter-store.ts
index b36ec5503..b3f08f69c 100644
--- a/stores/filter-store.ts
+++ b/stores/filter-store.ts
@@ -4,7 +4,8 @@ import type { FilterRule, SieveCapabilities, VacationAudience, VacationForward,
import { parseScript } from '@/lib/sieve/parser';
import { generateScript, supportsSpamGuard, VACATION_SCRIPT_NAME } from '@/lib/sieve/generator';
import {
- OpaqueFiltersError, applyScriptTransforms, assertFiltersSaveAllowed, filterRuleToView, supportsInclude, writeFiltersScript,
+ OpaqueFiltersError, applyScriptTransforms, assertFiltersSaveAllowed, filterRuleToView, readAccountFilters, renderFiltersScript,
+ supportsInclude, writeFiltersScript,
} from '@/lib/filters/account-filters';
import { countKeywordUses, renameKeywordInRules } from '@/lib/sieve/keyword-rename';
import { filterHooks } from '@/lib/plugin-hooks';
@@ -284,54 +285,35 @@ export const useFilterStore = create()((set, get) => ({
// permits, and which the caller's message migration, bound to the mail
// primary, would not have covered anyway.
const accountId = client.getSieveAccountId();
- const scripts = (await client.getSieveScripts(accountId)).filter(s => s.name !== 'vacation');
- const activeScript = scripts.find(s => s.isActive);
- if (!activeScript) {
- // Nothing is filtering mail right now. Rewriting an inactive script would
- // mean activating it - updateSieveScript activates on write - and a tag
- // rename must not switch someone's filters back on.
+ // The same read, render and write a normal save and a rule made from a
+ // message use, so the vacation include, forwarding, audience and the
+ // plugin pipeline survive the rename.
+ const filters = await readAccountFilters(client, accountId);
+ if (!filters.script || filters.activeScriptId !== filters.script.id) {
+ // Nothing is filtering mail right now. Writing would activate the
+ // script, and a tag rename must not switch someone's filters back on.
return nothing;
}
-
- const content = await client.getSieveScriptContent(activeScript.blobId, accountId);
- const parsed = parseScript(content);
- if (parsed.isOpaque) {
- return { changed: 0, unhandled: countKeywordUses(content, oldId) };
+ if (filters.parsed.isOpaque) {
+ return { changed: 0, unhandled: countKeywordUses(filters.content, oldId) };
}
- const { rules, changed } = renameKeywordInRules(parsed.rules, oldId, newId);
- if (changed === 0) return { changed: 0, unhandled: countKeywordUses(content, oldId) };
+ const { rules, changed } = renameKeywordInRules(filters.parsed.rules, oldId, newId);
+ if (changed === 0) return { changed: 0, unhandled: countKeywordUses(filters.content, oldId) };
- let script = generateScript(rules, parsed.vacation || undefined, {
- externalRequires: parsed.externalRequires,
- });
- // Same plugin pipeline a normal save runs through: a plugin's managed
- // section is grafted on at generate time, so skipping the transform here
- // would drop it from the script this write makes active.
- const transformed = await filterHooks.onSieveScriptGenerate.transform(script, { accountId });
- if (typeof transformed === 'string' && transformed.trim().length > 0) {
- script = transformed;
- }
-
- // Count what still names the old tag in the script actually being written,
+ // A plugin can veto filter saves; the caller warns when this throws.
+ await assertFiltersSaveAllowed(rules);
+ const written = await writeFiltersScript(client, accountId, renderFiltersScript(rules, filters), filters.script.id);
+ // Count what still names the old tag in the script actually written,
// after the plugin transform: a rule kept verbatim, a test comparing
// against the keyword, or a section a plugin just grafted back on.
- const unhandled = countKeywordUses(script, oldId);
-
- await client.updateSieveScript(activeScript.id, script, true, accountId);
+ const unhandled = countKeywordUses(written.content, oldId);
debug.log('filters', 'Renamed tag in', changed, 'filter action(s)');
const state = get();
if (state.selectedAccountId === accountId && !state.isOpaque) {
- set({
- rules,
- rawScript: script,
- activeScriptId: activeScript.id,
- vacationSettings: parsed.vacation || null,
- externalRequires: parsed.externalRequires,
- });
+ set({ rules, rawScript: written.content, activeScriptId: written.scriptId });
}
- void filterHooks.onSieveScriptChange.emit({ accountId, script });
return { changed, unhandled };
},
From ad9837d2d2de972471a93f324fff879f260cf136 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 15:05:08 +0200
Subject: [PATCH 071/238] fix: add the Estonian text for the tag rename warning
---
locales/et/common.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/locales/et/common.json b/locales/et/common.json
index 09d85edef..dde3afcb6 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -1246,6 +1246,7 @@
"cancel": "Tühista",
"migrating": "Sildi uuendamine olemasolevatel kirjadel…",
"migration_error": "Sildi uuendamine olemasolevatel kirjadel ebaõnnestus",
+ "filters_migration_error": "Mõned filtrireeglid kasutavad endiselt vana silti: uuenda need käsitsi",
"nesting": {
"label": "Pesastatud sildid",
"description": "Pesasta silte teiste siltide alla ja kuva neid külgribal puuna."
From ea1d1e2870f8cf894748cf191df27e5e06010c70 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 16:01:43 +0200
Subject: [PATCH 072/238] fix: stop re-rendering every row when a search starts
---
.../thread-list-item-source-folder.test.tsx | 19 ++++++++++++++++++-
components/email/thread-list-item.tsx | 12 ++++++++----
2 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/components/email/__tests__/thread-list-item-source-folder.test.tsx b/components/email/__tests__/thread-list-item-source-folder.test.tsx
index 023ab287d..672aad8da 100644
--- a/components/email/__tests__/thread-list-item-source-folder.test.tsx
+++ b/components/email/__tests__/thread-list-item-source-folder.test.tsx
@@ -1,4 +1,5 @@
-import { render, screen } from '@testing-library/react';
+import { Profiler } from 'react';
+import { act, render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ThreadListItem } from '../thread-list-item';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
@@ -56,6 +57,7 @@ function renderThread(emails: Email[]) {
);
}
+const noop = () => {};
const single = () => [makeEmail()];
const conversation = () => [
makeEmail({ id: 'email-1', receivedAt: '2024-01-15T10:00:00Z' }),
@@ -123,5 +125,20 @@ describe('ThreadListItem folder chip in search results (#1172)', () => {
renderThread(emails());
expect(screen.getByTitle('Archive')).toBeTruthy();
});
+
+ it('does not re-render a row without a folder when a search starts', () => {
+ // The folder's own list stays on screen until the results arrive; its
+ // rows carry no sourceFolder and have no chip to show.
+ const [thread] = groupEmailsByThread(emails().map((e) => ({ ...e, sourceFolder: undefined })));
+ const onRender = vi.fn();
+ render(
+
+
+ ,
+ );
+ onRender.mockClear();
+ act(() => useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: '' }));
+ expect(onRender).not.toHaveBeenCalled();
+ });
});
});
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index 95d7c3bff..0719eb98d 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -253,9 +253,13 @@ function SourceFolderTag({ name }: { name: string }) {
* Whether rows show their folder chip: in the aggregate "All …" views, and in
* search results that span every folder rather than the one picked in the
* search panel (#1172). Rows of a single-folder list carry no sourceFolder.
+ *
+ * Such a row never subscribes to the view state: starting a search flips it
+ * while the old list is still on screen, and every row re-rendered for a chip
+ * it has nothing to put in.
*/
-function useShowSourceFolder(): boolean {
- return useEmailStore((state) => state.isUnifiedView || isUnscopedSearchActive(state));
+function useShowSourceFolder(sourceFolder: string | undefined): boolean {
+ return useEmailStore((state) => !!sourceFolder && (state.isUnifiedView || isUnscopedSearchActive(state)));
}
/**
@@ -377,7 +381,7 @@ const SingleEmailItem = React.forwardRef(
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
// Show the originating folder in the aggregate "All …" views.
- const showSourceFolder = useShowSourceFolder() && !!email.sourceFolder;
+ const showSourceFolder = useShowSourceFolder(email.sourceFolder);
const getAccountById = useAccountStore((state) => state.getAccountById);
const account = email.accountId ? getAccountById(email.accountId) : undefined;
const accountColor = account?.avatarColor;
@@ -932,7 +936,7 @@ export const ThreadListItem = React.forwardRef state.getAccountById);
const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined;
const threadAccountColor = threadAccount?.avatarColor;
From 0e6733d5e6cb11b14c22f5c66a865b748531a050 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 16:23:54 +0200
Subject: [PATCH 073/238] fix: drop plain-text emphasis markers from message
previews #1260
---
lib/__tests__/preview-leading-css.test.ts | 24 ++++++++++++++++++++++-
lib/utils.ts | 18 +++++++++++++++--
2 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/lib/__tests__/preview-leading-css.test.ts b/lib/__tests__/preview-leading-css.test.ts
index faf8d2cd7..df335b91d 100644
--- a/lib/__tests__/preview-leading-css.test.ts
+++ b/lib/__tests__/preview-leading-css.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { cleanPreview, stripLeadingCss } from '@/lib/utils';
+import { cleanPreview, stripEmphasisMarkers, stripLeadingCss } from '@/lib/utils';
// Some servers build a message preview from its HTML without skipping the
// ${body}