From 21ea5009f49c7d6112abd2ec1493dcaaa969c799 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Thu, 23 Jul 2026 17:59:35 +0200 Subject: [PATCH 01/42] i18n: localize the editor toolbar in all 23 locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rich-text editor was the last hardcoded-English surface in the composer: 21 tooltip titles, the eight table-menu entries, the "Remove color" entry and the table size picker's "Pick size" label were plain strings while every other menu in the app is localized. All of them now come from a new email_composer.toolbar namespace, translated into all 23 locales using each platform's established editor terminology (Word/Docs conventions - de "Formatierung löschen", ar "مسح التنسيق", ja "書式をクリア", ...). The link prompt stays "URL", which is the same term in every language. Tooltips and self-sizing dropdowns have no width constraints, so longer translations are safe everywhere. --- components/email/rich-text-editor.tsx | 63 ++++++++++++++------------- locales/ar/common.json | 32 ++++++++++++++ locales/cs/common.json | 32 ++++++++++++++ locales/da/common.json | 32 ++++++++++++++ locales/de/common.json | 32 ++++++++++++++ locales/en/common.json | 32 ++++++++++++++ locales/es/common.json | 32 ++++++++++++++ locales/fa/common.json | 32 ++++++++++++++ locales/fr/common.json | 32 ++++++++++++++ locales/he/common.json | 32 ++++++++++++++ locales/hu/common.json | 32 ++++++++++++++ locales/it/common.json | 32 ++++++++++++++ locales/ja/common.json | 32 ++++++++++++++ locales/ko/common.json | 32 ++++++++++++++ locales/lv/common.json | 32 ++++++++++++++ locales/nl/common.json | 32 ++++++++++++++ locales/pl/common.json | 32 ++++++++++++++ locales/pt/common.json | 32 ++++++++++++++ locales/ro/common.json | 32 ++++++++++++++ locales/ru/common.json | 32 ++++++++++++++ locales/sk/common.json | 32 ++++++++++++++ locales/tr/common.json | 32 ++++++++++++++ locales/uk/common.json | 32 ++++++++++++++ locales/zh/common.json | 32 ++++++++++++++ 24 files changed, 769 insertions(+), 30 deletions(-) diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index c0e54519..446865b6 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -21,6 +21,7 @@ import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-ht import { SignatureBlock } from "@/components/email/signature-block"; import { cn } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settings-store"; +import { useTranslations } from "next-intl"; import { Bold, Italic, @@ -153,6 +154,7 @@ const TEXT_COLORS = [ ]; function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) { + const t = useTranslations("email_composer.toolbar"); const [hover, setHover] = useState<{ r: number; c: number } | null>(null); return (
@@ -180,7 +182,7 @@ function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => v })}
- {hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"} + {hover ? `${hover.r + 1} × ${hover.c + 1}` : t("pick_size")}
); @@ -343,6 +345,7 @@ export function RichTextEditor({ .run(); }, [editor]); + const tToolbar = useTranslations("email_composer.toolbar"); const [tableMenuOpen, setTableMenuOpen] = useState(false); const tableWrapperRef = useRef(null); const [colorMenuOpen, setColorMenuOpen] = useState(false); @@ -383,28 +386,28 @@ export function RichTextEditor({ editor.chain().focus().toggleBold().run()} - title="Bold" + title={tToolbar("bold")} > editor.chain().focus().toggleItalic().run()} - title="Italic" + title={tToolbar("italic")} > editor.chain().focus().toggleUnderline().run()} - title="Underline" + title={tToolbar("underline")} > editor.chain().focus().toggleStrike().run()} - title="Strikethrough" + title={tToolbar("strikethrough")} > @@ -412,7 +415,7 @@ export function RichTextEditor({ setColorMenuOpen((v) => !v)} - title="Text color" + title={tToolbar("text_color")} > {/* The icon itself previews the active colour - no layout shift. */} @@ -446,7 +449,7 @@ export function RichTextEditor({ setColorMenuOpen(false); }} > - Remove color + {tToolbar("remove_color")} )} @@ -457,14 +460,14 @@ export function RichTextEditor({ editor.chain().focus().toggleHeading({ level: 1 }).run()} - title="Heading 1" + title={tToolbar("heading_1")} > editor.chain().focus().toggleHeading({ level: 2 }).run()} - title="Heading 2" + title={tToolbar("heading_2")} > @@ -474,28 +477,28 @@ export function RichTextEditor({ editor.chain().focus().toggleBulletList().run()} - title="Bullet List" + title={tToolbar("bullet_list")} > editor.chain().focus().toggleOrderedList().run()} - title="Ordered List" + title={tToolbar("ordered_list")} > editor.chain().focus().toggleBlockquote().run()} - title="Quote" + title={tToolbar("quote")} > editor.chain().focus().toggleCodeBlock().run()} - title="Code Block" + title={tToolbar("code_block")} > @@ -505,21 +508,21 @@ export function RichTextEditor({ editor.chain().focus().setTextAlign("left").run()} - title="Align Left" + title={tToolbar("align_left")} > editor.chain().focus().setTextAlign("center").run()} - title="Align Center" + title={tToolbar("align_center")} > editor.chain().focus().setTextAlign("right").run()} - title="Align Right" + title={tToolbar("align_right")} > @@ -534,7 +537,7 @@ export function RichTextEditor({ editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir; editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run(); }} - title="Text direction (RTL/LTR)" + title={tToolbar("text_direction")} > @@ -545,7 +548,7 @@ export function RichTextEditor({ @@ -554,7 +557,7 @@ export function RichTextEditor({ setTableMenuOpen((v) => !v)} - title="Table" + title={tToolbar("table")} > @@ -567,28 +570,28 @@ export function RichTextEditor({ className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start" onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }} > - Add row above + {tToolbar("add_row_above")}
) : ( @@ -637,7 +640,7 @@ export function RichTextEditor({ editor.chain().focus().clearNodes().unsetAllMarks().run()} - title="Clear Formatting" + title={tToolbar("clear_formatting")} > @@ -647,14 +650,14 @@ export function RichTextEditor({ editor.chain().focus().undo().run()} disabled={!editor.can().undo()} - title="Undo" + title={tToolbar("undo")} > editor.chain().focus().redo().run()} disabled={!editor.can().redo()} - title="Redo" + title={tToolbar("redo")} > diff --git a/locales/ar/common.json b/locales/ar/common.json index e4ee13bd..2f5a4f70 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "الاسم المعروض", "autocomplete_search_server": "البحث في الخادم", "autocomplete_searching": "جارٍ البحث...", + "toolbar": { + "bold": "غامق", + "italic": "مائل", + "underline": "تسطير", + "strikethrough": "يتوسطه خط", + "text_color": "لون النص", + "remove_color": "إزالة اللون", + "heading_1": "عنوان 1", + "heading_2": "عنوان 2", + "bullet_list": "قائمة نقطية", + "ordered_list": "قائمة مرقمة", + "quote": "اقتباس", + "code_block": "كتلة برمجية", + "align_left": "محاذاة لليسار", + "align_center": "توسيط", + "align_right": "محاذاة لليمين", + "text_direction": "اتجاه النص (RTL/LTR)", + "link": "رابط", + "table": "جدول", + "clear_formatting": "مسح التنسيق", + "undo": "تراجع", + "redo": "إعادة", + "add_row_above": "إضافة صف بالأعلى", + "add_row_below": "إضافة صف بالأسفل", + "add_column_before": "إضافة عمود قبل", + "add_column_after": "إضافة عمود بعد", + "delete_row": "حذف الصف", + "delete_column": "حذف العمود", + "toggle_header_row": "تبديل صف الرأس", + "delete_table": "حذف الجدول", + "pick_size": "اختيار الحجم" + }, "send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة." }, "confirm_dialog": { diff --git a/locales/cs/common.json b/locales/cs/common.json index ba24711b..e58faf01 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Zobrazované jméno", "autocomplete_search_server": "Hledat na serveru", "autocomplete_searching": "Hledání...", + "toolbar": { + "bold": "Tučné", + "italic": "Kurzíva", + "underline": "Podtržené", + "strikethrough": "Přeškrtnuté", + "text_color": "Barva textu", + "remove_color": "Odebrat barvu", + "heading_1": "Nadpis 1", + "heading_2": "Nadpis 2", + "bullet_list": "Odrážkový seznam", + "ordered_list": "Číslovaný seznam", + "quote": "Citace", + "code_block": "Blok kódu", + "align_left": "Zarovnat vlevo", + "align_center": "Na střed", + "align_right": "Zarovnat vpravo", + "text_direction": "Směr textu (RTL/LTR)", + "link": "Odkaz", + "table": "Tabulka", + "clear_formatting": "Vymazat formátování", + "undo": "Zpět", + "redo": "Znovu", + "add_row_above": "Přidat řádek nad", + "add_row_below": "Přidat řádek pod", + "add_column_before": "Přidat sloupec před", + "add_column_after": "Přidat sloupec za", + "delete_row": "Odstranit řádek", + "delete_column": "Odstranit sloupec", + "toggle_header_row": "Přepnout řádek záhlaví", + "delete_table": "Odstranit tabulku", + "pick_size": "Vybrat velikost" + }, "send_filing_warning": "Odesláno - ale následný úklid selhal, může zůstat zastaralý koncept." }, "confirm_dialog": { diff --git a/locales/da/common.json b/locales/da/common.json index 4ba49bcc..81ca8917 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Visningsnavn", "autocomplete_search_server": "Søg på serveren", "autocomplete_searching": "Søger...", + "toolbar": { + "bold": "Fed", + "italic": "Kursiv", + "underline": "Understreget", + "strikethrough": "Gennemstreget", + "text_color": "Tekstfarve", + "remove_color": "Fjern farve", + "heading_1": "Overskrift 1", + "heading_2": "Overskrift 2", + "bullet_list": "Punktopstilling", + "ordered_list": "Nummereret liste", + "quote": "Citat", + "code_block": "Kodeblok", + "align_left": "Venstrejusteret", + "align_center": "Centreret", + "align_right": "Højrejusteret", + "text_direction": "Tekstretning (RTL/LTR)", + "link": "Link", + "table": "Tabel", + "clear_formatting": "Ryd formatering", + "undo": "Fortryd", + "redo": "Gentag", + "add_row_above": "Tilføj række over", + "add_row_below": "Tilføj række under", + "add_column_before": "Tilføj kolonne før", + "add_column_after": "Tilføj kolonne efter", + "delete_row": "Slet række", + "delete_column": "Slet kolonne", + "toggle_header_row": "Slå overskriftsrække til/fra", + "delete_table": "Slet tabel", + "pick_size": "Vælg størrelse" + }, "send_filing_warning": "Sendt - men oprydningen bagefter mislykkedes, en forældet kladde kan blive stående." }, "confirm_dialog": { diff --git a/locales/de/common.json b/locales/de/common.json index 6b1ceff3..928c79f2 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Anzeigename", "autocomplete_search_server": "Auf dem Server suchen", "autocomplete_searching": "Suche läuft...", + "toolbar": { + "bold": "Fett", + "italic": "Kursiv", + "underline": "Unterstrichen", + "strikethrough": "Durchgestrichen", + "text_color": "Textfarbe", + "remove_color": "Farbe entfernen", + "heading_1": "Überschrift 1", + "heading_2": "Überschrift 2", + "bullet_list": "Aufzählung", + "ordered_list": "Nummerierte Liste", + "quote": "Zitat", + "code_block": "Codeblock", + "align_left": "Linksbündig", + "align_center": "Zentriert", + "align_right": "Rechtsbündig", + "text_direction": "Schreibrichtung (RTL/LTR)", + "link": "Link", + "table": "Tabelle", + "clear_formatting": "Formatierung löschen", + "undo": "Rückgängig", + "redo": "Wiederholen", + "add_row_above": "Zeile oberhalb einfügen", + "add_row_below": "Zeile unterhalb einfügen", + "add_column_before": "Spalte davor einfügen", + "add_column_after": "Spalte danach einfügen", + "delete_row": "Zeile löschen", + "delete_column": "Spalte löschen", + "toggle_header_row": "Kopfzeile umschalten", + "delete_table": "Tabelle löschen", + "pick_size": "Größe wählen" + }, "send_filing_warning": "Gesendet - aber das Aufräumen danach schlug fehl, evtl. bleibt ein alter Entwurf sichtbar." }, "confirm_dialog": { diff --git a/locales/en/common.json b/locales/en/common.json index e9890db7..a1e14917 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Display name", "autocomplete_search_server": "Search the server", "autocomplete_searching": "Searching...", + "toolbar": { + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strikethrough": "Strikethrough", + "text_color": "Text color", + "remove_color": "Remove color", + "heading_1": "Heading 1", + "heading_2": "Heading 2", + "bullet_list": "Bullet list", + "ordered_list": "Ordered list", + "quote": "Quote", + "code_block": "Code block", + "align_left": "Align left", + "align_center": "Align center", + "align_right": "Align right", + "text_direction": "Text direction (RTL/LTR)", + "link": "Link", + "table": "Table", + "clear_formatting": "Clear formatting", + "undo": "Undo", + "redo": "Redo", + "add_row_above": "Add row above", + "add_row_below": "Add row below", + "add_column_before": "Add column before", + "add_column_after": "Add column after", + "delete_row": "Delete row", + "delete_column": "Delete column", + "toggle_header_row": "Toggle header row", + "delete_table": "Delete table", + "pick_size": "Pick size" + }, "send_filing_warning": "Sent - but the post-send cleanup failed, a stale draft may remain." }, "confirm_dialog": { diff --git a/locales/es/common.json b/locales/es/common.json index 8e789bde..17878b0a 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Nombre para mostrar", "autocomplete_search_server": "Buscar en el servidor", "autocomplete_searching": "Buscando...", + "toolbar": { + "bold": "Negrita", + "italic": "Cursiva", + "underline": "Subrayado", + "strikethrough": "Tachado", + "text_color": "Color del texto", + "remove_color": "Quitar color", + "heading_1": "Encabezado 1", + "heading_2": "Encabezado 2", + "bullet_list": "Lista con viñetas", + "ordered_list": "Lista numerada", + "quote": "Cita", + "code_block": "Bloque de código", + "align_left": "Alinear a la izquierda", + "align_center": "Centrar", + "align_right": "Alinear a la derecha", + "text_direction": "Dirección del texto (RTL/LTR)", + "link": "Enlace", + "table": "Tabla", + "clear_formatting": "Borrar formato", + "undo": "Deshacer", + "redo": "Rehacer", + "add_row_above": "Añadir fila encima", + "add_row_below": "Añadir fila debajo", + "add_column_before": "Añadir columna antes", + "add_column_after": "Añadir columna después", + "delete_row": "Eliminar fila", + "delete_column": "Eliminar columna", + "toggle_header_row": "Alternar fila de encabezado", + "delete_table": "Eliminar tabla", + "pick_size": "Elegir tamaño" + }, "send_filing_warning": "Enviado - pero la limpieza posterior falló, puede quedar un borrador obsoleto." }, "confirm_dialog": { diff --git a/locales/fa/common.json b/locales/fa/common.json index be4ec6c6..77253d78 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "نام نمایشی", "autocomplete_search_server": "جستجو در سرور", "autocomplete_searching": "در حال جستجو...", + "toolbar": { + "bold": "پررنگ", + "italic": "کج", + "underline": "زیرخط‌دار", + "strikethrough": "خط‌خورده", + "text_color": "رنگ متن", + "remove_color": "حذف رنگ", + "heading_1": "سرفصل ۱", + "heading_2": "سرفصل ۲", + "bullet_list": "فهرست نشانه‌دار", + "ordered_list": "فهرست شماره‌دار", + "quote": "نقل‌قول", + "code_block": "بلوک کد", + "align_left": "تراز چپ", + "align_center": "وسط‌چین", + "align_right": "تراز راست", + "text_direction": "جهت متن (RTL/LTR)", + "link": "پیوند", + "table": "جدول", + "clear_formatting": "پاک کردن قالب‌بندی", + "undo": "واگرد", + "redo": "انجام دوباره", + "add_row_above": "افزودن ردیف در بالا", + "add_row_below": "افزودن ردیف در پایین", + "add_column_before": "افزودن ستون قبل", + "add_column_after": "افزودن ستون بعد", + "delete_row": "حذف ردیف", + "delete_column": "حذف ستون", + "toggle_header_row": "تغییر وضعیت ردیف سرصفحه", + "delete_table": "حذف جدول", + "pick_size": "انتخاب اندازه" + }, "send_filing_warning": "ارسال شد - اما پاک‌سازی پس از ارسال ناموفق بود، ممکن است پیش‌نویس قدیمی باقی بماند." }, "confirm_dialog": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 2ca92cec..787d12db 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Nom d'affichage", "autocomplete_search_server": "Rechercher sur le serveur", "autocomplete_searching": "Recherche en cours...", + "toolbar": { + "bold": "Gras", + "italic": "Italique", + "underline": "Souligné", + "strikethrough": "Barré", + "text_color": "Couleur du texte", + "remove_color": "Supprimer la couleur", + "heading_1": "Titre 1", + "heading_2": "Titre 2", + "bullet_list": "Liste à puces", + "ordered_list": "Liste numérotée", + "quote": "Citation", + "code_block": "Bloc de code", + "align_left": "Aligner à gauche", + "align_center": "Centrer", + "align_right": "Aligner à droite", + "text_direction": "Sens du texte (RTL/LTR)", + "link": "Lien", + "table": "Tableau", + "clear_formatting": "Effacer la mise en forme", + "undo": "Annuler", + "redo": "Rétablir", + "add_row_above": "Insérer une ligne au-dessus", + "add_row_below": "Insérer une ligne en dessous", + "add_column_before": "Insérer une colonne avant", + "add_column_after": "Insérer une colonne après", + "delete_row": "Supprimer la ligne", + "delete_column": "Supprimer la colonne", + "toggle_header_row": "Basculer la ligne d'en-tête", + "delete_table": "Supprimer le tableau", + "pick_size": "Choisir la taille" + }, "send_filing_warning": "Envoyé - mais le nettoyage après envoi a échoué, un ancien brouillon peut subsister." }, "confirm_dialog": { diff --git a/locales/he/common.json b/locales/he/common.json index d659253f..2114c869 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -651,6 +651,38 @@ "recipient_name_placeholder": "שם תצוגה", "autocomplete_search_server": "חיפוש בשרת", "autocomplete_searching": "מחפש...", + "toolbar": { + "bold": "מודגש", + "italic": "נטוי", + "underline": "קו תחתון", + "strikethrough": "קו חוצה", + "text_color": "צבע טקסט", + "remove_color": "הסרת צבע", + "heading_1": "כותרת 1", + "heading_2": "כותרת 2", + "bullet_list": "רשימת תבליטים", + "ordered_list": "רשימה ממוספרת", + "quote": "ציטוט", + "code_block": "בלוק קוד", + "align_left": "יישור לשמאל", + "align_center": "מרכוז", + "align_right": "יישור לימין", + "text_direction": "כיוון טקסט (RTL/LTR)", + "link": "קישור", + "table": "טבלה", + "clear_formatting": "ניקוי עיצוב", + "undo": "ביטול", + "redo": "ביצוע מחדש", + "add_row_above": "הוספת שורה מעל", + "add_row_below": "הוספת שורה מתחת", + "add_column_before": "הוספת עמודה לפני", + "add_column_after": "הוספת עמודה אחרי", + "delete_row": "מחיקת שורה", + "delete_column": "מחיקת עמודה", + "toggle_header_row": "החלפת שורת כותרת", + "delete_table": "מחיקת טבלה", + "pick_size": "בחירת גודל" + }, "send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה." }, "confirm_dialog": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 3abd3143..c533eb7b 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Megjelenített név", "autocomplete_search_server": "Keresés a kiszolgálón", "autocomplete_searching": "Keresés...", + "toolbar": { + "bold": "Félkövér", + "italic": "Dőlt", + "underline": "Aláhúzott", + "strikethrough": "Áthúzott", + "text_color": "Betűszín", + "remove_color": "Szín eltávolítása", + "heading_1": "Címsor 1", + "heading_2": "Címsor 2", + "bullet_list": "Felsorolás", + "ordered_list": "Számozott lista", + "quote": "Idézet", + "code_block": "Kódblokk", + "align_left": "Balra igazítás", + "align_center": "Középre igazítás", + "align_right": "Jobbra igazítás", + "text_direction": "Szövegirány (RTL/LTR)", + "link": "Hivatkozás", + "table": "Táblázat", + "clear_formatting": "Formázás törlése", + "undo": "Visszavonás", + "redo": "Újra", + "add_row_above": "Sor beszúrása fölé", + "add_row_below": "Sor beszúrása alá", + "add_column_before": "Oszlop beszúrása elé", + "add_column_after": "Oszlop beszúrása mögé", + "delete_row": "Sor törlése", + "delete_column": "Oszlop törlése", + "toggle_header_row": "Fejlécsor váltása", + "delete_table": "Táblázat törlése", + "pick_size": "Méret kiválasztása" + }, "send_filing_warning": "Elküldve - de az utólagos rendrakás nem sikerült, egy elavult piszkozat megmaradhat." }, "confirm_dialog": { diff --git a/locales/it/common.json b/locales/it/common.json index 16481d66..a90df226 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Nome visualizzato", "autocomplete_search_server": "Cerca nel server", "autocomplete_searching": "Ricerca in corso...", + "toolbar": { + "bold": "Grassetto", + "italic": "Corsivo", + "underline": "Sottolineato", + "strikethrough": "Barrato", + "text_color": "Colore del testo", + "remove_color": "Rimuovi colore", + "heading_1": "Titolo 1", + "heading_2": "Titolo 2", + "bullet_list": "Elenco puntato", + "ordered_list": "Elenco numerato", + "quote": "Citazione", + "code_block": "Blocco di codice", + "align_left": "Allinea a sinistra", + "align_center": "Centra", + "align_right": "Allinea a destra", + "text_direction": "Direzione del testo (RTL/LTR)", + "link": "Link", + "table": "Tabella", + "clear_formatting": "Cancella formattazione", + "undo": "Annulla", + "redo": "Ripeti", + "add_row_above": "Aggiungi riga sopra", + "add_row_below": "Aggiungi riga sotto", + "add_column_before": "Aggiungi colonna prima", + "add_column_after": "Aggiungi colonna dopo", + "delete_row": "Elimina riga", + "delete_column": "Elimina colonna", + "toggle_header_row": "Attiva/disattiva riga di intestazione", + "delete_table": "Elimina tabella", + "pick_size": "Scegli dimensione" + }, "send_filing_warning": "Inviato - ma la pulizia successiva non è riuscita, potrebbe restare una bozza obsoleta." }, "confirm_dialog": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 9b59170e..f01bcd81 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "表示名", "autocomplete_search_server": "サーバーを検索", "autocomplete_searching": "検索中...", + "toolbar": { + "bold": "太字", + "italic": "斜体", + "underline": "下線", + "strikethrough": "取り消し線", + "text_color": "文字色", + "remove_color": "色を解除", + "heading_1": "見出し1", + "heading_2": "見出し2", + "bullet_list": "箇条書き", + "ordered_list": "番号付きリスト", + "quote": "引用", + "code_block": "コードブロック", + "align_left": "左揃え", + "align_center": "中央揃え", + "align_right": "右揃え", + "text_direction": "文字方向 (RTL/LTR)", + "link": "リンク", + "table": "表", + "clear_formatting": "書式をクリア", + "undo": "元に戻す", + "redo": "やり直す", + "add_row_above": "上に行を追加", + "add_row_below": "下に行を追加", + "add_column_before": "左に列を追加", + "add_column_after": "右に列を追加", + "delete_row": "行を削除", + "delete_column": "列を削除", + "toggle_header_row": "ヘッダー行の切り替え", + "delete_table": "表を削除", + "pick_size": "サイズを選択" + }, "send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。" }, "confirm_dialog": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 3be1c812..93797c4c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "표시 이름", "autocomplete_search_server": "서버에서 검색", "autocomplete_searching": "검색 중...", + "toolbar": { + "bold": "굵게", + "italic": "기울임꼴", + "underline": "밑줄", + "strikethrough": "취소선", + "text_color": "글자 색", + "remove_color": "색 제거", + "heading_1": "제목 1", + "heading_2": "제목 2", + "bullet_list": "글머리 기호 목록", + "ordered_list": "번호 매기기 목록", + "quote": "인용", + "code_block": "코드 블록", + "align_left": "왼쪽 정렬", + "align_center": "가운데 정렬", + "align_right": "오른쪽 정렬", + "text_direction": "텍스트 방향 (RTL/LTR)", + "link": "링크", + "table": "표", + "clear_formatting": "서식 지우기", + "undo": "실행 취소", + "redo": "다시 실행", + "add_row_above": "위에 행 추가", + "add_row_below": "아래에 행 추가", + "add_column_before": "앞에 열 추가", + "add_column_after": "뒤에 열 추가", + "delete_row": "행 삭제", + "delete_column": "열 삭제", + "toggle_header_row": "머리글 행 전환", + "delete_table": "표 삭제", + "pick_size": "크기 선택" + }, "send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다." }, "confirm_dialog": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 3002c607..59774db3 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Parādāmais vārds", "autocomplete_search_server": "Meklēt serverī", "autocomplete_searching": "Meklē...", + "toolbar": { + "bold": "Treknraksts", + "italic": "Kursīvs", + "underline": "Pasvītrots", + "strikethrough": "Pārsvītrots", + "text_color": "Teksta krāsa", + "remove_color": "Noņemt krāsu", + "heading_1": "Virsraksts 1", + "heading_2": "Virsraksts 2", + "bullet_list": "Aizzīmju saraksts", + "ordered_list": "Numurēts saraksts", + "quote": "Citāts", + "code_block": "Koda bloks", + "align_left": "Līdzināt pa kreisi", + "align_center": "Centrēt", + "align_right": "Līdzināt pa labi", + "text_direction": "Teksta virziens (RTL/LTR)", + "link": "Saite", + "table": "Tabula", + "clear_formatting": "Notīrīt formatējumu", + "undo": "Atsaukt", + "redo": "Atkārtot", + "add_row_above": "Pievienot rindu virs", + "add_row_below": "Pievienot rindu zem", + "add_column_before": "Pievienot kolonnu pirms", + "add_column_after": "Pievienot kolonnu pēc", + "delete_row": "Dzēst rindu", + "delete_column": "Dzēst kolonnu", + "toggle_header_row": "Pārslēgt galvenes rindu", + "delete_table": "Dzēst tabulu", + "pick_size": "Izvēlēties izmēru" + }, "send_filing_warning": "Nosūtīts - bet pēcapstrāde neizdevās, var palikt novecojis melnraksts." }, "confirm_dialog": { diff --git a/locales/nl/common.json b/locales/nl/common.json index bd3a6420..4223e4be 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Weergavenaam", "autocomplete_search_server": "Op de server zoeken", "autocomplete_searching": "Bezig met zoeken...", + "toolbar": { + "bold": "Vet", + "italic": "Cursief", + "underline": "Onderstrepen", + "strikethrough": "Doorhalen", + "text_color": "Tekstkleur", + "remove_color": "Kleur verwijderen", + "heading_1": "Kop 1", + "heading_2": "Kop 2", + "bullet_list": "Opsommingslijst", + "ordered_list": "Genummerde lijst", + "quote": "Citaat", + "code_block": "Codeblok", + "align_left": "Links uitlijnen", + "align_center": "Centreren", + "align_right": "Rechts uitlijnen", + "text_direction": "Tekstrichting (RTL/LTR)", + "link": "Link", + "table": "Tabel", + "clear_formatting": "Opmaak wissen", + "undo": "Ongedaan maken", + "redo": "Opnieuw", + "add_row_above": "Rij erboven toevoegen", + "add_row_below": "Rij eronder toevoegen", + "add_column_before": "Kolom ervoor toevoegen", + "add_column_after": "Kolom erna toevoegen", + "delete_row": "Rij verwijderen", + "delete_column": "Kolom verwijderen", + "toggle_header_row": "Koprij aan/uit", + "delete_table": "Tabel verwijderen", + "pick_size": "Grootte kiezen" + }, "send_filing_warning": "Verzonden - maar het opruimen daarna is mislukt, mogelijk blijft een oud concept staan." }, "confirm_dialog": { diff --git a/locales/pl/common.json b/locales/pl/common.json index e0a3f682..a18134b3 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Wyświetlana nazwa", "autocomplete_search_server": "Szukaj na serwerze", "autocomplete_searching": "Wyszukiwanie...", + "toolbar": { + "bold": "Pogrubienie", + "italic": "Kursywa", + "underline": "Podkreślenie", + "strikethrough": "Przekreślenie", + "text_color": "Kolor tekstu", + "remove_color": "Usuń kolor", + "heading_1": "Nagłówek 1", + "heading_2": "Nagłówek 2", + "bullet_list": "Lista punktowana", + "ordered_list": "Lista numerowana", + "quote": "Cytat", + "code_block": "Blok kodu", + "align_left": "Wyrównaj do lewej", + "align_center": "Wyśrodkuj", + "align_right": "Wyrównaj do prawej", + "text_direction": "Kierunek tekstu (RTL/LTR)", + "link": "Link", + "table": "Tabela", + "clear_formatting": "Wyczyść formatowanie", + "undo": "Cofnij", + "redo": "Ponów", + "add_row_above": "Dodaj wiersz powyżej", + "add_row_below": "Dodaj wiersz poniżej", + "add_column_before": "Dodaj kolumnę przed", + "add_column_after": "Dodaj kolumnę po", + "delete_row": "Usuń wiersz", + "delete_column": "Usuń kolumnę", + "toggle_header_row": "Przełącz wiersz nagłówka", + "delete_table": "Usuń tabelę", + "pick_size": "Wybierz rozmiar" + }, "send_filing_warning": "Wysłano - ale późniejsze porządkowanie nie powiodło się, może pozostać nieaktualna wersja robocza." }, "confirm_dialog": { diff --git a/locales/pt/common.json b/locales/pt/common.json index df5fb085..91c66bc3 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Nome de exibição", "autocomplete_search_server": "Pesquisar no servidor", "autocomplete_searching": "Pesquisando...", + "toolbar": { + "bold": "Negrito", + "italic": "Itálico", + "underline": "Sublinhado", + "strikethrough": "Tachado", + "text_color": "Cor do texto", + "remove_color": "Remover cor", + "heading_1": "Título 1", + "heading_2": "Título 2", + "bullet_list": "Lista com marcadores", + "ordered_list": "Lista numerada", + "quote": "Citação", + "code_block": "Bloco de código", + "align_left": "Alinhar à esquerda", + "align_center": "Centralizar", + "align_right": "Alinhar à direita", + "text_direction": "Direção do texto (RTL/LTR)", + "link": "Link", + "table": "Tabela", + "clear_formatting": "Limpar formatação", + "undo": "Desfazer", + "redo": "Refazer", + "add_row_above": "Adicionar linha acima", + "add_row_below": "Adicionar linha abaixo", + "add_column_before": "Adicionar coluna antes", + "add_column_after": "Adicionar coluna depois", + "delete_row": "Excluir linha", + "delete_column": "Excluir coluna", + "toggle_header_row": "Alternar linha de cabeçalho", + "delete_table": "Excluir tabela", + "pick_size": "Escolher tamanho" + }, "send_filing_warning": "Enviado - mas a limpeza posterior falhou, um rascunho antigo pode permanecer." }, "confirm_dialog": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 31cea1ef..67d5e06b 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Numele afișat", "autocomplete_search_server": "Caută pe server", "autocomplete_searching": "Se caută...", + "toolbar": { + "bold": "Aldin", + "italic": "Cursiv", + "underline": "Subliniat", + "strikethrough": "Tăiat", + "text_color": "Culoarea textului", + "remove_color": "Elimină culoarea", + "heading_1": "Titlu 1", + "heading_2": "Titlu 2", + "bullet_list": "Listă cu marcatori", + "ordered_list": "Listă numerotată", + "quote": "Citat", + "code_block": "Bloc de cod", + "align_left": "Aliniere la stânga", + "align_center": "Centrare", + "align_right": "Aliniere la dreapta", + "text_direction": "Direcția textului (RTL/LTR)", + "link": "Link", + "table": "Tabel", + "clear_formatting": "Șterge formatarea", + "undo": "Anulează", + "redo": "Refă", + "add_row_above": "Adaugă rând deasupra", + "add_row_below": "Adaugă rând dedesubt", + "add_column_before": "Adaugă coloană înainte", + "add_column_after": "Adaugă coloană după", + "delete_row": "Șterge rândul", + "delete_column": "Șterge coloana", + "toggle_header_row": "Comută rândul de antet", + "delete_table": "Șterge tabelul", + "pick_size": "Alege dimensiunea" + }, "send_filing_warning": "Trimis - dar curățarea ulterioară a eșuat, poate rămâne o ciornă veche." }, "confirm_dialog": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 0635033c..aaeee723 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Отображаемое имя", "autocomplete_search_server": "Искать на сервере", "autocomplete_searching": "Поиск...", + "toolbar": { + "bold": "Жирный", + "italic": "Курсив", + "underline": "Подчёркнутый", + "strikethrough": "Зачёркнутый", + "text_color": "Цвет текста", + "remove_color": "Убрать цвет", + "heading_1": "Заголовок 1", + "heading_2": "Заголовок 2", + "bullet_list": "Маркированный список", + "ordered_list": "Нумерованный список", + "quote": "Цитата", + "code_block": "Блок кода", + "align_left": "По левому краю", + "align_center": "По центру", + "align_right": "По правому краю", + "text_direction": "Направление текста (RTL/LTR)", + "link": "Ссылка", + "table": "Таблица", + "clear_formatting": "Очистить форматирование", + "undo": "Отменить", + "redo": "Повторить", + "add_row_above": "Вставить строку выше", + "add_row_below": "Вставить строку ниже", + "add_column_before": "Вставить столбец слева", + "add_column_after": "Вставить столбец справа", + "delete_row": "Удалить строку", + "delete_column": "Удалить столбец", + "toggle_header_row": "Переключить строку заголовка", + "delete_table": "Удалить таблицу", + "pick_size": "Выбрать размер" + }, "send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик." }, "confirm_dialog": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 012f92ac..b214e1f0 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Zobrazené meno", "autocomplete_search_server": "Hľadať na serveri", "autocomplete_searching": "Hľadanie...", + "toolbar": { + "bold": "Tučné", + "italic": "Kurzíva", + "underline": "Podčiarknuté", + "strikethrough": "Prečiarknuté", + "text_color": "Farba textu", + "remove_color": "Odstrániť farbu", + "heading_1": "Nadpis 1", + "heading_2": "Nadpis 2", + "bullet_list": "Odrážkový zoznam", + "ordered_list": "Číslovaný zoznam", + "quote": "Citát", + "code_block": "Blok kódu", + "align_left": "Zarovnať doľava", + "align_center": "Na stred", + "align_right": "Zarovnať doprava", + "text_direction": "Smer textu (RTL/LTR)", + "link": "Odkaz", + "table": "Tabuľka", + "clear_formatting": "Vymazať formátovanie", + "undo": "Späť", + "redo": "Znova", + "add_row_above": "Pridať riadok nad", + "add_row_below": "Pridať riadok pod", + "add_column_before": "Pridať stĺpec pred", + "add_column_after": "Pridať stĺpec za", + "delete_row": "Odstrániť riadok", + "delete_column": "Odstrániť stĺpec", + "toggle_header_row": "Prepnúť riadok záhlavia", + "delete_table": "Odstrániť tabuľku", + "pick_size": "Vybrať veľkosť" + }, "send_filing_warning": "Odoslané - ale následné upratovanie zlyhalo, môže zostať zastaraný koncept." }, "confirm_dialog": { diff --git a/locales/tr/common.json b/locales/tr/common.json index fab595f4..c721980c 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Görünen ad", "autocomplete_search_server": "Sunucuda ara", "autocomplete_searching": "Aranıyor...", + "toolbar": { + "bold": "Kalın", + "italic": "İtalik", + "underline": "Altı çizili", + "strikethrough": "Üstü çizili", + "text_color": "Metin rengi", + "remove_color": "Rengi kaldır", + "heading_1": "Başlık 1", + "heading_2": "Başlık 2", + "bullet_list": "Madde işaretli liste", + "ordered_list": "Numaralı liste", + "quote": "Alıntı", + "code_block": "Kod bloğu", + "align_left": "Sola hizala", + "align_center": "Ortala", + "align_right": "Sağa hizala", + "text_direction": "Metin yönü (RTL/LTR)", + "link": "Bağlantı", + "table": "Tablo", + "clear_formatting": "Biçimlendirmeyi temizle", + "undo": "Geri al", + "redo": "Yinele", + "add_row_above": "Üste satır ekle", + "add_row_below": "Alta satır ekle", + "add_column_before": "Öncesine sütun ekle", + "add_column_after": "Sonrasına sütun ekle", + "delete_row": "Satırı sil", + "delete_column": "Sütunu sil", + "toggle_header_row": "Başlık satırını aç/kapat", + "delete_table": "Tabloyu sil", + "pick_size": "Boyut seç" + }, "send_filing_warning": "Gönderildi - ancak sonrasındaki temizleme başarısız oldu, eski bir taslak kalabilir." }, "confirm_dialog": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 88363876..3fa92aee 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "Відображуване ім'я", "autocomplete_search_server": "Шукати на сервері", "autocomplete_searching": "Пошук...", + "toolbar": { + "bold": "Жирний", + "italic": "Курсив", + "underline": "Підкреслений", + "strikethrough": "Закреслений", + "text_color": "Колір тексту", + "remove_color": "Прибрати колір", + "heading_1": "Заголовок 1", + "heading_2": "Заголовок 2", + "bullet_list": "Маркований список", + "ordered_list": "Нумерований список", + "quote": "Цитата", + "code_block": "Блок коду", + "align_left": "По лівому краю", + "align_center": "По центру", + "align_right": "По правому краю", + "text_direction": "Напрямок тексту (RTL/LTR)", + "link": "Посилання", + "table": "Таблиця", + "clear_formatting": "Очистити форматування", + "undo": "Скасувати", + "redo": "Повторити", + "add_row_above": "Вставити рядок вище", + "add_row_below": "Вставити рядок нижче", + "add_column_before": "Вставити стовпець ліворуч", + "add_column_after": "Вставити стовпець праворуч", + "delete_row": "Видалити рядок", + "delete_column": "Видалити стовпець", + "toggle_header_row": "Переключити рядок заголовка", + "delete_table": "Видалити таблицю", + "pick_size": "Вибрати розмір" + }, "send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка." }, "confirm_dialog": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 9411f923..806f66f6 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -686,6 +686,38 @@ "recipient_name_placeholder": "显示名称", "autocomplete_search_server": "在服务器上搜索", "autocomplete_searching": "搜索中...", + "toolbar": { + "bold": "加粗", + "italic": "斜体", + "underline": "下划线", + "strikethrough": "删除线", + "text_color": "文字颜色", + "remove_color": "移除颜色", + "heading_1": "标题 1", + "heading_2": "标题 2", + "bullet_list": "项目符号列表", + "ordered_list": "编号列表", + "quote": "引用", + "code_block": "代码块", + "align_left": "左对齐", + "align_center": "居中对齐", + "align_right": "右对齐", + "text_direction": "文字方向 (RTL/LTR)", + "link": "链接", + "table": "表格", + "clear_formatting": "清除格式", + "undo": "撤销", + "redo": "重做", + "add_row_above": "在上方添加行", + "add_row_below": "在下方添加行", + "add_column_before": "在前面添加列", + "add_column_after": "在后面添加列", + "delete_row": "删除行", + "delete_column": "删除列", + "toggle_header_row": "切换标题行", + "delete_table": "删除表格", + "pick_size": "选择大小" + }, "send_filing_warning": "已发送,但发送后的清理失败,可能会残留旧草稿。" }, "confirm_dialog": { From 144d6503cc8a09754b4c1ada13b61b80caa0e9f2 Mon Sep 17 00:00:00 2001 From: marc0s Date: Thu, 23 Jul 2026 19:36:10 +0200 Subject: [PATCH 02/42] feat: add Catalan translation --- components/ui/flag-icons.tsx | 14 + components/ui/language-switcher.tsx | 1 + i18n/request.ts | 3 + i18n/routing.ts | 2 +- locales/ca/common.json | 3286 +++++++++++++++++++++++++++ 5 files changed, 3305 insertions(+), 1 deletion(-) create mode 100644 locales/ca/common.json diff --git a/components/ui/flag-icons.tsx b/components/ui/flag-icons.tsx index 0f07a60a..029abb31 100644 --- a/components/ui/flag-icons.tsx +++ b/components/ui/flag-icons.tsx @@ -76,6 +76,19 @@ export function FlagES(props: FlagProps) { ); } +/** Catalonia – Senyera: four red horizontal bars on a yellow field */ +export function FlagCAT(props: FlagProps) { + return ( + + + + + + + + ); +} + /** Italy – Green, White, Red vertical */ export function FlagIT(props: FlagProps) { return ( @@ -309,6 +322,7 @@ export function FlagSK(props: FlagProps) { /** Map locale codes to flag components */ export const flagComponents: Record ReactElement> = { + ca: FlagCAT, cs: FlagCS, sk: FlagSK, da: FlagDK, diff --git a/components/ui/language-switcher.tsx b/components/ui/language-switcher.tsx index dd877079..71671922 100644 --- a/components/ui/language-switcher.tsx +++ b/components/ui/language-switcher.tsx @@ -9,6 +9,7 @@ import { flagComponents } from './flag-icons'; const languages = [ { value: 'auto', label: 'Auto' }, { value: 'ar', label: 'العربية' }, + { value: 'ca', label: 'Català' }, { value: 'cs', label: 'Česky' }, { value: 'sk', label: 'Slovenčina' }, { value: 'da', label: 'Dansk' }, diff --git a/i18n/request.ts b/i18n/request.ts index df364bed..8553724a 100644 --- a/i18n/request.ts +++ b/i18n/request.ts @@ -36,6 +36,9 @@ export default getRequestConfig(async ({ requestLocale }) => { case 'ar': messages = (await import('../locales/ar/common.json')).default; break; + case 'ca': + messages = (await import('../locales/ca/common.json')).default; + break; case 'cs': messages = (await import('../locales/cs/common.json')).default; break; diff --git a/i18n/routing.ts b/i18n/routing.ts index a4fd0ece..f479dfe0 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', 'cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh'] as const; +const SUPPORTED_LOCALES = ['ar', 'ca', 'cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh'] 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/locales/ca/common.json b/locales/ca/common.json new file mode 100644 index 00000000..57df5f98 --- /dev/null +++ b/locales/ca/common.json @@ -0,0 +1,3286 @@ +{ + "meta_description": "Client de correu web minimalista que utilitza el protocol JMAP", + "login": { + "title": "Webmail", + "username_label": "Correu electrònic", + "username_placeholder": "usuari@exemple.com", + "password_label": "Contrasenya", + "password_placeholder": "Introduïu la contrasenya", + "jmap_endpoint_label": "Servidor JMAP", + "jmap_endpoint_placeholder": "https://mail.example.com", + "jmap_endpoint_cors_hint": "El servidor ha de permetre sol·licituds CORS des d'aquest domini.", + "jmap_server_label": "Servidor", + "jmap_server_auto_picked": "Servidor seleccionat a partir del domini del vostre correu electrònic.", + "sign_in": "Inicia la sessió", + "signing_in": "Iniciant la sessió...", + "loading": "Carregant...", + "reconnecting": "S'ha perdut la connexió. S'està intentant reconnectar…", + "error": { + "invalid_credentials": "El correu electrònic o la contrasenya no són vàlids. Comproveu les credencials i torneu-ho a provar.", + "connection_failed": "No s'ha pogut contactar amb el servidor. Comproveu la connexió a Internet i torneu-ho a provar.", + "cors_blocked": "El servidor és accessible, però està bloquejant les sol·licituds d'origen creuat. Comproveu la configuració CORS del servidor JMAP i permeteu aquest domini.", + "server_error": "El servidor no està disponible temporalment. Torneu-ho a provar més tard.", + "generic": "S'ha produït un error inesperat. Si el problema persisteix, contacteu amb l'administrador.", + "totp_required": "Cal un codi d'autenticació de dos factors. Introduïu-lo a continuació.", + "totp_invalid": "El codi d'autenticació no és vàlid. Comproveu l'aplicació d'autenticació i torneu-ho a provar.", + "oauth_discovery_failed": "El SSO està activat, però no s'ha pogut contactar amb el proveïdor d'identitat. Comproveu la configuració d'OAuth." + }, + "show_password": "Mostra la contrasenya", + "hide_password": "Amaga la contrasenya", + "totp_toggle": "Tinc un codi 2FA", + "remember_me": "Recorda'm", + "config_error": { + "title": "Error de configuració", + "fetch_failed": "No s'ha pogut carregar la configuració de l'aplicació. Torneu-ho a provar més tard.", + "server_not_configured": "El servidor de correu no s'ha configurat. Contacteu amb l'administrador." + }, + "remove_from_history": "Elimina de l'historial", + "totp_label": "Codi d'autenticació", + "totp_placeholder": "000000", + "session_expired": "La sessió ha caducat. Torneu a iniciar la sessió.", + "dismiss": "Descarta", + "or": "o", + "sign_in_sso": "Inicia la sessió amb SSO", + "add_account_title": "Afegeix un compte", + "add_account_subtitle": "Inicieu la sessió amb un altre compte", + "cancel": "Cancel·la", + "website": "Lloc web", + "imprint": "Avís legal", + "privacy_policy": "Política de privadesa", + "try_demo": "Prova la demo", + "demo_description": "Exploreu amb dades d'exemple - no cal cap compte", + "demo_launching": "Iniciant la demo...", + "demo_login_button": "Inicia la demo", + "demo_tagline": "Proveu un client de correu complet. No cal cap compte.", + "demo_no_signup": "No cal registrar-se - exploreu lliurement amb dades d'exemple", + "oauth_completing": "Completant l'inici de sessió...", + "oauth_error": { + "title": "Ha fallat l'autenticació", + "invalid_state": "Ha fallat la validació de seguretat. Torneu a iniciar la sessió.", + "missing_params": "Falten dades d'autorització. Torneu a iniciar la sessió.", + "token_exchange_failed": "No s'ha pogut completar l'autenticació. Torneu-ho a provar.", + "access_denied": "S'ha denegat l'accés. Contacteu amb l'administrador.", + "back_to_login": "Torna a l'inici de sessió" + } + }, + "sidebar": { + "close": "Tanca", + "compose": "Redacta", + "compose_hint": "Redacta (c)", + "search_placeholder": "Cerca al correu...", + "search_placeholder_hint": "Cerca al correu... (premeu /)", + "storage": "Emmagatzematge", + "storage_used": "Utilitzat", + "storage_free": "Lliure", + "storage_total": "Total", + "sign_out": "Tanca la sessió", + "sign_out_of": "Tanca la sessió de {account}", + "sign_out_all": "Tanca la sessió de tots els comptes", + "add_account": "Afegeix un compte", + "set_as_default": "Estableix com a predeterminat", + "switch_account": "Canvia de compte", + "contacts": "Contactes", + "calendar": "Calendari", + "settings": "Configuració", + "admin": "Administració", + "files": "Fitxers", + "loading_mailboxes": "Carregant les bústies...", + "push_connected": "Actualitzacions en temps real actives", + "push_disconnected": "Actualitzacions en temps real inactives", + "keyboard_shortcuts": "Dreceres de teclat", + "theme": { + "light": "Mode clar", + "dark": "Mode fosc", + "system": "Tema del sistema" + }, + "language": { + "title": "Idioma" + }, + "mailboxes": { + "inbox": "Safata d'entrada", + "sent": "Enviats", + "drafts": "Esborranys", + "trash": "Paperera", + "archive": "Arxiu", + "starred": "Destacats", + "all_mail": "Tot el correu", + "spam": "Correu brossa", + "important": "Important" + }, + "unified_inbox": "Safata d'entrada unificada", + "unified_sent": "Tots els enviats", + "unified_drafts": "Tots els esborranys", + "unified_trash": "Tota la paperera", + "unified_archive": "Tot l'arxiu", + "unified_junk": "Tot el correu brossa", + "all_accounts": "Tots els comptes", + "unified_mailbox": "Bústia unificada", + "expand": "Desplega", + "collapse": "Replega", + "expand_tooltip": "Desplega", + "collapse_tooltip": "Replega", + "mobile": { + "search": "Cerca", + "compose": "Redacta", + "go_back": "Torna enrere" + }, + "clear_search": "Neteja la cerca", + "vacation_active": "La resposta automàtica està activa", + "demo_banner": "Mode de demostració", + "demo_reset": "Reinicia", + "demo_tour": "Visita guiada", + "tags": "Etiquetes", + "folders": "Carpetes", + "shared": "Compartit", + "mail": "Correu", + "nav_label": "Navegació", + "add_app": "Aplicacions", + "scheduled": "Programats", + "unified_all_unread": "Tots els no llegits", + "unified_all_starred": "Tots els destacats", + "unified_all_mail": "Tot el correu", + "remove_account": "Elimina el compte", + "remove_account_confirm": "Voleu eliminar {account} d'aquest dispositiu? El podreu tornar a afegir més endavant." + }, + "protocol_handlers": { + "title": "Aplicacions predeterminades", + "description": "Trieu si els enllaços de correu i calendari s'obren al Bulwark. Tècnicament, el Bulwark es registra com a gestor de protocol per als enllaços mailto: i webcal:.", + "unsupported": "Aquest navegador o connexió no admet el registre manual de gestors de protocol. És possible que encara pugueu utilitzar la PWA instal·lada des de la configuració del navegador o del sistema operatiu.", + "mailto_label": "Enllaços de correu", + "mailto_description": "Obre els enllaços mailto: al Bulwark amb el redactor emplenat prèviament.", + "protocol_open_mode_label": "En obrir enllaços de protocol", + "protocol_open_mode_description": "Trieu si el Bulwark obre els enllaços mailto: i webcal: en una pestanya nova o reutilitza una sessió oberta. L'opció de sessió activa necessita permís de notificacions perquè pugueu prémer una notificació alternativa per portar el Bulwark al davant si el navegador bloqueja el focus.", + "protocol_open_mode_active_session": "Obre en la sessió activa si és possible", + "protocol_open_mode_new_tab": "Obre sempre una pestanya nova", + "focus_notification_title": "Obre el Bulwark", + "focus_notification_body": "L'enllaç s'ha obert al Bulwark. Feu clic per portar la finestra al davant.", + "webcal_label": "Enllaços de calendari", + "webcal_description": "Obre els enllaços webcal: al Bulwark amb el diàleg de subscripció al calendari emplenat prèviament.", + "register_mailto": "Registra com a aplicació de correu", + "register_webcal": "Registra com a aplicació de calendari", + "mailto_registered": "S'ha sol·licitat el registre del gestor de correu", + "webcal_registered": "S'ha sol·licitat el registre del gestor de calendari", + "registration_failed": "No s'ha pogut registrar el gestor de protocol", + "opening_mailto": "Obrint el redactor...", + "opening_webcal": "Obrint el calendari...", + "browser_note": "És possible que el navegador o el sistema operatiu us demani confirmar-ho, i que calgui tenir el Bulwark instal·lat per poder seleccionar-lo com a aplicació predeterminada.", + "select_account_title": "Trieu un compte", + "select_mailto_account": "Trieu quin compte ha d'obrir aquest enllaç de correu.", + "select_webcal_account": "Trieu quin compte ha d'obrir aquest enllaç de calendari.", + "select_account_note": "Això només s'aplica a aquest enllaç de protocol.", + "detail_to": "Per a", + "detail_subject": "Assumpte", + "detail_no_subject": "Sense assumpte", + "detail_calendar": "Calendari", + "detail_source": "Origen", + "active_account": "Actiu", + "switching_account": "Canviant de compte..." + }, + "sidebar_apps": { + "modal_title": "Aplicacions de la barra lateral", + "add_new": "Afegeix una aplicació", + "edit_app": "Edita l'aplicació", + "name_label": "Nom", + "name_placeholder": "La meva aplicació", + "name_required": "El nom és obligatori", + "url_label": "URL", + "url_required": "L'URL és obligatori", + "url_invalid": "Introduïu un URL http o https vàlid", + "icon_label": "Icona", + "icon_required": "La icona és obligatòria", + "open_mode_label": "Mode d'obertura", + "open_new_tab": "Pestanya nova", + "open_inline": "Incrustat", + "cancel": "Cancel·la", + "add": "Afegeix", + "update": "Actualitza", + "delete": "Suprimeix", + "delete_confirm_title": "Suprimeix l'aplicació", + "delete_confirm": "Segur que voleu suprimir «{name}»?", + "no_apps": "Encara no s'ha afegit cap aplicació", + "no_apps_hint": "Afegiu aplicacions i enllaços personalitzats a la barra lateral", + "search_icons": "Cerca icones...", + "show_popular": "Populars", + "show_all": "Totes", + "no_icons_found": "No s'ha trobat cap icona", + "inline_badge": "Incrustat", + "tab_badge": "Pestanya", + "show_on_mobile": "Mostra al mòbil" + }, + "email_list": { + "no_emails": "No s'ha trobat cap missatge", + "no_emails_description": "Aquesta bústia és buida", + "no_search_results": "No s'ha trobat cap resultat", + "no_search_results_description": "Proveu d'ajustar la cerca o els filtres", + "loading": "Carregant els correus...", + "unread": "no llegit", + "to_me": "Per a mi", + "to_recipients": "Per a {count} destinataris", + "and_others": "i {count} més", + "draft": "Esborrany", + "starred": "Destacat", + "conversations_count": "{count} de {total} converses", + "conversations_count_plus": "{count}+ converses", + "conversations_count_simple": "{count} converses", + "no_conversations": "Cap conversa", + "loading_more": "Carregant més correus...", + "no_more_emails": "No hi ha més correus per carregar", + "batch_actions": { + "select": "Selecciona correus", + "select_all": "Selecciona-ho tot", + "selected_messages": "{count, plural, one {1 correu seleccionat} other {# correus seleccionats}}", + "mark_read": "Marca com a llegit", + "mark_unread": "Marca com a no llegit", + "delete": "Suprimeix", + "delete_confirm_title": "Suprimeix els correus", + "delete_confirm_message": "Segur que voleu suprimir {count, plural, one {1 correu} other {# correus}}?", + "clear_selection": "Neteja la selecció" + }, + "permanent_delete": "Suprimeix definitivament", + "permanent_delete_confirm_title": "Suprimeix definitivament", + "permanent_delete_confirm_message": "Aquest correu se suprimirà definitivament. Aquesta acció no es pot desfer.", + "permanent_delete_confirm_batch_message": "Aquests {count, plural, one {1 correu} other {# correus}} se suprimiran definitivament. Aquesta acció no es pot desfer.", + "empty_folder": { + "button": "Buida la carpeta", + "confirm_title": "Buida la carpeta", + "confirm_message": "Tots els correus d'aquesta carpeta se suprimiran definitivament. Aquesta acció no es pot desfer.", + "confirm_button": "Buida la carpeta", + "junk_hint": "Podeu buidar la carpeta de correu brossa per eliminar definitivament tots els missatges.", + "trash_hint": "Podeu buidar la paperera per eliminar definitivament tots els missatges." + }, + "no_scheduled_emails": "Cap correu programat", + "no_scheduled_emails_description": "Els missatges programats per a més endavant apareixeran aquí.", + "scheduled_count": "{count, plural, one {1 correu programat} other {# correus programats}}", + "scheduled_actions_hint": "Els missatges programats es poden cancel·lar, reprogramar o editar des de les seves accions programades.", + "cancel_scheduled_send": "Cancel·la l'enviament", + "reschedule_send": "Reprograma", + "cancel_and_edit": "Cancel·la i edita", + "cancel_and_compose_again": "Cancel·la i torna a redactar", + "reschedule_prompt": "Introduïu una data i hora noves, p. ex. 2026-05-04T15:30" + }, + "email_viewer": { + "read_receipt": { + "prompt": "El remitent ha demanat rebre una notificació quan obriu aquest missatge.", + "send": "Envia la confirmació", + "ignore": "Ignora", + "sent": "S'ha enviat la confirmació de lectura.", + "send_failed": "No s'ha pogut enviar la confirmació de lectura", + "mdn_subject": "Llegit: {subject}", + "mdn_body": "Aquesta és una confirmació de recepció del missatge que heu enviat a {recipient}.\n\nNota: aquesta confirmació només indica que el missatge s'ha mostrat a l'ordinador del destinatari. No hi ha cap garantia que el destinatari hagi llegit o entès el contingut del missatge." + }, + "no_email_selected": "Cap correu seleccionat", + "no_email_description": "Seleccioneu un correu de la llista per visualitzar-lo aquí", + "no_conversation_selected": "Cap conversa seleccionada", + "no_conversation_description": "Trieu una conversa de la llista per llegir-la aquí", + "compose": "Redacta", + "compose_hint": "Redacta un missatge nou", + "no_subject": "(Sense assumpte)", + "no_body_content": "(No hi ha contingut disponible)", + "no_preview_available": "No hi ha cap previsualització disponible", + "show_quoted_text": "Mostra el text citat", + "hide_quoted_text": "Amaga el text citat", + "loading_email": "Carregant el correu...", + "loading": "Carregant...", + "reply": "Respon", + "reply_all": "Respon a tots", + "forward": "Reenvia", + "delete": "Suprimeix", + "archive": "Arxiva", + "star": "Marca amb estrella", + "unstar": "Treu l'estrella", + "mark_unread": "Marca com a no llegit", + "mark_read": "Marca com a llegit", + "unread": "No llegit", + "read": "Llegit", + "spam_short": "Brossa", + "not_spam_short": "No és brossa", + "move": "Mou", + "print": "Imprimeix", + "view_source": "Mostra el codi font", + "export_email": "Exporta com a .eml", + "import_email": "Importa .eml o .zip", + "keyboard_shortcuts": "Dreceres de teclat (?)", + "email_source": "Codi font del correu", + "draft_banner": "Aquest missatge és un esborrany", + "edit_draft": "Edita", + "copy_source": "Copia al porta-retalls", + "source_copied": "S'ha copiat el codi font al porta-retalls", + "attachments": "Fitxers adjunts", + "important": "Important", + "download": "Baixa", + "download_all": "Baixa-ho tot", + "from": "De", + "to": "Per a", + "cc": "CC", + "bcc": "CCO", + "date": "Data", + "subject": "Assumpte", + "show_details": "Mostra els detalls", + "hide_details": "Amaga els detalls", + "external_content_warning": "S'han bloquejat les imatges i el contingut extern", + "load_external_content": "Carrega les imatges", + "trust_sender": "Confia sempre en aquest remitent", + "back_to_list": "Torna a la llista", + "view_contact": "Mostra el contacte", + "message_details": "Detalls del missatge", + "more_reply_options": "Més opcions de resposta", + "set_color": "Estableix l'etiqueta", + "tag": "Etiqueta", + "more_actions": "Més accions", + "previous": "Anterior", + "next": "Següent", + "move_to": "Mou a...", + "remove_color": "Elimina l'etiqueta", + "more_count": "+{count} més", + "characters_count": "{count} caràcters", + "quick_reply_placeholder": "Escriviu una resposta ràpida...", + "more_options": "Més opcions", + "sending": "Enviant...", + "security_authentication": "Seguretat i autenticació", + "technical_details": "Detalls tècnics", + "message_id_label": "ID del missatge:", + "reply_to_label": "Respon a:", + "delivery_time_label": "Hora de lliurament:", + "conversation_part_label": "Part de la conversa:", + "previous_messages": "{count} missatge anterior", + "previous_messages_plural": "{count} missatges anteriors", + "time": { + "day": "dia", + "days": "dies", + "hour": "hora", + "hours": "hores", + "minute": "minut", + "minutes": "minuts" + }, + "unknown_sender": "Desconegut", + "recipient_me": "jo", + "recipient_and_others": "{name} i {count} més", + "recipient_to_prefix": "Per a:", + "authentication": { + "title": "Autenticació", + "status": { + "verified": "Verificat", + "warning": "Advertència", + "none": "No autenticat" + }, + "spf": { + "pass": "SPF correcte", + "fail": "SPF incorrecte", + "none": "Sense SPF" + }, + "dkim": { + "pass": "DKIM vàlid", + "fail": "DKIM no vàlid", + "none": "Sense DKIM" + }, + "dmarc": { + "pass": "DMARC correcte", + "fail": "DMARC incorrecte", + "none": "Sense DMARC" + }, + "spam_score": "Puntuació de brossa", + "tooltip_spf": "Sender Policy Framework: verifica que el servidor remitent estigui autoritzat a enviar correu en nom del domini", + "tooltip_dkim": "DomainKeys Identified Mail: confirma que el correu no s'ha alterat durant el trànsit mitjançant una signatura criptogràfica", + "tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: assegura que l'SPF i el DKIM coincideixin amb el domini del remitent i estableix una política per als errors", + "policy": "Política", + "result": { + "pass": "Correcte", + "fail": "Incorrecte", + "softfail": "Error lleu", + "neutral": "Neutre", + "permerror": "Error permanent", + "temperror": "Error temporal", + "none": "Cap" + } + }, + "details": { + "recipients_routing": "Destinataris i encaminament", + "authentication_security": "Autenticació i seguretat", + "identifiers_threading": "Identificadors i fils", + "mailing_list": "Llista de correu", + "message_properties": "Propietats del missatge", + "sent": "Enviat", + "received": "Rebut", + "delivery_time": "Hora de lliurament", + "in_reply_to": "In-Reply-To", + "references": "References", + "thread_id": "ID del fil", + "size": "Mida", + "mime_type": "Tipus MIME", + "attachments_summary": "{count} fitxers · {size}", + "list_id": "ID de la llista", + "list_help": "Ajuda de la llista", + "list_post": "Publicació a la llista", + "list_unsubscribe": "Cancel·la la subscripció", + "iprev": "DNS invers", + "spam_status": "Estat de brossa", + "ai_verdict": "Veredicte de la IA", + "account": "Compte", + "no_subject": "(sense assumpte)" + }, + "headers": { + "routing": "Encaminament", + "received": "Received", + "message_id": "ID del missatge", + "list_info": "Informació de la llista" + }, + "color_tag": { + "title": "Etiqueta de color", + "red": "Vermell", + "orange": "Taronja", + "yellow": "Groc", + "green": "Verd", + "blue": "Blau", + "purple": "Lila", + "pink": "Rosa", + "none": "Cap" + }, + "tooltips": { + "reply": "Respon (r)", + "reply_all": "Respon a tots (a)", + "forward": "Reenvia (f)", + "archive": "Arxiva (e)", + "delete": "Suprimeix (# o Supr)", + "star": "Marca amb estrella (s)", + "unstar": "Treu l'estrella (s)", + "compose": "Redacta (c)", + "previous": "Correu anterior", + "next": "Correu següent", + "edit_draft": "Edita l'esborrany" + }, + "spam": { + "button_title": "Denuncia com a brossa", + "not_spam_title": "Marca com a legítim", + "toast_success": "S'ha mogut a la brossa", + "toast_batch": "{count} correus moguts a la brossa", + "toast_undo": "Desfés", + "toast_not_spam_success": "S'ha mogut a la safata d'entrada", + "toast_not_spam_batch": "{count} correus moguts a la safata d'entrada", + "error": "No s'ha pogut denunciar com a brossa", + "error_not_spam": "No s'ha pogut restaurar el correu" + }, + "unsubscribe_banner": { + "label": "Butlletí", + "button": "Cancel·la la subscripció", + "confirm_title": "Voleu cancel·lar la subscripció d'aquest remitent?", + "confirm_button": "Confirma", + "cancel": "Cancel·la", + "success_http": "S'ha obert la pàgina de cancel·lació en una pestanya nova", + "success_mailto": "S'ha enviat el correu de cancel·lació", + "confirm_message_http": "La pàgina de cancel·lació s'obrirà en una pestanya nova.", + "confirm_message_mailto": "S'enviarà un correu de cancel·lació al remitent.", + "error": "No s'ha pogut cancel·lar la subscripció", + "dismiss": "Descarta" + }, + "calendar_invitation": { + "loading": "Carregant els detalls de l'esdeveniment…", + "title": "Invitació de calendari", + "published_title": "Esdeveniment publicat", + "response_title": "Resposta a l'esdeveniment", + "update_title": "Actualització de l'esdeveniment", + "counter_title": "Contraproposta", + "refresh_title": "Sol·licitud d'actualització", + "declined_counter_title": "Contraproposta rebutjada", + "cancelled_title": "Esdeveniment cancel·lat", + "organizer": "Organitzat per {name}", + "organizer_label": "Organitzat per", + "attendees": "{count, plural, one {# assistent} other {# assistents}}", + "accept": "Accepta", + "maybe": "Potser", + "decline": "Rebutja", + "add_to_calendar": "Afegeix al calendari", + "added": "S'ha afegit al calendari", + "rsvp_sent": "Resposta enviada", + "parse_error": "No s'ha pogut llegir la invitació", + "action_failed": "No s'ha pogut completar aquesta acció del calendari.", + "no_calendar": "Calendari no disponible", + "published_info": "Aquest esdeveniment s'ha compartit com a referència.", + "response_info": "Aquest missatge conté la resposta d'un assistent.", + "response_info_organizer": "Aquesta resposta de l'assistent actualitza el vostre esdeveniment.", + "update_info": "Aquest missatge actualitza un esdeveniment existent.", + "counter_info": "Aquest missatge proposa canvis a un esdeveniment.", + "counter_info_organizer": "Aquest assistent ha proposat canvis al vostre esdeveniment.", + "refresh_info": "Aquest missatge sol·licita els detalls més recents de l'esdeveniment.", + "refresh_info_organizer": "Un assistent ha sol·licitat els detalls més recents de l'esdeveniment.", + "declined_counter_info": "L'organitzador ha rebutjat una contraproposta.", + "authentication_failed_info": "Les comprovacions d'autenticació del correu d'aquesta invitació han fallat. Tracteu les accions del calendari amb precaució.", + "authentication_missing_info": "Aquesta invitació no inclou autenticació de correu verificada. Confirmeu els detalls amb l'organitzador si alguna cosa sembla estranya.", + "sender_mismatch_info": "Aquesta invitació s'ha enviat des de {sender}, mentre que l'organitzador indicat a les dades del calendari és {organizer}.", + "sender_mismatch_unverified_info": "Aquesta invitació s'ha enviat des de {sender}, mentre que l'organitzador indicat a les dades del calendari és {organizer}, i no s'ha pogut verificar el missatge.", + "organizer_role": "Vós organitzeu aquest esdeveniment", + "your_response": "La vostra resposta: {status}", + "response_needed": "Cal resposta", + "response_accepted": "Acceptat", + "response_tentative": "Provisional", + "response_declined": "Rebutjat", + "response_delegated": "Delegat", + "actor_sent_info": "Enviat per {name}.", + "actor_response_info": "{name} ha respost {status}.", + "actor_counter_info": "{name} ha proposat canvis a aquest esdeveniment.", + "actor_refresh_info": "{name} ha demanat els detalls més recents de l'esdeveniment.", + "actor_declined_counter_info": "{name} ha rebutjat la contraproposta.", + "actor_note": "Nota: {comment}", + "actor_unknown": "Algú", + "proposed_changes": "Canvis proposats", + "change_title": "Títol", + "change_time": "Hora", + "change_location": "Ubicació", + "change_description": "Descripció", + "change_empty": "Cap", + "change_from_to": "{before} -> {after}", + "apply_proposal": "Aplica els canvis proposats", + "proposal_applied": "S'han aplicat els canvis proposats.", + "review_proposal": "Revisa la proposta", + "review_request": "Revisa la sol·licitud", + "view_in_calendar": "Mostra al calendari", + "select_calendar": "Selecciona un calendari", + "already_in_calendar": "Ja és al vostre calendari", + "request_info": "Heu estat convidat a aquest esdeveniment. Responeu per informar l'organitzador de la vostra disponibilitat.", + "cancel_info": "L'organitzador ha cancel·lat aquest esdeveniment.", + "event_updated": "Actualització núm. {sequence}", + "event_status_tentative": "Provisional", + "event_status_cancelled": "Cancel·lat", + "expand": "Mostra els detalls", + "collapse": "Amaga els detalls" + }, + "send": "Envia", + "more": "més", + "scheduled_banner": "Programat per enviar-se el {date}", + "scheduled_send_created": "Correu programat per enviar-se", + "cancel_scheduled_send": "Cancel·la l'enviament", + "reschedule_send": "Reprograma", + "cancel_and_edit": "Cancel·la i edita", + "cancel_and_compose_again": "Cancel·la i torna a redactar", + "reschedule_prompt": "Introduïu una data i hora noves, p. ex. 2026-05-04T15:30", + "scheduled_actions_only": "La vista de programats només admet accions de programació", + "undo_send_scheduled": "Missatge programat per enviar-se", + "undo_send": "Desfés l'enviament", + "contact_sidebar": { + "title": "Contacte", + "close": "Tanca la barra lateral", + "action_email": "Correu electrònic", + "action_email_title": "Envia un correu", + "action_copy": "Copia", + "action_copy_title": "Copia el correu electrònic", + "action_edit_title": "Edita el contacte", + "section_emails": "Correus electrònics", + "section_phones": "Telèfons", + "section_organizations": "Organitzacions", + "section_addresses": "Adreces", + "section_notes": "Notes", + "not_in_contacts": "No és als vostres contactes", + "add_to_contacts": "Afegeix als contactes", + "copied": "Copiat!", + "copy_failed": "No s'ha pogut copiar" + }, + "send_now": "Envia ara" + }, + "email_composer": { + "read_receipt_on": "Confirmació de lectura sol·licitada (feu clic per desactivar-la)", + "read_receipt_off": "Sol·licita una confirmació de lectura", + "new_message": "Missatge nou", + "reply": "Respon", + "reply_all": "Respon a tots", + "forward": "Reenvia", + "reply_to": "Respon", + "reply_all_to": "Respon a tots", + "forward_message": "Reenvia", + "from": "De", + "to": "Per a", + "cc": "CC", + "bcc": "CCO", + "subject": "Assumpte", + "body_placeholder": "Escriviu el vostre missatge...", + "send": "Envia", + "cancel": "Cancel·la", + "attach": "Adjunta", + "attach_photos": "Fotos i vídeos", + "attach_files": "Fitxers", + "discard": "Descarta", + "discard_draft_title": "Voleu descartar l'esborrany?", + "discard_draft_confirm": "Teniu canvis sense desar. Voleu descartar aquest esborrany?", + "saving": "Desant...", + "sending": "Enviant...", + "add_link": "Afegeix un enllaç", + "link_url_prompt": "Introduïu l'URL", + "draft_saved": "S'ha desat l'esborrany", + "save_failed": "No s'ha pogut desar", + "to_placeholder": "Adreces electròniques dels destinataris", + "cc_placeholder": "Destinataris amb CC", + "bcc_placeholder": "Destinataris amb CCO", + "subject_placeholder": "Assumpte", + "cc_label": "CC:", + "bcc_label": "CCO:", + "subject_label": "Assumpte:", + "file_size_kb": "KB", + "prefix": { + "forward": "Reenv.:", + "reply": "Re:" + }, + "no_subject": "(Sense assumpte)", + "unknown_sender": "Desconegut", + "quote": { + "reply_header": "El {date}, {sender} va escriure:", + "forward_header": "---------- Missatge reenviat ----------", + "from": "De: {sender}", + "date": "Data: {date}", + "subject": "Assumpte: {subject}", + "to": "Per a: {recipients}" + }, + "remove_sub_address": "Elimina el subadreçament", + "from_override": { + "toggle_off": "Sobreescriu", + "toggle_on": "Cancel·la la sobreescriptura", + "toggle_tooltip": "Editeu lliurement el nom i l'adreça del remitent. El correu es continua enviant a través de la vostra identitat: només canvia la capçalera «De» visible.", + "name_label": "Nom del remitent", + "name_placeholder": "Nom", + "email_label": "Adreça electrònica del remitent", + "email_placeholder": "alias@exemple.com" + }, + "use_template": "Plantilla", + "save_as_template": "Desa com a plantilla", + "validation": { + "recipient_required": "Afegiu un destinatari per enviar", + "subject_required": "Afegiu un assumpte", + "body_required": "Escriviu un missatge o adjunteu un fitxer", + "attachments_uploading": "Els fitxers adjunts encara s'estan pujant — s'enviarà en acabar", + "attachment_upload_failed": "No s'ha enviat — no s'ha pogut pujar un fitxer adjunt. Elimineu-lo i torneu-ho a provar." + }, + "upload_progress": "Pujant {uploaded} / {total}", + "upload_cancel": "Cancel·la la pujada", + "upload_failed": "No s'ha pogut pujar {filename}", + "drop_files": "Deixeu anar els fitxers per adjuntar-los", + "show_less": "Mostra'n menys", + "send_failed": "No s'ha pogut enviar el correu", + "continue_draft": "Continua l'esborrany", + "close_draft_title": "Voleu desar o descartar l'esborrany?", + "close_draft_message": "Teniu canvis sense desar. Voleu desar-los com a esborrany o descartar-los?", + "smime_sign_on": "Signatura S/MIME activada", + "smime_sign_off": "Activa la signatura S/MIME", + "smime_encrypt_on": "Xifratge S/MIME activat", + "smime_encrypt_off": "Activa el xifratge S/MIME", + "smime_encrypt_unavailable": "Xifratge S/MIME no disponible – falten certificats del destinatari", + "smime_unlock_title": "Desbloqueja la clau S/MIME", + "smime_unlock_message": "Introduïu la contrasenya per desbloquejar la vostra clau de signatura S/MIME.", + "smime_unlock_button": "Desbloqueja", + "smime_passphrase_placeholder": "Contrasenya", + "forgot_attachment": { + "title": "Us heu oblidat d'un fitxer adjunt?", + "message": "El vostre missatge esmenta «{keyword}», però no hi ha cap fitxer adjunt. Voleu enviar-lo igualment?", + "send_anyway": "Envia igualment", + "back": "Torna a l'edició" + }, + "schedule_send": "Programa l'enviament", + "schedule_send_description": "Trieu quan ha d'alliberar el servidor aquest missatge.", + "schedule_send_required": "Trieu una data i hora.", + "schedule_send_invalid": "Introduïu una data i hora vàlides.", + "schedule_send_future": "Trieu una data i hora futures.", + "schedule_send_too_late": "Aquesta hora és més tardana del que permet el servidor.", + "schedule_send_unsupported": "Aquest compte no admet l'enviament programat.", + "schedule_send_cleanup_warning": "S'ha creat l'enviament programat, però no s'ha pogut netejar l'esborrany.", + "send_delay_unsupported": "Aquest compte no admet el retard d'enviament.", + "send_delay_unsupported_confirm": "Aquest compte no admet el retard d'enviament. Voleu enviar-lo immediatament?", + "recipient_edit_email": "Edita l'adreça electrònica", + "recipient_edit_name": "Edita el nom mostrat", + "recipient_email_placeholder": "Adreça electrònica", + "recipient_name_placeholder": "Nom mostrat", + "autocomplete_search_server": "Cerca al servidor", + "autocomplete_searching": "Cercant...", + "send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet." + }, + "confirm_dialog": { + "confirm": "Confirma", + "cancel": "Cancel·la" + }, + "common": { + "loading": "Carregant...", + "error": "Error", + "success": "Correcte", + "cancel": "Cancel·la", + "save": "Desa", + "delete": "Suprimeix", + "edit": "Edita", + "close": "Tanca", + "search": "Cerca", + "refresh": "Actualitza", + "settings": "Configuració", + "help": "Ajuda", + "logout": "Tanca la sessió", + "yes": "Sí", + "no": "No", + "unknown": "Desconegut", + "app_title": "Webmail", + "reconnecting": "S'ha perdut la connexió. S'està intentant reconnectar…", + "rate_limited_title": "L'autenticació al servidor està temporalment limitada.", + "rate_limited_detail": "El Bulwark ha aturat les sol·licituds en segon pla per evitar el bloqueig. Es tornarà a provar d'aquí a {seconds} s.", + "rate_limited_action_title": "Sol·licitud aturada per evitar el bloqueig.", + "rate_limited_action_detail": "El Bulwark espera que acabi el temps d'espera del servidor abans d'enviar més sol·licituds autenticades. Torneu-ho a provar d'aquí a {seconds} s." + }, + "notifications": { + "email_sent": "Correu enviat correctament", + "email_deleted": "Correu suprimit", + "email_archived": "Correu arxivat", + "email_starred": "Correu destacat", + "email_unstarred": "S'ha tret l'estrella del correu", + "email_marked_read": "Correu marcat com a llegit", + "email_marked_unread": "Correu marcat com a no llegit", + "copied_to_clipboard": "Copiat al porta-retalls", + "source_copied": "S'ha copiat el codi font al porta-retalls", + "error_sending": "No s'ha pogut enviar el correu", + "error_deleting": "No s'ha pogut suprimir el correu", + "error_loading": "No s'han pogut carregar els correus", + "new_email": "Correu nou", + "new_email_from": "De {sender}", + "click_to_view": "Feu clic per veure'l", + "email_moved": "Correu mogut", + "emails_moved": "{count} correus moguts", + "moved_to_mailbox": "Mogut a {mailbox}", + "move_failed": "No s'ha pogut moure", + "move_error": "No s'han pogut moure els correus a la carpeta seleccionada", + "email_tagged": "Correu etiquetat", + "emails_tagged": "{count} correus etiquetats", + "tag_failed": "No s'ha pogut etiquetar", + "identity_created": "Identitat creada correctament", + "identity_updated": "Identitat actualitzada correctament", + "identity_deleted": "Identitat suprimida", + "identity_set_primary": "Identitat principal actualitzada", + "identity_create_failed": "No s'ha pogut crear la identitat: {error}", + "identity_update_failed": "No s'ha pogut actualitzar la identitat: {error}", + "identity_delete_failed": "No s'ha pogut suprimir la identitat: {error}", + "identity_unauthorized": "No teniu autorització per enviar des d'aquesta adreça electrònica", + "identity_not_found": "No s'ha trobat la identitat", + "vacation_saved": "S'ha desat la configuració de la resposta automàtica", + "vacation_save_failed": "No s'ha pogut desar la configuració de la resposta automàtica", + "filters_saved": "Filtres desats correctament", + "filters_save_failed": "No s'han pogut desar els filtres", + "filters_deleted": "Regla de filtre suprimida", + "templates_exported": "Plantilles exportades correctament", + "templates_imported": "{count, plural, one {# plantilla importada} other {# plantilles importades}}", + "templates_import_errors": "Algunes plantilles no s'han pogut importar", + "templates_import_empty": "No s'ha trobat cap plantilla al fitxer", + "export_email_error": "No s'ha pogut exportar el correu", + "import_email_success": "Correu importat correctament", + "import_email_error": "No s'ha pogut importar el correu" + }, + "date": { + "today": "Avui", + "yesterday": "Ahir", + "this_week": "Aquesta setmana", + "last_week": "La setmana passada", + "this_month": "Aquest mes", + "older": "Més antics", + "just_now": "Ara mateix", + "minutes_ago": "Fa {count} minut", + "minutes_ago_plural": "Fa {count} minuts", + "hours_ago": "Fa {count} hora", + "hours_ago_plural": "Fa {count} hores", + "days_ago": "Fa {count} dia", + "days_ago_plural": "Fa {count} dies" + }, + "language": { + "title": "Idioma", + "english": "English", + "french": "Français", + "japanese": "日本語", + "spanish": "Español", + "italian": "Italiano", + "german": "Deutsch", + "dutch": "Nederlands", + "portuguese": "Português", + "russian": "Русский", + "select_language": "Selecciona l'idioma", + "switch_to_english": "Canvia a l'anglès", + "switch_to_french": "Canvia al francès", + "switch_to_japanese": "Canvia al japonès", + "switch_to_spanish": "Canvia a l'espanyol", + "switch_to_italian": "Canvia a l'italià", + "switch_to_german": "Canvia a l'alemany", + "switch_to_dutch": "Canvia al neerlandès", + "switch_to_portuguese": "Canvia al portuguès", + "switch_to_russian": "Canvia al rus", + "switching": "Canviant d'idioma...", + "switch": "Canvia d'idioma", + "current": "Idioma actual", + "polish": "Polski", + "switch_to_polish": "Canvia al polonès", + "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": "Canvia al persa" + }, + "settings": { + "title": "Configuració", + "back_to_mail": "Torna al correu", + "save_success": "Configuració desada correctament", + "import_success": "Configuració importada correctament", + "import_error": "No s'ha pogut importar la configuració", + "reset_confirm": "Segur que voleu restablir tota la configuració als valors per defecte?", + "unsaved_changes": "Teniu canvis sense desar", + "discard_changes": "Voleu descartar els canvis sense desar?", + "discard": "Descarta", + "keep_editing": "Continua editant", + "search_placeholder": "Cerca a la configuració", + "search_clear": "Neteja la cerca", + "search_no_results": "Cap configuració coincident", + "tabs": { + "appearance": "Aparença", + "language": "Idioma, regió i hora", + "email": "Comportament del correu", + "composer": "Redactor", + "privacy": "Privadesa i seguretat", + "account": "Compte", + "identities": "Identitats", + "vacation": "Resposta automàtica", + "advanced": "Avançat", + "calendar": "Calendari", + "filters": "Filtres", + "templates": "Plantilles", + "folders": "Carpetes", + "keywords": "Etiquetes", + "security": "Seguretat", + "files": "Fitxers", + "contacts": "Contactes", + "encryption": "Xifratge", + "protocol_handlers": "Aplicacions predeterminades", + "sidebar_apps": "Aplicacions de la barra lateral", + "notifications": "Notificacions", + "layout": "Disposició", + "reading": "Lectura", + "composing": "Redacció", + "downloads": "Baixades", + "content_senders": "Contingut i remitents", + "about_data": "Quant a i dades", + "debug": "Depuració" + }, + "tab_groups": { + "general": "General", + "account": "Compte i identitat", + "organization": "Organització del correu", + "apps": "Aplicacions", + "system": "Sistema", + "appearance": "Aparença", + "mail": "Correu", + "privacy": "Privadesa i seguretat", + "advanced": "Avançat" + }, + "appearance": { + "title": "Aparença", + "description": "Personalitzeu l'aspecte del vostre correu web", + "theme": { + "label": "Tema", + "description": "Trieu l'esquema de colors preferit", + "light": "Clar", + "dark": "Fosc", + "system": "Sistema" + }, + "language": { + "label": "Idioma", + "description": "Trieu l'idioma preferit" + }, + "font_size": { + "label": "Mida de la lletra", + "description": "Ajusteu la mida del text per millorar-ne la llegibilitat", + "small": "Petita", + "medium": "Mitjana", + "large": "Gran" + }, + "list_density": { + "label": "Densitat", + "description": "Controleu l'espaiat i el farciment de la interfície", + "extra_compact": "Extra compacta", + "compact": "Compacta", + "regular": "Normal", + "comfortable": "Còmoda" + }, + "animations": { + "label": "Activa les animacions", + "description": "Mostra transicions i efectes suaus" + }, + "toolbar_position": { + "label": "Posició de la barra d'eines", + "description": "On mostrar els botons d'acció del correu (Respon, Arxiva, Suprimeix, etc.)", + "top": "A dalt", + "below_subject": "Sota l'assumpte" + }, + "toolbar_labels": { + "label": "Mostra les etiquetes de la barra d'eines", + "description": "Mostra etiquetes de text al costat de les icones de la barra d'eines. Desactiveu-ho per estalviar espai un cop us hàgiu familiaritzat amb les icones." + }, + "hide_account_switcher": { + "label": "Amaga el selector de comptes de la barra lateral", + "description": "Amaga el selector de comptes a la part superior de la barra lateral de carpetes. Encara podreu canviar de compte des de la barra de navegació inferior." + }, + "show_rail_account_list": { + "label": "Mostra els avatars dels comptes a la barra de navegació", + "description": "Mostra cercles individuals per a cada compte a la part inferior de la barra de navegació per canviar-hi ràpidament, amb un botó de tancament de sessió a sota." + }, + "unified_mailbox": { + "label": "Bústia unificada", + "description": "Mostra carpetes combinades (Safata d'entrada, Enviats, etc.) del compte actiu i les seves carpetes compartides.", + "cross_account": { + "label": "En tots els comptes", + "description": "Combina la bústia unificada de tots els comptes connectats en lloc de limitar-la al compte actiu." + }, + "include_group": { + "label": "Inclou les safates d'entrada de grup", + "description": "Combina també les safates d'entrada compartides o de grup a la vista unificada." + } + }, + "all_mail": { + "folders_label": "Carpetes a les llistes unificades", + "folders_description": "Trieu quines carpetes d'aquest compte es combinen a les llistes Tot el correu / No llegits / Destacats.", + "account_hint": "S'aplica a {account}.", + "no_folders": "No hi ha cap carpeta disponible." + }, + "colorful_sidebar_icons": { + "label": "Icones de colors a la barra lateral", + "description": "Acoloreix les icones de carpetes i etiquetes segons el tipus (blau per a la safata d'entrada, vermell per a la brossa, verd per als enviats, etc.). Desactiveu-ho per a una barra lateral monocroma." + }, + "tint_list_rows": { + "label": "Acoloreix les files de la llista segons l'etiqueta", + "description": "Ombreja cada fila de missatge amb el color de la primera etiqueta. Desactiveu-ho per mantenir les files planes; els punts i les etiquetes encara mostraran el color." + }, + "show_folder_total_count": { + "label": "Mostra el nombre total de missatges", + "description": "Mostra el nombre total de missatges al costat de les carpetes i etiquetes, juntament amb el nombre de no llegits. Desactiveu-ho per mostrar només els no llegits." + }, + "favicon_unread_badge": { + "label": "Nombre de no llegits a la icona de la pestanya", + "description": "Mostra el nombre de no llegits de la safata d'entrada com a distintiu a la icona de la pestanya del navegador, perquè el correu nou sigui visible encara que la pestanya no tingui el focus. Desactiveu-ho per mantenir la icona sense distintiu." + }, + "pro_interface": { + "label": "Interfície Pro (experimental)", + "description": "Disposició avançada només per a escriptori amb navegació de missatges en diverses pestanyes i fluxos de treball entre comptes. La interfície estàndard no es veu afectada; podeu tornar-hi en qualsevol moment.", + "open_label": "Obre la interfície Pro", + "back_to_standard": "Torna a l'estàndard" + }, + "cross_unread": { + "label": "No llegits", + "description": "Mostra una entrada de No llegits a la bústia unificada amb el correu no llegit de les carpetes seleccionades." + }, + "cross_starred": { + "label": "Destacats", + "description": "Mostra una entrada de Destacats a la bústia unificada amb el correu destacat de les carpetes seleccionades." + }, + "cross_all": { + "label": "Tot el correu", + "description": "Mostra una entrada de Tot el correu a la bústia unificada amb tot el correu de les carpetes seleccionades." + } + }, + "keywords": { + "title": "Etiquetes de correu", + "description": "Definiu etiquetes per organitzar els correus amb colors. Es desen com a paraules clau JMAP al servidor.", + "add_keyword": "Afegeix una etiqueta", + "reset_defaults": "Restableix als valors per defecte", + "label_field": "Nom mostrat", + "label_placeholder": "p. ex. Feina, Personal, Urgent", + "id_field": "ID de l'etiqueta", + "id_placeholder": "p. ex. feina, personal", + "color_field": "Color", + "id_exists": "Aquest ID d'etiqueta ja existeix", + "edit": "Edita l'etiqueta", + "delete": "Suprimeix l'etiqueta", + "save": "Desa", + "add": "Afegeix", + "cancel": "Cancel·la", + "migrating": "Actualitzant l'etiqueta als correus existents…", + "migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents" + }, + "notifications": { + "test_sound": "Prova el so de notificació", + "sounds": { + "default": "Per defecte (bip)", + "cheerful": "Alegre", + "involved": "Elaborat", + "swift": "Gest ràpid", + "relax": "Relaxant" + }, + "push": { + "title": "Notificacions en segon pla", + "description": "Rebeu notificacions del sistema per al correu nou quan aquest lloc estigui tancat. S'entreguen mitjançant el repetidor push del Bulwark; el repetidor mai no veu el contingut del correu.", + "relay_label": "Repetidor push", + "relay_desc": "Per defecte utilitza el repetidor allotjat del Bulwark. Canvieu-ho només si allotgeu el vostre propi servidor.", + "relay_locked": "Establert per l'administrador", + "relay_locked_desc": "L'URL del repetidor push l'ha establert l'administrador i no es pot canviar.", + "relay_placeholder": "https://notifications.relay.example.com", + "status_active": "Actiu en aquest dispositiu", + "status_inactive": "No activat en aquest dispositiu", + "status_unsupported": "Aquest navegador no admet Web Push", + "status_busy": "Treballant…", + "enable": "Activa", + "reenable": "Torna a registrar", + "disable": "Desactiva", + "confirm_disable_title": "Voleu desactivar les notificacions en segon pla?", + "confirm_disable_message": "Aquest dispositiu deixarà de rebre avisos quan el lloc estigui tancat.", + "ios_hint": "A l'iOS, primer instal·leu el lloc a la pantalla d'inici - el Safari només entrega Web Push a les PWA instal·lades." + }, + "sound_selection": { + "title": "So de notificació", + "description": "Trieu quin so es reprodueix per a les notificacions", + "choose": "So", + "choose_desc": "Seleccioneu un to de notificació i feu clic a la icona de l'altaveu per escoltar-lo" + }, + "email": { + "title": "Notificacions de correu", + "description": "Configureu les notificacions per als correus entrants", + "enabled": "Notificacions de correu", + "enabled_desc": "Mostra notificacions quan arribin correus nous", + "sound": "So de notificació", + "sound_desc": "Reprodueix un avís sonor quan arribin correus nous" + }, + "calendar": { + "title": "Notificacions de calendari", + "description": "Configureu les notificacions per als esdeveniments del calendari", + "enabled": "Notificacions d'esdeveniments", + "enabled_desc": "Mostra avisos per als esdeveniments propers del calendari", + "sound": "So de notificació", + "sound_desc": "Reprodueix un avís sonor per als recordatoris del calendari", + "invitation_parsing": "Analitza les invitacions per correu", + "invitation_parsing_desc": "Detecta les invitacions de calendari als fitxers adjunts del correu i mostra les accions del calendari" + } + }, + "language_region": { + "title": "Idioma, regió i hora", + "description": "Idioma, format de data, format d'hora i altres preferències regionals", + "language": { + "label": "Idioma", + "description": "Trieu l'idioma preferit", + "english": "English", + "french": "Français" + }, + "date_format": { + "label": "Format de data", + "description": "Com es mostren les dates a la llista de correus", + "smart": "Intel·ligent (segons l'idioma)", + "relative": "Relatiu (fa 1 h, fa 2 d)", + "full": "Sempre la data completa", + "preview_today": "Avui:", + "preview_this_week": "Aquesta setmana:", + "preview_older": "Més antics:" + }, + "date_locale": { + "label": "Regió del format de data", + "description": "Com s'ordenen les dates numèriques (dia, mes, any)", + "auto": "Automàtic (segons l'idioma)", + "iso": "ISO 8601 (AAAA-MM-DD)", + "dmy": "Dia/Mes/Any", + "mdy": "Mes/Dia/Any" + }, + "time_format": { + "label": "Format d'hora", + "description": "Trieu entre el format de 12 o 24 hores", + "12h": "12 hores", + "24h": "24 hores" + }, + "first_day": { + "label": "Primer dia de la setmana", + "description": "Comença la setmana en diumenge o dilluns", + "sunday": "Diumenge", + "monday": "Dilluns" + } + }, + "email_behavior": { + "request_read_receipt": { + "label": "Sol·licita confirmacions de lectura per defecte", + "description": "Activa per defecte la sol·licitud de confirmació de lectura en redactar un missatge nou." + }, + "read_receipt_response": { + "label": "Respon a les sol·licituds de confirmació de lectura", + "description": "Què fer quan un missatge entrant sol·licita una confirmació de lectura.", + "ask": "Pregunta cada vegada", + "always": "Envia sempre", + "never": "No enviïs mai" + }, + "title": "Comportament del correu", + "description": "Configureu com es gestionen els correus", + "mark_read": { + "label": "Marca com a llegit", + "description": "Quan marcar els correus com a llegits en obrir-los", + "instant": "Immediatament", + "delay_3s": "Després de 3 segons", + "delay_5s": "Després de 5 segons", + "never": "Mai" + }, + "delete_action": { + "label": "Acció de supressió", + "description": "Què passa quan suprimiu un correu", + "trash": "Mou a la paperera", + "trash_and_read": "Mou a la paperera i marca com a llegit", + "permanent": "Suprimeix definitivament", + "warning": "Els correus se suprimiran definitivament i no es podran recuperar. Aquesta acció és irreversible." + }, + "message_spacing": { + "label": "Espaiat del missatge", + "description": "Farciment al voltant del cos del missatge en el lector", + "auto": "Automàtic (recomanat)", + "always": "Afegeix sempre espaiat", + "edge": "De vora a vora" + }, + "archive_mode": { + "label": "Arxiva a", + "description": "Com organitzar els correus en arxivar-los", + "single": "Una única carpeta", + "year": "Una carpeta per any", + "month": "Una carpeta per mes", + "reorganize": "Reorganitza l'arxiu existent", + "reorganize_success": "{count, plural, =0 {Cap correu per reorganitzar} =1 {1 correu reorganitzat} other {# correus reorganitzats}}", + "reorganize_error": "No s'ha pogut reorganitzar l'arxiu" + }, + "permanently_delete_junk": { + "label": "Suprimeix definitivament la brossa", + "description": "Suprimeix definitivament els correus de la carpeta de brossa en lloc de moure'ls a la paperera" + }, + "mail_layout": { + "label": "Disposició del correu", + "description": "Trieu entre la vista dividida clàssica, un flux de lectura centrat a l'estil Gmail, o una subfinestra de lectura inferior a l'estil Zimbra.", + "split": "Vista dividida", + "split_description": "Manté la llista de missatges i la subfinestra de lectura visibles una al costat de l'altra.", + "focus": "Llista centrada", + "focus_description": "Mostra una línia per missatge i obre el correu a amplada completa mantenint visible la barra lateral de carpetes.", + "horizontal": "Subfinestra de lectura a baix", + "horizontal_description": "Mostra la llista de missatges a dalt i obre el missatge seleccionat en una subfinestra de lectura a sota." + }, + "show_preview": { + "label": "Mostra el text de previsualització", + "description": "Mostra la previsualització del correu a la llista", + "focus_description": "Mostra el text de previsualització incrustat dins la llista de missatges d'una línia centrada" + }, + "disable_threading": { + "label": "Desactiva l'agrupació en converses", + "description": "Mostra els correus com a missatges individuals en lloc d'agrupar-los per conversa" + }, + "plain_text_mode": { + "label": "Només text sense format", + "description": "Desactiva l'editor de text enriquit i envia tots els correus només com a text sense format, incloses les respostes i els reenviaments" + }, + "auto_select_reply_identity": { + "label": "Respon des de l'adreça de recepció", + "description": "En respondre, envia des de l'adreça a la qual es va enviar originalment el missatge. Primer intenta coincidir amb les identitats; per als lliuraments de captura general de domini, reescriu la capçalera «De» amb l'àlies mentre envia a través de la identitat principal." + }, + "signature_position": { + "label": "Posició de la signatura", + "description": "On inserir la signatura a les respostes i reenviaments. Abans del text citat es llegeix de manera natural com a tancament de la resposta; després manté el missatge original contigu.", + "above_quote": "Abans del text citat", + "below_quote": "Després del text citat" + }, + "signature_separator": { + "label": "Delimitador de la signatura", + "description": "Prefixa la signatura amb la línia delimitadora estàndard «-- » (RFC 3676). Desactiveu-ho si preferiu passar directament del missatge a la signatura." + }, + "sub_address_delimiter": { + "label": "Delimitador de subadreça", + "description": "Caràcter que separa el nom d'usuari d'una etiqueta de subadreça. Feu-lo coincidir amb el delimitador que utilitza el servidor de correu (p. ex. user{delimiter}tag@domain.com).", + "option": "{delimiter} (user{delimiter}tag@domain.com)", + "custom": "Personalitzat…", + "custom_input_label": "Caràcter delimitador personalitzat" + }, + "attachment_click_action": { + "label": "Acció en clicar un fitxer adjunt", + "description": "Trieu si en clicar un fitxer adjunt es previsualitza o es baixa immediatament", + "preview": "Previsualitza quan sigui possible", + "download": "Baixa immediatament" + }, + "attachment_position": { + "label": "Posició dels fitxers adjunts", + "description": "On mostrar els fitxers adjunts a la capçalera del correu", + "beside-sender": "Al costat del remitent", + "below-header": "Sota la capçalera" + }, + "emails_per_page": { + "10": "10 correus", + "25": "25 correus", + "50": "50 correus", + "100": "100 correus", + "label": "Correus per pàgina", + "description": "Nombre de correus que es carreguen alhora" + }, + "always_light_mode": { + "label": "Mostra sempre els correus en mode clar", + "description": "Renderitza el contingut del correu en mode clar encara que l'aplicació estigui en mode fosc, evitant problemes de conversió del mode fosc" + }, + "external_content": { + "label": "Contingut extern", + "description": "Com gestionar les imatges i el contingut extern", + "ask": "Pregunta sempre", + "block": "Bloqueja sempre", + "allow": "Permet sempre" + }, + "trusted_senders": { + "label": "Remitents de confiança", + "description": "Gestioneu els remitents les imatges dels quals es carreguen automàticament", + "count_zero": "Cap", + "count_one": "1 remitent", + "count_other": "{count} remitents", + "modal_title": "Remitents de confiança", + "empty_title": "Encara no hi ha remitents de confiança", + "empty_description": "En visualitzar un correu amb imatges bloquejades, feu clic a «Confia sempre en aquest remitent» per afegir-lo aquí.", + "add_manually": "Afegeix un remitent manualment", + "add_button": "Afegeix", + "add_placeholder": "Introduïu l'adreça electrònica", + "search_placeholder": "Cerca remitents...", + "no_results": "Cap remitent coincideix amb la cerca", + "remove": "Elimina", + "close": "Tanca", + "invalid_email": "Introduïu una adreça electrònica vàlida", + "already_added": "Aquest remitent ja és de confiança", + "save_error": "No s'ha pogut desar - consulteu el registre de depuració de Contactes per obtenir més detalls", + "use_address_book_label": "Sincronitza amb la llibreta d'adreces", + "use_address_book_description": "Desa els remitents de confiança en una llibreta d'adreces dedicada «Remitents de confiança» perquè es sincronitzin a tots els dispositius" + }, + "hover_actions": { + "label": "Accions ràpides en passar el cursor", + "description": "Trieu quines accions ràpides apareixen en passar el cursor per sobre d'un correu de la llista", + "delete": "Suprimeix", + "star": "Marca/Treu l'estrella", + "mark_read": "Marca com a llegit/no llegit", + "archive": "Arxiva", + "tag": "Etiqueta", + "spam": "Marca com a brossa", + "not_spam": "No és brossa", + "none_selected": "Cap acció seleccionada", + "mode_label": "Mode de visualització", + "mode_inline": "Incrustat", + "mode_floating": "Flotant", + "corner_label": "Posició flotant", + "corner_top-left": "Superior esquerra", + "corner_top-right": "Superior dreta", + "corner_bottom-left": "Inferior esquerra", + "corner_bottom-right": "Inferior dreta" + }, + "default_mail_program": { + "label": "Programa de correu predeterminat", + "description": "Registra {appName} com al programa de correu predeterminat per als enllaços mailto:", + "button": "Estableix com a predeterminat", + "success": "S'ha demanat al navegador que l'estableixi com a predeterminat", + "error": "El navegador no admet aquesta funció" + }, + "attachment_reminder": { + "label": "Recordatori de fitxers adjunts", + "description": "Avisa abans d'enviar quan el missatge esmenta fitxers adjunts però no n'hi ha cap", + "keywords_label": "Paraules clau que ho activen", + "keywords_description": "Paraules o frases que activen el recordatori quan es troben al missatge", + "add_placeholder": "Afegeix una paraula clau...", + "add": "Afegeix", + "remove": "Elimina" + }, + "hide_inline_image_attachments": { + "label": "Amaga les imatges incrustades dels fitxers adjunts", + "description": "Les imatges incrustades al cos del missatge no es llisten com a fitxers adjunts separats" + }, + "attachment_image_previews": { + "label": "Mostra previsualitzacions d'imatge als fitxers adjunts", + "description": "Renderitza els fitxers adjunts d'imatge com a targetes de miniatura en lloc d'icones de fitxer genèriques" + }, + "send_delay": { + "label": "Desfés l'enviament / retard d'enviament", + "description": "Retarda els enviaments normals amb un petit marge al servidor.", + "off": "Desactivat", + "seconds": "{seconds} segons", + "unsupported": "El compte actual no indica compatibilitat amb l'enviament retardat. La configuració es continua desant per a altres comptes." + }, + "return_to_list_after_action": { + "label": "Torna a la llista després de suprimir o marcar com a no llegit", + "description": "Després de suprimir o marcar com a no llegit el missatge obert, torna a la llista de missatges en lloc d'obrir el següent." + }, + "rtl_editing": { + "label": "Compatibilitat amb edició de dreta a esquerra", + "description": "Afegeix un botó de direcció a la barra d'eines del redactor perquè pugueu establir els paràgrafs d'esquerra a dreta o de dreta a esquerra" + } + }, + "composer": { + "title": "Redactor", + "description": "Configureu les opcions de redacció de correu", + "autosave": { + "label": "Interval de desat automàtic", + "description": "Amb quina freqüència es desen automàticament els esborranys", + "30s": "Cada 30 segons", + "1m": "Cada minut", + "2m": "Cada 2 minuts", + "5m": "Cada 5 minuts" + }, + "send_confirmation": { + "label": "Confirmació d'enviament", + "description": "Demana confirmació abans d'enviar els correus" + }, + "default_reply": { + "label": "Mode de resposta predeterminat", + "description": "Acció predeterminada en clicar respon", + "reply": "Respon", + "reply_all": "Respon a tots" + } + }, + "privacy": { + "title": "Privadesa i seguretat", + "description": "Gestioneu la configuració de privadesa i seguretat", + "external_images": { + "label": "Bloqueja les imatges externes", + "description": "Evita el seguiment mitjançant imatges externes" + }, + "session_timeout": { + "label": "Temps d'espera de la sessió", + "description": "Tanca la sessió automàticament després d'inactivitat", + "never": "Mai", + "30m": "30 minuts", + "1h": "1 hora", + "4h": "4 hores" + }, + "clear_cache": { + "label": "Neteja la memòria cau", + "description": "Elimina les dades emmagatzemades i els fitxers temporals", + "button": "Neteja la memòria cau", + "confirm": "Segur que voleu netejar la memòria cau?", + "success": "Memòria cau netejada correctament" + } + }, + "account": { + "title": "Compte", + "description": "Vegeu la informació del vostre compte", + "name_label": "Nom mostrat", + "username_label": "Nom d'usuari", + "account_type_label": "Tipus de compte", + "auth_method_label": "Autenticació", + "auth_method_oauth": "Inici de sessió únic (OAuth/OIDC)", + "auth_method_basic": "Contrasenya", + "demo_account": "Compte de demostració", + "email": { + "label": "Adreça electrònica", + "value": "{email}" + }, + "server": { + "label": "Servidor JMAP", + "value": "{server}" + }, + "storage": { + "label": "Ús de l'emmagatzematge", + "used": "{used} de {total} utilitzats", + "percentage": "{percent}% utilitzat" + }, + "last_sync": { + "label": "Última sincronització", + "value": "{time}" + }, + "accounts": { + "title": "Comptes amb la sessió iniciada", + "description": "Arrossegueu per reordenar com apareixen els comptes al menú desplegable", + "active": "Compte actiu actualment", + "default_badge": "Compte predeterminat", + "set_default": "Estableix com a predeterminat", + "switch_to": "Canvia a aquest compte", + "move_up": "Mou amunt", + "move_down": "Mou avall", + "drag_handle": "Arrossegueu per reordenar", + "add": "Afegeix un compte" + }, + "shared_accounts": { + "title": "Compartit amb mi", + "description": "Comptes de grup i compartits que podeu gestionar. Seleccioneu-ne un per editar-ne els filtres, la resposta automàtica, els calendaris i els contactes.", + "shared_label": "Compte compartit" + } + }, + "scoped": { + "back": "Torna al meu compte", + "managing": "Gestionant: {name}" + }, + "security": { + "title": "Seguretat del compte", + "description": "Gestioneu la contrasenya, l'autenticació de dos factors i la configuració de seguretat", + "detecting": "Detectant les capacitats del servidor...", + "not_available": "La gestió de la seguretat del compte no està disponible per a aquest servidor de correu. És possible que els permisos necessaris estiguin desactivats. Consulteu la documentació per obtenir més detalls.", + "password": { + "title": "Canvia la contrasenya", + "current": "Contrasenya actual", + "new": "Contrasenya nova", + "confirm": "Confirma la contrasenya nova", + "submit": "Canvia la contrasenya", + "success": "Contrasenya canviada correctament", + "error_title": "No s'ha pogut canviar la contrasenya", + "error_mismatch": "Les contrasenyes noves no coincideixen", + "error_min_length": "La contrasenya ha de tenir com a mínim 8 caràcters", + "error_generic": "No s'ha pogut canviar la contrasenya" + }, + "display_name": { + "label": "Nom mostrat", + "description": "El vostre nom tal com apareix al servidor", + "placeholder": "Introduïu el nom mostrat", + "save": "Desa", + "success": "Nom mostrat actualitzat", + "error": "No s'ha pogut actualitzar el nom mostrat" + }, + "totp": { + "section_title": "Autenticació de dos factors", + "label": "Autenticació TOTP", + "description": "Afegiu una capa addicional de seguretat amb una contrasenya d'un sol ús basada en el temps", + "active": "Activada", + "inactive": "Desactivada", + "enabled": "Autenticació de dos factors activada", + "disabled": "Autenticació de dos factors desactivada", + "enable_error": "No s'ha pogut activar el 2FA", + "disable_error": "No s'ha pogut desactivar el 2FA", + "setup_instructions": "Copieu aquest URL a la vostra aplicació d'autenticació (Google Authenticator, Authy, etc.):", + "verification_code": "Codi de verificació", + "confirm": "Confirma", + "disable": "Desactiva", + "disable_confirm_prompt": "Introduïu la contrasenya per desactivar l'autenticació de dos factors.", + "password_required": "Cal la contrasenya", + "code_required": "Cal el codi de verificació", + "code_invalid": "El codi de verificació no és vàlid. Comproveu l'aplicació d'autenticació i torneu-ho a provar." + }, + "app_passwords": { + "title": "Contrasenyes d'aplicació", + "description": "Creeu contrasenyes per a aplicacions que no admeten l'autenticació de dos factors", + "add": "Afegeix", + "create": "Crea", + "cancel": "Cancel·la", + "done": "Fet", + "generate": "Genera", + "name_label": "Nom de l'aplicació", + "name_placeholder": "p. ex. Thunderbird, Correu de l'iPhone", + "expires_label": "Caduca (opcional)", + "allowed_ips_label": "IP permeses (opcional)", + "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24", + "allowed_ips_hint": "Separades per comes o espais. Deixeu-ho buit per permetre qualsevol IP.", + "password_label": "Contrasenya (deixeu-ho buit per generar-la automàticament)", + "password_placeholder": "Es genera automàticament si es deixa buit", + "copy_now_warning": "Copieu aquesta contrasenya ara - no es tornarà a mostrar.", + "added": "Contrasenya d'aplicació creada", + "removed": "Contrasenya d'aplicació eliminada", + "add_error": "No s'ha pogut crear la contrasenya d'aplicació", + "remove_error": "No s'ha pogut eliminar la contrasenya d'aplicació", + "none": "No hi ha cap contrasenya d'aplicació configurada" + }, + "api_keys": { + "title": "Claus API", + "description": "Creeu claus API per a scripts i integracions que es comuniquen directament amb el servidor", + "name_label": "Nom de la clau", + "name_placeholder": "p. ex. Script de còpia de seguretat, executor de CI", + "copy_now_warning": "Copieu aquesta clau API ara - no es tornarà a mostrar.", + "added": "Clau API creada", + "removed": "Clau API eliminada", + "add_error": "No s'ha pogut crear la clau API", + "remove_error": "No s'ha pogut eliminar la clau API", + "none": "No hi ha cap clau API configurada" + }, + "encryption": { + "section_title": "Xifratge en repòs", + "label": "Xifratge del correu", + "description": "Xifra els correus emmagatzemats al servidor per obtenir més privadesa", + "active": "Xifratge {type} activat", + "inactive": "Desactivat", + "enabled": "Xifratge en repòs activat", + "disabled_success": "Xifratge en repòs desactivat", + "error": "No s'ha pogut actualitzar la configuració de xifratge" + }, + "email_client": { + "title": "Configuració del client de correu", + "description": "Utilitzeu aquestes credencials per configurar el client de correu d'escriptori o mòbil (Thunderbird, Apple Mail, Outlook, etc.)", + "jmap_username_label": "Nom d'usuari JMAP", + "copy": "Copia", + "copied": "Copiat", + "password_instructions": "Utilitzeu el nom d'usuari JMAP anterior juntament amb una contrasenya d'aplicació per iniciar la sessió al client de correu. Creeu una contrasenya d'aplicació a la secció anterior si encara no ho heu fet." + }, + "link_device": { + "title": "Enllaça l'aplicació mòbil", + "description": "Inicieu la sessió a l'aplicació mòbil Bulwark Mail sense escriure res. Genereu un codi QR aquí i escanegeu-lo des de la pantalla d'inici de sessió de l'aplicació.", + "generate": "Mostra el codi QR", + "regenerate": "Mostra un codi nou", + "instructions": "Obriu l'aplicació Bulwark Mail, toqueu «Escaneja el codi QR» a la pantalla d'inici de sessió i apunteu la càmera aquí.", + "expires_in": "Aquest codi caduca d'aquí a {seconds} segons. Només es pot utilitzar una vegada.", + "expired": "Aquest codi ha caducat.", + "generating": "Generant…", + "error": "No s'ha pogut crear el codi d'aparellament. Torneu-ho a provar." + } + }, + "identities": { + "title": "Identitats d'enviament", + "description": "Gestioneu les adreces electròniques des de les quals podeu enviar", + "identities_count": { + "label": "Les vostres identitats", + "description": "Adreces electròniques configurades per a l'enviament", + "count_zero": "Cap identitat", + "count_one": "1 identitat", + "count_other": "{count} identitats" + }, + "manage": "Gestiona les identitats", + "sub_addressing": { + "label": "Subadreçament", + "description": "Utilitzeu etiquetes com user+tag@domain.com per organitzar el correu entrant", + "learn_more": "Més informació" + } + }, + "vacation": { + "title": "Resposta automàtica", + "description": "Respon automàticament als correus entrants mentre esteu fora", + "loading": "Carregant la configuració de la resposta automàtica...", + "not_supported": "El servidor de correu no admet respostes automàtiques.", + "fetch_error": "No s'ha pogut carregar la configuració de la resposta automàtica. Torneu-ho a provar.", + "status": { + "label": "Resposta automàtica", + "description": "Envia una resposta automàtica a les persones que us envien correu", + "active": "Activa", + "inactive": "Inactiva" + }, + "date_range": { + "title": "Interval de dates", + "description": "Opcionalment, limiteu la resposta automàtica a un període específic", + "start": "Data d'inici", + "start_description": "Deixeu-ho buit per no establir cap límit inicial", + "end": "Data de finalització", + "end_description": "Deixeu-ho buit per no establir cap límit final" + }, + "message": { + "title": "Missatge de resposta automàtica", + "description": "El missatge que s'enviarà com a resposta", + "subject_label": "Assumpte", + "subject_description": "Línia d'assumpte de la resposta automàtica", + "subject_placeholder": "Fora de l'oficina", + "body_label": "Cos del missatge", + "body_description": "Contingut del missatge en text sense format", + "body_placeholder": "Gràcies pel vostre correu. Actualment estic fora de l'oficina i respondré quan torni.", + "html_label": "Missatge amb format (HTML)", + "html_description": "Afegiu una versió amb format enriquit, amb enllaços i estils. Els destinataris el client de correu dels quals no la pugui mostrar rebran el text sense format anterior.", + "html_placeholder": "Escriviu una resposta amb format per a fora de l'oficina…" + }, + "preview": { + "title": "Previsualització", + "show": "Mostra la previsualització", + "hide": "Amaga la previsualització" + }, + "save": "Desa els canvis", + "saving": "Desant...", + "warnings": { + "end_before_start": "La data de finalització ha de ser posterior a la data d'inici", + "start_in_past": "La data d'inici és al passat", + "empty_body": "El cos del missatge està buit - els destinataris rebran una resposta en blanc" + } + }, + "folders": { + "role_memos": "Notes", + "title": "Carpetes", + "description": "Gestioneu les carpetes de correu i assigneu-hi rols estàndard", + "folder_list": "Les vostres carpetes", + "folder_list_description": "Feu clic a la icona d'una carpeta per personalitzar-la", + "standard_roles": "Rols estàndard de carpeta", + "standard_roles_description": "Assigneu quines carpetes s'utilitzen per als rols estàndard de la bústia, com la safata d'entrada, els enviats, la paperera, etc.", + "role_inbox": "Safata d'entrada", + "role_drafts": "Esborranys", + "role_sent": "Enviats", + "role_trash": "Paperera", + "role_junk": "Correu brossa", + "role_archive": "Arxiu", + "role_none": "Cap", + "create_folder": "Crea una carpeta", + "create_subfolder": "Crea una subcarpeta", + "reorder": "Arrossegueu per reordenar", + "reorder_error": "No s'han pogut reordenar les carpetes", + "subfolder_of": "Dins de {name}", + "subfolder_name": "Nom de la subcarpeta", + "new_folder_name": "Nom de la carpeta", + "rename": "Canvia el nom", + "change_icon": "Canvia la icona", + "delete": "Suprimeix", + "confirm_delete": "Segur que voleu suprimir «{name}»? Els correus d'aquesta carpeta es mouran a la paperera.", + "create": "Crea", + "cancel": "Cancel·la", + "no_folders": "Cap carpeta personalitzada", + "cannot_delete_role": "No es pot suprimir una carpeta amb un rol estàndard. Elimineu primer el rol.", + "folder_created": "Carpeta creada", + "folder_renamed": "Nom de la carpeta canviat", + "folder_deleted": "Carpeta suprimida", + "role_updated": "Rol de la carpeta actualitzat", + "error_create": "No s'ha pogut crear la carpeta", + "error_rename": "No s'ha pogut canviar el nom de la carpeta", + "error_delete": "No s'ha pogut suprimir la carpeta", + "error_delete_has_children": "No es pot suprimir la carpeta: encara conté subcarpetes. Suprimiu-les o moveu-les primer.", + "error_delete_has_email": "No es pot suprimir la carpeta: encara conté correus. Moveu-los o suprimiu-los primer.", + "error_role": "No s'ha pogut actualitzar el rol de la carpeta" + }, + "advanced": { + "title": "Avan\u00e7at", + "description": "Opcions avan\u00e7ades i configuraci\u00f3 per a desenvolupadors", + "debug_mode": { + "label": "Mode de depuraci\u00f3", + "description": "Activa el registre detallat per a la resoluci\u00f3 de problemes" + }, + "debug_categories": { + "description": "Seleccioneu quines categories registrar. Desactiveu les que no necessiteu per reduir el soroll a la consola.", + "jmap": "Client JMAP", + "jmap_description": "Operacions de b\u00fastia, obtenci\u00f3 de correus i sol\u00b7licituds del protocol JMAP", + "calendar": "Calendari", + "calendar_description": "Esdeveniments del calendari, importacions i missatges de programaci\u00f3", + "tasks": "Tasques", + "tasks_description": "Creaci\u00f3, obtenci\u00f3 i actualitzaci\u00f3 de tasques del calendari", + "auth": "Autenticaci\u00f3", + "auth_description": "Inici de sessi\u00f3, TOTP, intercanvi de testimonis i gesti\u00f3 de sessions", + "filters": "Filtres", + "filters_description": "Regles de filtre Sieve i scripts de resposta autom\u00e0tica", + "email": "Visualitzaci\u00f3 de correu", + "email_description": "Renderitzaci\u00f3 de correu, processament TNEF i marcatge com a llegit", + "push": "Notificacions push", + "push_description": "Configuraci\u00f3 i entrega de notificacions push", + "contacts": "Contactes i llibretes d'adreces", + "contacts_description": "Sincronitzaci\u00f3 de contactes, operacions de llibreta d'adreces i remitents de confian\u00e7a" + }, + "settings_sync": { + "label": "Sincronitzaci\u00f3 de la configuraci\u00f3", + "description": "Sincronitzeu la configuraci\u00f3 entre navegadors i dispositius" + }, + "sender_favicons": { + "label": "Icones dels remitents", + "description": "Mostra icones de llocs web com a imatges de perfil per als remitents d'empresa" + }, + "show_avatars_in_junk": { + "label": "Mostra els avatars a la carpeta de brossa", + "description": "Mostra imatges de perfil i icones dels remitents a la carpeta de brossa. Desactivat per defecte per evitar donar aparen\u00e7a de legitimitat a intents de pesca electr\u00f2nica." + }, + "keyboard_shortcuts": { + "label": "Dreceres de teclat", + "description": "Consulteu les dreceres de teclat disponibles", + "button": "Mostra les dreceres" + }, + "refresh_cache": { + "label": "Actualitza les dades emmagatzemades", + "description": "Torna a carregar els contactes, els calendaris i les carpetes des del servidor. Mant\u00e9 els comptes i les sessions \u2014 soluciona una vista obsoleta o incorrecta sense tancar la sessi\u00f3.", + "button": "Actualitza" + }, + "reset_settings": { + "label": "Restableix la configuraci\u00f3", + "description": "Restaura tota la configuraci\u00f3 als valors per defecte", + "button": "Restableix als valors per defecte" + }, + "export_settings": { + "label": "Exporta la configuraci\u00f3", + "description": "Baixeu la configuraci\u00f3 en format JSON", + "button": "Exporta" + }, + "import_settings": { + "label": "Importa la configuraci\u00f3", + "description": "Carregueu la configuraci\u00f3 des d'un fitxer JSON", + "button": "Importa" + }, + "about": { + "title": "Bulwark Webmail" + } + }, + "sidebar_apps": { + "title": "Aplicacions de la barra lateral", + "description": "Gestioneu aplicacions i enllaços personalitzats a la barra lateral", + "keep_loaded": "Mantén les aplicacions carregades", + "keep_loaded_description": "Manté les aplicacions incrustades en execució en segon pla en canviar entre elles per evitar recarregar-les", + "manage_title": "Aplicacions personalitzades", + "manage_description": "Afegiu, editeu o elimineu aplicacions personalitzades de la barra lateral" + }, + "contacts": { + "title": "Contactes", + "description": "Importeu i exporteu els contactes", + "group_by_letter_label": "Agrupa per lletra inicial", + "group_by_letter_description": "Mostra capçaleres de secció alfabètiques a la llista de contactes", + "import_label": "Importa contactes", + "import_description": "Importeu contactes des d'un fitxer vCard (.vcf)", + "export_label": "Exporta contactes", + "export_description": "Exporteu tots els contactes com a fitxer vCard (.vcf)", + "manage_title": "Llibretes d'adreces", + "manage_description": "Canvieu el nom de les llibretes d'adreces", + "no_address_books": "No s'ha trobat cap llibreta d'adreces", + "categories_title": "Categories", + "categories_description": "Canvieu el nom de les categories de contactes", + "no_categories": "No s'ha trobat cap categoria" + }, + "downloads": { + "title": "Baixades", + "description": "Personalitzeu com es nomenen els correus i fitxers adjunts baixats.", + "reset": "Restaura el valor per defecte", + "preview": "Previsualització:", + "email_template": { + "label": "Nom del fitxer de correu (.eml)", + "description": "Plantilla utilitzada en exportar un correu o arrossegar-lo al sistema de fitxers. L'extensió .eml s'afegeix automàticament." + }, + "attachment_template": { + "label": "Nom del fitxer adjunt", + "description": "Plantilla utilitzada en baixar o arrossegar un fitxer adjunt. Si ometeu «{filename}» i «{ext}», es conserva l'extensió original." + }, + "bundle_template": { + "label": "Nom del fitxer .zip de diversos correus", + "description": "Plantilla utilitzada en arrossegar o baixar diversos correus seleccionats com a un únic fitxer .zip. L'extensió .zip s'afegeix automàticament." + }, + "spaces": { + "label": "Espais", + "description": "Substitueix els espais del nom del fitxer resultant per un altre caràcter.", + "keep": "Mantén els espais", + "underscore": "Substitueix per _", + "dash": "Substitueix per -" + }, + "lowercase": { + "label": "Minúscules", + "description": "Força que tot el nom del fitxer estigui en minúscules." + }, + "strip_diacritics": { + "label": "Elimina els diacrítics", + "description": "Converteix les lletres accentuades als seus equivalents ASCII (ä → a, é → e). Útil per a eines que malmeten els noms de fitxer Unicode." + }, + "collapse_separators": { + "label": "Redueix els separadors repetits", + "description": "Redueix seqüències d'espais, guions baixos o guions a un sol caràcter." + }, + "after_export": { + "label": "Després d'exportar", + "description": "Opcionalment, moveu el correu després d'exportar-lo com a .eml.", + "keep": "Mantén-lo a la bústia", + "archive": "Mou a l'arxiu", + "trash": "Mou a la paperera" + } + }, + "filters": { + "title": "Filtres de correu", + "description": "Creeu regles per ordenar, etiquetar i gestionar automàticament els correus entrants", + "add_rule": "Afegeix una regla", + "no_rules": "Cap regla de filtre", + "no_rules_description": "Creeu regles per organitzar automàticament els correus entrants", + "vacation_active": "La resposta automàtica està activa", + "vacation_active_description": "La resposta automàtica està activada per als missatges entrants", + "vacation_configure": "Configura", + "edit_rule": "Edita la regla", + "new_rule": "Regla nova", + "delete_rule": "Suprimeix la regla", + "delete_confirm": "Segur que voleu suprimir aquesta regla?", + "enable": "Activa", + "disable": "Desactiva", + "raw_editor": "Editor Sieve en brut", + "raw_editor_warning": "Editar l'script Sieve en brut pot trencar l'edició visual de regles. Els canvis fets aquí substitueixen el constructor visual.", + "validate": "Valida", + "validation_success": "L'script és vàlid", + "validation_error": "L'script té errors", + "save": "Desa les regles", + "saving": "Desant...", + "saved": "Filtres desats correctament", + "save_failed": "No s'han pogut desar els filtres", + "loading": "Carregant els filtres...", + "not_supported": "El servidor de correu no admet filtres de correu.", + "rule_name": "Nom de la regla", + "rule_name_placeholder": "p. ex., Ordena els butlletins", + "match_all": "Coincideix amb TOTES les condicions", + "match_any": "Coincideix amb QUALSEVOL condició", + "conditions": "Condicions", + "add_condition": "Afegeix una condició", + "actions": "Accions", + "add_action": "Afegeix una acció", + "stop_processing": "Atura el processament de les regles següents", + "attachment_type_placeholder": "p. ex. pdf, doc, jpg", + "value_placeholder_multi": "Valor (múltiples separats per comes)", + "condition_fields": { + "attachment": "Fitxer adjunt", + "from": "De", + "to": "Per a", + "cc": "CC", + "subject": "Assumpte", + "header": "Capçalera personalitzada", + "size": "Mida", + "body": "Cos" + }, + "comparators": { + "has_any": "és present", + "has_type": "del tipus", + "contains": "conté", + "not_contains": "no conté", + "is": "és exactament", + "not_is": "no és", + "starts_with": "comença per", + "ends_with": "acaba amb", + "matches": "coincideix amb el patró", + "greater_than": "és més gran que", + "less_than": "és menys que" + }, + "action_types": { + "move": "Mou a una carpeta", + "copy": "Copia a una carpeta", + "forward": "Reenvia a", + "mark_read": "Marca com a llegit", + "star": "Marca el missatge amb estrella", + "add_label": "Afegeix una etiqueta", + "discard": "Descarta (suprimeix silenciosament)", + "reject": "Rebutja amb un missatge", + "keep": "Mantén a la safata d'entrada", + "stop": "Atura el processament" + }, + "move_to_folder": "Selecciona una carpeta", + "copy_to_folder": "Selecciona una carpeta", + "forward_to": "Reenvia a l'adreça electrònica", + "forward_placeholder": "correu@exemple.com", + "reject_message": "Missatge de rebuig", + "reject_placeholder": "El vostre correu ha estat rebutjat", + "label_name": "Nom de l'etiqueta", + "label_placeholder": "Selecciona una etiqueta", + "header_name": "Nom de la capçalera", + "header_placeholder": "p. ex., X-Mailing-List", + "size_bytes": "Mida en bytes", + "size_placeholder": "p. ex., 1000000", + "system_managed": "Regla gestionada pel sistema", + "opaque_warning": "Aquest script s'ha editat fora del constructor visual. Només està disponible l'edició Sieve en brut.", + "open_sieve_editor": "Obre l'editor Sieve en brut", + "fetch_error": "No s'han pogut carregar els filtres", + "expanded_view": "Vista ampliada", + "expanded_view_description": "Mostra les regles de filtre amb blocs detallats de condicions i accions", + "if": "Si", + "then": "Aleshores", + "match_all_conditions": "totes coincideixen", + "match_any_condition": "alguna coincideix", + "and": "i", + "or": "o", + "cancel": "Cancel·la", + "confirm_delete": "Suprimeix", + "rule_list": "Regles de filtre", + "drag_to_reorder": "Arrossegueu per reordenar", + "match_type": "Tipus de coincidència", + "reset_to_visual": "Torna al constructor visual", + "reset_warning": "Això descartarà l'script actual i començarà de nou.", + "confirm_reset": "Reinicia", + "validation_empty_name": "El nom de la regla és obligatori", + "validation_empty_conditions": "Cal com a mínim una condició amb un valor", + "validation_empty_actions": "Cal com a mínim una acció", + "templates_section": "Comença des d'una plantilla", + "template_newsletters": "Mou els butlletins a una carpeta", + "template_receipts": "Arxiva automàticament els rebuts", + "template_important": "Marca els correus importants", + "template_notifications": "Filtra les notificacions", + "sieve_editor": { + "title": "Editor d'scripts Sieve", + "warning": "Editar l'script Sieve en brut pot trencar l'edició visual de regles. Els canvis fets aquí substitueixen el constructor visual.", + "script_content": "Script Sieve", + "valid": "L'script és vàlid", + "invalid": "L'script té errors", + "save_warning": "En desar se sobreescriuran totes les regles visuals. Això no es pot desfer. Torneu a fer clic a Desa per confirmar-ho.", + "validating": "Validant...", + "validate": "Valida", + "cancel": "Cancel·la", + "save": "Desa", + "confirm_save": "Confirma el desament", + "validation_failed": "Ha fallat la sol·licitud de validació" + }, + "rule_summary": { + "conditions_count": "{count, plural, one {# condició} other {# condicions}}", + "actions_count": "{count, plural, one {# acció} other {# accions}}" + }, + "origin_external": "Extern", + "managed_by_tooltip": "Gestionat per {source}. Editeu-ho en aquesta aplicació, o utilitzeu l'editor Sieve en brut." + }, + "templates": { + "title": "Plantilles de correu", + "description": "Creeu plantilles de correu reutilitzables amb variables d'espai reservat", + "add": "Plantilla nova", + "edit": "Edita la plantilla", + "name": "Nom de la plantilla", + "name_placeholder": "p. ex., Correu de seguiment", + "category": "Categoria", + "category_placeholder": "p. ex., Feina, Personal", + "subject": "Assumpte", + "subject_placeholder": "Línia d'assumpte del correu", + "body": "Cos", + "body_placeholder": "Contingut del cos del correu...", + "recipients_placeholder": "correu@exemple.com", + "identity": "Envia com a", + "default_identity": "Identitat predeterminada", + "favorite": "Preferit", + "cancel": "Cancel·la", + "create": "Crea", + "update": "Actualitza", + "confirm_delete": "Suprimeix", + "no_templates": "Encara no hi ha plantilles", + "manage": "Gestiona les plantilles", + "count": "{count, plural, one {# plantilla} other {# plantilles}}", + "export_import": "Exporta i importa", + "export_import_description": "Feu una còpia de seguretat de les plantilles o transferiu-les a un altre dispositiu", + "export": "Exporta", + "import": "Importa", + "validation": { + "empty": "El nom de la plantilla és obligatori", + "too_long": "El nom de la plantilla ha de tenir com a màxim 200 caràcters" + } + }, + "files": { + "display": { + "title": "Visualització", + "description": "Configureu com es mostren els fitxers i les carpetes" + }, + "folder_layout": { + "label": "Navegació de carpetes", + "description": "Trieu com es mostren les carpetes: incrustades amb els fitxers o en un arbre a la barra lateral", + "inline": "Incrustades", + "sidebar": "Barra lateral" + }, + "default_view": { + "label": "Vista predeterminada", + "description": "Trieu entre disposició de quadrícula i de llista", + "list": "Llista", + "grid": "Quadrícula" + }, + "default_sort": { + "label": "Ordenació predeterminada", + "description": "Trieu l'ordenació predeterminada dels fitxers", + "name": "Nom", + "size": "Mida", + "modified": "Modificat" + }, + "sort_direction": { + "label": "Direcció de l'ordenació", + "description": "Trieu ordre ascendent o descendent", + "ascending": "Ascendent", + "descending": "Descendent" + }, + "icons": { + "title": "Icones", + "description": "Configureu l'aparença de les icones de fitxer" + }, + "show_icons": { + "label": "Mostra les icones de fitxer", + "description": "Mostra icones al costat dels fitxers i les carpetes" + }, + "colored_icons": { + "label": "Icones de colors", + "description": "Utilitza icones de colors en lloc de monocromes" + }, + "show_thumbnails": { + "label": "Mostra les miniatures", + "description": "Mostra previsualitzacions d'imatge en lloc d'icones per als fitxers d'imatge" + }, + "behavior": { + "title": "Comportament", + "description": "Configureu el comportament del navegador de fitxers" + }, + "show_hidden": { + "label": "Mostra els fitxers ocults", + "description": "Mostra els fitxers i les carpetes que comencen per un punt" + }, + "preview": { + "label": "Previsualització" + } + } + }, + "errors": { + "page_error_title": "S'ha produït un error", + "page_error_description": "S'ha produït un error inesperat. Torneu-ho a provar o torneu a la pàgina d'inici.", + "sidebar_error": "No s'han pogut carregar les bústies", + "email_list_error": "No s'han pogut carregar els correus", + "viewer_error_title": "No s'ha pogut mostrar el correu", + "viewer_error_description": "S'ha produït un problema en renderitzar aquest correu. És possible que contingui contingut no admès.", + "composer_error": "No s'ha pogut carregar el redactor", + "settings_error_title": "Configuració no disponible", + "settings_error_description": "No s'ha pogut carregar la configuració. És possible que les preferències no es desin.", + "try_again": "Torna-ho a provar", + "reload": "Torna a carregar", + "reload_emails": "Torna a carregar els correus", + "reload_settings": "Torna a carregar la configuració", + "retry": "Reintenta", + "go_home": "Vés a la safata d'entrada" + }, + "context_menu": { + "reply": "Respon", + "reply_all": "Respon a tots", + "forward": "Reenvia", + "mark_read": "Marca com a llegit", + "mark_unread": "Marca com a no llegit", + "star": "Marca amb estrella", + "unstar": "Treu l'estrella", + "pin": "Fixa", + "unpin": "Desfixa", + "move_to": "Mou a...", + "archive": "Arxiva", + "delete": "Suprimeix", + "mark_as_spam": "Denuncia com a brossa", + "not_spam": "No és brossa", + "color_tag": "Etiqueta", + "remove_color": "Elimina l'etiqueta", + "items_selected": "{count} correus seleccionats", + "edit_draft": "Edita l'esborrany", + "cancel_scheduled_send": "Cancel·la l'enviament", + "reschedule_send": "Reprograma", + "cancel_and_edit": "Cancel·la i edita", + "cancel_and_compose_again": "Cancel·la i torna a redactar" + }, + "mailbox_context_menu": { + "mark_folder_read": "Marca la carpeta com a llegida", + "mark_folder_tree_read": "Marca la carpeta i les subcarpetes com a llegides", + "mark_all_folders_read": "Marca totes les carpetes com a llegides", + "new_subfolder": "Subcarpeta nova...", + "new_folder": "Carpeta nova...", + "rename": "Canvia el nom...", + "import_email": "Importa .eml o .zip...", + "empty_folder": "Buida la carpeta", + "empty_folder_generic": "Buida la carpeta", + "delete_folder": "Suprimeix la carpeta", + "refresh": "Actualitza", + "mark_all_confirm_title": "Marca totes les carpetes com a llegides", + "mark_all_confirm_message": "Voleu marcar tots els missatges no llegits del vostre compte personal com a llegits?", + "delete_confirm_title": "Suprimeix la carpeta", + "delete_confirm_message": "Voleu suprimir definitivament la carpeta «{name}»? Aquesta acció no es pot desfer.", + "prompt_new_subfolder": "Introduïu un nom per a la subcarpeta nova.", + "prompt_new_folder": "Introduïu un nom per a la carpeta nova.", + "prompt_rename": "Introduïu un nom nou per a aquesta carpeta.", + "placeholder_folder_name": "Nom de la carpeta", + "create": "Crea", + "rename_confirm": "Canvia el nom", + "toast_marked_read": "Carpeta marcada com a llegida", + "toast_marked_read_count": "{count, plural, one {S'ha marcat 1 missatge com a llegit} other {S'han marcat # missatges com a llegits}}", + "toast_already_read": "No hi ha missatges no llegits", + "toast_marked_all_read": "Totes les carpetes marcades com a llegides", + "toast_emptied": "Carpeta buidada", + "toast_folder_created": "Carpeta creada", + "toast_folder_renamed": "Nom de la carpeta canviat", + "toast_folder_deleted": "Carpeta suprimida", + "toast_error_mark_read": "No s'ha pogut marcar com a llegit", + "toast_error_empty": "No s'ha pogut buidar la carpeta", + "toast_error_create": "No s'ha pogut crear la carpeta", + "toast_error_rename": "No s'ha pogut canviar el nom de la carpeta", + "toast_error_delete": "No s'ha pogut suprimir la carpeta", + "toast_error_delete_has_children": "La carpeta té subcarpetes. Elimineu-les primer.", + "toast_error_delete_has_email": "La carpeta no és buida. Buideu-la primer." + }, + "shortcuts": { + "title": "Dreceres de teclat", + "tip": "Premeu ? en qualsevol moment per mostrar aquesta ajuda", + "sections": { + "navigation": "Navegació", + "actions": "Accions de correu", + "global": "Global", + "threads": "Fils", + "composer": "Redactor" + }, + "navigation": { + "next_email": "Correu següent", + "previous_email": "Correu anterior", + "open_email": "Obre el correu", + "close_email": "Tanca / Desselecciona" + }, + "actions": { + "reply": "Respon", + "reply_all": "Respon a tots", + "forward": "Reenvia", + "star": "Commuta l'estrella", + "archive": "Arxiva", + "delete": "Suprimeix", + "mark_unread": "Marca com a no llegit", + "mark_read": "Marca com a llegit", + "toggle_spam": "Denuncia com a brossa / No és brossa" + }, + "global": { + "compose": "Redacta un correu nou", + "search": "Enfoca la cerca", + "help": "Mostra les dreceres", + "refresh": "Actualitza els correus", + "select_all": "Selecciona-ho tot" + }, + "threads": { + "expand_collapse": "Desplega/replega el fil" + }, + "composer": { + "send": "Envia el correu", + "schedule_send": "Programa l'enviament", + "template_picker": "Obre el selector de plantilles" + } + }, + "threads": { + "messages_one": "{count} missatge", + "messages_other": "{count} missatges", + "messages_tooltip": "{count, plural, one {# missatge en aquesta conversa} other {# missatges en aquesta conversa}}", + "expand": "Desplega la conversa", + "collapse": "Replega la conversa", + "loading": "Carregant la conversa...", + "mark_read": "Marca la conversa com a llegida", + "mark_unread": "Marca la conversa com a no llegida", + "archive": "Arxiva la conversa", + "delete": "Suprimeix la conversa", + "star": "Marca la conversa amb estrella", + "unstar": "Treu l'estrella de la conversa", + "toggle_thread": "Commuta el fil" + }, + "identities": { + "modal_title": "Gestiona les identitats d'enviament", + "create_new": "Crea una identitat nova", + "edit_identity": "Edita la identitat", + "delete_confirm": "Voleu suprimir aquesta identitat? Això no es pot desfer.", + "cannot_delete": "Aquesta identitat no es pot suprimir", + "primary_identity": "Principal", + "set_as_primary": "Estableix com a principal", + "no_identities": "No s'ha trobat cap identitat", + "display": { + "reply_to": "Respon a:", + "bcc": "CCO:", + "signature": "Signatura:", + "preview": "Previsualització:" + }, + "validation_errors": { + "invalid_emails": "Correus electrònics no vàlids: {emails}", + "unknown_error": "Error desconegut" + }, + "form": { + "name_label": "Nom mostrat", + "name_placeholder": "p. ex., Correu de feina, Personal", + "name_required": "El nom és obligatori", + "email_label": "Adreça electrònica", + "email_placeholder": "el.vostre.correu@exemple.com", + "email_required": "El correu electrònic és obligatori", + "email_invalid": "Introduïu una adreça electrònica vàlida", + "email_immutable": "L'adreça electrònica no es pot canviar després de crear-la", + "reply_to_label": "Respon a (opcional)", + "reply_to_placeholder": "diferent@exemple.com", + "bcc_label": "CCO automàtica (opcional)", + "bcc_placeholder": "arxiu@exemple.com", + "text_signature_label": "Signatura de text", + "html_signature_label": "Signatura HTML", + "signature_byte_counter": "{bytes} / {max} bytes", + "signature_byte_limit_reached": "S'ha arribat al límit del servidor", + "save": "Desa la identitat", + "cancel": "Cancel·la", + "creating": "Creant...", + "updating": "Actualitzant..." + }, + "sub_address": { + "button_tooltip": "Utilitza subadreça", + "popover_title": "Afegeix una etiqueta de subadreça", + "tag_input_placeholder": "Introduïu una etiqueta (p. ex., compres)", + "preview_label": "Previsualització:", + "recent_tags": "Etiquetes recents", + "suggested_tags": "Suggerides", + "use_address": "Utilitza aquesta adreça", + "invalid_tag": "L'etiqueta només pot contenir caràcters alfanumèrics i guions", + "tag_too_long": "L'etiqueta ha de tenir com a màxim 30 caràcters", + "help_text": "Els correus enviats a user{delimiter}tag@domain.com arribaran a la vostra safata d'entrada", + "validation": { + "empty": "L'etiqueta no pot estar buida", + "too_long": "L'etiqueta ha de tenir com a màxim {max} caràcters", + "invalid_chars": "L'etiqueta només pot contenir lletres, números i guions" + } + }, + "badge": { + "sent_via": "via", + "sub_address_tag": "Enviat mitjançant la subadreça: {tag}", + "identity_name": "Enviat mitjançant la identitat: {name}", + "identity_short": "via {name}", + "subaddress_tag": "+{tag}" + }, + "delete_button": "Suprimeix", + "delete_confirm_title": "Suprimeix la identitat" + }, + "templates": { + "picker_title": "Trieu una plantilla", + "search_placeholder": "Cerca plantilles...", + "section_favorites": "Preferides", + "section_recent": "Recents", + "section_uncategorized": "Altres", + "no_templates": "Encara no hi ha plantilles", + "no_results": "No s'ha trobat cap plantilla", + "fill_placeholders": "Emplena els valors de les variables", + "enter_value": "Introduïu un valor...", + "preview": "Previsualització", + "insert_with_values": "Insereix amb els valors", + "insert_raw": "Insereix sense processar", + "copy_suffix": "(còpia)", + "placeholder": "Variable", + "placeholders": { + "recipient_name": "Nom del destinatari", + "company": "Nom de l'empresa", + "date": "Data actual", + "day_of_week": "Dia de la setmana", + "sender_name": "El vostre nom" + } + }, + "contacts": { + "title": "Contactes", + "search_placeholder": "Cerca contactes...", + "create_new": "Contacte nou", + "no_category": "Sense categoria", + "rename_category": "Canvia el nom de la categoria", + "category_name_label": "Nom de la categoria", + "category_renamed": "Nom de la categoria canviat", + "category_rename_failed": "No s'ha pogut canviar el nom de la categoria", + "category_added": "Contacte afegit a {name}", + "category_added_plural": "{count} contactes afegits a {name}", + "empty_state": "Encara no hi ha contactes", + "empty_state_title": "Encara no hi ha contactes", + "empty_state_subtitle": "Creeu el primer contacte o importeu-lo des d'un fitxer vCard", + "empty_search": "Cap contacte coincideix amb la cerca", + "empty_search_hint": "Proveu amb un altre terme de cerca", + "empty_filtered": "Cap contacte coincideix amb els filtres", + "empty_filtered_hint": "Proveu d'ajustar o netejar els filtres", + "clear_search": "Neteja la cerca", + "import_vcard": "Importa vCard", + "delete_confirm_title": "Suprimeix el contacte", + "delete_confirm": "Segur que voleu suprimir aquest contacte?", + "local_mode": "Els contactes es desen localment (el servidor no admet JMAP Contacts)", + "back_to_contacts": "Torna als contactes", + "back_to_email": "Torna al correu", + "open_categories": "Obre les categories", + "tabs": { + "all": "Tots", + "groups": "Grups" + }, + "shared": { + "title": "Compartit" + }, + "address_books": { + "create": "Llibreta d'adreces nova", + "created": "Llibreta d'adreces creada", + "create_failed": "No s'ha pogut crear la llibreta d'adreces", + "title": "Les meves llibretes d'adreces", + "shared_prefix": "Compartida: {name}", + "moved": "Contacte mogut a {name}", + "moved_plural": "{count} contactes moguts a {name}", + "move_failed": "No s'ha pogut moure el contacte", + "address_book": "Llibreta d'adreces", + "rename": "Canvia el nom de la llibreta d'adreces", + "name_label": "Nom de la llibreta d'adreces", + "renamed": "Nom de la llibreta d'adreces canviat", + "rename_failed": "No s'ha pogut canviar el nom de la llibreta d'adreces", + "default": "Predeterminada", + "manage": "Gestiona les llibretes d'adreces", + "share": "Comparteix la llibreta d'adreces", + "new_contact_in_book": "Contacte nou en aquesta llibreta d'adreces", + "delete": "Suprimeix la llibreta d'adreces", + "confirm_delete": "Voleu suprimir «{name}»? S'eliminaran tots els contactes d'aquesta llibreta d'adreces.", + "deleted": "Llibreta d'adreces suprimida", + "delete_failed": "No s'ha pogut suprimir la llibreta d'adreces" + }, + "detail": { + "emails": "Adreces electròniques", + "phones": "Números de telèfon", + "organizations": "Organitzacions", + "addresses": "Adreces", + "notes": "Notes", + "titles": "Càrrecs i funcions", + "online_services": "Serveis en línia", + "anniversaries": "Commemoracions", + "personal_info": "Informació personal", + "languages": "Idiomes", + "categories": "Categories", + "related_contacts": "Contactes relacionats", + "crypto_keys": "Claus criptogràfiques", + "cert_issuer": "Emissor", + "cert_expires": "Caduca", + "cert_expired": "Caducat", + "cert_fingerprint": "Empremta digital", + "cert_algorithm": "Algorisme", + "import_to_smime": "Importa a S/MIME", + "cert_already_imported": "Ja importat a S/MIME", + "cert_imported": "Certificat importat a l'emmagatzematge S/MIME", + "cert_import_failed": "No s'ha pogut importar el certificat", + "no_contact_selected": "Seleccioneu un contacte per veure'n els detalls", + "compose_email": "Redacta un correu", + "copy_email": "Copia el correu electrònic", + "copy_phone": "Copia el número de telèfon", + "copy_url": "Copia l'URL", + "copied": "Copiat al porta-retalls", + "copy_failed": "No s'ha pogut copiar al porta-retalls", + "created": "Creat", + "updated": "Última actualització", + "timezone": "Fus horari", + "anniversary_birth": "Aniversari", + "anniversary_death": "Traspàs", + "anniversary_wedding": "Commemoració", + "anniversary_other": "Altres", + "personal_expertise": "Especialitat", + "personal_hobby": "Afició", + "personal_interest": "Interès", + "personal_other": "Altres", + "gender": "Gènere", + "gender_masculine": "Home", + "gender_feminine": "Dona", + "gender_other": "Altres", + "gender_none": "No aplicable", + "gender_unknown": "Desconegut", + "calendar": "Calendari", + "calendar_uri": "URL del calendari", + "scheduling_uri": "URL de programació", + "freebusy_uri": "URL de disponibilitat", + "section_contact": "Dades de contacte", + "section_work": "Feina", + "section_personal": "Personal", + "email_default_label": "Correu electrònic", + "phone_default_label": "Telèfon", + "address_default_label": "Adreça", + "online_service_default_label": "En línia", + "organization_label": "Organització", + "title_label": "Càrrec", + "role_label": "Funció", + "language_label": "Idioma", + "related_default_label": "Relacionat", + "more_actions": "Més accions", + "age_years": "{count, plural, one {1 any} other {# anys}}", + "years_since": "{count, plural, one {1 any} other {# anys}}" + }, + "activity": { + "recent_emails": "Correus recents", + "upcoming_events": "Propers esdeveniments", + "no_emails": "Cap correu recent", + "no_events": "Cap esdeveniment proper", + "no_subject": "(Sense assumpte)", + "no_title": "(Sense títol)", + "load_failed": "No s'ha pogut carregar", + "unknown_sender": "Remitent desconegut", + "all_day": "Tot el dia" + }, + "form": { + "create_title": "Contacte nou", + "edit_title": "Edita el contacte", + "section_address_book": "Directori", + "select_address_book": "Seleccioneu un directori...", + "section_identity": "Nom i identitat", + "section_work": "Feina i organització", + "prefix": "Prefix", + "prefix_placeholder": "Dr., Sr., Sra.", + "given_name": "Nom", + "middle_name": "Segon nom", + "surname": "Cognoms", + "suffix": "Sufix", + "suffix_placeholder": "Jr., Sr., III", + "nickname": "Sobrenom", + "nickname_placeholder": "Sobrenom", + "email": "Correu electrònic", + "email_placeholder": "correu@exemple.com", + "phone": "Telèfon", + "phone_placeholder": "+1 234 567 890", + "phone_type": "Tipus", + "phone_voice": "Veu", + "phone_cell": "Mòbil", + "phone_fax": "Fax", + "phone_pager": "Buscapersones", + "phone_video": "Vídeo", + "phone_text": "SMS", + "organization": "Organització", + "organization_placeholder": "Nom de l'empresa", + "department": "Departament", + "department_placeholder": "Departament", + "job_title": "Càrrec", + "job_title_placeholder": "p. ex., Enginyer/a de programari", + "role": "Funció", + "role_placeholder": "p. ex., Cap d'equip", + "addresses": "Adreces", + "add_address": "Afegeix una adreça", + "street": "Carrer", + "city": "Ciutat", + "region": "Estat / Regió", + "postcode": "Codi postal", + "country": "País", + "online_services": "Serveis en línia", + "add_online_service": "Afegeix un servei en línia", + "url_placeholder": "https://...", + "service_placeholder": "Servei", + "anniversaries": "Commemoracions", + "add_anniversary": "Afegeix una data", + "anniversary_birth": "Aniversari", + "anniversary_wedding": "Commemoració", + "anniversary_death": "Traspàs", + "anniversary_other": "Altres", + "personal_info": "Informació personal", + "add_personal_info": "Afegeix una entrada", + "personal_info_placeholder": "p. ex., Fotografia", + "personal_expertise": "Especialitat", + "personal_hobby": "Afició", + "personal_interest": "Interès", + "personal_other": "Altres", + "level": "Nivell", + "level_high": "Alt", + "level_medium": "Mitjà", + "level_low": "Baix", + "categories": "Categories", + "categories_placeholder": "p. ex., Família, Amics, Companys", + "categories_hint": "Escriviu per cercar o afegir categories", + "category_add": "Afegeix", + "note": "Notes", + "note_placeholder": "Afegiu una nota...", + "gender": "Gènere", + "gender_sex": "Sexe", + "gender_male": "Home", + "gender_female": "Dona", + "gender_other": "Altres", + "gender_none": "No aplicable", + "gender_unknown": "Desconegut", + "gender_identity": "Identitat de gènere", + "gender_identity_placeholder": "Identitat de gènere...", + "calendar": "Calendari", + "calendar_uri": "URL del calendari", + "scheduling_uri": "URL de programació", + "freebusy_uri": "URL de disponibilitat", + "context_work": "Feina", + "context_private": "Privat", + "add_email": "Afegeix un correu electrònic", + "add_phone": "Afegeix un telèfon", + "save": "Desa", + "cancel": "Cancel·la", + "creating": "Creant...", + "updating": "Actualitzant...", + "name_required": "Cal com a mínim un nom o un cognom", + "email_invalid": "Introduïu una adreça electrònica vàlida", + "email_error_inline": "Format de correu electrònic no vàlid", + "save_failed": "No s'ha pogut desar el contacte", + "delete": "Suprimeix", + "upload_photo": "Puja una foto", + "remove_photo": "Elimina la foto", + "photo_hint": "JPG o PNG, fins a 10 MB. Es redimensionarà.", + "photo_too_large": "La imatge és massa gran (màxim 10 MB)", + "photo_invalid": "Fitxer d'imatge no vàlid", + "change_photo": "Canvia" + }, + "groups": { + "create": "Grup nou", + "edit": "Edita el grup", + "empty": "Encara no hi ha grups", + "delete_confirm_title": "Suprimeix el grup", + "delete_confirm": "Segur que voleu suprimir aquest grup?", + "name_label": "Nom del grup", + "name_placeholder": "p. ex., Equip, Família", + "name_required": "El nom del grup és obligatori", + "save_failed": "No s'ha pogut desar el grup", + "members_label": "Membres", + "search_members": "Cerca contactes per afegir...", + "no_members": "Cap membre en aquest grup", + "member_count": "{count, plural, =0 {Cap membre} one {1 membre} other {# membres}}", + "send_email": "Envia un correu al grup", + "send_email_to": "Per a", + "send_email_cc": "CC", + "send_email_bcc": "CCO", + "no_member_emails": "Aquest grup no té cap membre amb adreça electrònica." + }, + "import": { + "title": "Importa contactes", + "drop_hint": "Feu clic per seleccionar un fitxer vCard", + "file_types": "Fitxers .vcf o .vcard", + "no_contacts": "No s'ha trobat cap contacte al fitxer", + "parse_error": "No s'ha pogut analitzar el fitxer vCard", + "found": "{count, plural, one {1 contacte trobat} other {# contactes trobats}}", + "duplicate": "Duplicat", + "select_all": "Selecciona-ho tot", + "deselect_all": "Desselecciona-ho tot", + "selected": "{count, plural, one {1 seleccionat} other {# seleccionats}}", + "import_button": "Importa", + "importing": "Important...", + "success": "{count, plural, one {1 contacte importat} other {# contactes importats}}", + "failed": "No s'ha pogut importar", + "close": "Tanca", + "file_too_large": "El fitxer és massa gran (màxim 5 MB)" + }, + "export": { + "title": "Exporta contactes", + "success": "{count, plural, one {1 contacte exportat} other {# contactes exportats}}" + }, + "bulk": { + "selected": "{count, plural, one {1 seleccionat} other {# seleccionats}}", + "select_all": "Selecciona-ho tot", + "delete": "Suprimeix", + "delete_confirm_title": "Suprimeix els contactes", + "delete_confirm": "Voleu suprimir {count, plural, one {1 contacte} other {# contactes}}?", + "deleted": "{count, plural, one {1 contacte suprimit} other {# contactes suprimits}}", + "add_to_group": "Afegeix al grup", + "choose_group": "Trieu un grup", + "adding_contacts": "Afegint {count, plural, one {1 contacte} other {# contactes}}", + "added_to_group": "Contactes afegits al grup", + "export": "Exporta", + "clear": "Neteja la selecció" + }, + "toast": { + "created": "Contacte creat", + "updated": "Contacte actualitzat", + "deleted": "Contacte suprimit", + "error_create": "No s'ha pogut crear el contacte", + "error_update": "No s'ha pogut actualitzar el contacte", + "error_delete": "No s'ha pogut suprimir el contacte" + }, + "context_menu": { + "open": "Obre", + "edit": "Edita", + "send_email": "Envia un correu", + "add_to_group": "Afegeix al grup", + "export_vcard": "Exporta com a vCard", + "delete": "Suprimeix", + "call": "Truca", + "duplicate": "Duplica", + "print": "Imprimeix" + }, + "filters": { + "toggle": "Filtres", + "select": "Selecciona", + "clear": "Neteja", + "close": "Tanca", + "title": "Filtres avançats", + "organization": "Empresa", + "organization_placeholder": "p. ex. Acme Corp", + "job_title": "Càrrec", + "job_title_placeholder": "p. ex. Dissenyador/a", + "location": "Ubicació", + "location_placeholder": "Ciutat o país", + "email_domain": "Domini del correu", + "email_domain_placeholder": "exemple.com", + "birthday_month": "Aniversari a", + "any_month": "Qualsevol mes", + "has_email": "Té correu electrònic", + "has_phone": "Té telèfon", + "has_photo": "Té foto" + } + }, + "calendar": { + "title": "Calendari", + "back_to_email": "Torna al correu", + "back_to_month": "Torna al mes", + "my_calendars": "Calendaris", + "birthday_calendar": "Aniversaris", + "mini_calendar_change": "Feu clic per canviar de mes", + "views": { + "month": "Mes", + "week": "Setmana", + "day": "Dia", + "agenda": "Agenda", + "today": "Avui", + "month_hint": "Mes (m)", + "week_hint": "Setmana (w)", + "day_hint": "Dia (d)", + "agenda_hint": "Agenda (a)", + "tasks": "Tasques", + "tasks_hint": "Tasques (k)" + }, + "events": { + "create": "Crea un esdeveniment", + "edit": "Edita l'esdeveniment", + "delete": "Suprimeix l'esdeveniment", + "details": "Detalls de l'esdeveniment", + "no_events": "Cap esdeveniment", + "all_day": "Tot el dia", + "more": "+{count} més", + "no_title": "(Sense títol)", + "resize": "Redimensiona l'esdeveniment", + "duplicate": "Duplica", + "today_header": "Avui", + "tomorrow_header": "Demà", + "export_ics": "Exporta com a .ics", + "copy_title": "Copia el títol", + "copy_link": "Copia l'enllaç de la reunió", + "new_event": "Esdeveniment nou", + "new_all_day_event": "Esdeveniment de tot el dia nou", + "new_task": "Tasca nova", + "go_to_today": "Vés a avui" + }, + "detail": { + "add_note": "Afegiu una nota...", + "save_note": "Desa", + "note_saved": "Nota afegida", + "open_link": "Obre l'enllaç", + "meeting_link": "Enllaç de la reunió", + "tentative": "Provisional", + "cancelled": "Cancel·lat", + "delete_confirm": "Voleu suprimir aquest esdeveniment?" + }, + "form": { + "title": "Títol", + "description": "Descripció", + "location": "Ubicació", + "meeting_link": "Enllaç de la reunió", + "start_date": "Data d'inici", + "end_date": "Data de finalització", + "start_time": "Hora d'inici", + "end_time": "Hora de finalització", + "all_day_event": "Esdeveniment de tot el dia", + "calendar_select": "Calendari", + "save": "Desa", + "cancel": "Cancel·la", + "delete_confirm": "Segur que voleu suprimir aquest esdeveniment?", + "color": "Color" + }, + "participants": { + "title": "Participants", + "add": "Afegeix un participant", + "organizer": "Organitzador", + "attendee": "Assistent", + "accepted": "Acceptat", + "declined": "Rebutjat", + "tentative": "Provisional", + "needs_action": "Cal resposta", + "remove": "Elimina", + "edit": "Edita", + "email_placeholder": "Afegiu una adreça electrònica o cerqueu contactes", + "send_invitations": "Envia invitacions als participants", + "status_summary": "{accepted} acceptats, {pending} pendents", + "invited_by": "Invitació de {name}", + "respond_below": "Responeu utilitzant els botons de sota", + "rsvp_label": "La vostra resposta", + "cancel_notification": "S'informarà els participants de la cancel·lació", + "you_organizer": "Sou l'organitzador", + "you_attendee": "Sou un assistent", + "no_participants": "Cap participant", + "count": "{count, plural, one {# participant} other {# participants}}" + }, + "recurrence": { + "title": "Repetició", + "none": "No es repeteix", + "daily": "Diàriament", + "weekly": "Setmanalment", + "monthly": "Mensualment", + "yearly": "Anualment", + "every_n_days": "Cada {count} dies", + "every_n_weeks": "Cada {count} setmanes", + "every_n_months": "Cada {count} mesos", + "until": "Fins a", + "occurrences": "{count} repeticions", + "custom": "Personalitzat…", + "edit_custom": "Edita la repetició personalitzada", + "every_n_years": "Cada {count} anys", + "on_days": "el {days}", + "on_day_n": "el dia {day}", + "on_the_nth": "el {nth} {day}", + "in_month": "al mes de {month}", + "nth_1": "primer", + "nth_2": "segon", + "nth_3": "tercer", + "nth_4": "quart", + "nth_last": "últim", + "editor_freq_day": "Dia", + "editor_freq_week": "Setmana", + "editor_freq_month": "Mes", + "editor_freq_year": "Any", + "editor_repeats_on": "Es repeteix el", + "editor_every": "Cada", + "editor_unit_days": "dia(dies)", + "editor_unit_weeks": "setmana(es)", + "editor_unit_months": "mes(os)", + "editor_unit_years": "any(s)", + "editor_on_day": "el dia", + "editor_on_the": "el", + "editor_in": "al", + "editor_ends": "Acaba", + "editor_never": "Mai", + "editor_ends_on": "El", + "editor_ends_after": "Després de", + "editor_occurrences": "repeticions" + }, + "recurrence_scope": { + "edit_title": "Edita l'esdeveniment periòdic", + "delete_title": "Suprimeix l'esdeveniment periòdic", + "description": "Aquest és un esdeveniment periòdic. Quins esdeveniments voleu modificar?", + "this_event": "Només aquest esdeveniment", + "this_and_future": "Aquest i els següents esdeveniments", + "all_events": "Tots els esdeveniments", + "cancel": "Cancel·la", + "save": "Desa", + "delete": "Suprimeix" + }, + "alerts": { + "title": "Recordatoris", + "none": "Cap recordatori", + "at_time": "A l'hora de l'esdeveniment", + "minutes_before": "{count, plural, one {# minut abans} other {# minuts abans}}", + "hours_before": "{count, plural, one {# hora abans} other {# hores abans}}", + "days_before": "{count, plural, one {# dia abans} other {# dies abans}}", + "weeks_before": "{count, plural, one {# setmana abans} other {# setmanes abans}}", + "unit_minutes_before": "minuts abans", + "unit_hours_before": "hores abans", + "unit_days_before": "dies abans", + "unit_weeks_before": "setmanes abans", + "add": "Afegeix un recordatori", + "remove": "Elimina el recordatori", + "amount": "Quantitat del recordatori", + "unit": "Unitat del recordatori" + }, + "settings": { + "title": "Configuració del calendari", + "default_view": "Vista predeterminada", + "week_starts_on": "La setmana comença el", + "time_format": "Format d'hora", + "default_calendar": "Calendari predeterminat", + "default_reminder": "Recordatori predeterminat", + "time_format_12h": "12 hores", + "time_format_24h": "24 hores", + "notifications_enabled": "Notificacions d'esdeveniments", + "notifications_enabled_desc": "Mostra avisos per als esdeveniments propers del calendari", + "notification_sound": "So de notificació", + "notification_sound_desc": "Reprodueix un so per als avisos del calendari", + "invitation_parsing": "Analitza les invitacions per correu", + "invitation_parsing_desc": "Detecta les invitacions de calendari als fitxers adjunts del correu i mostra les accions del calendari", + "show_time_in_month_view": "Mostra l'hora a la vista mensual", + "show_time_in_month_view_desc": "Mostra l'hora dels esdeveniments a la vista mensual del calendari. En pantalles petites, això mostra entrades completes d'esdeveniment en lloc de punts.", + "show_week_numbers": "Mostra els números de setmana", + "show_week_numbers_desc": "Mostra els números de setmana al minicalendari", + "enable_tasks": "Activa les tasques", + "enable_tasks_desc": "Mostra una vista de tasques al calendari per gestionar pendents", + "show_tasks_on_calendar": "Mostra les tasques al calendari", + "show_tasks_on_calendar_desc": "Mostra etiquetes de tasca a les vistes de dia i setmana del calendari", + "hover_preview": "Previsualització en passar el cursor", + "hover_preview_desc": "Mostra una finestra emergent de detalls en passar el cursor per sobre dels esdeveniments", + "hover_preview_instant": "Instantània", + "hover_preview_delay_500ms": "Retard de 0,5 segons", + "hover_preview_delay_1s": "Retard d'1 segon", + "hover_preview_delay_2s": "Retard de 2 segons", + "hover_preview_off": "Desactivada", + "show_birthday_calendar": "Calendari d'aniversaris dels contactes", + "show_birthday_calendar_desc": "Mostra un calendari virtual amb els aniversaris dels vostres contactes" + }, + "days": { + "monday": "Dilluns", + "tuesday": "Dimarts", + "wednesday": "Dimecres", + "thursday": "Dijous", + "friday": "Divendres", + "saturday": "Dissabte", + "sunday": "Diumenge", + "mon": "Dl", + "tue": "Dt", + "wed": "Dc", + "thu": "Dj", + "fri": "Dv", + "sat": "Ds", + "sun": "Dg" + }, + "months": { + "jan": "Gen", + "feb": "Feb", + "mar": "Mar", + "apr": "Abr", + "may": "Maig", + "jun": "Juny", + "jul": "Jul", + "aug": "Ag", + "sep": "Set", + "oct": "Oct", + "nov": "Nov", + "dec": "Des", + "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": "Esdeveniment creat", + "event_updated": "Esdeveniment actualitzat", + "event_deleted": "Esdeveniment suprimit", + "calendar_created": "Calendari creat", + "calendar_deleted": "Calendari suprimit", + "event_move_error": "No s'ha pogut moure l'esdeveniment", + "event_resize_error": "No s'ha pogut redimensionar l'esdeveniment", + "alert_title": "Esdeveniment proper", + "alert_now": "Comença ara", + "alert_in_minutes": "D'aquí a {count} min", + "invitation_sent": "Invitacions enviades", + "rsvp_updated": "Resposta actualitzada", + "rsvp_error": "No s'ha pogut actualitzar la resposta", + "event_duplicated": "Esdeveniment duplicat", + "event_error": "No s'ha pogut desar l'esdeveniment", + "task_due": "Tasca pendent", + "event_exported": "Esdeveniment exportat", + "title_copied": "Títol copiat", + "link_copied": "Enllaç copiat" + }, + "status": { + "loading_calendars": "Carregant els calendaris...", + "loading_events": "Carregant els esdeveniments..." + }, + "quick_create": { + "placeholder": "Títol del nou esdeveniment", + "aria_label": "Crea un esdeveniment ràpidament" + }, + "nav_prev": "Anterior", + "nav_next": "Següent", + "nav_open_menu": "Obre el menú", + "import": { + "title": "Importa un calendari", + "tab_file": "Fitxer", + "tab_url": "URL", + "select_file": "Selecciona un fitxer .ics", + "drop_file": "o deixeu anar el fitxer aquí", + "supported_formats": "Admet fitxers iCalendar (.ics)", + "url_description": "Introduïu l'URL d'un canal iCalendar (.ics) extern per importar-ne els esdeveniments.", + "url_placeholder": "https://example.com/calendar.ics", + "url_hint": "Admet URL de CalDAV i iCalendar (.ics)", + "fetch": "Obtén", + "invalid_url": "Introduïu un URL vàlid", + "url_fetch_failed": "No s'ha pogut obtenir el calendari des de l'URL", + "parsing": "Analitzant el fitxer del calendari...", + "parsed_events": "{count} esdeveniments trobats", + "no_events": "No s'ha trobat cap esdeveniment al fitxer", + "select_all": "Selecciona-ho tot", + "deselect_all": "Desselecciona-ho tot", + "target_calendar": "Importa al calendari", + "import_button": "Importa la selecció", + "importing": "Important els esdeveniments...", + "success": "{count} esdeveniments importats correctament", + "error": "No s'ha pogut importar el calendari", + "file_too_large": "El fitxer supera el límit de 10 MB", + "invalid_format": "Format de fitxer de calendari no vàlid" + }, + "webcal_action": { + "title": "Obre l'enllaç de calendari", + "description": "Com voleu utilitzar «{name}»?", + "import_title": "Importa una vegada", + "import_description": "Obté els esdeveniments ara i copia'ls a un dels vostres calendaris.", + "subscribe_title": "Subscriu-te", + "subscribe_description": "Mantén aquest calendari sincronitzat automàticament com a calendari separat.", + "cancel": "Cancel·la" + }, + "management": { + "title": "Gestió de calendaris", + "description": "Creeu, canvieu el nom i personalitzeu els calendaris. Feu clic dret a un calendari a la barra lateral per canviar-ne ràpidament el color.", + "name": "Nom", + "name_placeholder": "Nom del calendari", + "color": "Color", + "change_color": "Canvia el color", + "random_color": "Color aleatori nou", + "add_calendar": "Afegeix un calendari", + "edit": "Edita", + "delete": "Suprimeix", + "save": "Desa", + "create": "Crea", + "cancel": "Cancel·la", + "default": "Predeterminat", + "confirm_delete": "Voleu suprimir «{name}»? S'eliminaran tots els esdeveniments d'aquest calendari.", + "confirm_clear": "Voleu esborrar tots els esdeveniments de «{name}»? Això no es pot desfer.", + "set_default": "Estableix com a predeterminat", + "default_updated": "Calendari predeterminat actualitzat", + "error_default": "No s'ha pogut establir el calendari predeterminat", + "clear_events": "Esborra els esdeveniments", + "events_cleared": "{count} esdeveniments esborrats", + "error_clear": "No s'han pogut esborrar els esdeveniments del calendari", + "calendar_created": "Calendari creat", + "calendar_updated": "Calendari actualitzat", + "calendar_deleted": "Calendari suprimit", + "color_updated": "Color del calendari actualitzat", + "error_create": "No s'ha pogut crear el calendari", + "error_update": "No s'ha pogut actualitzar el calendari", + "error_delete": "No s'ha pogut suprimir el calendari", + "caldav_url": "URL de CalDAV", + "copy_url": "Copia l'URL de CalDAV", + "url_copied": "URL de CalDAV copiat al porta-retalls", + "share": "Comparteix el calendari", + "new_event_in_calendar": "Esdeveniment nou en aquest calendari" + }, + "subscription": { + "title": "Subscripció iCal", + "section_title": "Subscripcions iCal", + "description": "Subscriviu-vos a un canal iCalendar extern. Els esdeveniments se sincronitzaran automàticament en el seu propi calendari. Admet URL https:// i webcal://.", + "url_label": "URL del calendari", + "url_placeholder": "https://example.com/calendar.ics or webcal://...", + "name_label": "Nom del calendari", + "name_placeholder": "p. ex. Festius", + "color_label": "Color", + "refresh_interval": "Interval d'actualització", + "interval_15": "Cada 15 minuts", + "interval_30": "Cada 30 minuts", + "interval_60": "Cada hora", + "interval_360": "Cada 6 hores", + "interval_1440": "Cada dia", + "subscribe": "Subscriu-te", + "subscribing": "Subscrivint...", + "save": "Desa els canvis", + "saving": "Desant...", + "edit": "Edita", + "edit_title": "Edita la subscripció", + "updated": "S'ha actualitzat «{name}»", + "update_error": "No s'ha pogut actualitzar la subscripció", + "invalid_url": "Introduïu un URL vàlid", + "success": "Subscrit a «{name}»", + "error": "No s'ha pogut afegir la subscripció", + "refresh": "Actualitza ara", + "refresh_success": "Subscripció actualitzada", + "refresh_error": "No s'ha pogut actualitzar la subscripció", + "unsubscribe": "Cancel·la la subscripció", + "confirm_delete": "Voleu cancel·lar la subscripció a «{name}»? S'eliminaran el calendari i tots els seus esdeveniments.", + "deleted": "Subscripció eliminada", + "delete_error": "No s'ha pogut eliminar la subscripció", + "last_refreshed": "Última actualització: {time}" + }, + "tasks": { + "label": "Tasques", + "no_tasks": "Cap tasca", + "no_title": "(Sense títol)", + "mark_complete": "Marca com a completada", + "mark_incomplete": "Marca com a incompleta", + "filter_all": "Totes", + "filter_pending": "Pendents", + "filter_completed": "Completades", + "filter_overdue": "Vençudes", + "show_completed": "Mostra les completades", + "create": "Tasca nova", + "edit": "Edita la tasca", + "title_placeholder": "Títol de la tasca", + "description_placeholder": "Afegiu una descripció...", + "due_date": "Data de venciment", + "include_time": "Inclou l'hora", + "priority": "Prioritat", + "priority_none": "Cap", + "priority_high": "Alta", + "priority_medium": "Mitjana", + "priority_low": "Baixa", + "progress": "Estat", + "progress_needs_action": "Cal actuar", + "progress_in_process": "En curs", + "progress_completed": "Completada", + "progress_cancelled": "Cancel·lada", + "calendar": "Calendari", + "alert": "Recordatori", + "alert_none": "Cap", + "alert_at_time": "A l'hora de venciment", + "alert_5min": "5 minuts abans", + "alert_15min": "15 minuts abans", + "alert_30min": "30 minuts abans", + "alert_1hr": "1 hora abans", + "alert_1day": "1 dia abans", + "delete": "Suprimeix", + "cancel": "Cancel·la", + "save": "Desa", + "quick_add_placeholder": "Afegiu una tasca...", + "due_today": "Avui", + "due_tomorrow": "Demà", + "overdue": "Vençuda" + } + }, + "sharing": { + "title": "Comparteix «{name}»", + "description": "Concediu accés a altres usuaris o grups d'aquest servidor. Els canvis s'apliquen immediatament.", + "no_shares": "Encara no s'ha compartit amb ningú.", + "add_person": "Afegeix una persona o un grup", + "search_placeholder": "Cerca per nom o correu electrònic…", + "loading_principals": "Carregant els usuaris…", + "no_principals": "No s'ha trobat cap altre usuari o grup.", + "no_match": "Cap coincidència.", + "remove": "Elimina l'accés", + "group": "Grup", + "share_added": "Accés concedit", + "share_updated": "Accés actualitzat", + "share_removed": "Accés eliminat", + "share_failed": "No s'ha pogut actualitzar la compartició", + "preset": { + "freeBusy": "Només disponibilitat", + "read": "Només lectura", + "readWrite": "Lectura i escriptura", + "manager": "Gestor", + "custom": "Personalitzat" + } + }, + "advanced_search": { + "title": "Cerca avançada", + "from": "De", + "from_placeholder": "Nom o correu del remitent", + "to": "Per a", + "to_placeholder": "Nom o correu del destinatari", + "subject": "Assumpte", + "subject_placeholder": "L'assumpte conté...", + "body": "Cos", + "body_placeholder": "El cos conté...", + "folder": "Carpeta", + "all_folders": "Totes les carpetes", + "has_attachment": "Fitxers adjunts", + "date_after": "Després de", + "date_before": "Abans de", + "starred": "Destacat", + "unread": "No llegit", + "read": "Llegit", + "yes": "Sí", + "no": "No", + "clear": "Neteja", + "clear_all": "Neteja-ho tot", + "filters_active": "{count} filtre", + "filters_active_plural": "{count} filtres", + "toggle_filters": "Més", + "search_hint": "Utilitzeu els filtres avançats per a una cerca precisa", + "advanced_filters_tooltip": "Filtres de cerca avançada", + "results_found": "{count, plural, =0 {No s'ha trobat cap resultat} one {# resultat trobat} other {# resultats trobats}}", + "results_found_more": "{count}+ resultats trobats" + }, + "welcome": { + "title": "Us donem la benvinguda a la bústia", + "tip_compose": "Premeu c per redactar un correu nou", + "tip_shortcuts": "Premeu ? per veure totes les dreceres de teclat", + "tip_sidebar": "Trobeu el Calendari, els Contactes i la Configuració al menú de la barra lateral", + "tip_settings": "Personalitzeu la vostra experiència a la Configuració", + "got_it": "Entesos", + "settings": "Configuració", + "dismiss": "Descarta", + "start_tour": "Comença la visita guiada" + }, + "demo_welcome": { + "title": "Us donem la benvinguda al Bulwark Mail", + "description": "Exploreu un client de correu web complet, directament al navegador. Totes les dades es queden al vostre dispositiu, així que proveu-ho tot sense cap problema.", + "feature_email": "Llegiu i redacteu correus", + "feature_organize": "Etiquetes, estrelles i carpetes", + "feature_shortcuts": "Dreceres de teclat", + "feature_privacy": "Demo 100% privada", + "hint": "Feu clic a qualsevol correu de l'esquerra per començar, o feu la visita guiada de sota." + }, + "files": { + "open_in_new_tab": "Obre en una pestanya nova", + "title": "Fitxers", + "search_placeholder": "Cerca fitxers...", + "empty_state_title": "Encara no hi ha fitxers", + "empty_state_description": "Pugeu fitxers o creeu carpetes per començar", + "upload": "Puja", + "upload_files": "Puja fitxers", + "new_folder": "Carpeta nova", + "new_folder_name": "Nom de la carpeta", + "rename": "Canvia el nom", + "rename_title": "Canvia el nom", + "new_name": "Nom nou", + "delete": "Suprimeix", + "delete_confirm_title": "Suprimeix el recurs", + "delete_confirm_message": "Segur que voleu suprimir «{name}»? Això no es pot desfer.", + "download": "Baixa", + "name": "Nom", + "size": "Mida", + "modified": "Modificat", + "type": "Tipus", + "folder": "Carpeta", + "file": "Fitxer", + "parent_directory": "Directori superior", + "breadcrumb_root": "Inici", + "other_accounts": "Altres comptes", + "no_accounts": "Cap compte connectat.", + "drop_files_here": "Deixeu anar fitxers o carpetes aquí per pujar-los", + "uploading": "Pujant...", + "upload_success": "{count, plural, one {1 fitxer pujat} other {# fitxers pujats}}", + "upload_error": "No s'ha pogut pujar el fitxer", + "create_folder_success": "Carpeta creada", + "create_folder_error": "No s'ha pogut crear la carpeta", + "delete_success": "Suprimit correctament", + "delete_error": "No s'ha pogut suprimir", + "rename_success": "Nom canviat correctament", + "rename_error": "No s'ha pogut canviar el nom", + "download_error": "No s'ha pogut baixar", + "not_available": "L'emmagatzematge de fitxers no està disponible en aquest servidor", + "cancel": "Cancel·la", + "create": "Crea", + "save": "Desa", + "no_results": "Cap fitxer coincideix amb la cerca", + "batch_delete_confirm_message": "Segur que voleu suprimir {count, plural, one {1 element} other {# elements}}? Això no es pot desfer.", + "batch_delete_success": "{count, plural, one {1 element suprimit} other {# elements suprimits}}", + "grid_view": "Vista de quadrícula", + "list_view": "Vista de llista", + "details": "Detalls", + "path": "Camí", + "preview": "Previsualització", + "preview_error": "No s'ha pogut carregar la previsualització", + "cut": "Retalla", + "copy": "Copia", + "paste": "Enganxa", + "move_success": "{count, plural, one {1 element mogut} other {# elements moguts}}", + "move_error": "No s'ha pogut moure", + "paste_success": "Enganxat correctament", + "paste_error": "No s'ha pogut enganxar", + "new_text_file": "Fitxer de text nou", + "file_name": "Nom del fitxer", + "retry": "Reintenta", + "refresh": "Actualitza", + "toggle_favorite": "Commuta preferit", + "duplicate": "Duplica", + "duplicate_success": "Duplicat correctament", + "duplicate_error": "No s'ha pogut duplicar", + "share": "Comparteix", + "shared": "Compartit", + "shared_with_me": "Compartit amb mi", + "shared_by": "Compartit per {name}", + "create_file_success": "Fitxer creat", + "create_file_error": "No s'ha pogut crear el fitxer", + "favorites": "Preferits", + "recent": "Recents", + "properties": "Propietats", + "open_folder": "Obre la carpeta", + "upload_folder": "Puja una carpeta", + "file_too_large": "«{name}» supera la mida màxima de fitxer ({max})", + "undo": "Desfés", + "undo_success": "Acció desfeta", + "undo_error": "No s'ha pogut desfer", + "toolbar": "Accions de fitxer", + "open_folder_tree": "Obre l'arbre de carpetes", + "file_list": "Fitxers i carpetes", + "context_menu": "Accions", + "settings_title": "Configuració de fitxers", + "settings_display": "Visualització", + "settings_default_view": "Vista predeterminada", + "settings_default_view_desc": "Trieu entre disposició de quadrícula i de llista", + "settings_default_sort": "Ordenació predeterminada", + "settings_default_sort_desc": "Trieu l'ordenació predeterminada dels fitxers", + "settings_sort_direction": "Direcció de l'ordenació", + "settings_sort_direction_desc": "Trieu ordre ascendent o descendent", + "settings_ascending": "Ascendent", + "settings_descending": "Descendent", + "settings_icons": "Icones", + "settings_show_icons": "Mostra les icones de fitxer", + "settings_show_icons_desc": "Mostra icones al costat dels fitxers i les carpetes", + "settings_colored_icons": "Icones de colors", + "settings_colored_icons_desc": "Utilitza icones de colors en lloc de monocromes", + "settings_show_thumbnails": "Mostra les miniatures", + "settings_show_thumbnails_desc": "Mostra previsualitzacions d'imatge en lloc d'icones per als fitxers d'imatge", + "settings_behavior": "Comportament", + "settings_show_hidden": "Mostra els fitxers ocults", + "settings_show_hidden_desc": "Mostra els fitxers i les carpetes que comencen per un punt", + "settings_folder_layout": "Navegació de carpetes", + "settings_folder_layout_desc": "Trieu com es mostren les carpetes: incrustades amb els fitxers o en un arbre a la barra lateral", + "settings_folder_layout_inline": "Incrustades", + "settings_folder_layout_sidebar": "Barra lateral", + "disabled_title": "La funció de fitxers està desactivada per l'administrador", + "disabled_description": "Les pujades de fitxers grans via WebDAV poden causar inestabilitat a Stalwart/RocksDB, incloent-hi fallades per manca de memòria i ús de disc irrecuperable. És possible que els fitxers suprimits no s'eliminin immediatament de l'emmagatzematge de blobs. No es recomana aquesta funció per a entorns de producció.", + "stability_warning": "Les pujades de fitxers grans poden causar inestabilitat al servidor. És possible que els fitxers suprimits no s'eliminin immediatament de l'emmagatzematge. Utilitzeu-ho amb precaució.", + "migration_title": "Actualitzant els vostres fitxers…", + "migration_description": "S'estan organitzant les carpetes i els fitxers en la seva estructura adequada. Això només passa una vegada." + }, + "smime": { + "your_certificates": "Els vostres certificats", + "your_certificates_desc": "Importeu i gestioneu els certificats S/MIME per signar i xifrar correus", + "recipient_certificates": "Certificats de destinatari", + "recipient_certificates_desc": "Certificats públics per xifrar correus als destinataris", + "identity_bindings": "Vinculacions de clau a identitat", + "identity_bindings_desc": "Vinculeu certificats S/MIME a les vostres identitats de correu", + "defaults_title": "Valors per defecte", + "defaults_desc": "Configureu el comportament predeterminat de signatura i xifratge", + "import_pkcs12": "Importa PKCS#12 (.p12/.pfx)", + "import_public_cert": "Importa un certificat", + "no_certificates": "Encara no s'ha importat cap certificat", + "no_recipient_certs": "Cap certificat de destinatari", + "expires": "Caduca", + "expired": "Caducat", + "bound_to": "Vinculat a", + "no_key_bound": "Cap", + "lock": "Bloqueja la clau", + "unlock": "Desbloqueja la clau", + "details": "Mostra els detalls", + "delete": "Suprimeix", + "encrypt_by_default": "Xifra per defecte", + "encrypt_by_default_desc": "Xifra automàticament els correus quan tots els destinataris tinguin certificat", + "remember_unlocked": "Recorda les claus desbloquejades", + "remember_unlocked_desc": "Manté les claus desbloquejades durant aquesta sessió del navegador", + "sign_default_for": "Signa per defecte per a", + "enter_p12_passphrase": "Introduïu la contrasenya del PKCS#12", + "p12_passphrase_desc": "Introduïu la contrasenya que protegeix aquest fitxer de certificat", + "enter_storage_passphrase": "Estableix la contrasenya d'emmagatzematge", + "storage_passphrase_desc": "Trieu una contrasenya per protegir aquesta clau en repòs al navegador", + "next": "Següent", + "import": "Importa", + "unlock_key": "Desbloqueja la clau", + "unlock_key_desc": "Introduïu la contrasenya d'emmagatzematge per desbloquejar aquesta clau per signar o desxifrar", + "passphrase_placeholder": "Introduïu la contrasenya", + "confirm_passphrase_placeholder": "Confirmeu la contrasenya", + "passphrase_mismatch": "Les contrasenyes no coincideixen", + "cancel": "Cancel·la", + "processing": "Processant…", + "close": "Tanca", + "certificate_details": "Detalls del certificat", + "cert_subject": "Subjecte", + "cert_issuer": "Emissor", + "cert_email": "Correu electrònic", + "cert_serial": "Número de sèrie", + "cert_validity": "Validesa", + "cert_fingerprint": "Empremta digital (SHA-256)", + "cert_algorithm": "Algorisme", + "cert_capabilities": "Capacitats", + "cert_source": "Origen", + "cert_expired": "Aquest certificat ha caducat", + "cert_not_yet_valid": "Aquest certificat encara no és vàlid", + "cap_sign": "Signatura", + "cap_encrypt": "Xifratge", + "cap_none": "Cap", + "show_passphrase": "Mostra la contrasenya", + "hide_passphrase": "Amaga la contrasenya", + "sign_toggle": "Signa", + "encrypt_toggle": "Xifra", + "missing_recipient_certs": "Falten certificats per a: {emails}", + "missing_sender_cert": "Cap certificat vinculat a aquesta identitat", + "status_encrypted_ok": "Aquest missatge s'ha xifrat", + "status_encrypted_no_key": "Aquest missatge està xifrat, però no s'ha trobat cap clau coincident", + "status_encrypted_failed": "No s'ha pogut desxifrar aquest missatge", + "status_signed_valid": "Signatura verificada", + "status_signed_invalid": "Ha fallat la verificació de la signatura", + "status_signed_expired_cert": "Signat amb un certificat caducat", + "status_signed_self_signed": "Signatura vàlida, però el certificat és autosignat (no fiable)", + "status_signed_mismatch": "Signatura vàlida, però el signant no coincideix amb el remitent", + "status_unsupported": "Format S/MIME no admès", + "auto_import_signer_certs": "Importa automàticament els certificats dels signants", + "auto_import_signer_certs_desc": "Desa automàticament els certificats dels correus signats verificats per a un xifratge futur", + "export": "Exporta", + "enter_export_passphrase": "Estableix la contrasenya d'exportació", + "export_passphrase_desc": "Trieu una contrasenya per protegir el fitxer PKCS#12 exportat", + "export_storage_desc": "Introduïu la contrasenya d'emmagatzematge per desxifrar la clau per exportar-la", + "incorrect_passphrase": "Contrasenya incorrecta" + }, + "tour": { + "step_counter": "Pas {current} de {total}", + "skip": "Omet la visita", + "back": "Enrere", + "next": "Següent", + "finish": "Finalitza", + "take_a_tour": "Feu una visita guiada de la interfície", + "restart_title": "Visita guiada introductòria", + "restart_desc": "Torneu a veure la visita guiada de la interfície", + "restart_button": "Reinicia la visita guiada", + "show_on_new_devices_title": "Mostra en dispositius nous", + "show_on_new_devices_desc": "Torna a mostrar el bàner de benvinguda i la visita guiada la primera vegada que inicieu la sessió en un dispositiu nou, encara que ja els hàgiu completat en un altre lloc", + "sidebar_title": "Les vostres bústies", + "sidebar_desc": "Aquesta és la barra lateral de carpetes. Feu clic a qualsevol bústia per veure'n els correus. Podeu crear carpetes, arrossegar-hi correus i veure d'un cop d'ull el nombre de no llegits.", + "compose_title": "Redacteu un correu", + "compose_desc": "Feu clic aquí per escriure un correu nou. Podeu afegir destinataris, fitxers adjunts i utilitzar format de text enriquit.", + "search_title": "Cerqueu al vostre correu", + "search_desc": "Cerqueu per remitent, assumpte o contingut. Feu clic a la icona de filtre per a opcions avançades com l'interval de dates, els fitxers adjunts i els missatges destacats.", + "email_list_title": "La vostra llista de correus", + "email_list_desc": "Els correus apareixen aquí. Feu-hi clic per llegir-los a la dreta. Utilitzeu la casella de selecció per seleccionar-ne diversos i moure'ls, suprimir-los o etiquetar-los en bloc.", + "email_viewer_title": "Subfinestra de lectura", + "email_viewer_desc": "El correu seleccionat s'obre aquí. Responeu, reenvieu, arxiveu o suprimiu amb els botons de la barra d'eines. També podeu marcar correus amb estrella o afegir-hi etiquetes de color.", + "keywords_title": "Etiquetes de color", + "keywords_desc": "Organitzeu el correu amb etiquetes de color. Arrossegueu un correu sobre una etiqueta per etiquetar-lo, o feu clic dret a un correu per assignar-hi etiquetes.", + "calendar_title": "Calendari", + "calendar_desc": "Canvieu al calendari per gestionar els vostres esdeveniments. Creeu esdeveniments, establiu recordatoris i visualitzeu-los per dia, setmana o mes.", + "contacts_title": "Contactes", + "contacts_desc": "Aquí hi teniu la llibreta d'adreces. Importeu contactes, creeu grups i feu clic a qualsevol contacte per veure'n tots els detalls.", + "settings_title": "Configuració", + "settings_desc": "Personalitzeu-ho tot: tema, densitat, signatures, filtres, dreceres de teclat, valors predeterminats del calendari i molt més.", + "shortcuts_title": "Dreceres de teclat", + "shortcuts_desc": "Als usuaris avançats els encanta. Premeu ? en qualsevol moment per veure totes les dreceres disponibles. Podeu navegar, redactar i gestionar correus sense tocar el ratolí.", + "compose_open_title": "El redactor", + "compose_open_desc": "Aquest és el redactor de correu. Afegiu destinataris, escriviu el missatge, adjunteu fitxers i utilitzeu format de text enriquit. També podeu desar esborranys i utilitzar plantilles.", + "calendar_view_title": "El vostre calendari", + "calendar_view_desc": "Aquí teniu el vostre calendari amb esdeveniments d'exemple. Podeu canviar entre les vistes de dia, setmana, mes i agenda amb la barra d'eines.", + "create_event_title": "Creeu un esdeveniment", + "create_event_desc": "Feu clic a aquest botó per crear un esdeveniment de calendari nou. Podeu establir un títol, una data, una hora i afegir-hi participants.", + "event_modal_title": "Detalls de l'esdeveniment", + "event_modal_desc": "Aquest és el formulari de l'esdeveniment. Ompliu el títol, trieu una data i hora, afegiu una ubicació o participants. Deseu-lo quan hàgiu acabat, o tanqueu-lo i continueu.", + "contacts_list_title": "Els vostres contactes", + "contacts_list_desc": "Aquí teniu els vostres contactes. Feu clic a qualsevol contacte per veure'n tots els detalls a la dreta. També podeu crear contactes nous, importar vCards o organitzar contactes en grups.", + "settings_tabs_title": "Menú de configuració", + "settings_tabs_desc": "Aquí teniu totes les categories de configuració. Personalitzeu l'aparença, gestioneu identitats, configureu filtres de correu, ajusteu el calendari i molt més.", + "files_title": "Emmagatzematge de fitxers", + "files_desc": "El navegador de fitxers us permet pujar, organitzar i compartir fitxers, com un disc al núvol personal integrat al correu.", + "demo_banner_title": "Controls de la demo", + "demo_banner_desc": "Esteu en mode de demostració: tot es queda al navegador. Premeu «Reinicia la demo» en qualsevol moment per començar de nou amb dades d'exemple netes.", + "quota_title": "Ús de l'emmagatzematge", + "quota_desc": "Feu un seguiment de la mida de la bústia aquí. El cercle s'omple a mesura que utilitzeu més espai." + }, + "unified_mailbox": { + "search_unavailable": "La cerca no està disponible a la vista unificada" + }, + "quote_header": { + "reply_line": "El {date}, {from} va escriure:", + "forwarded_separator": "---------- Missatge reenviat ----------", + "from_label": "De", + "date_label": "Data", + "subject_label": "Assumpte" + }, + "pwa_install": { + "title": "Instal·la {appName}", + "description": "Instal·leu la nostra aplicació per a un accés ràpid i compatibilitat sense connexió.", + "not_now": "Ara no", + "install": "Instal·la", + "dont_remind": "No m'ho tornis a recordar", + "dismiss_aria": "Descarta l'avís d'instal·lació" + } +} From 15ad783848e16c01d188fee705335b061c55703b Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:58:42 +0200 Subject: [PATCH 03/42] fix(email): make the "Move to" context menu work across accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving a message to a folder in another account (own ↔ delegated/shared) via the "Move to" context menu was a no-op — the handlers always issued a single-account Email/set, which can't move between JMAP accounts. Drag-and-drop already routed these correctly; the context menu never did. Add moveToMailboxCrossAware: it detects a cross-account destination (own and shared mailboxes both carry accountId) and routes through the drag-and-drop crossAccountMoveEmails pipeline, else falls back to the single-account move. Fix the pipeline for delegated folders too: a client can't stage a blob in a delegated account (Blob/upload → blobNotFound), so importing into a shared folder failed. When one client reaches both accounts, use a server-side JMAP Email/copy (+ destroy original) instead of blob copy+import; the blob path is kept only for separate cross-server login accounts. Adds client.copyEmailAcrossAccounts. Unit tests for the dispatch; the two 08-shared-moves specs are un-pinned. Full docker integration suite green (37 passed). --- app/(main)/[locale]/page.tsx | 5 +- integration/tests/08-shared-moves.spec.ts | 11 +- lib/demo/demo-client.ts | 1 + lib/jmap/client-interface.ts | 8 ++ lib/jmap/client.ts | 26 +++++ .../email-store-cross-account-move.test.ts | 102 ++++++++++++++++++ stores/email-store.ts | 93 +++++++++++++++- 7 files changed, 234 insertions(+), 12 deletions(-) create mode 100644 stores/__tests__/email-store-cross-account-move.test.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 547b66dc..7345bb80 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -282,6 +282,7 @@ export default function Home() { toggleStar, setEmailKeywordsLocal, moveToMailbox, + moveToMailboxCrossAware, moveThreadToMailbox, searchEmails, searchQuery, @@ -3229,7 +3230,7 @@ export default function Home() { }} onMoveToMailbox={async (emailId, mailboxId) => { if (client) { - await moveToMailbox(client, emailId, mailboxId); + await moveToMailboxCrossAware(client, emailId, mailboxId); } }} onMarkAsSpam={async (email) => { @@ -3499,7 +3500,7 @@ export default function Home() { selectedMailbox={selectedMailbox} onMoveToMailbox={async (mailboxId) => { if (client && selectedEmail) { - await moveToMailbox(client, selectedEmail.id, mailboxId); + await moveToMailboxCrossAware(client, selectedEmail.id, mailboxId); } }} className={isMobile ? "flex-1" : undefined} diff --git a/integration/tests/08-shared-moves.spec.ts b/integration/tests/08-shared-moves.spec.ts index b508d795..0ac079ff 100644 --- a/integration/tests/08-shared-moves.spec.ts +++ b/integration/tests/08-shared-moves.spec.ts @@ -77,12 +77,9 @@ test.describe('Shared-folder moves', () => { expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy(); }); - // KNOWN LIMITATION (documented via test.fail): the "Move to" submenu offers a - // shared folder as a destination for an own-account message, but clicking it - // does NOT relocate the message across the account boundary — it stays put. - // Same in reverse (shared -> own). If cross-account moves get implemented, - // these will start passing; flip them back to plain tests then. - test.fail('own account -> shared folder', async ({ page }) => { + // The "Move to" submenu relocates a message across the account boundary + // (own ↔ shared folder) via copy+delete, matching drag-and-drop. + test('own account -> shared folder', async ({ page }) => { const s = subj('mv-own2sh'); await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' }); await jc.waitForEmail(s); @@ -100,7 +97,7 @@ test.describe('Shared-folder moves', () => { expect(await ja.findEmailBySubject(s, teamA), 'message in shared TeamA').toBeTruthy(); }); - test.fail('shared folder -> own account', async ({ page }) => { + test('shared folder -> own account', async ({ page }) => { const s = subj('mv-sh2own'); await seedInto(teamA, s); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 76aa0104..79b5fc67 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -1094,6 +1094,7 @@ export class DemoJMAPClient implements IJMAPClient { // ── S/MIME raw-email helpers ────────────────────────────────── async importRawEmail(): Promise { return generateDemoId('email'); } + async copyEmailAcrossAccounts(): Promise { return generateDemoId('email'); } async submitEmail(): Promise { /* no-op */ } async submitRawEmail(blob: Blob, identityId: string, diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index aef7cc44..35e6f1ad 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -345,4 +345,12 @@ export interface IJMAPClient { // ── S/MIME raw-email helpers ────────────────────────────────── importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record, accountId?: string): Promise; submitEmail(emailId: string, identityId: string): Promise; + /** + * Server-side move of one email across accounts reachable through THIS client + * (JMAP `Email/copy` + destroy-original). Used for delegated/shared folders, + * where the two accounts share a client but a client can't stage a blob in a + * delegated account (so the blob copy+import path doesn't work). Returns the + * new email id in the destination account. + */ + copyEmailAcrossAccounts(emailId: string, fromAccountId: string, toAccountId: string, destMailboxId: string): Promise; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d0071b6b..cddecc5b 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -6394,6 +6394,32 @@ export class JMAPClient implements IJMAPClient { * a shared mailbox owned by another user). When omitted, falls back to the * client's own primary account. */ + async copyEmailAcrossAccounts( + emailId: string, + fromAccountId: string, + toAccountId: string, + destMailboxId: string, + ): Promise { + const response = await this.request([ + ["Email/copy", { + fromAccountId, + accountId: toAccountId, + create: { c: { id: emailId, mailboxIds: { [destMailboxId]: true } } }, + onSuccessDestroyOriginal: true, + }, "0"], + ]); + const res = response.methodResponses?.[0]?.[1]; + const err = res?.notCreated?.c; + if (err) { + throw new Error(err.description || err.type || "Failed to copy email across accounts"); + } + const id = res?.created?.c?.id; + if (!id) { + throw new Error("Email/copy succeeded but no ID returned"); + } + return id; + } + async importRawEmail( blob: Blob, mailboxIds: Record, diff --git a/stores/__tests__/email-store-cross-account-move.test.ts b/stores/__tests__/email-store-cross-account-move.test.ts new file mode 100644 index 00000000..fcfdb9c2 --- /dev/null +++ b/stores/__tests__/email-store-cross-account-move.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +type Store = ReturnType; + +function makeMailbox(overrides: Partial): Mailbox { + return { + id: 'inbox', + name: 'Inbox', + 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, + }; +} + +function makeEmail(id: string, mailboxServerId: string): Email { + return { + id, threadId: `t-${id}`, mailboxIds: { [mailboxServerId]: true }, keywords: {}, + size: 100, receivedAt: new Date().toISOString(), + from: [{ name: 'X', email: 'x@example.com' }], to: [{ name: 'Y', email: 'y@example.com' }], + subject: id, preview: '', hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {}, + }; +} + +// Own account (JMAP acct "jmap-A", reached via local account "local-A") and a +// delegated/shared folder owned by another JMAP account ("jmap-B"). +const ownInbox = makeMailbox({ id: 'inbox-A', role: 'inbox', accountId: 'jmap-A', originalId: 'srv-inbox-A' }); +const ownArchive = makeMailbox({ id: 'archive-A', role: 'archive', accountId: 'jmap-A', originalId: 'srv-archive-A' }); +const sharedTeamA = makeMailbox({ id: 'jmap-B:srv-teamA', name: 'TeamA', accountId: 'jmap-B', originalId: 'srv-teamA', isShared: true }); + +describe('email-store moveToMailboxCrossAware', () => { + let crossSpy: ReturnType; + let moveSpy: ReturnType; + const client = {} as IJMAPClient; + + beforeEach(() => { + const email = makeEmail('e1', 'srv-inbox-A'); + email.accountId = 'local-A'; + crossSpy = vi.fn().mockResolvedValue(undefined); + moveSpy = vi.fn().mockResolvedValue(undefined); + useEmailStore.setState({ + emails: [email], + mailboxes: [ownInbox, ownArchive, sharedTeamA], + selectedMailbox: 'inbox-A', + viewingAccountId: 'local-A', + isUnifiedView: false, + accountMailboxes: {}, + crossAccountMoveEmails: crossSpy as unknown as Store['crossAccountMoveEmails'], + moveToMailbox: moveSpy as unknown as Store['moveToMailbox'], + }); + }); + + it('routes an own → shared (cross-account) move through crossAccountMoveEmails', async () => { + await useEmailStore.getState().moveToMailboxCrossAware(client, 'e1', 'jmap-B:srv-teamA'); + + expect(moveSpy).not.toHaveBeenCalled(); + // copy into the owner's (jmap-B) TeamA via the viewer's client, using the + // destination's raw server id; source is own, so no source override. + expect(crossSpy).toHaveBeenCalledWith( + new Map([['local-A', ['e1']]]), + 'local-A', + 'srv-teamA', + 'jmap-B', + undefined, + ); + }); + + it('routes a same-account move through the single-account moveToMailbox', async () => { + await useEmailStore.getState().moveToMailboxCrossAware(client, 'e1', 'archive-A'); + + expect(crossSpy).not.toHaveBeenCalled(); + expect(moveSpy).toHaveBeenCalledWith(client, 'e1', 'archive-A'); + }); + + it('reverse: shared → own also routes cross-account (source override set)', async () => { + const email = makeEmail('e2', 'srv-teamA'); + email.accountId = 'local-A'; + useEmailStore.setState({ emails: [email], selectedMailbox: 'jmap-B:srv-teamA' }); + + await useEmailStore.getState().moveToMailboxCrossAware(client, 'e2', 'inbox-A'); + + expect(moveSpy).not.toHaveBeenCalled(); + expect(crossSpy).toHaveBeenCalledWith( + new Map([['local-A', ['e2']]]), + 'local-A', + 'srv-inbox-A', + undefined, // dest (own) not shared + 'jmap-B', // source shared → override to owner account + ); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 14ca81d2..785a4966 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -161,6 +161,14 @@ interface EmailStore { deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise; moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: 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 + * mailbox, or a different connected account) — the "Move to" context-menu + * equivalent of what drag-and-drop already does. Falls back to the plain + * single-account `moveToMailbox` when source and destination share an account. + */ + moveToMailboxCrossAware: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise; moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; /** @@ -424,6 +432,23 @@ function resolveEmailActionContext( }; } +/** + * Local account id ("user@host") whose connected client owns `mailbox`. + * `mailbox.accountId` is the JMAP server's opaque id; map it back to a local + * client id, falling back to the viewing/active account — a delegated/shared + * folder has no separately-connected client, it's reached through the viewer's. + * Mirrors resolveDestAccountId in use-mailbox-drop.ts. + */ +function resolveDestLocalAccountId(mailbox: Mailbox): string | null { + const jmapId = mailbox.accountId; + if (jmapId) { + for (const [localId, client] of useAuthStore.getState().getAllConnectedClients()) { + if (client.getAccountId() === jmapId) return localId; + } + } + return useEmailStore.getState().viewingAccountId ?? useAuthStore.getState().activeAccountId; +} + /** * Builds the `UnifiedAccountClient[]` list used by every unified fan-out * action (browse, load-more, search). Each entry has a JMAP client plus a @@ -1595,6 +1620,54 @@ export const useEmailStore = create((set, get) => ({ } }, + moveToMailboxCrossAware: async (client, emailId, destinationMailboxId) => { + const state = get(); + const email = state.emails.find((e) => e.id === emailId); + if (!email) return; + + const { mailboxes } = resolveEmailActionContext(email, client); + const find = (id: string) => + mailboxes.find((mb) => mb.id === id) ?? state.mailboxes.find((mb) => mb.id === id); + const destMailbox = find(destinationMailboxId); + // A context-menu move acts on the visible list, so the source folder is the + // one currently open. + const sourceMailbox = find(state.selectedMailbox ?? ''); + + // Cross-account when the two folders live in different JMAP accounts (both + // own and shared mailboxes carry accountId, so this catches own↔shared too). + const isCrossAccount = + !!destMailbox && + !!sourceMailbox?.accountId && + !!destMailbox.accountId && + sourceMailbox.accountId !== destMailbox.accountId; + + if (!isCrossAccount) { + await get().moveToMailbox(client, emailId, destinationMailboxId); + return; + } + + const destAccountId = resolveDestLocalAccountId(destMailbox!); + const sourceAccountId = + email.accountId ?? state.viewingAccountId ?? useAuthStore.getState().activeAccountId; + if (!destAccountId || !sourceAccountId) { + // Can't resolve the local endpoints — fall back rather than drop the mail. + await get().moveToMailbox(client, emailId, destinationMailboxId); + return; + } + + // JMAP has no cross-account move: copy the raw message into the destination + // account's mailbox, then delete the original (crossAccountMoveEmails). The + // *Jmap* overrides target the owner account when a shared folder is reached + // through another user's client. + await get().crossAccountMoveEmails( + new Map([[sourceAccountId, [emailId]]]), + destAccountId, + destMailbox!.originalId ?? destMailbox!.id, + destMailbox!.isShared ? destMailbox!.accountId : undefined, + sourceMailbox?.isShared ? sourceMailbox.accountId : undefined, + ); + }, + moveToMailbox: async (client, emailId, destinationMailboxId) => { try { const email = get().emails.find(e => e.id === emailId); @@ -1763,9 +1836,23 @@ export const useEmailStore = create((set, get) => ({ // the source clean in the happy path. const results = await Promise.allSettled( emailIds.map(async (emailId) => { - // When the source is a delegated/shared mailbox, the email, - // its blob, and the destroy all live in the owner's JMAP - // account, not the source client's primary one. + // Delegated/shared folders: one client reaches both accounts, so a + // server-side Email/copy moves the message. A client can't stage a + // blob in a *delegated* account (blobNotFound), so the blob + // copy+import path below is only valid across separate login + // clients/servers. + if (sourceClient === destClient) { + await sourceClient.copyEmailAcrossAccounts( + emailId, + sourceJmapAccountId ?? sourceClient.getAccountId(), + destJmapAccountId ?? destClient.getAccountId(), + destMailboxId, + ); + return emailId; + } + // Separate clients (cross-server multi-account): the email, its + // blob, and the destroy all live in the owner's JMAP account, not + // the source client's primary one. const full = await sourceClient.getEmail(emailId, sourceJmapAccountId); if (!full?.blobId) { throw new Error('Source email has no raw blob to copy'); From 6248bb9825784635805035a2d6f7b8155f71e837 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:58:25 +0200 Subject: [PATCH 04/42] fix(email): preserve read state and remove source on cross-account move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving a message across the account boundary (own ↔ shared folder, or between two owners' shared folders) left the original in the source folder and showed the moved copy as unread. Same-account moves were fine. Cause (verified against a live Stalwart): - Email/copy drops keywords unless the create sets them, so the copy lost $seen and arrived unread. - onSuccessDestroyOriginal is unreliable — the implicit destroy reports notFound and leaves the original behind (flaky), so the move duplicated. Fix (copyEmailAcrossAccounts): read the source keywords and carry them into the Email/copy create, then destroy the original with an explicit Email/set on the source account instead of onSuccessDestroyOriginal. Tests: 08-shared-moves now asserts the source is gone and the read state survives on every cross-account case, and adds a cross-owner shared → shared move (alice's folder → bob's folder). Confirmed red on the old code (3 cross-account cases fail), green with the fix. --- integration/tests/08-shared-moves.spec.ts | 82 +++++++++++++++++------ lib/jmap/client.ts | 23 ++++++- 2 files changed, 83 insertions(+), 22 deletions(-) diff --git a/integration/tests/08-shared-moves.spec.ts b/integration/tests/08-shared-moves.spec.ts index 0ac079ff..32901562 100644 --- a/integration/tests/08-shared-moves.spec.ts +++ b/integration/tests/08-shared-moves.spec.ts @@ -13,39 +13,50 @@ import { /** * Moving mail across the own-account / shared-folder boundary, in both - * directions, and between two shared folders. The move is driven from the list - * context menu's "Move to" submenu; the authoritative check is the server-side - * mailbox the message ends up in, with the reliably-updating (own-account) - * counters checked in the UI too. + * directions, and between two shared folders (same owner and across owners). + * The move is driven from the list context menu's "Move to" submenu; the + * authoritative check is the server-side mailbox the message ends up in. Each + * cross-account case also asserts the source copy is gone (no duplicate) and + * the read state survives the move (Email/copy drops keywords unless carried). */ -const { alice, carol } = ACCOUNTS; +const { alice, bob, carol } = ACCOUNTS; const subj = (l: string) => `IT ${l} ${Date.now()}`; test.describe('Shared-folder moves', () => { - let ja: JmapClient; // owner + let ja: JmapClient; // owner A + let jb: JmapClient; // owner B (cross-owner shared → shared) let jc: JmapClient; // grantee let teamA: string; let teamB: string; + let teamC: string; // owned by bob test.beforeEach(async () => { ja = await JmapClient.connect(alice.email, alice.password); + jb = await JmapClient.connect(bob.email, bob.password); jc = await JmapClient.connect(carol.email, carol.password); await ja.reset(); + await jb.reset(); await jc.reset(); teamA = await ja.createSharedFolder('TeamA', carol.email); teamB = await ja.createSharedFolder('TeamB', carol.email); + teamC = await jb.createSharedFolder('TeamC', carol.email); }); - async function seedInto(mailboxId: string, subject: string, owner = ja): Promise { - const acct = owner === ja ? alice : carol; + // Seed a message into a mailbox and mark it read, so a lost $seen after the + // move is observable as the moved copy coming back unread. + async function seedRead(mailboxId: string, subject: string, owner = ja): Promise { + const acct = owner === ja ? alice : owner === jb ? bob : carol; await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' }); const m = await owner.waitForEmail(subject); await owner.moveEmail(m.id, mailboxId); + await owner.setSeen(m.id, true); } - test('shared folder A -> shared folder B', async ({ page }) => { + const seenOf = (m: any) => Boolean(m?.keywords?.$seen); + + test('shared folder A -> shared folder B (same owner)', async ({ page }) => { const s = subj('mv-a2b'); - await seedInto(teamA, s); + await seedRead(teamA, s); await login(page, carol); await expandSharedFolders(page, alice.email); @@ -56,13 +67,15 @@ test.describe('Shared-folder moves', () => { await moveEmailTo(page, s, dest); await page.waitForTimeout(1500); - expect(await ja.findEmailBySubject(s, teamB), 'message in TeamB').toBeTruthy(); + const inB = await ja.findEmailBySubject(s, teamB); + expect(inB, 'message in TeamB').toBeTruthy(); expect(await ja.findEmailBySubject(s, teamA), 'message left TeamA').toBeFalsy(); + expect(seenOf(inB), 'read state kept').toBe(true); }); - test('shared folder B -> shared folder A', async ({ page }) => { + test('shared folder B -> shared folder A (same owner)', async ({ page }) => { const s = subj('mv-b2a'); - await seedInto(teamB, s); + await seedRead(teamB, s); await login(page, carol); await expandSharedFolders(page, alice.email); @@ -73,8 +86,32 @@ test.describe('Shared-folder moves', () => { await moveEmailTo(page, s, dest); await page.waitForTimeout(1500); - expect(await ja.findEmailBySubject(s, teamA), 'message in TeamA').toBeTruthy(); + const inA = await ja.findEmailBySubject(s, teamA); + expect(inA, 'message in TeamA').toBeTruthy(); expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy(); + expect(seenOf(inA), 'read state kept').toBe(true); + }); + + // Cross-owner shared → shared: source is alice's account, destination is bob's, + // so this exercises the true cross-account Email/copy + destroy path. + test('shared folder (owner A) -> shared folder (owner B)', async ({ page }) => { + const s = subj('mv-a2c'); + await seedRead(teamA, s); + + await login(page, carol); + await expandSharedFolders(page, alice.email); + await expandSharedFolders(page, bob.email); + const dest = await folderMailboxId(page, { name: 'TeamC', shared: true }); + await openFolder(page, { name: 'TeamA', shared: true }); + await forceSync(page); + + await moveEmailTo(page, s, dest); + await page.waitForTimeout(2000); + + const inC = await jb.findEmailBySubject(s, teamC); + expect(inC, 'message in bob TeamC').toBeTruthy(); + expect(await ja.findEmailBySubject(s, teamA), 'message left alice TeamA').toBeFalsy(); + expect(seenOf(inC), 'read state kept').toBe(true); }); // The "Move to" submenu relocates a message across the account boundary @@ -82,7 +119,8 @@ test.describe('Shared-folder moves', () => { test('own account -> shared folder', async ({ page }) => { const s = subj('mv-own2sh'); await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' }); - await jc.waitForEmail(s); + const own = await jc.waitForEmail(s); + await jc.setSeen(own.id, true); await login(page, carol); await expandSharedFolders(page, alice.email); @@ -93,13 +131,15 @@ test.describe('Shared-folder moves', () => { await moveEmailTo(page, s, dest); await page.waitForTimeout(2000); - // Expected (once supported): the message moves to the owner's shared TeamA. - expect(await ja.findEmailBySubject(s, teamA), 'message in shared TeamA').toBeTruthy(); + const inTeam = await ja.findEmailBySubject(s, teamA); + expect(inTeam, 'message in shared TeamA').toBeTruthy(); + expect(await jc.findEmailBySubject(s), 'message left own account').toBeFalsy(); + expect(seenOf(inTeam), 'read state kept').toBe(true); }); test('shared folder -> own account', async ({ page }) => { const s = subj('mv-sh2own'); - await seedInto(teamA, s); + await seedRead(teamA, s); await login(page, carol); await expandSharedFolders(page, alice.email); @@ -110,7 +150,9 @@ test.describe('Shared-folder moves', () => { await moveEmailTo(page, s, dest); await page.waitForTimeout(2000); - // Expected (once supported): the message arrives in carol's own Inbox. - expect(await jc.findEmailBySubject(s), 'message in own account').toBeTruthy(); + const inOwn = await jc.findEmailBySubject(s); + expect(inOwn, 'message in own account').toBeTruthy(); + expect(await ja.findEmailBySubject(s, teamA), 'message left shared TeamA').toBeFalsy(); + expect(seenOf(inOwn), 'read state kept').toBe(true); }); }); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index cddecc5b..ca18ff54 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -6400,12 +6400,18 @@ export class JMAPClient implements IJMAPClient { toAccountId: string, destMailboxId: string, ): Promise { + // Email/copy drops keywords unless the create sets them, so carry the + // source's over — otherwise the moved message shows up as unread. + const srcResp = await this.request([ + ["Email/get", { accountId: fromAccountId, ids: [emailId], properties: ["keywords"] }, "0"], + ]); + const keywords = srcResp.methodResponses?.[0]?.[1]?.list?.[0]?.keywords ?? {}; + const response = await this.request([ ["Email/copy", { fromAccountId, accountId: toAccountId, - create: { c: { id: emailId, mailboxIds: { [destMailboxId]: true } } }, - onSuccessDestroyOriginal: true, + create: { c: { id: emailId, mailboxIds: { [destMailboxId]: true }, keywords } }, }, "0"], ]); const res = response.methodResponses?.[0]?.[1]; @@ -6417,6 +6423,19 @@ export class JMAPClient implements IJMAPClient { if (!id) { throw new Error("Email/copy succeeded but no ID returned"); } + + // onSuccessDestroyOriginal is unreliable on Stalwart (implicit destroy reports + // notFound and leaves the original behind), so remove the source explicitly. + const delResp = await this.request([ + ["Email/set", { accountId: fromAccountId, destroy: [emailId] }, "0"], + ]); + const notDestroyed = delResp.methodResponses?.[0]?.[1]?.notDestroyed?.[emailId]; + if (notDestroyed) { + throw new Error( + notDestroyed.description || notDestroyed.type || + "Copied email but failed to remove the original from the source folder", + ); + } return id; } From b48b6e0871e7e0ef3445d4b7039430e0b040c0d0 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:02:40 +0200 Subject: [PATCH 05/42] fix(email): defer source removal on cross-account move to Stalwart The explicit Email/set destroy workaround for the duplicate-on-move bug is removed now that the root cause is filed upstream (support.stalw.art #1150: onSuccessDestroyOriginal destroys the copy's create-id instead of the source id). copyEmailAcrossAccounts keeps requesting onSuccessDestroyOriginal, so the move self-heals once Stalwart ships the fix. Kept: the keyword-preservation fix (carry the source keywords into Email/copy) so the moved message keeps its read state. Tests: 08-shared-moves still asserts delivery + read-state on every cross-account case; the source-removal checks are re-pinned test.fail, scoped to a nested describe, until #1150 is fixed. Suite green (5 pass, 3 expected-fail). --- integration/tests/08-shared-moves.spec.ts | 126 ++++++++++++---------- lib/jmap/client.ts | 18 +--- 2 files changed, 72 insertions(+), 72 deletions(-) diff --git a/integration/tests/08-shared-moves.spec.ts b/integration/tests/08-shared-moves.spec.ts index 32901562..258e1084 100644 --- a/integration/tests/08-shared-moves.spec.ts +++ b/integration/tests/08-shared-moves.spec.ts @@ -15,12 +15,18 @@ import { * Moving mail across the own-account / shared-folder boundary, in both * directions, and between two shared folders (same owner and across owners). * The move is driven from the list context menu's "Move to" submenu; the - * authoritative check is the server-side mailbox the message ends up in. Each - * cross-account case also asserts the source copy is gone (no duplicate) and - * the read state survives the move (Email/copy drops keywords unless carried). + * authoritative check is the server-side mailbox the message ends up in. + * + * Each cross-account case asserts delivery *and* that the read state survives + * (Email/copy drops keywords unless carried). Removing the source, however, is + * currently blocked by a Stalwart bug — onSuccessDestroyOriginal destroys the + * copy's create-id instead of the source id, so the original is left behind + * (support.stalw.art #1150). Those source-removal checks are pinned test.fail + * until Stalwart ships the fix; same-account moves (Email/set) are unaffected. */ const { alice, bob, carol } = ACCOUNTS; const subj = (l: string) => `IT ${l} ${Date.now()}`; +type FolderSel = Parameters[1]; test.describe('Shared-folder moves', () => { let ja: JmapClient; // owner A @@ -54,18 +60,28 @@ test.describe('Shared-folder moves', () => { const seenOf = (m: any) => Boolean(m?.keywords?.$seen); + // Log in as carol, reveal the relevant shared owners, and move `subject` from + // `source` to `dest` via the context menu. + async function uiMove( + page: import('@playwright/test').Page, + opts: { subject: string; owners: string[]; source: FolderSel; dest: FolderSel }, + ): Promise { + await login(page, carol); + for (const o of opts.owners) await expandSharedFolders(page, o); + const destId = await folderMailboxId(page, opts.dest); + await openFolder(page, opts.source); + await forceSync(page); + await moveEmailTo(page, opts.subject, destId); + await page.waitForTimeout(2000); + } + + const inbox: FolderSel = { role: 'inbox', shared: false }; + const shared = (name: string): FolderSel => ({ name, shared: true }); + test('shared folder A -> shared folder B (same owner)', async ({ page }) => { const s = subj('mv-a2b'); await seedRead(teamA, s); - - await login(page, carol); - await expandSharedFolders(page, alice.email); - const dest = await folderMailboxId(page, { name: 'TeamB', shared: true }); - await openFolder(page, { name: 'TeamA', shared: true }); - await forceSync(page); - - await moveEmailTo(page, s, dest); - await page.waitForTimeout(1500); + await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamA'), dest: shared('TeamB') }); const inB = await ja.findEmailBySubject(s, teamB); expect(inB, 'message in TeamB').toBeTruthy(); @@ -76,15 +92,7 @@ test.describe('Shared-folder moves', () => { test('shared folder B -> shared folder A (same owner)', async ({ page }) => { const s = subj('mv-b2a'); await seedRead(teamB, s); - - await login(page, carol); - await expandSharedFolders(page, alice.email); - const dest = await folderMailboxId(page, { name: 'TeamA', shared: true }); - await openFolder(page, { name: 'TeamB', shared: true }); - await forceSync(page); - - await moveEmailTo(page, s, dest); - await page.waitForTimeout(1500); + await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamB'), dest: shared('TeamA') }); const inA = await ja.findEmailBySubject(s, teamA); expect(inA, 'message in TeamA').toBeTruthy(); @@ -92,67 +100,67 @@ test.describe('Shared-folder moves', () => { expect(seenOf(inA), 'read state kept').toBe(true); }); - // Cross-owner shared → shared: source is alice's account, destination is bob's, - // so this exercises the true cross-account Email/copy + destroy path. - test('shared folder (owner A) -> shared folder (owner B)', async ({ page }) => { + // Cross-account cases: delivery + read state must hold (our fix); removing the + // source is pinned test.fail below (Stalwart #1150). + test('cross-owner shared -> shared: delivers and keeps read state', async ({ page }) => { const s = subj('mv-a2c'); await seedRead(teamA, s); - - await login(page, carol); - await expandSharedFolders(page, alice.email); - await expandSharedFolders(page, bob.email); - const dest = await folderMailboxId(page, { name: 'TeamC', shared: true }); - await openFolder(page, { name: 'TeamA', shared: true }); - await forceSync(page); - - await moveEmailTo(page, s, dest); - await page.waitForTimeout(2000); + await uiMove(page, { subject: s, owners: [alice.email, bob.email], source: shared('TeamA'), dest: shared('TeamC') }); const inC = await jb.findEmailBySubject(s, teamC); expect(inC, 'message in bob TeamC').toBeTruthy(); - expect(await ja.findEmailBySubject(s, teamA), 'message left alice TeamA').toBeFalsy(); expect(seenOf(inC), 'read state kept').toBe(true); }); - // The "Move to" submenu relocates a message across the account boundary - // (own ↔ shared folder) via copy+delete, matching drag-and-drop. - test('own account -> shared folder', async ({ page }) => { + test('own account -> shared folder: delivers and keeps read state', async ({ page }) => { const s = subj('mv-own2sh'); await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' }); const own = await jc.waitForEmail(s); await jc.setSeen(own.id, true); - - await login(page, carol); - await expandSharedFolders(page, alice.email); - const dest = await folderMailboxId(page, { name: 'TeamA', shared: true }); - await openFolder(page, { role: 'inbox', shared: false }); - await forceSync(page); - - await moveEmailTo(page, s, dest); - await page.waitForTimeout(2000); + await uiMove(page, { subject: s, owners: [alice.email], source: inbox, dest: shared('TeamA') }); const inTeam = await ja.findEmailBySubject(s, teamA); expect(inTeam, 'message in shared TeamA').toBeTruthy(); - expect(await jc.findEmailBySubject(s), 'message left own account').toBeFalsy(); expect(seenOf(inTeam), 'read state kept').toBe(true); }); - test('shared folder -> own account', async ({ page }) => { + test('shared folder -> own account: delivers and keeps read state', async ({ page }) => { const s = subj('mv-sh2own'); await seedRead(teamA, s); - - await login(page, carol); - await expandSharedFolders(page, alice.email); - const dest = await folderMailboxId(page, { role: 'inbox', shared: false }); - await openFolder(page, { name: 'TeamA', shared: true }); - await forceSync(page); - - await moveEmailTo(page, s, dest); - await page.waitForTimeout(2000); + await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamA'), dest: inbox }); const inOwn = await jc.findEmailBySubject(s); expect(inOwn, 'message in own account').toBeTruthy(); - expect(await ja.findEmailBySubject(s, teamA), 'message left shared TeamA').toBeFalsy(); expect(seenOf(inOwn), 'read state kept').toBe(true); }); + + // Pinned failing: Stalwart's onSuccessDestroyOriginal leaves the original in + // place on a cross-account copy (support.stalw.art #1150). Un-pin once fixed + // upstream (our copyEmailAcrossAccounts already requests the destroy). + test.describe('source is removed after a cross-account move', () => { + test.fail(true, 'blocked by Stalwart #1150 (onSuccessDestroyOriginal destroys wrong id)'); + + test('cross-owner shared -> shared', async ({ page }) => { + const s = subj('rm-a2c'); + await seedRead(teamA, s); + await uiMove(page, { subject: s, owners: [alice.email, bob.email], source: shared('TeamA'), dest: shared('TeamC') }); + expect(await ja.findEmailBySubject(s, teamA), 'original left alice TeamA').toBeFalsy(); + }); + + test('own account -> shared folder', async ({ page }) => { + const s = subj('rm-own2sh'); + await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' }); + const own = await jc.waitForEmail(s); + await jc.setSeen(own.id, true); + await uiMove(page, { subject: s, owners: [alice.email], source: inbox, dest: shared('TeamA') }); + expect(await jc.findEmailBySubject(s), 'original left own account').toBeFalsy(); + }); + + test('shared folder -> own account', async ({ page }) => { + const s = subj('rm-sh2own'); + await seedRead(teamA, s); + await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamA'), dest: inbox }); + expect(await ja.findEmailBySubject(s, teamA), 'original left shared TeamA').toBeFalsy(); + }); + }); }); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index ca18ff54..771eaed2 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -6407,11 +6407,16 @@ export class JMAPClient implements IJMAPClient { ]); const keywords = srcResp.methodResponses?.[0]?.[1]?.list?.[0]?.keywords ?? {}; + // onSuccessDestroyOriginal is the spec-correct way to remove the source, but + // Stalwart currently destroys the copy's create-id instead of the source id, + // so the original is left behind — a duplicate on every cross-account move. + // Reported upstream (support.stalw.art #1150); this self-heals once fixed. const response = await this.request([ ["Email/copy", { fromAccountId, accountId: toAccountId, create: { c: { id: emailId, mailboxIds: { [destMailboxId]: true }, keywords } }, + onSuccessDestroyOriginal: true, }, "0"], ]); const res = response.methodResponses?.[0]?.[1]; @@ -6423,19 +6428,6 @@ export class JMAPClient implements IJMAPClient { if (!id) { throw new Error("Email/copy succeeded but no ID returned"); } - - // onSuccessDestroyOriginal is unreliable on Stalwart (implicit destroy reports - // notFound and leaves the original behind), so remove the source explicitly. - const delResp = await this.request([ - ["Email/set", { accountId: fromAccountId, destroy: [emailId] }, "0"], - ]); - const notDestroyed = delResp.methodResponses?.[0]?.[1]?.notDestroyed?.[emailId]; - if (notDestroyed) { - throw new Error( - notDestroyed.description || notDestroyed.type || - "Copied email but failed to remove the original from the source folder", - ); - } return id; } From f188e29152fb0decc2243976e83c54a85d93d3b6 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:04:15 +0200 Subject: [PATCH 06/42] feat(settings): always show the Unified Mailbox switch in Layout settings Drop the `accounts.length > 1 || hasGroupInboxes` gate that hid the Unified Mailbox toggle for single-account users with no visible shared folder. The admin `isSettingHidden('enableUnifiedMailbox')` policy gate is preserved, so admins can still hide it. --- components/settings/layout-settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/settings/layout-settings.tsx b/components/settings/layout-settings.tsx index c6112c4e..9cc305d1 100644 --- a/components/settings/layout-settings.tsx +++ b/components/settings/layout-settings.tsx @@ -245,7 +245,7 @@ export function LayoutSettings() { /> - {(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && ( + {!isSettingHidden('enableUnifiedMailbox') && ( Date: Sat, 25 Jul 2026 17:38:55 +0200 Subject: [PATCH 07/42] docs: fix facts and rewrite tone --- .env.example | 4 +- CONTRIBUTING.md | 92 ++++++++++---- FEATURES.md | 208 ++++++++++++++++---------------- README.md | 102 +++++++++++----- hooks/use-keyboard-shortcuts.ts | 5 +- 5 files changed, 251 insertions(+), 160 deletions(-) diff --git a/.env.example b/.env.example index 6036141b..2d47077f 100644 --- a/.env.example +++ b/.env.example @@ -274,7 +274,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # # Fallback UI locale used when the visitor's Accept-Language header does not # match any supported locale. Defaults to "en". -# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh +# Supported: ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, +# pt, ro, ru, sk, tr, uk, zh +# An unsupported value falls back to "en". # NEXT_PUBLIC_DEFAULT_LOCALE=tr # Locale prefix mode for URLs. Recommended "always" when proxying under a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d941e32..67f38b82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,13 +10,13 @@ # Contributing to Bulwark Webmail -We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale. +We're writing the webmail we wanted in 2026 and didn't find: a JMAP-native client with an interface built this decade. It's AGPL and self-hosted, run by the people who use it rather than sold to them. -If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change. +If that sounds like your kind of project, we'd love the help. ## Join the Community -You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this. +You don't need to be an expert to contribute. A dev environment that won't start, a bug you're not sure how to report, a translation you're stuck on: Discord is the fastest way to get unstuck and to meet the people working on this. - **Get support** - real-time help with development hurdles - **Share ideas** - feature suggestions, design feedback, doc improvements @@ -46,15 +46,21 @@ You don't need to be an expert to contribute. Whether you're setting up your dev 3. **Set up environment**: ```bash - cp .env.example .env.local - # Edit .env.local with your JMAP server URL + cp .env.dev.example .env.local ``` + This enables the built-in mock JMAP server (`DEV_MOCK_JMAP=true`), so you can + develop without a mail server. Log in with any username and password. To work + against a real server instead, copy `.env.example` and set `JMAP_SERVER_URL`. + 4. **Start development server**: + ```bash npm run dev ``` + Then open http://localhost:3000. + ### Code Quality Before submitting a pull request, ensure your code passes all checks: @@ -72,6 +78,19 @@ npm run lint:fix These checks run automatically on commit via Husky pre-commit hooks. +### Testing + +| Suite | Command | What it covers | +| ---------------- | -------------------------- | ------------------------------------------------------------------ | +| **Unit** | `npx vitest run` | Vitest + jsdom. Tests live in `__tests__/` folders next to the code | +| **Translations** | `npm run test:translations` | Locale files checked for structural drift against English | +| **Integration** | `npm run test:integration` | Playwright against a real Stalwart server in Docker | +| **E2E smoke** | `npx playwright test` | UI smoke tests against `npm run dev` | + +Run a single unit test file with `npx vitest run lib/__tests__/.test.ts`, or `npx vitest` to watch. + +The integration suite needs Docker and takes several minutes; it has its own setup notes and findings log in [integration/README.md](integration/README.md). New behavior that touches mail/folder synchronization or multi-account handling belongs there. + ## Code Style Guidelines ### TypeScript @@ -97,7 +116,9 @@ These checks run automatically on commit via Husky pre-commit hooks. ## Internationalization (i18n) -This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh). +This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 23 additional locales (ar, ca, cs, da, de, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, sk, tr, uk, zh). + +Arabic, Hebrew, and Persian render right-to-left (see `i18n/direction.ts`). Use Tailwind's **logical** utilities (`ms-*`/`me-*`, `ps-*`/`pe-*`, `start-*`/`end-*`) rather than physical ones (`ml-*`, `pl-*`, `left-*`) so layouts flip correctly. For popovers positioned in JS via `getBoundingClientRect()`, check `isDocumentRTL()`: inline `position: fixed` styles don't pick up logical utilities. ### Rules @@ -126,6 +147,19 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour router.push(`/${params.locale}/settings`); ``` +### Adding a new locale + +Registering a new locale takes edits in four places: + +1. `locales//common.json` - copy `locales/en/common.json` and translate +2. `i18n/routing.ts` - add the code to `SUPPORTED_LOCALES` +3. `i18n/request.ts` - add a `case` to the static-import switch +4. `components/ui/language-switcher.tsx` - add `{ value, label }` with the **native** language name, plus a flag in `components/ui/flag-icons.tsx` + +For a right-to-left language, also add the code to `rtlLocales` in `i18n/direction.ts`. + +Run `npm run test:translations` afterwards - it checks the locale files for structural drift against English. + ## Pull Request Process ### Before Submitting @@ -138,13 +172,13 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour 2. **Make your changes** following the code style guidelines -3. **Test your changes** thoroughly +3. **Test your changes** thoroughly, and add unit tests for new logic 4. **Update translations** if you added user-facing text 5. **Run all checks**: ```bash - npm run typecheck && npm run lint + npm run typecheck && npm run lint && npx vitest run ``` ### Submitting @@ -181,21 +215,33 @@ docs: update README with keyboard shortcuts ``` webmail/ -├── app/ # Next.js App Router pages -│ └── [locale]/ # Locale-aware routing -├── components/ # React components -│ ├── email/ # Email-related components -│ ├── layout/ # Layout components -│ ├── settings/ # Settings components -│ └── ui/ # Reusable UI components -├── contexts/ # React contexts -├── hooks/ # Custom React hooks -├── lib/ # Utilities and libraries -│ └── jmap/ # JMAP client implementation -├── locales/ # Translation files -│ ├── en/ # English translations -│ └── fr/ # French translations -└── stores/ # Zustand state stores +├── app/ # Next.js App Router +│ ├── (main)/[locale]/ # Locale-aware app pages (mail, calendar, contacts, files, settings) +│ ├── (main)/admin/ # Admin dashboard +│ ├── (main)/setup/ # First-launch setup wizard +│ ├── (sandbox)/ # Isolated plugin sandbox routes +│ └── api/ # Route handlers (auth, admin, jmap, caldav, …) +├── components/ # React components +│ ├── email/ # Email list, viewer, composer +│ ├── calendar/ contacts/ files/ filters/ templates/ +│ ├── layout/ # Sidebar, shell, navigation +│ ├── settings/ # Settings panels +│ ├── plugins/ # Plugin host UI +│ └── ui/ # Reusable primitives +├── contexts/ # React contexts +├── hooks/ # Custom React hooks +├── i18n/ # next-intl routing, locale detection, RTL direction +├── lib/ # Utilities and libraries +│ ├── jmap/ # JMAP client implementation +│ ├── stalwart/ # Stalwart-specific admin/API helpers +│ ├── admin/ auth/ oauth/ # Config, sessions, OAuth flows +│ ├── plugin-sandbox/ # Plugin sandbox bridge and hardening +│ └── __tests__/ # Vitest unit tests +├── locales/ # Translation files, one directory per locale +├── stores/ # Zustand state stores +├── public/ # Static assets and branding +├── e2e/ # Playwright smoke tests (against `npm run dev`) +└── integration/ # Dockerized Stalwart + Playwright suite ``` ## Security diff --git a/FEATURES.md b/FEATURES.md index 1043c7c1..c2cd4c08 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2,145 +2,147 @@ ## Mail -- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables) -- Gmail-style threading with inline expansion and an optional conversation toggle -- Unified Mailbox – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account -- Aggregated All mail / Unread / Starred entries in the Unified Mailbox – scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message -- Search inside the Unified Mailbox – text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes -- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom -- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies -- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning -- Scheduled send and configurable send delay +- Read, compose, reply, reply-all, and forward in a Tiptap rich-text editor that handles inline images, drag-and-drop embedding, and tables +- Gmail-style threading, expanded inline, with a conversation toggle you can switch off +- The Unified Mailbox combines Inbox, Sent, Drafts, Junk, Archive, and Trash. By default it stays inside the active account and its shared/group folders; an admin can unlock a cross-account mode that spans every connected account. +- All mail, Unread, and Starred obey that same account boundary and can be narrowed to a per-account folder selection. Every row names the folder its message came from. +- Search runs across all unified views; the per-role mailboxes add the full filter panel on top +- Three mail layouts: split three-pane, focused list, or reading pane at the bottom +- Drafts auto-save, keeping the chosen identity, the HTML body, and correct `In-Reply-To` / `References` headers on replies +- Attachments upload, download, drag out to the file system, and preview inline. Images and PDFs render on desktop and mobile, composer attachments open on click, and `.eml` (`message/rfc822`) parts display as a nested email. There are list thumbnails, and a warning when you mention an attachment and forget it. +- Scheduled send, plus a configurable delay before anything leaves the outbox - Read receipts (MDN, RFC 8098) -- Editable, layout-preserving quote island when replying -- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries -- Batch operations – multi-select, archive, delete, move, tag -- Archive modes – direct, by year, or by month -- Multi-tag support with color labels, reordering, and drag-and-drop assignment -- Star/unstar with configurable mark-as-read delay -- Virtual scrolling for large mailboxes plus prefetching of initial email data on login -- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers -- Plain-text composer mode and Reply-To support -- Configurable signature position (above or below quoted text) per identity -- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity -- `.eml` file import via folder right-click menu +- Quoted text lands in an editable island that keeps the original layout +- Full-text search with a JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries +- Multi-select for batch archive, delete, move, and tag +- Archive directly, by year, or by month +- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them +- Star or unstar, with a configurable mark-as-read delay +- Large mailboxes scroll virtually, and the first page of mail prefetches at login +- Quick reply, hover actions, favicon-based sender avatars, recipient popovers +- Plain-text composer mode and Reply-To +- The signature sits above or below the quoted text, per identity +- Override the From header in the composer. Reply to an alias on a domain you own and it auto-fills as the sender, even when no identity exists for it. +- Import `.eml` files from the folder right-click menu - TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping -- Folder management with icon picker, subfolders, and sidebar counts -- Print directly from the viewer -- Browser history sync for back/forward navigation +- Folders take an icon, nest, and show counts in the sidebar +- Print from the viewer +- Browser back and forward move through mail history ## Calendar -- Month, week, day, and agenda views with a mini-calendar sidebar and task list -- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap -- Recurring events with scoped edit/delete (this / this and following / all) -- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment -- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import -- iCalendar import with preview, bulk create, and UID deduplication -- iCal / webcal subscriptions with editing and batch import -- Auto-generated birthday calendar from contacts -- Virtual locations (video conference URLs) as first-class event fields -- Task management with due dates, priority, and completion status -- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors -- Week numbers, event hover preview, notifications with sound picker -- Real-time sync via JMAP push +- Month, week, day, and agenda views, with a mini-calendar and task list in the sidebar +- Drag an event to reschedule it, click-drag to create one, pull an edge to resize. Everything snaps to 15 minutes. +- Recurring events edit and delete by scope: this occurrence, this and following, or all +- iMIP invitations on create and update (RFC 5545 / 6047), an organizer/attendee panel, and RSVP with trust assessment +- `.ics` attachments are detected in the email viewer, so you can RSVP or import without leaving the message +- iCalendar import previews first, then bulk-creates, deduplicating on UID +- iCal / webcal subscriptions, editable, with batch import +- A birthday calendar generated from your contacts +- Virtual locations (video-conference URLs) are first-class event fields +- Tasks with due dates, priority, and completion status +- Shared calendars through CalDAV discovery, resolving homes across accounts, colored per viewer +- Week numbers, hover preview, notifications with a sound picker +- JMAP push keeps everything in sync ## Contacts -- JMAP sync (RFC 9553 / 9610) with local fallback -- Multiple address books with drag-and-drop between books -- Contact groups with member management -- vCard import/export (RFC 6350) with duplicate detection -- Trusted senders stored in a dedicated JMAP address book -- Autocomplete in the composer (To / Cc / Bcc) +- JMAP sync (RFC 9553 / 9610), falling back to local storage +- Several address books, with drag-and-drop between them +- Groups with member management +- vCard import/export (RFC 6350) that flags duplicates +- Trusted senders live in their own JMAP address book +- Autocomplete on To, Cc, and Bcc ## Filters & Templates -- Server-side filters via JMAP Sieve Scripts (RFC 9661) -- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…) -- Preserves rules authored in other clients +- Server-side filters as JMAP Sieve Scripts (RFC 9661) +- A visual rule builder: conditions on From, To, Subject, Size, Body, Attachment and more, each matching multiple values, with actions to move, forward, star, or discard +- Rules written in other clients survive the round-trip - Raw Sieve editor with syntax validation -- Vacation responder with date range scheduling -- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …) +- A vacation responder you can schedule to a date range +- Templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …) ## Files -- JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load -- Streamed WebDAV PUT upload and folder upload with progress tracking -- Dynamic upload limits based on server configuration -- Grid and list views with sorting by name, size, or date -- Previews for images, text, audio, and video -- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files -- JMAP sharing (RFC 9670) for files and folders – share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you +- Browse Stalwart's native JMAP FileNode storage as a real folder tree. Legacy flat-named files migrate into nested `FileNode` folders on first load. +- Streamed WebDAV PUT upload, whole folders included, with progress +- Upload limits follow the server's own configuration +- Grid or list, sorted by name, size, or date +- Preview images, text, audio, and video +- Cut, copy, paste, duplicate; favorites; recent files +- JMAP sharing (RFC 9670) for files and folders. Pick a user or group from the principal picker and grant read, read/write, or manager. Shared items get an indicator, and anything other principals share with you appears under "Shared with me". ## Security & Privacy -- External content blocked by default, with a trusted senders list -- HTML sanitization via DOMPurify -- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation -- SPF / DKIM / DMARC status indicators – surfaces the most severe SPF result and hides the "via" badge on spoofed mail -- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments +- External content stays blocked until you say otherwise, and trusted senders are remembered +- HTML sanitized through DOMPurify +- S/MIME: manage certificates, then sign, encrypt, decrypt, and verify. Legacy 3DES / PBE is supported, and keys stay isolated per account. +- SPF / DKIM / DMARC indicators surface the most severe SPF result and drop the "via" badge on spoofed mail +- OAuth2 / OIDC with PKCE against Keycloak, Authentik, or the built-in provider, plus OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments - TOTP two-factor authentication -- Account security panel for password and 2FA management via the Stalwart admin API -- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie -- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention -- Plugin hardening with dangerous-pattern detection and admin approval +- Password and 2FA management through the Stalwart admin API +- "Remember me" is optional and rides an AES-256-GCM encrypted httpOnly cookie +- CSP is enforced with a per-request nonce, alongside SSRF redirect validation, a sandboxed PDF iframe, and IP spoofing prevention +- Plugins are scanned for dangerous patterns and need admin approval - Newsletter unsubscribe (RFC 2369) ## Interface -- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns -- Dark and light themes with intelligent email color transformation -- Bundled color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips -- Responsive desktop, tablet, and mobile layouts +- Split three-pane, focused list, or bottom reading pane, columns resizable +- Dark and light themes. Email colors are remapped by luminance, so a mail hard-coded to dark-on-white stays readable on a dark background. +- Bundled themes such as Aurora Glass and Elastic. Each theme card renders as a miniature mailbox built from that theme's own colors, with chips for the light and dark variants. +- Layouts for desktop, tablet, and mobile - Full keyboard navigation -- Drag-and-drop email organization and tag assignment -- Interactive guided tour for new users -- Right-click context menus, toast notifications with undo -- Customizable toolbar position, favicon, and login branding -- Pinnable sidebar apps with drag-and-drop reordering -- Encrypted settings sync across devices +- Drag and drop to organize mail and assign tags +- A guided tour for first-time users +- Right-click menus, and toasts that offer an undo +- Toolbar position, favicon, and login branding are configurable +- Sidebar apps pin and reorder by drag +- Settings sync between devices, encrypted - Storage quota display -- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions +- WCAG AA contrast, reduced-motion support, focus traps, and screen-reader live regions ## Internationalization -19 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文 +24 languages: Català · Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Slovenčina · Türkçe · Русский · Українська · עברית · العربية · فارسی · 한국어 · 日本語 · 简体中文 -Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. +- Arabic, Hebrew, and Persian render right-to-left; document direction and logical layout flip automatically +- The browser's `Accept-Language` picks the first language, and the choice persists per user +- `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback, `NEXT_PUBLIC_LOCALE_PREFIX` the URL prefix ## Identity & Multi-Account -- Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1) -- Account switcher with connection status and default account selection -- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list -- Configurable signature position (above or below quoted text) -- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions +- Run several accounts at once and switch instantly, each keeping its own session. The 5-account cap lifts on HTTP/2 servers; on HTTP/1.1, browser connection pooling still sets the limit. +- An account switcher showing connection status, and a default account +- Multiple sender identities, each with its own signature, synced automatically and badged in the viewer and list +- Signature above or below the quoted text +- Sub-addressing (`user+tag@domain.com`), delimiter configurable, with tag suggestions drawn from context - Shared folders across accounts -- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the Unified Mailbox ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync -- Multiple JMAP servers per deployment with optional auto-pick by email domain -- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) +- Shared and group (delegated) accounts put their folders next to your own, and "Include group inboxes" merges them into the Unified Mailbox. You can open, mark read, flag as spam or not-spam, move, delete, and archive their messages from there, and folder unread counts stay in step. +- Several JMAP servers per deployment, optionally auto-picked by email domain +- Custom JMAP endpoints on the login form, when `ALLOW_CUSTOM_JMAP_ENDPOINT` permits it ## Admin & Extensibility -- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required -- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page -- Admin policy gates for the Unified Mailbox – enable or disable the All mail / Unread / Starred entries org-wide, plus a cross-account capability gate (off by default; auto-enabled on upgrade for instances that already used the cross-account views); each gated view still respects the user's own toggle -- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps) -- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts -- Admin toggle for search-engine indexing (`robots.txt` / `noindex`) -- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement -- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins` -- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles -- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard -- Bundled plugins including Jitsi Meet calendar integration +- A setup wizard runs on first launch and walks through JMAP servers, OAuth/OIDC, the session secret, logging, branding (uploads included), and the admin password. It writes to the admin config dir, so `.env.local` stays untouched. +- The Stalwart admin dashboard, its policy sections collapsed into one tabbed page +- Admin policy gates for the Unified Mailbox: turn All mail / Unread / Starred on or off org-wide, and gate cross-account capability separately (off by default, auto-enabled on upgrade for instances already using it). A gated view still respects the user's own toggle. +- Admin storage splits in two. `ADMIN_CONFIG_DIR` is operator-authored and can be mounted read-only once setup finishes; `ADMIN_STATE_DIR` holds the runtime audit log and login timestamps. +- JSON config can read secrets from files (`passwordHashFile`, `sessionSecretFile`, `oauthClientSecretFile`) for Docker and Kubernetes secret mounts +- An admin toggle controls search-engine indexing (`robots.txt` / `noindex`) +- Plugin system: a schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (sandboxed plugins localize through manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement +- Plugins hot-reload, load from a dev folder, bundle `src/` on demand through esbuild, and can request `http:fetch` scoped by `httpOrigins` +- Themes upload as ZIP bundles, and admins can enforce one +- An extension marketplace browses and installs plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`). Installing and uninstalling stay in the admin dashboard. +- Bundled plugins, including Jitsi Meet for the calendar ## Operations -- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots -- Automatic update check with server-side logging of new releases and a non-dismissible update notice -- Structured logging (`text` or `json`) with category-based levels -- Anonymous instance telemetry (opt-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) – version, platform, bucketed account counts, feature toggles only -- Release (`main`) and development (`dev`) Docker images on GHCR -- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy -- Demo mode with fixture data – no mail server required +- Progressive Web App: service worker, install prompt, web push for new inbox mail, a dynamic manifest, and install screenshots configurable per domain +- Update checks run on their own, log new releases server-side, and raise a notice that can't be dismissed +- Structured logging (`text` or `json`) with per-category levels +- Anonymous instance telemetry, off unless you enable it through the admin UI, the installer, or `BULWARK_TELEMETRY=on`. It reports version, platform, bucketed account counts, and feature toggles. +- Docker images on GHCR, for release (`main`) and development (`dev`) +- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy +- Demo mode runs on fixture data, no mail server required diff --git a/README.md b/README.md index de76d879..63a4d73a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ # Bulwark Webmail -A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol. +A self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) @@ -20,12 +20,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar ## Installer -New in **1.6.4**: a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container. - - - - Setup wizard - +Since **1.6.4**, a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container. Point a browser at the running container and the wizard guides you through: @@ -70,21 +65,21 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM Settings -Light mode – full theme support with intelligent color transformation for HTML emails. +Light mode – full theme support, remapping HTML email colors by luminance so dark-on-dark text stays readable. Settings – appearance, identities, filters, templates, security, and more. ## Overview -Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login: +Bulwark is a full webmail suite. It bundles the four apps most self-hosters end up wanting: - **Mail** – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates - **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions - **Contacts** – multiple address books, groups, vCard import/export - **Files** – Stalwart's JMAP FileNode storage with previews and folder upload -Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard. +They share one login, one settings store, and one admin dashboard. SSO, 2FA, multi-account, 24 languages, PWA install, themes, and plugins apply across all four. Full feature list: **[FEATURES.md](FEATURES.md)**. @@ -104,7 +99,7 @@ Or with Docker Compose: docker compose up -d ``` -On first launch, open `http://localhost:3000` – the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration). +On first launch, open `http://localhost:3000` and the setup wizard takes over. Installs that already define `JMAP_SERVER_URL` skip it and keep the env-managed flow under [Configuration](#configuration). ### From Source @@ -119,16 +114,20 @@ npm run build && npm start ### Development ```bash -npm run dev # Dev server with a mock JMAP server +cp .env.dev.example .env.local # Built-in mock JMAP server, no mail server needed + +npm run dev # Dev server npm run typecheck npm run lint +npx vitest run # Unit tests +npm run test:integration # Dockerized Stalwart + Playwright suite (see integration/README.md) ``` ## Configuration -Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI. +Most deployments are configured through the setup wizard on first launch, then the admin dashboard; those values live in the admin config directory rather than `.env.local`. Environment variables still work, and they suit read-only or immutable infrastructure better. An environment variable always wins over the admin-managed value, so setting `JMAP_SERVER_URL` hides that field from the wizard and locks it in the admin UI. -All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`: +Nearly all variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. The exceptions are the `NEXT_PUBLIC_*` ones noted below, which Next.js bakes in at build time. Edit `.env.local`: ```env # Optional – overrides whatever the wizard writes @@ -151,13 +150,28 @@ PORT=3000 ```env OAUTH_ENABLED=true +OAUTH_ONLY=true # hide the username/password form entirely OAUTH_CLIENT_ID=webmail OAUTH_CLIENT_SECRET= # optional, for confidential clients OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret OAUTH_ISSUER_URL= # optional, for external IdPs +OAUTH_AUTHORIZE_URL= # override only the user-facing authorize endpoint +OAUTH_ALLOW_PRIVATE_ENDPOINTS= # allow discovery to resolve to RFC-1918 addresses ``` -Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. +Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. `OAUTH_ALLOW_PRIVATE_ENDPOINTS` is off by default as an SSRF guard. Enable it only for split-DNS deployments where the issuer's public hostname resolves to an internal IP. + + + +
+Anonymous telemetry + +```env +BULWARK_TELEMETRY=on # opt-in; off by default +TELEMETRY_DATA_DIR=./data/telemetry # instance id and consent; mount a volume +``` + +Off unless you turn it on, in the admin UI, the installer, or here. Heartbeats carry version, platform, bucketed account counts, and feature toggles. No email addresses, hostnames, or IPs. Setting the variable (to either value) locks the choice and disables the admin toggle.
@@ -255,6 +269,25 @@ The split lets you mount the config volume read-only after the setup wizard comp +
+Default UI locale + +The UI language follows each visitor's `Accept-Language` header and their stored preference. `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback used when neither matches a supported locale (default `en`): + +```env +NEXT_PUBLIC_DEFAULT_LOCALE=de +``` + +Supported: `ar`, `ca`, `cs`, `da`, `de`, `en`, `es`, `fa`, `fr`, `he`, `hu`, `it`, `ja`, `ko`, `lv`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `tr`, `uk`, `zh`. An unsupported value falls back to `en`. + +Like `NEXT_PUBLIC_BASE_PATH`, this is read at **build time**. To use it with the published Docker image, build your own: + +```bash +docker build --build-arg NEXT_PUBLIC_DEFAULT_LOCALE=de -t bulwark-webmail . +``` + +
+
Subpath / reverse proxy mount @@ -271,37 +304,46 @@ Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** b docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail . ``` -Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly. +Then point your reverse proxy at the container without stripping the prefix. The app expects requests under `/webmail/...` and serves every route (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, and so on) accordingly.
## Keyboard Shortcuts -| Key | Action | -| ------------- | ----------------------- | -| `j` / `k` | Navigate between emails | -| `Enter` / `o` | Open email | -| `Esc` | Close / deselect | -| `c` | Compose | -| `r` / `R` | Reply / Reply all | -| `f` | Forward | -| `s` | Star | -| `e` | Archive | -| `#` | Delete | -| `/` | Search | -| `?` | Show all shortcuts | +| Key | Action | +| -------------------- | ----------------------- | +| `j` `↓` / `k` `↑` | Navigate between emails | +| `Enter` / `o` | Open email | +| `Esc` | Close / deselect | +| `x` | Expand / collapse thread | +| `c` | Compose | +| `r` / `R` `a` | Reply / Reply all | +| `f` | Forward | +| `s` | Star | +| `e` | Archive | +| `#` / `Del` | Delete | +| `u` / `Shift`+`I` | Mark unread / read | +| `!` | Toggle spam | +| `Ctrl`+`A` | Select all | +| `Shift`+`G` | Refresh | +| `/` | Search | +| `?` | Show all shortcuts | + +In the composer: `Ctrl/Cmd`+`Enter` sends, `Ctrl/Cmd`+`Shift`+`Enter` opens scheduled send, and `t` opens the template picker. ## Tech Stack | | | | ------------- | ------------------------------------------------- | -| **Framework** | [Next.js 16](https://nextjs.org/) with App Router | +| **Framework** | [Next.js 16](https://nextjs.org/) with App Router, React 19 | | **Language** | TypeScript | | **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) | | **State** | [Zustand](https://zustand-demo.pmnd.rs/) | | **Protocol** | Custom JMAP client (RFC 8620) | +| **Editor** | [Tiptap](https://tiptap.dev/) | | **i18n** | [next-intl](https://next-intl-docs.vercel.app/) | | **Icons** | [Lucide React](https://lucide.dev/) | +| **Testing** | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) | ## Why Stalwart? diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts index 03134a72..a648a511 100644 --- a/hooks/use-keyboard-shortcuts.ts +++ b/hooks/use-keyboard-shortcuts.ts @@ -303,9 +303,8 @@ export const KEYBOARD_SHORTCUTS = { { key: "x", description: "shortcuts.threads.expand_collapse" }, ], composer: [ - { key: "Ctrl + Enter", description: "shortcuts.composer.send" }, - { key: "Ctrl + Shift + Enter", description: "shortcuts.composer.schedule_send" }, - { key: "t", description: "shortcuts.composer.template_picker" }, { key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" }, + { key: "Ctrl/Cmd + Shift + Enter", description: "shortcuts.composer.schedule_send" }, + { key: "t", description: "shortcuts.composer.template_picker" }, ], } as const; From 934967b9df93ab9f880b3d18e78aa7247164e1fe Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:46:12 +0200 Subject: [PATCH 08/42] docs: document remaining env vars in env templates --- .env.dev.example | 10 +++ .env.example | 159 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/.env.dev.example b/.env.dev.example index efc75a09..d26b9f14 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -39,6 +39,16 @@ SETTINGS_SYNC_ENABLED=true LOG_FORMAT=text LOG_LEVEL=debug +# ============================================================================= +# Plugin Development +# ============================================================================= + +# Load plugins from a directory on disk instead of installing them as ZIPs. +# Each immediate subfolder is one plugin and needs a manifest.json. When the +# manifest's entrypoint exists under src/, it's bundled on demand with esbuild, +# so you can edit sources and just refresh the browser. +# PLUGIN_DEV_DIR=../my-plugins + # ============================================================================= # Login Page Customization (optional) # ============================================================================= diff --git a/.env.example b/.env.example index 2d47077f..065c9f92 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Access-Control-Allow-Origin header, or browser requests will be blocked. # ALLOW_CUSTOM_JMAP_ENDPOINT=true +# Offer several JMAP servers on the login form. JSON array; each entry needs +# id, label, and url. "domains" and a per-server "oauth" block are optional. +# Prefer configuring this from the admin dashboard - the env form exists for +# stateless deployments. +# 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, +# matching against each entry's "domains" list. Default: false. +# JMAP_SERVER_AUTO_PICK_BY_DOMAIN=true + # ============================================================================= # Stalwart Mail Server Integration # ============================================================================= @@ -59,6 +69,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com # OAuth issuer's public hostname resolves to an internal IP from this server. # OAUTH_ALLOW_PRIVATE_ENDPOINTS=true +# Replace the scopes requested at authorization. Space-separated. Leave unset +# to use the defaults the client already asks for. +# OAUTH_SCOPES=openid email profile offline_access + +# Append scopes instead of replacing them. Use this when your IdP needs one +# extra scope and you don't want to restate the defaults. +# OAUTH_EXTRA_SCOPES=groups + +# Send the user straight to the identity provider, skipping the login form. +# Intended for embedded deployments where the parent app already authenticated +# them. Default: false. +# AUTO_SSO_ENABLED=true + # ============================================================================= # Session & Security # ============================================================================= @@ -132,6 +155,17 @@ JMAP_SERVER_URL=https://your-jmap-server.com # so the instance id and consent choice survive upgrades. # TELEMETRY_DATA_DIR=./data/telemetry +# Legacy kill switch, honoured only when BULWARK_TELEMETRY is unset. +# BULWARK_TELEMETRY_DISABLED=1 + +# Let heartbeats reach a private/loopback address. Off by default as an SSRF +# guard; only useful when running a collector locally during development. +# BULWARK_TELEMETRY_ALLOW_PRIVATE=1 + +# Report a fixed Stalwart version instead of probing the JMAP server's Server +# header. Useful when a proxy strips that header. +# STALWART_VERSION=0.16.0 + # ============================================================================= # Server Listen Address # ============================================================================= @@ -197,6 +231,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Should match your app's main background color. Default: #ffffff # PWA_BACKGROUND_COLOR=#ffffff +# Screenshots shown in the browser's install prompt. Absolute URLs or paths +# relative to public/. Both are optional; per-domain overrides are available +# through DOMAIN_BRANDING. +# PWA_SCREENSHOT_MOBILE_URL=/branding/screenshot-mobile.png +# PWA_SCREENSHOT_DESKTOP_URL=/branding/screenshot-desktop.png + # --------------------------------------------------------------------------- # Logos # --------------------------------------------------------------------------- @@ -234,6 +274,23 @@ LOGIN_COMPANY_NAME=Bulwark Webmail # URL for the company website link on the login page. LOGIN_WEBSITE_URL=https://bulwarkmail.org +# Cap the login logo's rendered size. Any CSS length ("120px", "8rem"). +# Unset means the logo renders at its natural size. +# LOGIN_LOGO_MAX_HEIGHT=96px +# LOGIN_LOGO_MAX_WIDTH=320px + +# Hide parts of the login page. All default to true. +# Turn the heading and subtitle off when the logo already reads as the brand. +# LOGIN_SHOW_HEADING=false +# LOGIN_SHOW_SUBTITLE=false +# +# Hide the optional TOTP field. A server that requires TOTP (totp_required) +# still shows it regardless of this setting. +# LOGIN_SHOW_TOTP=false +# +# Hide the version number, so it isn't disclosed to unauthenticated visitors. +# LOGIN_SHOW_VERSION=false + # --------------------------------------------------------------------------- # Per-domain branding overrides (optional) # --------------------------------------------------------------------------- @@ -266,6 +323,108 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # your own directory (e.g. http://localhost:3001 for local development). # EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org +# ============================================================================= +# Admin Dashboard Access +# ============================================================================= + +# Bootstrap password for the admin dashboard. Read only when admin.json does +# not already exist; the app hashes it, writes admin.json, and logs a warning +# telling you to remove this variable. Without it (and without the setup +# wizard) the admin dashboard stays disabled. +# Accepts a plaintext password or an existing hash. +# ADMIN_PASSWORD=change-me + +# Admin session lifetime in seconds. Default: 3600 (1 hour). +# ADMIN_SESSION_TTL=3600 + +# How many trusted reverse proxies sit in front of the app. The client IP is +# taken that many entries from the right of X-Forwarded-For, so an attacker +# can't spoof it by prepending values. Default: 1. +# TRUSTED_PROXY_DEPTH=2 + +# Allow search engines to index the app (robots.txt / noindex). Default: false. +# SEARCH_ENGINE_INDEXING=true + +# ============================================================================= +# Cookies, Embedding & Reverse Proxies +# ============================================================================= + +# SameSite attribute for session cookies: lax (default), strict, or none. +# Embedding the app cross-origin in an iframe requires "none". +# COOKIE_SAME_SITE=none + +# Force the Secure flag on cookies. Defaults to on when NODE_ENV=production or +# COOKIE_SAME_SITE=none. Set to false only for local HTTP development. +# COOKIE_SECURE=false + +# Who may frame the app, as a CSP frame-ancestors value. Defaults to 'none', +# which blocks all framing. Space-separate multiple origins. +# ALLOWED_FRAME_ANCESTORS=https://portal.example.com + +# Origin of the parent page when embedded, used for postMessage handshakes. +# NEXT_PUBLIC_PARENT_ORIGIN=https://portal.example.com + +# ============================================================================= +# Update Check +# ============================================================================= + +# The app periodically checks for new releases and shows a notice. Set to +# "off" (or false/0/no) to disable the check entirely. +# BULWARK_UPDATE_CHECK=off + +# Override the endpoint it checks. Takes priority over the on-disk state file. +# An explicit empty value also disables the check. +# BULWARK_UPDATE_CHECK_URL=https://updates.example.com/bulwark.json + +# Where the check stores its state. Default: ./data/version-check +# VERSION_CHECK_DATA_DIR=./data/version-check + +# ============================================================================= +# Translation Proxy (optional) +# ============================================================================= + +# /api/translate defaults to the public MyMemory API, which needs no setup. +# Point it at a LibreTranslate instance instead to keep message text on +# infrastructure you control. LibreTranslate also auto-detects the source +# language natively. +# LIBRETRANSLATE_URL=https://libretranslate.example.com +# LIBRETRANSLATE_API_KEY= + +# ============================================================================= +# Web Push +# ============================================================================= + +# Push notifications go through a hosted relay so self-hosters don't need +# their own VAPID keys and Firebase project. Point this at your own relay to +# avoid the default. Build-time variable. +# Default: https://notifications.relay.bulwarkmail.org +# NEXT_PUBLIC_PUSH_RELAY_URL=https://push.example.com + +# ============================================================================= +# Demo Mode +# ============================================================================= + +# Serve fixture data instead of talking to a mail server. Default: false. +# DEMO_MODE=true + +# ============================================================================= +# Stalwart Impersonation (advanced) +# ============================================================================= + +# Lets a trusted platform mint a JWT that logs a user in without their +# password, using a Stalwart master account. Intended for embedded +# deployments where an outer platform already authenticated the user. +# +# SECURITY: this grants sign-in as any mailbox on the server. The endpoint +# returns 404 unless all three required variables below are set, so leaving +# them unset keeps the feature fully off. Treat the secret and the master +# password as you would a root credential. +# +# BULWARK_JWT_AUTH_SECRET= # required, >= 32 characters +# BULWARK_STALWART_MASTER_USER= # required, e.g. master@example.com +# BULWARK_STALWART_MASTER_PASSWORD= # required +# BULWARK_JWT_AUTH_ISSUER= # optional, default "platform-api/webmail" + # ============================================================================= # Internationalization # ============================================================================= From 9c04950a943f2c9df845962a3b4c00275ae918c8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:46:48 +0200 Subject: [PATCH 09/42] docs: use sentence case for headings --- CONTRIBUTING.md | 20 ++++++++++---------- FEATURES.md | 8 ++++---- README.md | 10 +++++----- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67f38b82..921cf0f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ We're writing the webmail we wanted in 2026 and didn't find: a JMAP-native clien If that sounds like your kind of project, we'd love the help. -## Join the Community +## Join the community You don't need to be an expert to contribute. A dev environment that won't start, a bug you're not sure how to report, a translation you're stuck on: Discord is the fastest way to get unstuck and to meet the people working on this. @@ -26,9 +26,9 @@ You don't need to be an expert to contribute. A dev environment that won't start --- -## Getting Started +## Getting started -### Development Setup +### Development setup 1. **Fork and clone** the repository: @@ -61,7 +61,7 @@ You don't need to be an expert to contribute. A dev environment that won't start Then open http://localhost:3000. -### Code Quality +### Code quality Before submitting a pull request, ensure your code passes all checks: @@ -91,7 +91,7 @@ Run a single unit test file with `npx vitest run lib/__tests__/.test.ts`, The integration suite needs Docker and takes several minutes; it has its own setup notes and findings log in [integration/README.md](integration/README.md). New behavior that touches mail/folder synchronization or multi-account handling belongs there. -## Code Style Guidelines +## Code style guidelines ### TypeScript @@ -100,7 +100,7 @@ The integration suite needs Docker and takes several minutes; it has its own set - Avoid `any` types when possible - Use meaningful variable and function names -### React Components +### React components - Use functional components with hooks - Keep components focused and single-purpose @@ -160,9 +160,9 @@ For a right-to-left language, also add the code to `rtlLocales` in `i18n/directi Run `npm run test:translations` afterwards - it checks the locale files for structural drift against English. -## Pull Request Process +## Pull request process -### Before Submitting +### Before submitting 1. **Create a feature branch**: @@ -191,7 +191,7 @@ Run `npm run test:translations` afterwards - it checks the locale files for stru - Screenshots for UI changes - Reference to any related issues -### Commit Message Convention +### Commit message convention Follow the conventional commits format: @@ -211,7 +211,7 @@ fix: resolve attachment download issue docs: update README with keyboard shortcuts ``` -## Project Structure +## Project structure ``` webmail/ diff --git a/FEATURES.md b/FEATURES.md index c2cd4c08..f561981a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -54,7 +54,7 @@ - Trusted senders live in their own JMAP address book - Autocomplete on To, Cc, and Bcc -## Filters & Templates +## Filters & templates - Server-side filters as JMAP Sieve Scripts (RFC 9661) - A visual rule builder: conditions on From, To, Subject, Size, Body, Attachment and more, each matching multiple values, with actions to move, forward, star, or discard @@ -73,7 +73,7 @@ - Cut, copy, paste, duplicate; favorites; recent files - JMAP sharing (RFC 9670) for files and folders. Pick a user or group from the principal picker and grant read, read/write, or manager. Shared items get an indicator, and anything other principals share with you appears under "Shared with me". -## Security & Privacy +## Security & privacy - External content stays blocked until you say otherwise, and trusted senders are remembered - HTML sanitized through DOMPurify @@ -111,7 +111,7 @@ - The browser's `Accept-Language` picks the first language, and the choice persists per user - `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback, `NEXT_PUBLIC_LOCALE_PREFIX` the URL prefix -## Identity & Multi-Account +## Identity & multi-account - Run several accounts at once and switch instantly, each keeping its own session. The 5-account cap lifts on HTTP/2 servers; on HTTP/1.1, browser connection pooling still sets the limit. - An account switcher showing connection status, and a default account @@ -123,7 +123,7 @@ - Several JMAP servers per deployment, optionally auto-picked by email domain - Custom JMAP endpoints on the login form, when `ALLOW_CUSTOM_JMAP_ENDPOINT` permits it -## Admin & Extensibility +## Admin & extensibility - A setup wizard runs on first launch and walks through JMAP servers, OAuth/OIDC, the session secret, logging, branding (uploads included), and the admin password. It writes to the admin config dir, so `.env.local` stays untouched. - The Stalwart admin dashboard, its policy sections collapsed into one tabbed page diff --git a/README.md b/README.md index 63a4d73a..e7175763 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM -## Overview +## What Bulwark includes Bulwark is a full webmail suite. It bundles the four apps most self-hosters end up wanting: @@ -85,7 +85,7 @@ Full feature list: **[FEATURES.md](FEATURES.md)**. --- -## Quick Start +## Quick start ### Docker @@ -101,7 +101,7 @@ docker compose up -d On first launch, open `http://localhost:3000` and the setup wizard takes over. Installs that already define `JMAP_SERVER_URL` skip it and keep the env-managed flow under [Configuration](#configuration). -### From Source +### From source ```bash git clone https://github.com/bulwarkmail/webmail.git @@ -308,7 +308,7 @@ Then point your reverse proxy at the container without stripping the prefix. The -## Keyboard Shortcuts +## Keyboard shortcuts | Key | Action | | -------------------- | ----------------------- | @@ -331,7 +331,7 @@ Then point your reverse proxy at the container without stripping the prefix. The In the composer: `Ctrl/Cmd`+`Enter` sends, `Ctrl/Cmd`+`Shift`+`Enter` opens scheduled send, and `t` opens the template picker. -## Tech Stack +## Tech stack | | | | ------------- | ------------------------------------------------- | From 246df49c039f41ed2a59477196183ff89e9f9f57 Mon Sep 17 00:00:00 2001 From: shukiv Date: Mon, 27 Jul 2026 05:20:53 +0300 Subject: [PATCH 10/42] fix(impersonation): reconcile stale persisted account chip after handoff After a master-user impersonation handoff (GET /api/auth/impersonate) the server swaps the slot-0 session cookie but the client's persisted account registry (account-registry / auth-storage in localStorage) still lists the previous account, so the top-left account chip keeps showing the old mailbox until a manual sign-out. Redirect impersonation to /?impersonated=1 and add a headless ImpersonationReconciler that drops the stale persisted account/auth state (and server-derived caches) then reloads to a clean URL, so the app rehydrates empty and re-derives the single account from the fresh session. Cookies untouched, so the just-granted session survives. Runs exactly once. Reported downstream: shukiv/jabali-panel#646. --- app/(main)/[locale]/layout.tsx | 2 + app/api/auth/impersonate/route.ts | 5 +- .../impersonation-reconciler.tsx | 61 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 components/impersonation/impersonation-reconciler.tsx diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 8c3126af..e91cab21 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast- import { TourProvider } from "@/components/tour/tour-provider"; import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider"; import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; +import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; @@ -39,6 +40,7 @@ export default async function LocaleLayout({ + {children} diff --git a/app/api/auth/impersonate/route.ts b/app/api/auth/impersonate/route.ts index 7a5dca04..858ccb8e 100644 --- a/app/api/auth/impersonate/route.ts +++ b/app/api/auth/impersonate/route.ts @@ -39,7 +39,8 @@ function impersonationCookieOptions() { * Master-user impersonation via signed JWT. The token carries the target * mailbox; Bulwark verifies the signature, resolves the configured Stalwart * master credentials from env, then mints the same session cookies the - * password-login path produces. The browser is redirected to "/" and the + * password-login path produces. The browser is redirected to "/?impersonated=1" (see + * ImpersonationReconciler, GH #646) and the * SPA hydrates as if the user had just logged in with master@target%master. * * Returns 404 when the feature is not configured so an unconfigured @@ -136,6 +137,6 @@ export async function GET(request: NextRequest) { // when running behind a reverse proxy that doesn't set X-Forwarded-Host. return new NextResponse(null, { status: 303, - headers: { Location: '/' }, + headers: { Location: '/?impersonated=1' }, }); } diff --git a/components/impersonation/impersonation-reconciler.tsx b/components/impersonation/impersonation-reconciler.tsx new file mode 100644 index 00000000..cd34673b --- /dev/null +++ b/components/impersonation/impersonation-reconciler.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useEffect } from 'react'; + +import { evictAll } from '@/lib/account-state-manager'; + +/** + * After a master-user impersonation handoff (`GET /api/auth/impersonate`), the + * server swaps the slot-0 session cookie but the client's *persisted* account + * registry still lists the PREVIOUS account — so the top-left account chip keeps + * showing the old mailbox even though the message list is correctly the new one. + * Only a manual sign-out (which clears `account-registry` / `auth-storage`) fixes + * it, because that state lives in localStorage and the impersonation redirect + * never reconciles it. (Reported downstream: jabali-panel #646.) + * + * The impersonate route now redirects to `/?impersonated=1`. Here we drop the + * stale persisted account + auth state (and the server-derived caches) and + * reload to a clean URL, so the app rehydrates empty and re-derives the single + * account from the fresh session cookie — the same result as the manual + * sign-out-then-reopen, done automatically. Cookies are untouched, so the + * just-granted impersonation session survives the reload. + */ +const STALE_KEYS = [ + 'account-registry', + 'auth-storage', + 'identity-storage', + 'contact-storage', + 'calendar-storage', + 'calendar-notification-storage', +]; + +export function ImpersonationReconciler() { + useEffect(() => { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + if (params.get('impersonated') !== '1') return; + + try { + evictAll(); + } catch { + /* in-memory snapshots are best-effort */ + } + for (const key of STALE_KEYS) { + try { + window.localStorage.removeItem(key); + } catch { + /* ignore storage access errors */ + } + } + + // Reload to a clean URL (drop the marker) so the now-empty persisted stores + // rehydrate and the app reconnects + re-derives the impersonated account + // from the session cookie. The marker is gone on the second load, so this + // runs exactly once. + params.delete('impersonated'); + const query = params.toString(); + window.location.replace(window.location.pathname + (query ? `?${query}` : '')); + }, []); + + return null; +} From 3ea22161d94b45a77c240371feb5287fba810bf3 Mon Sep 17 00:00:00 2001 From: Aaron Guise Date: Mon, 27 Jul 2026 15:31:52 +1200 Subject: [PATCH 11/42] feat: add "Forward as attachment" next to Export as .eml Adds a "Forward as attachment" action to the message overflow menu (desktop and mobile), right beside the existing "Export as .eml" action. Opens a new forward-mode compose window with the original message attached as a message/rfc822 file instead of quoted inline - useful for reporting spam/phishing to an upstream gateway that expects the raw original as an attachment (the primary motivating use case: gateways like MxGuarddog require complete original headers, including the full mail path, for scanning), or for preserving a message's exact formatting/headers when forwarding. Implementation reuses the composer's existing attachment-carry-forward mechanism (the `attachments` useState initializer in email-composer.tsx already carries a forwarded message's own attachments into the new compose via `replyTo.attachments`) - this just adds one synthetic entry representing the whole original message, referenced by its existing blobId. No re-fetch or re-upload needed, since JMAP blobs are account-scoped rather than per-email. The inline quote-header step (prepareComposerQuoteHeader) is skipped, so the body starts blank instead of quoting the original. The core "build subject + attachment entry" logic is extracted into a pure, unit-tested helper (lib/forward-as-attachment.ts) rather than left inline in the already-large page component. Adds the forward_as_attachment locale key to all 24 locales (English text as a placeholder pending translation, following the existing add-a-key convention) to satisfy the translations completeness test. --- app/(main)/[locale]/page.tsx | 48 +++++++++++++++++++++ components/email/email-viewer.tsx | 22 ++++++++++ lib/__tests__/forward-as-attachment.test.ts | 48 +++++++++++++++++++++ lib/forward-as-attachment.ts | 44 +++++++++++++++++++ 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/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/common.json | 1 + 28 files changed, 186 insertions(+) create mode 100644 lib/__tests__/forward-as-attachment.test.ts create mode 100644 lib/forward-as-attachment.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 7345bb80..a683d1c6 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -77,6 +77,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from import { emailToReadView } from "@/lib/plugin-projection"; import { buildQuoteHeader } from "@/lib/quote-header"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; +import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; import { getEffectiveLocale } from '@/i18n/detect-locale'; import type { QuoteHeader } from "@/lib/plugin-types"; @@ -1539,6 +1540,52 @@ export default function Home() { if (isMobile) setActiveView('viewer'); }; + // Forward the original message as a message/rfc822 attachment instead of + // inline-quoted text - e.g. for reporting spam to an upstream gateway + // that expects the raw original as an attachment, or preserving exact + // formatting/headers the recipient needs to see untouched. Reuses the + // same attachment-carry-forward mechanism native Forward already uses + // for a forwarded message's own attachments (see the `attachments` + // useState initializer in email-composer.tsx) - we just add one more + // synthetic entry representing the whole original message, referenced + // by its existing blobId (no re-fetch/re-upload needed - JMAP blobs are + // account-scoped, not per-email). Skips prepareComposerQuoteHeader + // entirely, so the body starts blank instead of quoting the original. + const handleForwardAsAttachment = async () => { + if (!selectedEmail) return; + const payload = buildForwardAsAttachmentPayload(selectedEmail, t('email_composer.prefix.forward')); + if (!payload) return; + + const ok = await emailHooks.onBeforeForward.intercept({ + originalEmailId: selectedEmail.id, + originalEmail: emailToReadView(selectedEmail), + mode: 'forward' as const, + }); + if (!ok) return; + + startFreshComposerSession(); + setPendingDraft({ + to: "", + cc: "", + bcc: "", + subject: payload.subject, + body: "", + showCc: false, + showBcc: false, + selectedIdentityId: null, + subAddressTag: "", + mode: "forward", + draftId: null, + replyTo: { + subject: selectedEmail.subject, + attachments: [payload.attachment], + }, + }); + setComposerMode('forward'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }; + const handleDelete = async (emailToDelete: Email | null = selectedEmail) => { if (!client || !emailToDelete) return; @@ -3436,6 +3483,7 @@ export default function Home() { onReply={handleReply} onReplyAll={handleReplyAll} onForward={handleForward} + onForwardAsAttachment={handleForwardAsAttachment} onDelete={() => { // Deleting the open message returns to the list (Gmail-style), // not the next email — unless the user turned the setting off. diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index dd59209e..b95e019e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -19,6 +19,7 @@ import { Reply, ReplyAll, Forward, + Paperclip, Trash2, Archive, Star, @@ -109,6 +110,7 @@ interface EmailViewerProps { onReply?: (draftText?: string) => void; onReplyAll?: () => void; onForward?: () => void; + onForwardAsAttachment?: () => void; onDelete?: () => void; onArchive?: () => void; onToggleStar?: () => void; @@ -621,6 +623,7 @@ export function EmailViewer({ onReply, onReplyAll, onForward, + onForwardAsAttachment, onDelete, onArchive, onToggleStar, @@ -3340,6 +3343,16 @@ export function EmailViewer({ )}
+ {/* Forward as attachment */} + {onForwardAsAttachment && ( + + )} {/* Export email */} )}
+ {onForwardAsAttachment && ( + + )} )}
- {onForwardAsAttachment && ( + {onForwardAsAttachment && email?.blobId && ( - ); - })} +
+ {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} +
{currentColors.length > 0 && ( <> diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 44c7e915..ac6178fb 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -16,6 +16,7 @@ import { useUIStore } from "@/stores/ui-store"; import { EmailIdentityBadge } from "./email-identity-badge"; import { EmailHoverActions } from "./email-hover-actions"; import { getEmailColorTags } from "@/lib/thread-utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; interface EmailListItemProps { email: Email; @@ -40,6 +41,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const { tagName } = useKeywordFormat(); const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const { identities } = useAuthStore(); @@ -232,7 +234,11 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte )} {email.hasAttachment && } {keywordDefs.map((kd) => ( - + ))} + )} title={tagName(kd.id)}> {kd.label} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0aa7fb97..7dca9126 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -12,6 +12,8 @@ import { withBasePath } from "@/lib/browser-navigation"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; +import { TagOptionLabel } from "./tag-option-label"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; import { generateEmailSource } from "@/lib/email-source"; @@ -667,6 +669,7 @@ export function EmailViewer({ const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender); const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const { tagName, tagNameCandidates } = useKeywordFormat(); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -711,7 +714,7 @@ export function EmailViewer({ // Color options for email tags (from user-defined keyword settings) const colorOptions = emailKeywords.map((kw) => ({ - name: kw.label, + candidates: tagNameCandidates(kw.id), value: kw.id, color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500', })); @@ -3012,9 +3015,10 @@ export function EmailViewer({ })} {showToolbarLabels && currentColors.length === 1 && ( - - {emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]} - + )} ) : ( @@ -3038,7 +3042,7 @@ export function EmailViewer({ )} > - {option.name} + {isActive && } ); @@ -3273,7 +3277,7 @@ export function EmailViewer({ )} > - {option.name} + {isActive && } ); @@ -3555,7 +3559,7 @@ export function EmailViewer({ )} > - {option.name} + {isActive && } ); @@ -3634,7 +3638,11 @@ export function EmailViewer({ const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' }; const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500'; return ( - + ); })} diff --git a/components/email/tag-option-label.tsx b/components/email/tag-option-label.tsx new file mode 100644 index 00000000..e396bdad --- /dev/null +++ b/components/email/tag-option-label.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { useShortenedText } from "@/hooks/use-shortened-text"; + +/** + * A tag name inside one of the tag pickers, shortened to what that picker has + * room for. + * + * The pickers differ in width - a narrow popover, a context submenu, a + * full-width mobile sheet - so each row measures itself instead of sharing one + * cap. `candidates` runs longest first (see `keywordRenderings`); the full name + * stays reachable through the tooltip. + */ +export function TagOptionLabel({ + candidates, + className, +}: { + candidates: string[]; + className?: string; +}) { + const [labelRef, shortenedLabel] = useShortenedText(candidates); + + return ( + + {shortenedLabel} + + ); +} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 51503972..6f4812da 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -11,6 +11,7 @@ import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; import { useAccountStore } from "@/stores/account-store"; import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { ThreadEmailItem } from "./thread-email-item"; @@ -90,7 +91,8 @@ const SingleEmailItem = React.forwardRef( const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const emailKeywords = useSettingsStore((state) => state.emailKeywords); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); + const { tagName } = useKeywordFormat(); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); @@ -282,7 +284,11 @@ const SingleEmailItem = React.forwardRef( )} {email.hasAttachment && } {resolvedKeywordDefs.map((kd) => ( - + ))} {showSourceFolder && } {scheduledSendLabel ? ( @@ -351,7 +357,7 @@ const SingleEmailItem = React.forwardRef( + )} title={tagName(kd.id)}> {kd.label} @@ -499,7 +505,8 @@ export const ThreadListItem = React.forwardRef state.emailKeywords); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); + const { tagName } = useKeywordFormat(); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; @@ -746,7 +753,10 @@ export const ThreadListItem = React.forwardRef} {keywordDef && ( - + )} {showSourceFolder && } {scheduledSendLabel ? ( @@ -827,7 +837,7 @@ export const ThreadListItem = React.forwardRef + )} title={tagName(keywordDef.id)}> {keywordDef.label} diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 221a6e7a..cca7a279 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -18,6 +18,7 @@ import type { import type { Mailbox } from "@/lib/jmap/types"; import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settings-store"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; interface FilterRuleModalProps { rule?: FilterRule; @@ -89,6 +90,7 @@ export function FilterRuleModal({ const t = useTranslations("settings.filters"); const isEdit = !!rule; const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const { tagName } = useKeywordFormat(); const [name, setName] = useState(rule?.name || ""); const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all"); @@ -477,7 +479,7 @@ export function FilterRuleModal({ > {emailKeywords.map((kw) => ( - + ))} )} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index bc61bcc6..06a9b662 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -38,6 +38,9 @@ import { } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; +import { buildKeywordTree, hasChildKeywords, type KeywordNode } from "@/lib/keyword-nesting"; +import { useShortenedText } from "@/hooks/use-shortened-text"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { isEditableEventTarget } from "@/lib/keyboard"; import { Mailbox } from "@/lib/jmap/types"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -51,7 +54,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop"; import { useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { useVacationStore } from "@/stores/vacation-store"; -import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store"; +import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; @@ -241,6 +244,9 @@ function SidebarRowCounts({ interface SidebarRowProps { icon: ReactNode; label: string; + /** Progressively shorter renderings of `label`, longest first. The widest one + * that fits the row is shown; without this the full label is used. */ + labelCandidates?: string[]; depth?: number; isSelected?: boolean; isVirtual?: boolean; @@ -266,6 +272,7 @@ interface SidebarRowProps { function SidebarRow({ icon, label, + labelCandidates, depth = 0, isSelected = false, isVirtual = false, @@ -288,6 +295,7 @@ function SidebarRow({ }: SidebarRowProps) { const t = useTranslations('sidebar'); const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP; + const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]); return (
{!isCollapsed && ( <> - {label} + {shortenedLabel} = { }; function TagItem({ - kw, - isSelected, + node, + selectedKeyword, + expandedTags, isCollapsed, onTagSelect, - totalCount, - unreadCount, + onToggleExpand, + tagCounts, colorful, }: { - kw: KeywordDefinition; - isSelected: boolean; + node: KeywordNode; + selectedKeyword: string | null; + expandedTags: Set; isCollapsed: boolean; onTagSelect?: (keywordId: string | null) => void; - totalCount: number; - unreadCount: number; + onToggleExpand: (keywordId: string) => void; + tagCounts: Record; colorful: boolean; }) { const t = useTranslations('notifications'); - const palette = KEYWORD_PALETTE[kw.color]; + const { tagNameCandidates } = useKeywordFormat(); + const palette = KEYWORD_PALETTE[node.color]; + const hasChildren = node.children.length > 0; + const isExpanded = expandedTags.has(node.id); + const isSelected = selectedKeyword === node.id; + // Nested rows are placed by their indentation, so they show their own name. + // A root spells out its path, which matters when an intermediate tag is + // missing from this client's settings and the row would otherwise read as a + // bare leaf name. Toasts have the room for the whole thing. + const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label]; + const label = labelCandidates[0]; const { isDragging: globalDragging } = useDragDropContext(); const { dropHandlers, isValidDropTarget } = useTagDrop({ - tagId: kw.id, - onSuccess: (count, _tagLabel) => { + tagId: node.id, + onSuccess: (count) => { if (count === 1) { - toast.success(t('email_tagged'), kw.label); + toast.success(t('email_tagged'), label); } else { - toast.success(t('emails_tagged', { count }), kw.label); + toast.success(t('emails_tagged', { count }), label); } }, onError: () => { - toast.error(t('tag_failed'), kw.label); + toast.error(t('tag_failed'), label); }, }); const tagIcon = colorful ? ( ) : ( @@ -602,18 +622,38 @@ function TagItem({ ); return ( - onTagSelect?.(isSelected ? null : kw.id)} - isCollapsed={isCollapsed} - dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} - isValidDropTarget={isValidDropTarget} - /> + <> + onTagSelect?.(isSelected ? null : node.id)} + hasChildren={hasChildren} + isExpanded={isExpanded} + onExpandToggle={() => onToggleExpand(node.id)} + isCollapsed={isCollapsed} + dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} + isValidDropTarget={isValidDropTarget} + /> + + {hasChildren && isExpanded && !isCollapsed && node.children.map((child) => ( + + ))} + ); } @@ -737,6 +777,7 @@ export function Sidebar({ const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(); const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [expandedTags, setExpandedTags] = useState>(new Set()); const [foldersExpanded, setFoldersExpanded] = useState(() => { try { const stored = localStorage.getItem('sidebarFoldersExpanded'); @@ -779,6 +820,7 @@ export function Sidebar({ return new Set(); }); const emailKeywords = useSettingsStore(s => s.emailKeywords); + const nestedTags = useSettingsStore(s => s.nestedTags); const isEmbedded = useIsEmbedded(); // The Pro shell owns the global chrome (rail + tab bar), so the sidebar's // own AccountSwitcher would be a redundant second account UI in the same @@ -842,6 +884,37 @@ export function Sidebar({ }); }; + useEffect(() => { + const stored = localStorage.getItem('expandedTags'); + if (stored) { + try { + const parsed = JSON.parse(stored); + setExpandedTags(new Set(parsed)); + } catch (e) { + debug.error('Failed to parse expanded tags:', e); + } + } else { + setExpandedTags( + new Set(emailKeywords.filter((kw) => hasChildKeywords(kw.id, emailKeywords)).map((kw) => kw.id)) + ); + } + }, [emailKeywords]); + + const handleToggleTagExpand = (keywordId: string) => { + setExpandedTags((prev) => { + const next = new Set(prev); + if (next.has(keywordId)) { + next.delete(keywordId); + } else { + next.add(keywordId); + } + try { + localStorage.setItem('expandedTags', JSON.stringify(Array.from(next))); + } catch { /* storage full or unavailable */ } + return next; + }); + }; + // When the app renders its own virtual "Scheduled" folder (for delayed // sends, driven by EmailSubmission), hide the server-provided scheduled // mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled') @@ -852,6 +925,13 @@ export function Sidebar({ const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n)); const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-')); + // With nesting off every tag is its own root, so the same rows render through + // one path whether or not the ids describe a hierarchy. + const tagTree: KeywordNode[] = nestedTags + ? buildKeywordTree(emailKeywords) + : emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 })); + + // Multi-account mode (Pro shell): render every connected account as its // own collapsible group. The active account's tree comes from the // `mailboxes` prop (which is the live email-store value); other accounts @@ -1265,15 +1345,16 @@ export function Sidebar({ /> {((tagsExpanded && !isCollapsed) || isCollapsed) && ( <> - {emailKeywords.map((kw) => ( + {tagTree.map((node) => ( ))} diff --git a/components/settings/__tests__/keyword-settings.test.tsx b/components/settings/__tests__/keyword-settings.test.tsx index 1c3c5f94..8593c41f 100644 --- a/components/settings/__tests__/keyword-settings.test.tsx +++ b/components/settings/__tests__/keyword-settings.test.tsx @@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { KeywordSettings } from '../keyword-settings'; import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; -// Mock SettingsSection to just render children -vi.mock('../settings-section', () => ({ +// Mock SettingsSection to just render children, keeping the real controls +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, })); describe('KeywordSettings', () => { beforeEach(() => { - useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] }); + useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false }); }); it('renders all default keywords', () => { @@ -139,4 +140,74 @@ describe('KeywordSettings', () => { expect(added.id).toBe('my-custom-tag'); expect(added.label).toBe('My Custom Tag!'); }); + + it('offers no parent picker while nesting is off', () => { + render(); + fireEvent.click(screen.getByText('add_keyword')); + + expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument(); + }); + + it('nests a new tag under the selected parent', () => { + useSettingsStore.setState({ + emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }], + nestedTags: true, + }); + render(); + fireEvent.click(screen.getByText('add_keyword')); + + fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } }); + fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } }); + fireEvent.click(screen.getByText('add')); + + const keywords = useSettingsStore.getState().emailKeywords; + expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' }); + }); + + it('shows nested tags by their full path', () => { + useSettingsStore.setState({ + emailKeywords: [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, + ], + nestedTags: true, + }); + render(); + + expect(screen.getByText('Work/Clients')).toBeInTheDocument(); + expect(screen.getByText('$label:work/clients')).toBeInTheDocument(); + }); + + it('rejects a path that would exceed the keyword length limit', () => { + const deepId = 'a'.repeat(240); + useSettingsStore.setState({ + emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }], + nestedTags: true, + }); + render(); + fireEvent.click(screen.getByText('add_keyword')); + + fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } }); + fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } }); + + expect(screen.getByText('too_long')).toBeInTheDocument(); + expect(screen.getByText('add').closest('button')).toBeDisabled(); + }); + + it('locks the name and the delete action of a tag that has nested tags', () => { + useSettingsStore.setState({ + emailKeywords: [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, + ], + nestedTags: true, + }); + render(); + + expect(screen.getByTitle('has_children_delete')).toBeDisabled(); + + fireEvent.click(screen.getAllByTitle('edit')[0]); + expect(screen.getByDisplayValue('Work')).toBeDisabled(); + expect(screen.getByText('has_children_locked')).toBeInTheDocument(); + }); }); diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index c4fcdb2d..cd6f408f 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -2,12 +2,30 @@ import React, { useState } from "react"; import { useTranslations } from "next-intl"; -import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store"; +import { + useSettingsStore, + KEYWORD_PALETTE, + DEFAULT_KEYWORDS, + type KeywordDefinition, +} from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; -import { SettingsSection } from "./settings-section"; +import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section"; import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; +import { KEYWORD_PREFIX } from "@/lib/thread-utils"; +import { + buildKeywordTree, + composeKeywordId, + getParentKeywordId, + hasChildKeywords, + isKeywordDescendant, + keywordLevels, + type KeywordNode, + MAX_KEYWORD_ID_LENGTH, +} from "@/lib/keyword-nesting"; +import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format"; +import { useShortenedText } from "@/hooks/use-shortened-text"; const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE); @@ -39,6 +57,8 @@ function KeywordColorPicker({ function KeywordRow({ keyword, + keywords, + nestedTags, onEdit, onDelete, onDragStart, @@ -49,6 +69,8 @@ function KeywordRow({ isDragging, }: { keyword: KeywordDefinition; + keywords: KeywordDefinition[]; + nestedTags: boolean; onEdit: () => void; onDelete: () => void; onDragStart: () => void; @@ -60,6 +82,13 @@ function KeywordRow({ }) { const t = useTranslations("settings.keywords"); const palette = KEYWORD_PALETTE[keyword.color]; + const hasChildren = hasChildKeywords(keyword.id, keywords); + const nameCandidates = keywordRenderings(formatKeywordLabels(keyword.id, keywords, nestedTags)); + const [nameRef, shortenedName] = useShortenedText(nameCandidates); + // Measured with the prefix attached, since that is what occupies the column. + const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) + .map((rendering) => KEYWORD_PREFIX + rendering); + const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates); return (
- {keyword.label} - {"$label:" + keyword.id} + + {shortenedName} + + + {shortenedKeyword} +
@@ -102,37 +144,74 @@ function KeywordRow({ function KeywordEditForm({ initial, + keywords, existingIds, + nestedTags, onSave, onCancel, }: { initial?: KeywordDefinition; + keywords: KeywordDefinition[]; existingIds: string[]; + nestedTags: boolean; onSave: (keyword: KeywordDefinition) => void; onCancel: () => void; }) { const t = useTranslations("settings.keywords"); const [label, setLabel] = useState(initial?.label || ""); const [color, setColor] = useState(initial?.color || "blue"); + const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : ""); const isEditing = !!initial; - const normalizedId = label - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); + // Renaming or re-parenting a tag rewrites the keyword on every message below + // it, and this client only knows about the tags in its own settings - the + // server may hold nested keywords created elsewhere. Freeze the identity of a + // tag that has children and allow the color to change. + const isLocked = !!initial && hasChildKeywords(initial.id, keywords); + const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label); const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId); - const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate; + const isTooLong = normalizedId.length > MAX_KEYWORD_ID_LENGTH; + const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong; + + // Every tag is a candidate parent except the one being edited and anything + // already below it, which would detach the branch from its own root. + const parentOptions: { value: string; label: string }[] = [{ value: "", label: t("no_parent") }]; + const collectParentOptions = (nodes: KeywordNode[]) => { + for (const node of nodes) { + if (initial && (node.id === initial.id || isKeywordDescendant(node.id, initial.id))) continue; + parentOptions.push({ value: node.id, label: formatKeyword(node.id, keywords, true) }); + collectParentOptions(node.children); + } + }; + collectParentOptions(buildKeywordTree(keywords)); const handleSave = () => { if (!isValid) return; + if (isLocked && initial) { + onSave({ ...initial, color }); + return; + } onSave({ id: normalizedId, label: label.trim(), color }); }; return (
+ {nestedTags && ( +
+ + onChange(e.target.value)} + disabled={disabled} + aria-label={ariaLabel} dir="auto" - className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground" + className={cn( + "px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150", + disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground", + className + )} > {options.map((option) => (
diff --git a/components/settings/__tests__/keyword-settings.test.tsx b/components/settings/__tests__/keyword-settings.test.tsx index 8593c41f..eb3119f3 100644 --- a/components/settings/__tests__/keyword-settings.test.tsx +++ b/components/settings/__tests__/keyword-settings.test.tsx @@ -210,4 +210,20 @@ describe('KeywordSettings', () => { expect(screen.getByDisplayValue('Work')).toBeDisabled(); expect(screen.getByText('has_children_locked')).toBeInTheDocument(); }); + + it('defaults every tag to always visible in the sidebar', () => { + render(); + + const pickers = screen.getAllByLabelText('visibility_field'); + expect(pickers).toHaveLength(DEFAULT_KEYWORDS.length); + pickers.forEach((picker) => expect(picker).toHaveValue('show')); + }); + + it('stores the visibility chosen for a tag', () => { + render(); + + fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } }); + + expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread'); + }); }); diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index cd6f408f..9689ab08 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -6,7 +6,9 @@ import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, + getKeywordVisibility, type KeywordDefinition, + type KeywordVisibility, } from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; @@ -61,6 +63,7 @@ function KeywordRow({ nestedTags, onEdit, onDelete, + onVisibilityChange, onDragStart, onDragOver, onDrop, @@ -73,6 +76,7 @@ function KeywordRow({ nestedTags: boolean; onEdit: () => void; onDelete: () => void; + onVisibilityChange: (visibility: KeywordVisibility) => void; onDragStart: () => void; onDragOver: (e: React.DragEvent) => void; onDrop: () => void; @@ -89,6 +93,11 @@ function KeywordRow({ const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) .map((rendering) => KEYWORD_PREFIX + rendering); const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates); + const visibilityOptions = [ + { value: "show", label: t("visibility.show") }, + { value: "unread", label: t("visibility.unread") }, + { value: "hide", label: t("visibility.hide") }, + ]; return (
{shortenedKeyword} + setQuery(event.target.value)} + placeholder={t("tag_filter_placeholder")} + aria-label={t("tag_filter_placeholder")} + className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ )} + +
+ {trimmedQuery ? ( + matches.length > 0 ? ( + matches.map((keyword) => renderRow(keyword.id, tagName(keyword.id))) + ) : ( +

{t("tag_no_matches")}

+ ) + ) : ( + renderBranch(tree) + )} +
+ + {onClearAll && selectedIds.length > 0 && ( + <> +
+ + + )} + + ); +} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index bd35f4c5..a2859e66 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; +import { getEmailTagIds } from "@/lib/thread-utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { useTagDisplay } from "@/hooks/use-tag-display"; +import { TagBadge } from "./tag-badge"; interface ThreadEmailItemProps { email: Email; @@ -35,6 +39,11 @@ export function ThreadEmailItem({ const isStarred = email.keywords?.$flagged; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; + const { sortTagIds } = useKeywordFormat(); + const { variant: tagVariant } = useTagDisplay(); + // A message inside an expanded thread carries its own tags; the collapsed + // 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, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const density = useSettingsStore((state) => state.density); @@ -178,6 +187,9 @@ export function ThreadEmailItem({ {email.hasAttachment && ( )} + {tagIds.map((id) => ( + + ))}
{/* Preview snippet */} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 6f4812da..70a49f05 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -6,12 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { SelectableAvatar } from "@/components/email/selectable-avatar"; import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react"; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; import { useAccountStore } from "@/stores/account-store"; -import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils"; +import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { useTagDisplay } from "@/hooks/use-tag-display"; +import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { ThreadEmailItem } from "./thread-email-item"; @@ -35,6 +37,28 @@ function SourceFolderTag({ name }: { name: string }) { ); } +/** + * How many messages a collapsed thread stands for. + * + * Built from the tag lozenge so it lines up with the tags it sits next to: the + * same shape, and the same group spacing. + */ +function ThreadCountPill({ count, hasUnread, title }: { count: number; hasUnread: boolean; title: string }) { + return ( + + + {count} + + ); +} + interface ThreadListItemProps { thread: ThreadGroup; isExpanded: boolean; @@ -51,7 +75,7 @@ interface ThreadListItemProps { onMarkAsRead?: (email: Email, read: boolean) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; - onSetColorTag?: (emailId: string, color: string | null) => void; + onSetTag?: (emailId: string, tagId: string | null) => void; onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; } @@ -63,18 +87,18 @@ interface SingleEmailItemProps { onDoubleClick?: () => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; showPreview: boolean; - colorTag: string | null; + rowTint: string | null; onToggleStar?: () => void; onMarkAsRead?: (read: boolean) => void; onDelete?: () => void; onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; + onSetTag?: (tagId: string | null) => void; onMarkAsSpam?: () => void; onUndoSpam?: () => void; } const SingleEmailItem = React.forwardRef( - function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) { + function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) { const t = useTranslations('email_viewer'); const tBatch = useTranslations('email_list.batch_actions'); const isUnread = !email.keywords?.$seen; @@ -90,9 +114,9 @@ const SingleEmailItem = React.forwardRef( ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; - const emailKeywords = useSettingsStore((state) => state.emailKeywords); - const { tagName } = useKeywordFormat(); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); + const { sortTagIds, tagColor } = useKeywordFormat(); + const { variant: tagVariant, placement: tagPlacement } = useTagDisplay(); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); @@ -112,14 +136,8 @@ const SingleEmailItem = React.forwardRef( ? formatDateTime(email.scheduledSendAt, timeFormat) : null; - // Resolve color tags using keyword definitions; unknown tags fall back to gray - const tagIds = getEmailColorTags(email.keywords); - const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); - const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; - const resolvedColorTag = !tintListRowsByTag ? null : (() => { - if (colorTag) return colorTag; - return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; - })(); + const tagIds = sortTagIds(getEmailTagIds(email.keywords)); + const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null)); const { dragHandlers, isDragging } = useEmailDrag({ email, @@ -174,16 +192,16 @@ const SingleEmailItem = React.forwardRef( data-unread={isUnread ? 'true' : 'false'} className={cn( "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", - resolvedColorTag ? resolvedColorTag : ( + resolvedRowTint ? resolvedRowTint : ( selected ? "bg-accent" : "bg-background" ), - selected && !resolvedColorTag && "shadow-sm", - !resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm", - !resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", - resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110", - isUnread && !resolvedColorTag && "bg-accent/30", + selected && !resolvedRowTint && "shadow-sm", + !resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm", + !resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", + resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110", + isUnread && !resolvedRowTint && "bg-accent/30", isChecked && "ring-2 ring-primary/20 bg-accent/40", isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30", isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30" @@ -260,6 +278,13 @@ const SingleEmailItem = React.forwardRef( {sender?.name || sender?.email || 'Unknown'}
+ {tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} ( )} {email.hasAttachment && } - {resolvedKeywordDefs.map((kd) => ( - - ))} {showSourceFolder && } {scheduledSendLabel ? ( ( )}> {sender?.name || sender?.email || "Unknown"} + {tagPlacement === 'sender' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )}
{isPinned && ( @@ -353,15 +378,6 @@ const SingleEmailItem = React.forwardRef(
- {resolvedKeywordDefs.map((kd) => ( - - - {kd.label} - - ))} {showSourceFolder && } {scheduledSendLabel ? ( (
-
- {email.subject || "(no subject)"} +
+ {tagPlacement === 'subject' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} + + {email.subject || "(no subject)"} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -412,12 +437,12 @@ const SingleEmailItem = React.forwardRef( {!email.isScheduled && ( state.emailKeywords); - const { tagName } = useKeywordFormat(); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); - const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; - const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; + const { sortTagIds, tagColor } = useKeywordFormat(); + const { variant: tagVariant, placement: tagPlacement } = useTagDisplay(); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); + // A collapsed row speaks for every message under it, so it carries their tags too. + const tagIds = sortTagIds(getThreadTagIds(thread.emails)); + const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null; const isSelected = selectedEmailId === latestEmail.id || thread.emails.some(e => e.id === selectedEmailId); @@ -525,12 +550,12 @@ export const ThreadListItem = React.forwardRef onEmailDoubleClick(latestEmail) : undefined} onContextMenu={onContextMenu} showPreview={showPreview} - colorTag={colorTag} + rowTint={rowTint} onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onArchive={onArchive ? () => onArchive(latestEmail) : undefined} - onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} /> @@ -604,16 +629,16 @@ export const ThreadListItem = React.forwardRef )} {displayNames.join(', ')} - - - {emailCount} -
+ + + {tagIds.map((id) => ( + + ))} + )} {hasAttachment && } - {keywordDef && ( - - )} {showSourceFolder && } {scheduledSendLabel ? ( {displayNames.join(", ")} - - - {emailCount} + + + {tagPlacement === 'sender' && tagIds.map((id) => ( + + ))}
{hasPinned && ( @@ -833,15 +853,6 @@ export const ThreadListItem = React.forwardRef
- {keywordDef && ( - - - {keywordDef.label} - - )} {showSourceFolder && } {scheduledSendLabel ? (
-
- {latestEmail.subject || "(no subject)"} +
+ {tagPlacement === 'subject' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} + + {latestEmail.subject || "(no subject)"} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -892,12 +912,12 @@ export const ThreadListItem = React.forwardRef onToggleStar(latestEmail) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onArchive={onArchive ? () => onArchive(latestEmail) : undefined} - onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 31070e1d..f0c936a9 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -61,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop"; import { useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { useVacationStore } from "@/stores/vacation-store"; -import { useSettingsStore, KEYWORD_PALETTE, getKeywordVisibility } from "@/stores/settings-store"; +import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; @@ -557,22 +557,6 @@ function MailboxTreeItem({ ); } -const TAG_ICON_COLOR: Record = { - red: "text-red-600/75 dark:text-red-400/75", - orange: "text-orange-600/75 dark:text-orange-400/75", - yellow: "text-yellow-600/75 dark:text-yellow-400/75", - green: "text-green-600/75 dark:text-green-400/75", - blue: "text-blue-600/75 dark:text-blue-400/75", - purple: "text-purple-600/75 dark:text-purple-400/75", - pink: "text-pink-600/75 dark:text-pink-400/75", - teal: "text-teal-600/75 dark:text-teal-400/75", - cyan: "text-cyan-600/75 dark:text-cyan-400/75", - indigo: "text-indigo-600/75 dark:text-indigo-400/75", - amber: "text-amber-600/75 dark:text-amber-400/75", - lime: "text-lime-600/75 dark:text-lime-400/75", - gray: "text-gray-500", -}; - function ShowAllTagsRow({ hiddenCount, showAll, @@ -617,8 +601,8 @@ function TagItem({ colorful: boolean; }) { const t = useTranslations('notifications'); - const { tagNameCandidates } = useKeywordFormat(); - const palette = KEYWORD_PALETTE[node.color]; + const { tagNameCandidates, tagColor } = useKeywordFormat(); + const palette = tagColor(node.id); const hasChildren = node.children.length > 0; const isExpanded = expandedTags.has(node.id); const isSelected = selectedKeyword === node.id; @@ -644,12 +628,9 @@ function TagItem({ }); const tagIcon = colorful ? ( - + ) : ( - + ); return ( diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 625a6982..2a412f7e 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -236,7 +236,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { } }, [client, markAsRead]); - const handleSetColorTag = useCallback((emailId: string, color: string | null) => { + const handleSetTag = useCallback((emailId: string, tagId: string | null) => { if (!email || email.id !== emailId) return; // Drop existing color keywords, optionally add the new one. Matches the // mail page's local optimistic update. @@ -244,9 +244,8 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { for (const kw of settingsKeywords) { delete keywords[`$label:${kw.id}`]; } - if (color) { - const def = settingsKeywords.find((k) => k.color === color); - if (def) keywords[`$label:${def.id}`] = true; + if (tagId) { + keywords[`$label:${tagId}`] = true; } setEmailKeywordsLocal(emailId, keywords); setEmail({ ...email, keywords }); @@ -333,7 +332,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { onArchive={handleArchive} onToggleStar={handleToggleStar} onMarkAsRead={handleMarkAsRead} - onSetColorTag={handleSetColorTag} + onSetTag={handleSetTag} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} onEditDraft={handleEditDraft} diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index 4547663e..51beee54 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { useSettingsStore, KEYWORD_PALETTE, + KEYWORD_PALETTE_ROWS, getKeywordVisibility, type KeywordDefinition, type KeywordVisibility, @@ -25,11 +26,11 @@ import { type KeywordNode, MAX_KEYWORD_ID_LENGTH, } from "@/lib/keyword-nesting"; -import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format"; +import { formatKeyword, keywordRenderings } from "@/lib/keyword-format"; import { useShortenedText } from "@/hooks/use-shortened-text"; +import { TagBadge } from "@/components/email/tag-badge"; -const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE); - +/** Lighter, base and darker shade of each hue, one row per shade. */ function KeywordColorPicker({ value, onChange, @@ -38,19 +39,23 @@ function KeywordColorPicker({ onChange: (color: string) => void; }) { return ( -
- {PALETTE_KEYS.map((colorKey) => ( -
))}
); @@ -84,10 +89,7 @@ function KeywordRow({ isDragging: boolean; }) { const t = useTranslations("settings.keywords"); - const palette = KEYWORD_PALETTE[keyword.color]; const hasChildren = hasChildKeywords(keyword.id, keywords); - const nameCandidates = keywordRenderings(formatKeywordLabels(keyword.id, keywords, nestedTags)); - const [nameRef, shortenedName] = useShortenedText(nameCandidates); // Measured with the prefix attached, since that is what occupies the column. const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) .map((rendering) => KEYWORD_PREFIX + rendering); @@ -112,14 +114,9 @@ function KeywordRow({ )} > -
- - {shortenedName} - +
+ +
{ + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true }); + }); + + describe('tagColor', () => { + it('resolves a tag to its palette entry, including the new shades', () => { + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.tagColor('work')).toBe(KEYWORD_PALETTE.blue); + expect(result.current.tagColor('archive')).toBe(KEYWORD_PALETTE['red-dark']); + }); + + it('falls back to grey for a keyword this client has no definition for', () => { + // Set on the message by another client, or its tag was deleted here. + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.tagColor('never-heard-of-it')).toBe(KEYWORD_PALETTE.gray); + }); + + it('falls back to grey for a colour that is not in the palette', () => { + useSettingsStore.setState({ emailKeywords: [{ id: 'odd', label: 'Odd', color: 'chartreuse' }] }); + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.tagColor('odd')).toBe(KEYWORD_PALETTE.gray); + }); + }); + + describe('sortTagIds', () => { + it('follows the order the user arranged in settings', () => { + // Settings order is work, work/clients, archive - drag-reorderable, and + // deliberately not alphabetical. + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.sortTagIds(['archive', 'work/clients', 'work'])).toEqual([ + 'work', + 'work/clients', + 'archive', + ]); + }); + + it('is stable however the keywords happen to arrive', () => { + const { result } = renderHook(() => useKeywordFormat()); + const expected = ['work', 'work/clients', 'archive']; + + expect(result.current.sortTagIds(['work', 'archive', 'work/clients'])).toEqual(expected); + expect(result.current.sortTagIds(['archive', 'work', 'work/clients'])).toEqual(expected); + }); + + it('follows a reordering of the settings list', () => { + useSettingsStore.setState({ emailKeywords: [TAGS[2], TAGS[0], TAGS[1]] }); + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.sortTagIds(['work', 'archive'])).toEqual(['archive', 'work']); + }); + + it('puts a tag with no local definition last, ordered by name', () => { + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.sortTagIds(['zz-unknown', 'work', 'aa-unknown'])).toEqual([ + 'work', + 'aa-unknown', + 'zz-unknown', + ]); + }); + + it("leaves the caller's array alone", () => { + const { result } = renderHook(() => useKeywordFormat()); + const input = ['archive', 'work']; + + result.current.sortTagIds(input); + + expect(input).toEqual(['archive', 'work']); + }); + }); +}); diff --git a/hooks/use-keyword-format.ts b/hooks/use-keyword-format.ts index f0f0b862..91c05bba 100644 --- a/hooks/use-keyword-format.ts +++ b/hooks/use-keyword-format.ts @@ -1,18 +1,22 @@ "use client"; import { useMemo } from "react"; -import { useSettingsStore } from "@/stores/settings-store"; +import { + useSettingsStore, + KEYWORD_PALETTE, + FALLBACK_KEYWORD_COLOR, + type KeywordColor, +} from "@/stores/settings-store"; import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format"; /** - * Names tags for the screen, bound to the user's tag settings. + * Names and colours tags for the screen, bound to the user's tag settings. * * Resolving the definitions and the nesting setting here rather than at every * call site means no caller can forget the setting and render a nested name to - * someone who never asked for nesting. Subscribing to it also keeps names in - * step the moment it is toggled: reading it straight from the store inside the - * formatter would leave every list showing stale names until something else - * happened to re-render them. + * someone who never asked for nesting. Subscribing to them also keeps tags in + * step the moment either changes: reading the store inside the formatter would + * leave every list stale until something else happened to re-render it. */ export function useKeywordFormat() { const keywords = useSettingsStore((state) => state.emailKeywords); @@ -22,8 +26,38 @@ export function useKeywordFormat() { () => ({ /** The tag's display name. */ tagName: (id: string) => formatKeyword(id, keywords, nested), + /** Its progressively shorter forms, longest first, for `useShortenedText`. */ tagNameCandidates: (id: string) => keywordRenderings(formatKeywordLabels(id, keywords, nested)), + + /** + * The tag's colour. Falls back to grey for a keyword this client has no + * definition for - one created on another device, or whose tag was + * deleted - so such a tag still shows rather than silently vanishing. + */ + tagColor: (id: string): KeywordColor => { + const color = keywords.find((keyword) => keyword.id === id)?.color; + return (color ? KEYWORD_PALETTE[color] : undefined) ?? KEYWORD_PALETTE[FALLBACK_KEYWORD_COLOR]; + }, + + /** + * Tag ids in the order the user arranged them in settings. + * + * The keywords on a message arrive as an unordered JMAP map, so without + * this the same two tags can swap places between rows. A tag with no + * local definition has no place in that order, so it sorts last, by name. + */ + sortTagIds: (ids: string[]): string[] => { + const rank = (id: string) => { + const index = keywords.findIndex((keyword) => keyword.id === id); + return index === -1 ? keywords.length : index; + }; + return [...ids].sort( + (a, b) => + rank(a) - rank(b) || + formatKeyword(a, keywords, nested).localeCompare(formatKeyword(b, keywords, nested)), + ); + }, }), [keywords, nested], ); diff --git a/hooks/use-tag-display.ts b/hooks/use-tag-display.ts new file mode 100644 index 00000000..0afdb186 --- /dev/null +++ b/hooks/use-tag-display.ts @@ -0,0 +1,70 @@ +"use client"; + +import { createContext, useContext, useEffect, useMemo, useState, type RefObject } from "react"; +import type { TagBadgeVariant } from "@/components/email/tag-badge"; + +/** + * Below this, a named tag beside the subject would leave the subject nothing to + * occupy, so tags move up to the sender line instead. The split list runs + * 240-600px wide and defaults to 384, so it reads that way until widened, while + * the full-width focus and bottom-pane layouts keep tags with the subject. + */ +const TAG_BESIDE_SUBJECT_MIN_WIDTH = 560; + +/** + * Below this there is no room to name a tag anywhere on the row, and colour + * alone has to carry it. Well under the split list's default, because the + * sender line still has room for a name long after the subject line does not. + */ +const TAG_NAME_MIN_WIDTH = 320; + +export interface TagDisplay { + /** Whether a tag is named or shown as colour alone. */ + variant: TagBadgeVariant; + /** Which line of a multi-line row the tags belong on. */ + placement: "subject" | "sender"; +} + +const NAMED_BESIDE_SUBJECT: TagDisplay = { variant: "badge", placement: "subject" }; + +/** + * How message rows should draw their tags. + * + * One value for the whole list, never per row: rows are all the same width, so + * measuring each would burn a `ResizeObserver` per virtualised row and, worse, + * let neighbours disagree - one naming its tags while the next showed dots. + */ +export const TagDisplayContext = createContext(NAMED_BESIDE_SUBJECT); + +export function useTagDisplay(): TagDisplay { + return useContext(TagDisplayContext); +} + +/** + * Watches a container and reports what its rows have room for. Falls back to + * naming tags beside the subject where measurement is unavailable - server + * rendering, and jsdom under test - since that is the most informative form. + */ +export function useMeasuredTagDisplay(ref: RefObject): TagDisplay { + const [width, setWidth] = useState(null); + + useEffect(() => { + const element = ref.current; + if (!element || typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver((entries) => { + const measured = entries[0]?.contentRect.width; + if (measured !== undefined) setWidth(measured); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [ref]); + + return useMemo(() => { + if (width === null) return NAMED_BESIDE_SUBJECT; + return { + variant: width >= TAG_NAME_MIN_WIDTH ? "badge" : "dot", + placement: width >= TAG_BESIDE_SUBJECT_MIN_WIDTH ? "subject" : "sender", + }; + }, [width]); +} diff --git a/lib/__tests__/thread-utils.test.ts b/lib/__tests__/thread-utils.test.ts index 62cf41a2..5de19307 100644 --- a/lib/__tests__/thread-utils.test.ts +++ b/lib/__tests__/thread-utils.test.ts @@ -4,8 +4,9 @@ import { sortThreadGroups, getThreadParticipants, mergeThreadEmails, - getEmailColorTag, - getThreadColorTag, + getEmailTagId, + getThreadTagId, + getThreadTagIds, } from '../thread-utils'; import type { Email, ThreadGroup } from '../jmap/types'; @@ -245,47 +246,47 @@ describe('mergeThreadEmails', () => { }); }); -describe('getEmailColorTag', () => { +describe('getEmailTagId', () => { it('returns label from $label: keyword', () => { - expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red'); + expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red'); }); it('returns label from legacy $color: keyword', () => { - expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red'); + expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red'); }); it('returns null when no color keyword', () => { - expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull(); + expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull(); }); it('returns null for undefined keywords', () => { - expect(getEmailColorTag(undefined)).toBeNull(); + expect(getEmailTagId(undefined)).toBeNull(); }); it('ignores keywords set to false', () => { - expect(getEmailColorTag({ '$label:red': false } as unknown as Record)).toBeNull(); + expect(getEmailTagId({ '$label:red': false } as unknown as Record)).toBeNull(); }); it('prefers $label: over $color: when both exist', () => { - expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue'); + expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue'); }); it('handles custom keyword ids', () => { - expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag'); + expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag'); }); it('returns null for empty keywords object', () => { - expect(getEmailColorTag({})).toBeNull(); + expect(getEmailTagId({})).toBeNull(); }); }); -describe('getThreadColorTag', () => { +describe('getThreadTagId', () => { it('returns first color found across thread emails', () => { const emails = [ makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e2', keywords: { '$label:blue': true } }), ]; - expect(getThreadColorTag(emails)).toBe('blue'); + expect(getThreadTagId(emails)).toBe('blue'); }); it('returns null when no emails have color tags', () => { @@ -293,7 +294,7 @@ describe('getThreadColorTag', () => { makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e2', keywords: { $flagged: true } }), ]; - expect(getThreadColorTag(emails)).toBeNull(); + expect(getThreadTagId(emails)).toBeNull(); }); it('returns first tag from earliest tagged email', () => { @@ -301,7 +302,7 @@ describe('getThreadColorTag', () => { makeEmail({ id: 'e1', keywords: { '$label:red': true } }), makeEmail({ id: 'e2', keywords: { '$label:blue': true } }), ]; - expect(getThreadColorTag(emails)).toBe('red'); + expect(getThreadTagId(emails)).toBe('red'); }); it('returns legacy tag from thread emails', () => { @@ -309,10 +310,41 @@ describe('getThreadColorTag', () => { makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e2', keywords: { '$color:green': true } }), ]; - expect(getThreadColorTag(emails)).toBe('green'); + expect(getThreadTagId(emails)).toBe('green'); }); it('returns null for empty email array', () => { - expect(getThreadColorTag([])).toBeNull(); + expect(getThreadTagId([])).toBeNull(); + }); +}); + +describe('getThreadTagIds', () => { + it('gathers the tags of every message in the thread', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', keywords: { '$label:blue': true, '$label:green': true } }), + ]; + expect(getThreadTagIds(emails).sort()).toEqual(['blue', 'green', 'red']); + }); + + it('reports a tag shared by several messages once', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', keywords: { '$label:red': true } }), + ]; + expect(getThreadTagIds(emails)).toEqual(['red']); + }); + + it('reads the legacy prefix alongside the current one', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { '$color:green': true } }), + makeEmail({ id: 'e2', keywords: { '$label:red': true } }), + ]; + expect(getThreadTagIds(emails).sort()).toEqual(['green', 'red']); + }); + + it('is empty for an untagged or empty thread', () => { + expect(getThreadTagIds([makeEmail({ id: 'e1', keywords: { $seen: true } })])).toEqual([]); + expect(getThreadTagIds([])).toEqual([]); }); }); diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index 41037492..4a152568 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -168,10 +168,10 @@ export const KEYWORD_PREFIX = "$label:"; export const KEYWORD_PREFIX_LEGACY = "$color:"; /** - * Gets all active label/color tag IDs from email keywords. + * Gets every tag id set on a message. * Reads both the current $label: prefix and the legacy $color: prefix. */ -export function getEmailColorTags(keywords: Record | undefined): string[] { +export function getEmailTagIds(keywords: Record | undefined): string[] { if (!keywords) return []; const tags: string[] = []; for (const key of Object.keys(keywords)) { @@ -187,22 +187,39 @@ export function getEmailColorTags(keywords: Record | undefined) } /** - * Gets label/color tag from email keywords (if any). + * Gets the first tag id set on a message, if any. * Reads both the current $label: prefix and the legacy $color: prefix. - * @deprecated Use getEmailColorTags for multi-tag support. + * @deprecated Use getEmailTagIds for multi-tag support. */ -export function getEmailColorTag(keywords: Record | undefined): string | null { - const tags = getEmailColorTags(keywords); +export function getEmailTagId(keywords: Record | undefined): string | null { + const tags = getEmailTagIds(keywords); return tags.length > 0 ? tags[0] : null; } /** - * Checks if a thread has any color tag (returns first found). + * The first tag id found anywhere in a thread, if any. */ -export function getThreadColorTag(emails: Email[]): string | null { +export function getThreadTagId(emails: Email[]): string | null { for (const email of emails) { - const color = getEmailColorTag(email.keywords); + const color = getEmailTagId(email.keywords); if (color) return color; } return null; } + +/** + * Every tag anywhere in a thread, deduplicated. + * + * A collapsed thread row stands in for all its messages, so it has to account + * for all their tags - showing only the first message's would hide the rest + * with nothing to indicate they exist. + */ +export function getThreadTagIds(emails: Email[]): string[] { + const tags = new Set(); + for (const email of emails) { + for (const tag of getEmailTagIds(email.keywords)) { + tags.add(tag); + } + } + return [...tags]; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 17ac17c6..23d15d86 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -325,13 +325,13 @@ "view_contact": "عرض جهة الاتصال", "message_details": "تفاصيل الرسالة", "more_reply_options": "خيارات رد إضافية", - "set_color": "تعيين وسم", + "set_tag": "تعيين وسم", "tag": "وسم", "more_actions": "المزيد من الإجراءات", "previous": "السابق", "next": "التالي", "move_to": "نقل إلى...", - "remove_color": "إزالة الوسم", + "remove_tag": "إزالة الوسم", "more_count": "+{count} أخرى", "characters_count": "{count} حرفًا", "quick_reply_placeholder": "اكتب ردًا سريعًا...", @@ -425,17 +425,6 @@ "message_id": "معرّف الرسالة", "list_info": "معلومات القائمة" }, - "color_tag": { - "title": "وسم لوني", - "red": "أحمر", - "orange": "برتقالي", - "yellow": "أصفر", - "green": "أخضر", - "blue": "أزرق", - "purple": "بنفسجي", - "pink": "وردي", - "none": "بلا" - }, "tooltips": { "reply": "رد (r)", "reply_all": "الرد على الجميع (a)", @@ -2033,8 +2022,7 @@ "delete": "حذف", "mark_as_spam": "الإبلاغ عن بريد مزعج", "not_spam": "ليس مزعجًا", - "color_tag": "وسم", - "remove_color": "إزالة الوسم", + "tag": "وسم", "items_selected": "{count} رسالة محددة", "edit_draft": "تعديل المسودة", "cancel_scheduled_send": "إلغاء الإرسال", diff --git a/locales/ca/common.json b/locales/ca/common.json index 3845877a..97919417 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -325,13 +325,13 @@ "view_contact": "Mostra el contacte", "message_details": "Detalls del missatge", "more_reply_options": "Més opcions de resposta", - "set_color": "Estableix l'etiqueta", + "set_tag": "Estableix l'etiqueta", "tag": "Etiqueta", "more_actions": "Més accions", "previous": "Anterior", "next": "Següent", "move_to": "Mou a...", - "remove_color": "Elimina l'etiqueta", + "remove_tag": "Elimina l'etiqueta", "more_count": "+{count} més", "characters_count": "{count} caràcters", "quick_reply_placeholder": "Escriviu una resposta ràpida...", @@ -425,17 +425,6 @@ "message_id": "ID del missatge", "list_info": "Informació de la llista" }, - "color_tag": { - "title": "Etiqueta de color", - "red": "Vermell", - "orange": "Taronja", - "yellow": "Groc", - "green": "Verd", - "blue": "Blau", - "purple": "Lila", - "pink": "Rosa", - "none": "Cap" - }, "tooltips": { "reply": "Respon (r)", "reply_all": "Respon a tots (a)", @@ -2001,8 +1990,7 @@ "delete": "Suprimeix", "mark_as_spam": "Denuncia com a brossa", "not_spam": "No és brossa", - "color_tag": "Etiqueta", - "remove_color": "Elimina l'etiqueta", + "tag": "Etiqueta", "items_selected": "{count} correus seleccionats", "edit_draft": "Edita l'esborrany", "cancel_scheduled_send": "Cancel·la l'enviament", diff --git a/locales/cs/common.json b/locales/cs/common.json index 8f76d8ac..a8886132 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -325,13 +325,13 @@ "view_contact": "Zobrazit kontakt", "message_details": "Podrobnosti zprávy", "more_reply_options": "Další možnosti odpovědi", - "set_color": "Nastavit štítek", + "set_tag": "Nastavit štítek", "tag": "Štítek", "more_actions": "Další akce", "previous": "Předchozí", "next": "Další", "move_to": "Přesunout do...", - "remove_color": "Odebrat štítek", + "remove_tag": "Odebrat štítek", "more_count": "+{count} dalších", "characters_count": "{count} znaků", "quick_reply_placeholder": "Napsat rychlou odpověď...", @@ -400,17 +400,6 @@ "message_id": "ID zprávy", "list_info": "Informace o konferenci" }, - "color_tag": { - "title": "Barevný štítek", - "red": "Červený", - "orange": "Oranžový", - "yellow": "Žlutý", - "green": "Zelený", - "blue": "Modrý", - "purple": "Fialový", - "pink": "Růžový", - "none": "Žádný" - }, "tooltips": { "reply": "Odpovědět (r)", "reply_all": "Odpovědět všem (a)", @@ -2033,8 +2022,7 @@ "delete": "Odstranit", "mark_as_spam": "Nahlásit spam", "not_spam": "Není spam", - "color_tag": "Štítek", - "remove_color": "Odebrat štítek", + "tag": "Štítek", "items_selected": "{count} vybraných zpráv", "edit_draft": "Upravit koncept", "cancel_scheduled_send": "Zrušit odeslání", diff --git a/locales/da/common.json b/locales/da/common.json index 8fa78adb..44aa3d90 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -325,13 +325,13 @@ "view_contact": "Vis kontakt", "message_details": "Beskeddetaljer", "more_reply_options": "Flere svar-muligheder", - "set_color": "Sæt tag", + "set_tag": "Sæt tag", "tag": "Tag", "more_actions": "Flere handlinger", "previous": "Forrige", "next": "Næste", "move_to": "Flyt til...", - "remove_color": "Fjern tag", + "remove_tag": "Fjern tag", "more_count": "+{count} mere", "characters_count": "{count} tegn", "quick_reply_placeholder": "Skriv et hurtigt svar...", @@ -425,17 +425,6 @@ "message_id": "Besked-ID", "list_info": "Listeinformation" }, - "color_tag": { - "title": "Farvetag", - "red": "Rød", - "orange": "Orange", - "yellow": "Gul", - "green": "Grøn", - "blue": "Blå", - "purple": "Lilla", - "pink": "Pink", - "none": "Ingen" - }, "tooltips": { "reply": "Svar (r)", "reply_all": "Svar alle (a)", @@ -2033,8 +2022,7 @@ "delete": "Slet", "mark_as_spam": "Rapportér spam", "not_spam": "Ikke spam", - "color_tag": "Tag", - "remove_color": "Fjern tag", + "tag": "Tag", "items_selected": "{count} e-mails valgt", "edit_draft": "Redigér kladde", "cancel_scheduled_send": "Annuller afsendelse", diff --git a/locales/de/common.json b/locales/de/common.json index 1d4ea7ad..20067cbc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -325,11 +325,11 @@ "view_contact": "Kontakt anzeigen", "message_details": "Nachrichtendetails", "more_reply_options": "Weitere Antwortoptionen", - "set_color": "Label setzen", + "set_tag": "Label setzen", "tag": "Label", "more_actions": "Weitere Aktionen", "move_to": "Verschieben nach...", - "remove_color": "Label entfernen", + "remove_tag": "Label entfernen", "more_count": "+{count} weitere", "characters_count": "{count} Zeichen", "quick_reply_placeholder": "Eine kurze Antwort schreiben...", @@ -398,17 +398,6 @@ "message_id": "Nachrichten-ID", "list_info": "Listeninformationen" }, - "color_tag": { - "title": "Farb-Tag", - "red": "Rot", - "orange": "Orange", - "yellow": "Gelb", - "green": "Grün", - "blue": "Blau", - "purple": "Violett", - "pink": "Rosa", - "none": "Keine" - }, "tooltips": { "reply": "Antworten", "reply_all": "Allen antworten (a)", @@ -2033,8 +2022,7 @@ "delete": "Löschen", "mark_as_spam": "Spam melden", "not_spam": "Kein Spam", - "color_tag": "Label", - "remove_color": "Label entfernen", + "tag": "Label", "items_selected": "{count} E-Mails ausgewählt", "edit_draft": "Entwurf bearbeiten", "cancel_scheduled_send": "Senden abbrechen", diff --git a/locales/en/common.json b/locales/en/common.json index 5555f2c6..96e19eb7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -327,13 +327,15 @@ "view_contact": "View contact", "message_details": "Message Details", "more_reply_options": "More reply options", - "set_color": "Set tag", + "set_tag": "Set tag", "tag": "Tag", "more_actions": "More actions", "previous": "Prev", "next": "Next", "move_to": "Move to...", - "remove_color": "Remove tag", + "remove_tag": "Remove tag", + "tag_filter_placeholder": "Filter tags", + "tag_no_matches": "No matching tags", "more_count": "+{count} more", "characters_count": "{count} characters", "quick_reply_placeholder": "Write a quick reply...", @@ -427,17 +429,6 @@ "message_id": "Message ID", "list_info": "List Information" }, - "color_tag": { - "title": "Color Tag", - "red": "Red", - "orange": "Orange", - "yellow": "Yellow", - "green": "Green", - "blue": "Blue", - "purple": "Purple", - "pink": "Pink", - "none": "None" - }, "tooltips": { "reply": "Reply (r)", "reply_all": "Reply All (a)", @@ -1019,7 +1010,7 @@ }, "keywords": { "title": "Email Tags", - "description": "Define tags to organize your emails with colors. These are stored as JMAP keywords on the server.", + "description": "Define tags to organize your emails. These are stored as JMAP keywords on the server.", "add_keyword": "Add Tag", "label_field": "Display Name", "label_placeholder": "e.g. Work, Personal, Urgent", @@ -2050,8 +2041,7 @@ "delete": "Delete", "mark_as_spam": "Report spam", "not_spam": "Not spam", - "color_tag": "Tag", - "remove_color": "Remove tag", + "tag": "Tag", "items_selected": "{count} emails selected", "edit_draft": "Edit Draft", "cancel_scheduled_send": "Cancel send", diff --git a/locales/es/common.json b/locales/es/common.json index 3f76eabe..4c2a3da9 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -325,11 +325,11 @@ "view_contact": "Ver contacto", "message_details": "Detalles del Mensaje", "more_reply_options": "Más opciones de respuesta", - "set_color": "Establecer etiqueta", + "set_tag": "Establecer etiqueta", "tag": "Etiqueta", "more_actions": "Más acciones", "move_to": "Mover a...", - "remove_color": "Eliminar etiqueta", + "remove_tag": "Eliminar etiqueta", "more_count": "+{count} más", "characters_count": "{count} caracteres", "quick_reply_placeholder": "Escriba una respuesta rápida...", @@ -398,17 +398,6 @@ "message_id": "ID del Mensaje", "list_info": "Información de Lista" }, - "color_tag": { - "title": "Etiqueta de Color", - "red": "Rojo", - "orange": "Naranja", - "yellow": "Amarillo", - "green": "Verde", - "blue": "Azul", - "purple": "Morado", - "pink": "Rosa", - "none": "Ninguno" - }, "tooltips": { "reply": "Responder", "reply_all": "Responder a todos (a)", @@ -2033,8 +2022,7 @@ "delete": "Eliminar", "mark_as_spam": "Reportar spam", "not_spam": "No es spam", - "color_tag": "Etiqueta", - "remove_color": "Eliminar etiqueta", + "tag": "Etiqueta", "items_selected": "{count} correos seleccionados", "edit_draft": "Editar borrador", "cancel_scheduled_send": "Cancelar envío", diff --git a/locales/fa/common.json b/locales/fa/common.json index 31fcb160..4599c53b 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -325,13 +325,13 @@ "view_contact": "مشاهده مخاطب", "message_details": "جزئیات پیام", "more_reply_options": "گزینه‌های بیشتر پاسخ", - "set_color": "تنظیم برچسب", + "set_tag": "تنظیم برچسب", "tag": "برچسب", "more_actions": "عملیات بیشتر", "previous": "قبلی", "next": "بعدی", "move_to": "انتقال به...", - "remove_color": "حذف برچسب", + "remove_tag": "حذف برچسب", "more_count": "+{count} بیشتر", "characters_count": "{count} کاراکتر", "quick_reply_placeholder": "پاسخ سریع بنویسید...", @@ -425,17 +425,6 @@ "message_id": "شناسه پیام", "list_info": "اطلاعات لیست" }, - "color_tag": { - "title": "برچسب رنگی", - "red": "قرمز", - "orange": "نارنجی", - "yellow": "زرد", - "green": "سبز", - "blue": "آبی", - "purple": "بنفش", - "pink": "صورتی", - "none": "هیچکدام" - }, "tooltips": { "reply": "پاسخ (r)", "reply_all": "پاسخ به همه (a)", @@ -2033,8 +2022,7 @@ "delete": "حذف", "mark_as_spam": "گزارش هرزنامه", "not_spam": "هرزنامه نیست", - "color_tag": "برچسب", - "remove_color": "حذف برچسب", + "tag": "برچسب", "items_selected": "{count} ایمیل انتخاب شده", "edit_draft": "ویرایش پیش‌نویس", "cancel_scheduled_send": "لغو ارسال", diff --git a/locales/fr/common.json b/locales/fr/common.json index 7a7145af..cf60e2df 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -325,11 +325,11 @@ "view_contact": "Voir le contact", "message_details": "Détails du message", "more_reply_options": "Plus d'options de réponse", - "set_color": "Définir l'étiquette", + "set_tag": "Définir l'étiquette", "tag": "Étiquette", "more_actions": "Plus d'actions", "move_to": "Déplacer vers...", - "remove_color": "Retirer l'étiquette", + "remove_tag": "Retirer l'étiquette", "more_count": "+{count} de plus", "characters_count": "{count} caractères", "quick_reply_placeholder": "Écrivez une réponse rapide...", @@ -398,17 +398,6 @@ "message_id": "ID du message", "list_info": "Information de liste" }, - "color_tag": { - "title": "Étiquette de couleur", - "red": "Rouge", - "orange": "Orange", - "yellow": "Jaune", - "green": "Vert", - "blue": "Bleu", - "purple": "Violet", - "pink": "Rose", - "none": "Aucune" - }, "tooltips": { "reply": "Répondre", "reply_all": "Répondre à tous (a)", @@ -2033,8 +2022,7 @@ "delete": "Supprimer", "mark_as_spam": "Signaler comme spam", "not_spam": "Pas un spam", - "color_tag": "Étiquette", - "remove_color": "Supprimer l'étiquette", + "tag": "Étiquette", "items_selected": "{count} emails sélectionnés", "edit_draft": "Modifier le brouillon", "cancel_scheduled_send": "Annuler l’envoi", diff --git a/locales/he/common.json b/locales/he/common.json index 52d48ed9..918ccec8 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -272,13 +272,13 @@ "view_contact": "הצג איש קשר", "message_details": "פרטי הודעה", "more_reply_options": "אפשרויות תשובה נוספות", - "set_color": "הגדר תג", + "set_tag": "הגדר תג", "tag": "תג", "more_actions": "עוד פעולות", "previous": "הקודם", "next": "הבא", "move_to": "העבר ל...", - "remove_color": "הסר תג", + "remove_tag": "הסר תג", "more_count": "+{count}נוספים", "characters_count": "{count} תווים", "quick_reply_placeholder": "תשובה מהירה", @@ -347,17 +347,6 @@ "message_id": "מזהה הודעה", "list_info": "רשימת מידע" }, - "color_tag": { - "title": "תג צבע", - "red": "אדום", - "orange": "כתום", - "yellow": "צהוב", - "green": "ירוק", - "blue": "כחול", - "purple": "סגול", - "pink": "ורוד", - "none": "אין" - }, "tooltips": { "reply": "תשובה (ר)", "reply_all": "השב לכולם (א)", @@ -1999,8 +1988,7 @@ "delete": "לִמְחוֹק", "mark_as_spam": "דווח על ספאם", "not_spam": "לא ספאם", - "color_tag": "תווית", - "remove_color": "הסר תווית", + "tag": "תווית", "items_selected": "נבחרו הודעות דוא\"ל מסוג{count}", "edit_draft": "ערוך טיוטה", "cancel_scheduled_send": "ביטול שליחה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 2a1a8361..790f4e8a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -325,13 +325,13 @@ "view_contact": "Névjegy megtekintése", "message_details": "Üzenet részletei", "more_reply_options": "További válasz opciók", - "set_color": "Címke beállítása", + "set_tag": "Címke beállítása", "tag": "Címke", "more_actions": "További műveletek", "previous": "Előző", "next": "Következő", "move_to": "Áthelyezés ide...", - "remove_color": "Címke eltávolítása", + "remove_tag": "Címke eltávolítása", "more_count": "+{count} további", "characters_count": "{count} karakter", "quick_reply_placeholder": "Gyors válasz írása...", @@ -425,17 +425,6 @@ "message_id": "Üzenet azonosító", "list_info": "Lista információk" }, - "color_tag": { - "title": "Színes címke", - "red": "Piros", - "orange": "Narancs", - "yellow": "Sárga", - "green": "Zöld", - "blue": "Kék", - "purple": "Lila", - "pink": "Rózsaszín", - "none": "Nincs" - }, "tooltips": { "reply": "Válasz (r)", "reply_all": "Válasz mindenkinek (a)", @@ -2033,8 +2022,7 @@ "delete": "Törlés", "mark_as_spam": "Spam jelentése", "not_spam": "Nem spam", - "color_tag": "Címke", - "remove_color": "Címke eltávolítása", + "tag": "Címke", "items_selected": "{count} e-mail kijelölve", "edit_draft": "Piszkozat szerkesztése", "cancel_scheduled_send": "Küldés megszakítása", diff --git a/locales/it/common.json b/locales/it/common.json index 1eff3cac..d43d911b 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -325,11 +325,11 @@ "view_contact": "Visualizza contatto", "message_details": "Dettagli del messaggio", "more_reply_options": "Più opzioni di risposta", - "set_color": "Imposta etichetta", + "set_tag": "Imposta etichetta", "tag": "Etichetta", "more_actions": "Altre azioni", "move_to": "Sposta in...", - "remove_color": "Rimuovi etichetta", + "remove_tag": "Rimuovi etichetta", "more_count": "+{count} altri", "characters_count": "{count} caratteri", "quick_reply_placeholder": "Scrivi una risposta veloce...", @@ -398,17 +398,6 @@ "message_id": "ID messaggio", "list_info": "Informazioni lista" }, - "color_tag": { - "title": "Etichetta colore", - "red": "Rosso", - "orange": "Arancione", - "yellow": "Giallo", - "green": "Verde", - "blue": "Blu", - "purple": "Viola", - "pink": "Rosa", - "none": "Nessuno" - }, "tooltips": { "reply": "Rispondi", "reply_all": "Rispondi a tutti (a)", @@ -2033,8 +2022,7 @@ "delete": "Elimina", "mark_as_spam": "Segnala come spam", "not_spam": "Non spam", - "color_tag": "Etichetta", - "remove_color": "Rimuovi etichetta", + "tag": "Etichetta", "items_selected": "{count} messaggi selezionati", "edit_draft": "Modifica bozza", "cancel_scheduled_send": "Annulla invio", diff --git a/locales/ja/common.json b/locales/ja/common.json index df35f292..2502dfa7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -325,11 +325,11 @@ "view_contact": "連絡先を表示", "message_details": "メッセージの詳細", "more_reply_options": "その他の返信オプション", - "set_color": "ラベルを設定", + "set_tag": "ラベルを設定", "tag": "ラベル", "more_actions": "その他の操作", "move_to": "移動...", - "remove_color": "ラベルを削除", + "remove_tag": "ラベルを削除", "more_count": "他{count}件", "characters_count": "{count}文字", "quick_reply_placeholder": "クイック返信を入力...", @@ -398,17 +398,6 @@ "message_id": "メッセージID", "list_info": "リスト情報" }, - "color_tag": { - "title": "カラータグ", - "red": "赤", - "orange": "オレンジ", - "yellow": "黄色", - "green": "緑", - "blue": "青", - "purple": "紫", - "pink": "ピンク", - "none": "なし" - }, "tooltips": { "reply": "返信", "reply_all": "全員に返信 (a)", @@ -2033,8 +2022,7 @@ "delete": "削除", "mark_as_spam": "迷惑メールを報告", "not_spam": "迷惑メールでない", - "color_tag": "ラベル", - "remove_color": "ラベルを削除", + "tag": "ラベル", "items_selected": "{count}件のメールを選択", "edit_draft": "下書きを編集", "cancel_scheduled_send": "送信をキャンセル", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6f9547ba..bfb65416 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -325,13 +325,13 @@ "view_contact": "연락처 보기", "message_details": "메시지 상세 정보", "more_reply_options": "답장 옵션 더보기", - "set_color": "태그 설정", + "set_tag": "태그 설정", "tag": "태그", "more_actions": "작업 더보기", "previous": "이전", "next": "다음", "move_to": "이동...", - "remove_color": "태그 제거", + "remove_tag": "태그 제거", "more_count": "+{count}개 더보기", "characters_count": "{count}자", "quick_reply_placeholder": "간단하게 답장을 작성해 보세요...", @@ -400,17 +400,6 @@ "message_id": "메시지 ID", "list_info": "목록 정보" }, - "color_tag": { - "title": "색상 태그", - "red": "빨간색", - "orange": "주황색", - "yellow": "노란색", - "green": "초록색", - "blue": "파란색", - "purple": "보라색", - "pink": "분홍색", - "none": "없음" - }, "tooltips": { "reply": "답장 (r)", "reply_all": "전체 답장 (a)", @@ -2033,8 +2022,7 @@ "delete": "삭제", "mark_as_spam": "스팸 신고", "not_spam": "정상 메일", - "color_tag": "태그", - "remove_color": "태그 제거", + "tag": "태그", "items_selected": "{count}개의 메일 선택됨", "edit_draft": "임시보관 메일 수정", "cancel_scheduled_send": "보내기 취소", diff --git a/locales/lv/common.json b/locales/lv/common.json index 9f980f28..c742af88 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -325,13 +325,13 @@ "view_contact": "Skatīt kontaktu", "message_details": "Informācija par ziņojumu", "more_reply_options": "Papildu atbildēšanas iespējas", - "set_color": "Iestatīt tagu", + "set_tag": "Iestatīt tagu", "tag": "Tags", "more_actions": "Citas darbības", "previous": "Iepr.", "next": "Nāk.", "move_to": "Pārvietot uz...", - "remove_color": "Noņemt tagu", + "remove_tag": "Noņemt tagu", "more_count": "+vairāk {count}", "characters_count": "{count} rakstzīmes", "quick_reply_placeholder": "Rakstīt ātru atbildi...", @@ -400,17 +400,6 @@ "message_id": "Ziņojuma ID", "list_info": "Informācija par adresātu sarakstu" }, - "color_tag": { - "title": "Krāsu tags", - "red": "Sarkans", - "orange": "Oranžs", - "yellow": "Dzeltens", - "green": "Zaļš", - "blue": "Zils", - "purple": "Violets", - "pink": "Rozā", - "none": "Nav" - }, "tooltips": { "reply": "Atbildēt (r)", "reply_all": "Atbildēt visiem (a)", @@ -2033,8 +2022,7 @@ "delete": "Dzēst", "mark_as_spam": "Atzīmēt kā mēstuli", "not_spam": "Nav mēstule", - "color_tag": "Tags", - "remove_color": "Noņemt tagu", + "tag": "Tags", "items_selected": "{count} vēstules atlasītas", "edit_draft": "Rediģēt melnrakstu", "cancel_scheduled_send": "Atcelt sūtīšanu", diff --git a/locales/nl/common.json b/locales/nl/common.json index 05966a6f..8048229b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -327,11 +327,13 @@ "view_contact": "Contact bekijken", "message_details": "Berichtdetails", "more_reply_options": "Meer antwoordopties", - "set_color": "Label instellen", + "set_tag": "Label instellen", "tag": "Label", "more_actions": "Meer acties", "move_to": "Verplaatsen naar...", - "remove_color": "Label verwijderen", + "remove_tag": "Label verwijderen", + "tag_filter_placeholder": "Labels filteren", + "tag_no_matches": "Geen overeenkomende labels", "more_count": "+{count} meer", "characters_count": "{count} tekens", "quick_reply_placeholder": "Schrijf een snel antwoord...", @@ -400,17 +402,6 @@ "message_id": "Bericht-ID", "list_info": "Lijstinformatie" }, - "color_tag": { - "title": "Kleurtag", - "red": "Rood", - "orange": "Oranje", - "yellow": "Geel", - "green": "Groen", - "blue": "Blauw", - "purple": "Paars", - "pink": "Roze", - "none": "Geen" - }, "tooltips": { "reply": "Beantwoorden", "reply_all": "Allen beantwoorden (a)", @@ -1016,7 +1007,7 @@ }, "keywords": { "title": "E-maillabels", - "description": "Definieer labels om uw e-mails met kleuren te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.", + "description": "Definieer labels om uw e-mails te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.", "add_keyword": "Label toevoegen", "label_field": "Weergavenaam", "label_placeholder": "bijv. Werk, Persoonlijk, Urgent", @@ -2050,8 +2041,7 @@ "delete": "Verwijderen", "mark_as_spam": "Spam melden", "not_spam": "Geen spam", - "color_tag": "Label", - "remove_color": "Label verwijderen", + "tag": "Label", "items_selected": "{count} e-mails geselecteerd", "edit_draft": "Concept bewerken", "cancel_scheduled_send": "Verzenden annuleren", diff --git a/locales/pl/common.json b/locales/pl/common.json index 3cd54d10..9378c706 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -325,13 +325,13 @@ "view_contact": "Pokaż kontakt", "message_details": "Szczegóły wiadomości", "more_reply_options": "Więcej opcji odpowiedzi", - "set_color": "Ustaw etykietę", + "set_tag": "Ustaw etykietę", "tag": "Etykieta", "more_actions": "Więcej działań", "previous": "Poprz.", "next": "Nast.", "move_to": "Przenieś do...", - "remove_color": "Usuń etykietę", + "remove_tag": "Usuń etykietę", "more_count": "+{count} więcej", "characters_count": "{count} znaków", "quick_reply_placeholder": "Napisz szybką odpowiedź...", @@ -400,17 +400,6 @@ "message_id": "ID wiadomości", "list_info": "Informacje o liście" }, - "color_tag": { - "title": "Kolorowa etykieta", - "red": "Czerwony", - "orange": "Pomarańczowy", - "yellow": "Żółty", - "green": "Zielony", - "blue": "Niebieski", - "purple": "Fioletowy", - "pink": "Różowy", - "none": "Brak" - }, "tooltips": { "reply": "Odpowiedz (r)", "reply_all": "Odpowiedz wszystkim (a)", @@ -2033,8 +2022,7 @@ "delete": "Usuń", "mark_as_spam": "Zgłoś spam", "not_spam": "To nie spam", - "color_tag": "Etykieta", - "remove_color": "Usuń etykietę", + "tag": "Etykieta", "items_selected": "{count} zaznaczonych wiadomości", "edit_draft": "Edytuj szkic", "cancel_scheduled_send": "Anuluj wysyłkę", diff --git a/locales/pt/common.json b/locales/pt/common.json index 42b5e2de..6bbd2b2e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -325,11 +325,11 @@ "view_contact": "Ver contato", "message_details": "Detalhes da Mensagem", "more_reply_options": "Mais opções de resposta", - "set_color": "Definir etiqueta", + "set_tag": "Definir etiqueta", "tag": "Etiqueta", "more_actions": "Mais ações", "move_to": "Mover para...", - "remove_color": "Remover etiqueta", + "remove_tag": "Remover etiqueta", "more_count": "+{count} mais", "characters_count": "{count} caracteres", "quick_reply_placeholder": "Escreva uma resposta rápida...", @@ -398,17 +398,6 @@ "message_id": "ID da Mensagem", "list_info": "Informações da Lista" }, - "color_tag": { - "title": "Etiqueta de Cor", - "red": "Vermelho", - "orange": "Laranja", - "yellow": "Amarelo", - "green": "Verde", - "blue": "Azul", - "purple": "Roxo", - "pink": "Rosa", - "none": "Nenhuma" - }, "tooltips": { "reply": "Responder", "reply_all": "Responder a todos (a)", @@ -2033,8 +2022,7 @@ "delete": "Excluir", "mark_as_spam": "Reportar spam", "not_spam": "Não é spam", - "color_tag": "Etiqueta", - "remove_color": "Remover etiqueta", + "tag": "Etiqueta", "items_selected": "{count} e-mails selecionados", "edit_draft": "Editar rascunho", "cancel_scheduled_send": "Cancelar envio", diff --git a/locales/ro/common.json b/locales/ro/common.json index 02f011a4..12968735 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -325,13 +325,13 @@ "view_contact": "Vizualizare contact", "message_details": "Detalii mesaj", "more_reply_options": "Mai multe opțiuni de răspuns", - "set_color": "Setați eticheta", + "set_tag": "Setați eticheta", "tag": "Etichetă", "more_actions": "Alte acțiuni", "previous": "Anterior", "next": "Următorul", "move_to": "Mergi la...", - "remove_color": "Eliminați eticheta", + "remove_tag": "Eliminați eticheta", "more_count": "+{count} mai multe", "characters_count": "{count} caractere", "quick_reply_placeholder": "Scrie un răspuns rapid...", @@ -425,17 +425,6 @@ "message_id": "IDul mesajelor", "list_info": "Informații despre listă" }, - "color_tag": { - "title": "Etichetă de culoare", - "red": "Roșu", - "orange": "Portocaliu", - "yellow": "Galben", - "green": "Verde", - "blue": "Albastru", - "purple": "Violet", - "pink": "Roz", - "none": "Niciunul" - }, "tooltips": { "reply": "Răspunde (r)", "reply_all": "Răspunde tuturor (a)", @@ -2033,8 +2022,7 @@ "delete": "Șterge", "mark_as_spam": "Raportează spamul", "not_spam": "Nu este spam", - "color_tag": "Etichetă", - "remove_color": "Eliminați eticheta", + "tag": "Etichetă", "items_selected": "{count} e-mailuri selectate", "edit_draft": "Editează schița", "cancel_scheduled_send": "Anulează trimiterea", diff --git a/locales/ru/common.json b/locales/ru/common.json index 878c9af6..9302e3a6 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -325,13 +325,13 @@ "view_contact": "Просмотреть контакт", "message_details": "Детали сообщения", "more_reply_options": "Дополнительные параметры ответа", - "set_color": "Установить тег", + "set_tag": "Установить тег", "tag": "Тег", "more_actions": "Другие действия", "previous": "Пред.", "next": "След.", "move_to": "Переместить в...", - "remove_color": "Удалить тег", + "remove_tag": "Удалить тег", "more_count": "+{count} ещё", "characters_count": "{count} символов", "quick_reply_placeholder": "Написать быстрый ответ...", @@ -400,17 +400,6 @@ "message_id": "Идентификатор сообщения", "list_info": "Информация о рассылке" }, - "color_tag": { - "title": "Цветной тег", - "red": "Красный", - "orange": "Оранжевый", - "yellow": "Жёлтый", - "green": "Зелёный", - "blue": "Синий", - "purple": "Фиолетовый", - "pink": "Розовый", - "none": "Нет" - }, "tooltips": { "reply": "Ответить (r)", "reply_all": "Ответить всем (a)", @@ -2033,8 +2022,7 @@ "delete": "Удалить", "mark_as_spam": "Отметить как спам", "not_spam": "Не спам", - "color_tag": "Тег", - "remove_color": "Удалить тег", + "tag": "Тег", "items_selected": "{count} писем выбрано", "edit_draft": "Редактировать черновик", "cancel_scheduled_send": "Отменить отправку", diff --git a/locales/sk/common.json b/locales/sk/common.json index 3d10b558..0d2fba68 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -325,13 +325,13 @@ "view_contact": "Zobraziť kontakt", "message_details": "Podrobnosti správy", "more_reply_options": "Viac možností odpovede", - "set_color": "Nastaviť štítok", + "set_tag": "Nastaviť štítok", "tag": "Štítok", "more_actions": "Viac akcií", "previous": "Predchádzajúci", "next": "Ďalší", "move_to": "Presunúť do...", - "remove_color": "Odstrániť štítok", + "remove_tag": "Odstrániť štítok", "more_count": "+{count} ďalších", "characters_count": "{count} znakov", "quick_reply_placeholder": "Napísať rýchlu odpoveď...", @@ -425,17 +425,6 @@ "message_id": "ID správy", "list_info": "Informácie o zozname" }, - "color_tag": { - "title": "Farebný štítok", - "red": "Červený", - "orange": "Oranžový", - "yellow": "Žltý", - "green": "Zelený", - "blue": "Modrý", - "purple": "Fialový", - "pink": "RŪžový", - "none": "Žiadny" - }, "tooltips": { "reply": "Odpovedať (r)", "reply_all": "Odpovedať všetkým (a)", @@ -2033,8 +2022,7 @@ "delete": "Odstrániť", "mark_as_spam": "Nahlásiť spam", "not_spam": "Nie je spam", - "color_tag": "Štítok", - "remove_color": "Odstrániť štítok", + "tag": "Štítok", "items_selected": "{count} vybraných e-mailov", "edit_draft": "Upraviť koncept", "cancel_scheduled_send": "Zrušiť odoslanie", diff --git a/locales/tr/common.json b/locales/tr/common.json index baa6a5cf..6e6ed5a0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -325,13 +325,13 @@ "view_contact": "Kişiyi görüntüle", "message_details": "İleti Ayrıntıları", "more_reply_options": "Daha fazla yanıt seçeneği", - "set_color": "Etiket ayarla", + "set_tag": "Etiket ayarla", "tag": "Etiket", "more_actions": "Diğer işlemler", "previous": "Önceki", "next": "Sonraki", "move_to": "Şuraya taşı...", - "remove_color": "Etiketi kaldır", + "remove_tag": "Etiketi kaldır", "more_count": "+{count} daha", "characters_count": "{count} karakter", "quick_reply_placeholder": "Hızlı yanıt yazın...", @@ -400,17 +400,6 @@ "message_id": "İleti Kimliği", "list_info": "Liste Bilgisi" }, - "color_tag": { - "title": "Renk Etiketi", - "red": "Kırmızı", - "orange": "Turuncu", - "yellow": "Sarı", - "green": "Yeşil", - "blue": "Mavi", - "purple": "Mor", - "pink": "Pembe", - "none": "Yok" - }, "tooltips": { "reply": "Yanıtla (r)", "reply_all": "Tümünü Yanıtla (a)", @@ -2033,8 +2022,7 @@ "delete": "Sil", "mark_as_spam": "Spam bildir", "not_spam": "Spam değil", - "color_tag": "Etiket", - "remove_color": "Etiketi kaldır", + "tag": "Etiket", "items_selected": "{count} e-posta seçildi", "edit_draft": "Taslağı Düzenle", "cancel_scheduled_send": "Göndermeyi iptal et", diff --git a/locales/uk/common.json b/locales/uk/common.json index 31a97d4e..6da37126 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -325,13 +325,13 @@ "view_contact": "Переглянути контакт", "message_details": "Деталі повідомлення", "more_reply_options": "Більше варіантів відповіді", - "set_color": "Встановити тег", + "set_tag": "Встановити тег", "tag": "Тег", "more_actions": "Більше дій", "previous": "попередня", "next": "Далі", "move_to": "Перейти до...", - "remove_color": "Видалити тег", + "remove_tag": "Видалити тег", "more_count": "+ ще {count}", "characters_count": "{count} символів", "quick_reply_placeholder": "Напишіть швидку відповідь...", @@ -400,17 +400,6 @@ "message_id": "ID повідомлення", "list_info": "Інформація про список" }, - "color_tag": { - "title": "Кольоровий тег", - "red": "Червоний", - "orange": "Помаранчевий", - "yellow": "Жовтий", - "green": "Зелений", - "blue": "Синій", - "purple": "Фіолетовий", - "pink": "Рожевий", - "none": "Жодного" - }, "tooltips": { "reply": "Відповісти (р)", "reply_all": "Відповісти всім (а)", @@ -2033,8 +2022,7 @@ "delete": "Видалити", "mark_as_spam": "Повідомити про спам", "not_spam": "Не спам", - "color_tag": "Мітка", - "remove_color": "Видалити мітку", + "tag": "Мітка", "items_selected": "Вибрано електронних листів: {count}", "edit_draft": "Редагувати чернетку", "cancel_scheduled_send": "Скасувати надсилання", diff --git a/locales/zh/common.json b/locales/zh/common.json index 6015e0c0..54273d96 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -325,13 +325,13 @@ "view_contact": "查看联系人", "message_details": "邮件详情", "more_reply_options": "更多回复选项", - "set_color": "设置颜色标签", + "set_tag": "设置颜色标签", "tag": "标签", "more_actions": "更多操作", "previous": "上一封", "next": "下一封", "move_to": "移动到…", - "remove_color": "删除标签", + "remove_tag": "删除标签", "more_count": "+{count} 更多", "characters_count": "{count} 个字符", "quick_reply_placeholder": "快速回复...", @@ -400,17 +400,6 @@ "message_id": "消息 ID", "list_info": "邮件列表信息" }, - "color_tag": { - "title": "颜色标签", - "red": "红色", - "orange": "橙色", - "yellow": "黄色", - "green": "绿色", - "blue": "蓝色", - "purple": "紫色", - "pink": "粉色", - "none": "无" - }, "tooltips": { "reply": "回复 (r)", "reply_all": "全部回复 (a)", @@ -2033,8 +2022,7 @@ "delete": "删除", "mark_as_spam": "举报垃圾邮件", "not_spam": "不是垃圾邮件", - "color_tag": "标签", - "remove_color": "删除标签", + "tag": "标签", "items_selected": "已选择 {count} 封邮件", "edit_draft": "编辑草稿", "cancel_scheduled_send": "取消发送", diff --git a/stores/__tests__/settings-store-keywords.test.ts b/stores/__tests__/settings-store-keywords.test.ts index 202e8428..822c8a72 100644 --- a/stores/__tests__/settings-store-keywords.test.ts +++ b/stores/__tests__/settings-store-keywords.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE, getKeywordVisibility } from '../settings-store'; +import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS, getKeywordVisibility } from '../settings-store'; import type { KeywordDefinition } from '../settings-store'; describe('settings-store keywords', () => { @@ -16,7 +16,7 @@ describe('settings-store keywords', () => { DEFAULT_KEYWORDS.forEach((kw) => { expect(KEYWORD_PALETTE[kw.color]).toBeDefined(); expect(KEYWORD_PALETTE[kw.color].dot).toBeTruthy(); - expect(KEYWORD_PALETTE[kw.color].bg).toBeTruthy(); + expect(KEYWORD_PALETTE[kw.color].fill).toBeTruthy(); }); }); @@ -27,14 +27,39 @@ describe('settings-store keywords', () => { }); describe('KEYWORD_PALETTE', () => { - it('has 13 colors', () => { - expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(13); + it('has a lighter, base and darker shade of every hue', () => { + expect(KEYWORD_PALETTE_ROWS).toHaveLength(3); + KEYWORD_PALETTE_ROWS.forEach((row) => expect(row).toHaveLength(13)); + expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(39); }); - it('each color has dot and bg classes', () => { + it('lays every row out in the same hue order', () => { + const [light, base, dark] = KEYWORD_PALETTE_ROWS; + expect(light).toEqual(base.map((key) => `${key}-light`)); + expect(dark).toEqual(base.map((key) => `${key}-dark`)); + }); + + it('keeps the bare hue name on the base row, so saved tags still resolve', () => { + // A tag stored as `red` predates the lighter and darker rows. + expect(KEYWORD_PALETTE_ROWS[1]).toContain('red'); + expect(KEYWORD_PALETTE.red).toBeDefined(); + }); + + it('spells every class out so Tailwind can find it', () => { + // A composed class name would compile to nothing, so none may be built + // at runtime and each has to carry its own utility prefix. Object.values(KEYWORD_PALETTE).forEach((entry) => { expect(entry.dot).toMatch(/^bg-/); - expect(entry.bg).toMatch(/^bg-/); + expect(entry.fill).toMatch(/^bg-/); + expect(entry.border).toMatch(/^border-/); + expect(entry.text).toMatch(/^text-.* dark:text-/); + expect(entry.rowTint).toMatch(/^bg-.* dark:bg-/); + }); + }); + + it('resolves every row key', () => { + KEYWORD_PALETTE_ROWS.flat().forEach((key) => { + expect(KEYWORD_PALETTE[key]).toBeDefined(); }); }); }); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index b8afec55..e2ae45bf 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -125,23 +125,86 @@ export interface SidebarApp { showOnMobile: boolean; } -// Available color palette for keywords -export const KEYWORD_PALETTE: Record = { - red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' }, - orange: { dot: 'bg-orange-500', bg: 'bg-orange-50 dark:bg-orange-950/30' }, - yellow: { dot: 'bg-yellow-500', bg: 'bg-yellow-50 dark:bg-yellow-950/30' }, - green: { dot: 'bg-green-500', bg: 'bg-green-50 dark:bg-green-950/30' }, - blue: { dot: 'bg-blue-500', bg: 'bg-blue-50 dark:bg-blue-950/30' }, - purple: { dot: 'bg-purple-500', bg: 'bg-purple-50 dark:bg-purple-950/30' }, - pink: { dot: 'bg-pink-500', bg: 'bg-pink-50 dark:bg-pink-950/30' }, - teal: { dot: 'bg-teal-500', bg: 'bg-teal-50 dark:bg-teal-950/30' }, - cyan: { dot: 'bg-cyan-500', bg: 'bg-cyan-50 dark:bg-cyan-950/30' }, - indigo: { dot: 'bg-indigo-500', bg: 'bg-indigo-50 dark:bg-indigo-950/30' }, - amber: { dot: 'bg-amber-500', bg: 'bg-amber-50 dark:bg-amber-950/30' }, - lime: { dot: 'bg-lime-500', bg: 'bg-lime-50 dark:bg-lime-950/30' }, - gray: { dot: 'bg-gray-500', bg: 'bg-gray-50 dark:bg-gray-950/30' }, +export interface KeywordColor { + /** Solid swatch: the dot form and the settings swatches. */ + dot: string; + /** The same solid colour as `dot`, for glyphs that take a text colour. */ + icon: string; + /** Lozenge background. */ + fill: string; + /** Lozenge border. */ + border: string; + /** Lozenge text. */ + text: string; + /** Full-row wash when `tintListRowsByTag` is on. */ + rowTint: string; +} + +/** + * Tag colours, written out literally. + * + * Tailwind v4 scans this file, but only for classes that appear verbatim - + * a composed `bg-${hue}-500` would compile to nothing. Every shade a tag can + * take therefore has to be spelled out, which is why this map is long. + * + * Three shades per hue: the middle one keeps the bare hue name, so a tag + * saved before the lighter and darker rows existed still resolves. + */ +export const KEYWORD_PALETTE: Record = { + // light + 'red-light': { dot: 'bg-red-300', icon: 'text-red-300', fill: 'bg-red-300/10', border: 'border-red-300/30', text: 'text-red-600 dark:text-red-200', rowTint: 'bg-red-50/60 dark:bg-red-950/20' }, + 'orange-light': { dot: 'bg-orange-300', icon: 'text-orange-300', fill: 'bg-orange-300/10', border: 'border-orange-300/30', text: 'text-orange-600 dark:text-orange-200', rowTint: 'bg-orange-50/60 dark:bg-orange-950/20' }, + 'amber-light': { dot: 'bg-amber-300', icon: 'text-amber-300', fill: 'bg-amber-300/10', border: 'border-amber-300/30', text: 'text-amber-600 dark:text-amber-200', rowTint: 'bg-amber-50/60 dark:bg-amber-950/20' }, + 'yellow-light': { dot: 'bg-yellow-300', icon: 'text-yellow-300', fill: 'bg-yellow-300/10', border: 'border-yellow-300/30', text: 'text-yellow-600 dark:text-yellow-200', rowTint: 'bg-yellow-50/60 dark:bg-yellow-950/20' }, + 'lime-light': { dot: 'bg-lime-300', icon: 'text-lime-300', fill: 'bg-lime-300/10', border: 'border-lime-300/30', text: 'text-lime-600 dark:text-lime-200', rowTint: 'bg-lime-50/60 dark:bg-lime-950/20' }, + 'green-light': { dot: 'bg-green-300', icon: 'text-green-300', fill: 'bg-green-300/10', border: 'border-green-300/30', text: 'text-green-600 dark:text-green-200', rowTint: 'bg-green-50/60 dark:bg-green-950/20' }, + 'teal-light': { dot: 'bg-teal-300', icon: 'text-teal-300', fill: 'bg-teal-300/10', border: 'border-teal-300/30', text: 'text-teal-600 dark:text-teal-200', rowTint: 'bg-teal-50/60 dark:bg-teal-950/20' }, + 'cyan-light': { dot: 'bg-cyan-300', icon: 'text-cyan-300', fill: 'bg-cyan-300/10', border: 'border-cyan-300/30', text: 'text-cyan-600 dark:text-cyan-200', rowTint: 'bg-cyan-50/60 dark:bg-cyan-950/20' }, + 'blue-light': { dot: 'bg-blue-300', icon: 'text-blue-300', fill: 'bg-blue-300/10', border: 'border-blue-300/30', text: 'text-blue-600 dark:text-blue-200', rowTint: 'bg-blue-50/60 dark:bg-blue-950/20' }, + 'indigo-light': { dot: 'bg-indigo-300', icon: 'text-indigo-300', fill: 'bg-indigo-300/10', border: 'border-indigo-300/30', text: 'text-indigo-600 dark:text-indigo-200', rowTint: 'bg-indigo-50/60 dark:bg-indigo-950/20' }, + 'purple-light': { dot: 'bg-purple-300', icon: 'text-purple-300', fill: 'bg-purple-300/10', border: 'border-purple-300/30', text: 'text-purple-600 dark:text-purple-200', rowTint: 'bg-purple-50/60 dark:bg-purple-950/20' }, + 'pink-light': { dot: 'bg-pink-300', icon: 'text-pink-300', fill: 'bg-pink-300/10', border: 'border-pink-300/30', text: 'text-pink-600 dark:text-pink-200', rowTint: 'bg-pink-50/60 dark:bg-pink-950/20' }, + 'gray-light': { dot: 'bg-gray-300', icon: 'text-gray-300', fill: 'bg-gray-300/10', border: 'border-gray-300/30', text: 'text-gray-600 dark:text-gray-200', rowTint: 'bg-gray-50/60 dark:bg-gray-950/20' }, + // base + red: { dot: 'bg-red-500', icon: 'text-red-500', fill: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-700 dark:text-red-300', rowTint: 'bg-red-50 dark:bg-red-950/30' }, + orange: { dot: 'bg-orange-500', icon: 'text-orange-500', fill: 'bg-orange-500/10', border: 'border-orange-500/30', text: 'text-orange-700 dark:text-orange-300', rowTint: 'bg-orange-50 dark:bg-orange-950/30' }, + amber: { dot: 'bg-amber-500', icon: 'text-amber-500', fill: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-700 dark:text-amber-300', rowTint: 'bg-amber-50 dark:bg-amber-950/30' }, + yellow: { dot: 'bg-yellow-500', icon: 'text-yellow-500', fill: 'bg-yellow-500/10', border: 'border-yellow-500/30', text: 'text-yellow-700 dark:text-yellow-300', rowTint: 'bg-yellow-50 dark:bg-yellow-950/30' }, + lime: { dot: 'bg-lime-500', icon: 'text-lime-500', fill: 'bg-lime-500/10', border: 'border-lime-500/30', text: 'text-lime-700 dark:text-lime-300', rowTint: 'bg-lime-50 dark:bg-lime-950/30' }, + green: { dot: 'bg-green-500', icon: 'text-green-500', fill: 'bg-green-500/10', border: 'border-green-500/30', text: 'text-green-700 dark:text-green-300', rowTint: 'bg-green-50 dark:bg-green-950/30' }, + teal: { dot: 'bg-teal-500', icon: 'text-teal-500', fill: 'bg-teal-500/10', border: 'border-teal-500/30', text: 'text-teal-700 dark:text-teal-300', rowTint: 'bg-teal-50 dark:bg-teal-950/30' }, + cyan: { dot: 'bg-cyan-500', icon: 'text-cyan-500', fill: 'bg-cyan-500/10', border: 'border-cyan-500/30', text: 'text-cyan-700 dark:text-cyan-300', rowTint: 'bg-cyan-50 dark:bg-cyan-950/30' }, + blue: { dot: 'bg-blue-500', icon: 'text-blue-500', fill: 'bg-blue-500/10', border: 'border-blue-500/30', text: 'text-blue-700 dark:text-blue-300', rowTint: 'bg-blue-50 dark:bg-blue-950/30' }, + indigo: { dot: 'bg-indigo-500', icon: 'text-indigo-500', fill: 'bg-indigo-500/10', border: 'border-indigo-500/30', text: 'text-indigo-700 dark:text-indigo-300', rowTint: 'bg-indigo-50 dark:bg-indigo-950/30' }, + purple: { dot: 'bg-purple-500', icon: 'text-purple-500', fill: 'bg-purple-500/10', border: 'border-purple-500/30', text: 'text-purple-700 dark:text-purple-300', rowTint: 'bg-purple-50 dark:bg-purple-950/30' }, + pink: { dot: 'bg-pink-500', icon: 'text-pink-500', fill: 'bg-pink-500/10', border: 'border-pink-500/30', text: 'text-pink-700 dark:text-pink-300', rowTint: 'bg-pink-50 dark:bg-pink-950/30' }, + gray: { dot: 'bg-gray-500', icon: 'text-gray-500', fill: 'bg-gray-500/10', border: 'border-gray-500/30', text: 'text-gray-700 dark:text-gray-300', rowTint: 'bg-gray-50 dark:bg-gray-950/30' }, + // dark + 'red-dark': { dot: 'bg-red-700', icon: 'text-red-700', fill: 'bg-red-700/10', border: 'border-red-700/30', text: 'text-red-800 dark:text-red-400', rowTint: 'bg-red-100 dark:bg-red-950/50' }, + 'orange-dark': { dot: 'bg-orange-700', icon: 'text-orange-700', fill: 'bg-orange-700/10', border: 'border-orange-700/30', text: 'text-orange-800 dark:text-orange-400', rowTint: 'bg-orange-100 dark:bg-orange-950/50' }, + 'amber-dark': { dot: 'bg-amber-700', icon: 'text-amber-700', fill: 'bg-amber-700/10', border: 'border-amber-700/30', text: 'text-amber-800 dark:text-amber-400', rowTint: 'bg-amber-100 dark:bg-amber-950/50' }, + 'yellow-dark': { dot: 'bg-yellow-700', icon: 'text-yellow-700', fill: 'bg-yellow-700/10', border: 'border-yellow-700/30', text: 'text-yellow-800 dark:text-yellow-400', rowTint: 'bg-yellow-100 dark:bg-yellow-950/50' }, + 'lime-dark': { dot: 'bg-lime-700', icon: 'text-lime-700', fill: 'bg-lime-700/10', border: 'border-lime-700/30', text: 'text-lime-800 dark:text-lime-400', rowTint: 'bg-lime-100 dark:bg-lime-950/50' }, + 'green-dark': { dot: 'bg-green-700', icon: 'text-green-700', fill: 'bg-green-700/10', border: 'border-green-700/30', text: 'text-green-800 dark:text-green-400', rowTint: 'bg-green-100 dark:bg-green-950/50' }, + 'teal-dark': { dot: 'bg-teal-700', icon: 'text-teal-700', fill: 'bg-teal-700/10', border: 'border-teal-700/30', text: 'text-teal-800 dark:text-teal-400', rowTint: 'bg-teal-100 dark:bg-teal-950/50' }, + 'cyan-dark': { dot: 'bg-cyan-700', icon: 'text-cyan-700', fill: 'bg-cyan-700/10', border: 'border-cyan-700/30', text: 'text-cyan-800 dark:text-cyan-400', rowTint: 'bg-cyan-100 dark:bg-cyan-950/50' }, + 'blue-dark': { dot: 'bg-blue-700', icon: 'text-blue-700', fill: 'bg-blue-700/10', border: 'border-blue-700/30', text: 'text-blue-800 dark:text-blue-400', rowTint: 'bg-blue-100 dark:bg-blue-950/50' }, + 'indigo-dark': { dot: 'bg-indigo-700', icon: 'text-indigo-700', fill: 'bg-indigo-700/10', border: 'border-indigo-700/30', text: 'text-indigo-800 dark:text-indigo-400', rowTint: 'bg-indigo-100 dark:bg-indigo-950/50' }, + 'purple-dark': { dot: 'bg-purple-700', icon: 'text-purple-700', fill: 'bg-purple-700/10', border: 'border-purple-700/30', text: 'text-purple-800 dark:text-purple-400', rowTint: 'bg-purple-100 dark:bg-purple-950/50' }, + 'pink-dark': { dot: 'bg-pink-700', icon: 'text-pink-700', fill: 'bg-pink-700/10', border: 'border-pink-700/30', text: 'text-pink-800 dark:text-pink-400', rowTint: 'bg-pink-100 dark:bg-pink-950/50' }, + 'gray-dark': { dot: 'bg-gray-700', icon: 'text-gray-700', fill: 'bg-gray-700/10', border: 'border-gray-700/30', text: 'text-gray-800 dark:text-gray-400', rowTint: 'bg-gray-100 dark:bg-gray-950/50' }, } as const; +/** Palette laid out as the settings picker shows it: lighter, base, darker. */ +export const KEYWORD_PALETTE_ROWS: string[][] = [ + ['red-light', 'orange-light', 'amber-light', 'yellow-light', 'lime-light', 'green-light', 'teal-light', 'cyan-light', 'blue-light', 'indigo-light', 'purple-light', 'pink-light', 'gray-light'], + ['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray'], + ['red-dark', 'orange-dark', 'amber-dark', 'yellow-dark', 'lime-dark', 'green-dark', 'teal-dark', 'cyan-dark', 'blue-dark', 'indigo-dark', 'purple-dark', 'pink-dark', 'gray-dark'], +]; + +/** The colour a tag falls back to when its definition is gone. */ +export const FALLBACK_KEYWORD_COLOR = 'gray'; + export const DEFAULT_KEYWORDS: KeywordDefinition[] = [ { id: 'red', label: 'Red', color: 'red' }, { id: 'orange', label: 'Orange', color: 'orange' }, From d9d9f91a867b589e8652342938e8f57b510a567d Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 14:50:03 +0200 Subject: [PATCH 30/42] feat: Make it easier to handle multiple tags - Tags can now be removed straight from the email header - Tagging control now allows the user to (de)select multiple tags in one go --- app/(main)/[locale]/page.tsx | 17 ++++-- components/email/__tests__/tag-badge.test.tsx | 41 ++++++++++++++ .../email/__tests__/tag-picker.test.tsx | 26 +++++++-- components/email/email-context-menu.tsx | 20 +------ components/email/email-list.tsx | 46 +++++++++------- components/email/email-viewer.tsx | 36 +++++------- components/email/tag-badge.tsx | 27 ++++++++- components/email/tag-picker.tsx | 55 +++++++++++-------- components/pro/pro-email-tab-body.tsx | 29 +++++++--- lib/__tests__/thread-utils.test.ts | 25 +++++++++ lib/thread-utils.ts | 7 ++- 11 files changed, 221 insertions(+), 108 deletions(-) create mode 100644 components/email/__tests__/tag-badge.test.tsx diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index cd844fc7..a4627415 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -34,6 +34,7 @@ import { debug } from "@/lib/debug"; import { playNotificationSound } from "@/lib/notification-sound"; import { cn } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; +import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; import { ErrorBoundary, SidebarErrorFallback, @@ -1870,18 +1871,22 @@ export default function Home() { if (tagId === null) { // Remove all tag keywords Object.keys(keywords).forEach(key => { - if (key.startsWith("$label:") || key.startsWith("$color:")) { + if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) { keywords[key] = false; } }); } else { - const jmapKey = `$label:${tagId}`; - if (keywords[jmapKey]) { - // Toggle off if already active - keywords[jmapKey] = false; + // Both prefixes name the same tag when read, so taking one off has to + // clear whichever spellings are actually set. + const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId] + .filter(key => keywords[key]); + if (activeKeys.length > 0) { + activeKeys.forEach(key => { + keywords[key] = false; + }); } else { // Add the tag without disturbing others - keywords[jmapKey] = true; + keywords[KEYWORD_PREFIX + tagId] = true; } } diff --git a/components/email/__tests__/tag-badge.test.tsx b/components/email/__tests__/tag-badge.test.tsx new file mode 100644 index 00000000..70775ff6 --- /dev/null +++ b/components/email/__tests__/tag-badge.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TagBadge } from '../tag-badge'; +import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store'; + +const TAGS: KeywordDefinition[] = [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, +]; + +describe('TagBadge', () => { + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true }); + }); + + it('names the tag by its full path', () => { + render(); + expect(screen.getByText('Work/Clients')).toBeInTheDocument(); + }); + + it('names a tag it has no definition for by its id', () => { + render(); + expect(screen.getByText('from-elsewhere')).toBeInTheDocument(); + }); + + it('offers removal only when asked to', () => { + const onRemove = vi.fn(); + const { rerender } = render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'remove_tag' })); + expect(onRemove).toHaveBeenCalledOnce(); + }); + + it('leaves the dot alone, having nowhere to put the control', () => { + render( {}} />); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Work')).toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/tag-picker.test.tsx b/components/email/__tests__/tag-picker.test.tsx index e7c7e664..048d9096 100644 --- a/components/email/__tests__/tag-picker.test.tsx +++ b/components/email/__tests__/tag-picker.test.tsx @@ -53,12 +53,28 @@ describe('TagPicker', () => { expect(onToggle).toHaveBeenCalledWith('work/clients'); }); - it('offers the clear-all row only while something is applied', () => { - const { rerender } = render( {}} onClearAll={() => {}} />); - expect(screen.queryByText('remove_tag')).not.toBeInTheDocument(); + it('lists a tag it has no definition for, so it can be taken off', () => { + const onToggle = vi.fn(); + const { rerender } = render(); - rerender( {}} onClearAll={() => {}} />); - expect(screen.getByText('remove_tag')).toBeInTheDocument(); + const row = screen.getByText('from-elsewhere').closest('button')!; + expect(row).toHaveAttribute('aria-checked', 'true'); + + fireEvent.click(row); + expect(onToggle).toHaveBeenCalledWith('from-elsewhere'); + + // Nothing but the message says it exists, so deselecting is the last of it. + rerender(); + expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument(); + }); + + it('counts undefined tags towards the filter box, and matches them', () => { + const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`); + const { container } = render( {}} />); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } }); + expect(within(container).getByText('stray-3')).toBeInTheDocument(); + expect(within(container).queryByText('Work')).not.toBeInTheDocument(); }); it('hides the filter box until the list is long enough to need one', () => { diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 5c26667d..2cef2b5b 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -36,6 +36,7 @@ import { } from "lucide-react"; import { buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; +import { getEmailTagIds } from "@/lib/thread-utils"; import { TagPicker } from "./tag-picker"; interface Position { @@ -99,20 +100,6 @@ const getMailboxIcon = (role?: string) => { } }; -/** Every tag id set on a message, reading the current prefix and the legacy one. */ -const getCurrentTagIds = (keywords: Record | undefined): string[] => { - if (!keywords) return []; - const tags: string[] = []; - for (const key of Object.keys(keywords)) { - if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { - tags.push( - key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length) - ); - } - } - return tags; -}; - export function EmailContextMenu({ email, position, @@ -155,7 +142,7 @@ export function EmailContextMenu({ const isStarred = email.keywords?.$flagged; const isPinned = email.keywords?.['$pinned'] === true; const isDraft = email.keywords?.['$draft'] === true; - const currentTagIds = getCurrentTagIds(email.keywords); + const currentTagIds = getEmailTagIds(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; // Marking your own outgoing mail as spam makes no sense - hide the action @@ -373,8 +360,7 @@ export function EmailContextMenu({
handleAction(() => onSetTag?.(tagId))} - onClearAll={() => handleAction(() => onSetTag?.(null))} + onToggle={(tagId) => onSetTag?.(tagId)} />
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 4333f72d..4329a53d 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -133,6 +133,14 @@ export function EmailList({ }, [emails, disableThreading, isScheduledView, threadEmailCounts]); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); + /** + * The row the menu was opened on, as the list currently has it. The menu holds + * the message it was handed when it opened, but tags can be applied from + * inside it without dismissing it, so what it draws has to keep up. + */ + const contextMenuEmail = contextMenu.data + ? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data + : null; const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const [isProcessing, setIsProcessing] = useState(false); @@ -574,9 +582,9 @@ export function EmailList({
{/* Context Menu */} - {contextMenu.data && ( + {contextMenuEmail && ( onReply?.(contextMenu.data!)} - onReplyAll={() => onReplyAll?.(contextMenu.data!)} - onForward={() => onForward?.(contextMenu.data!)} - onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenu.data!)} + onReply={() => onReply?.(contextMenuEmail!)} + onReplyAll={() => onReplyAll?.(contextMenuEmail!)} + onForward={() => onForward?.(contextMenuEmail!)} + onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)} onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)} - onToggleStar={() => onToggleStar?.(contextMenu.data!)} - onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined} - onDelete={() => onDelete?.(contextMenu.data!)} - onArchive={() => onArchive?.(contextMenu.data!)} - onSetTag={(color) => onSetTag?.(contextMenu.data!.id, color)} - onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)} - onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} - onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} - onEditDraft={() => onEditDraft?.(contextMenu.data!)} - onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined} - onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined} - onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined} + onToggleStar={() => onToggleStar?.(contextMenuEmail!)} + onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined} + onDelete={() => onDelete?.(contextMenuEmail!)} + onArchive={() => onArchive?.(contextMenuEmail!)} + onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)} + onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)} + onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)} + onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)} + onEditDraft={() => onEditDraft?.(contextMenuEmail!)} + onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined} + onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined} + onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={() => client && batchDelete(client)} onBatchArchive={async () => { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 8883e444..bbdc0a76 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -16,6 +16,7 @@ import { TagBadge } from "./tag-badge"; import { TagPicker } from "./tag-picker"; import { useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { getEmailTagIds } from "@/lib/thread-utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; import { generateEmailSource } from "@/lib/email-source"; @@ -204,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st return 'Attachment'; }; -const getCurrentTagIds = (keywords: Record | undefined): string[] => { - if (!keywords) return []; - const tags: string[] = []; - for (const key of Object.keys(keywords)) { - if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { - tags.push( - key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length) - ); - } - } - return tags; -}; - // Helper function to format recipients with contextual display const _formatRecipients = ( recipients: Array<{ name?: string; email: string }> | undefined, @@ -818,7 +806,7 @@ export function EmailViewer({ const moveMenuRef = useRef(null); const toolbarRef = useRef(null); const [hiddenPriorities, setHiddenPriorities] = useState>(new Set()); - const currentTagIds = getCurrentTagIds(email?.keywords); + const currentTagIds = getEmailTagIds(email?.keywords); const sortedTagIds = sortTagIds(currentTagIds); // The header spans the reading pane, so it measures its own width rather than // inheriting the message list's answer. @@ -3011,8 +2999,7 @@ export function EmailViewer({
{ if (email) onSetTag?.(email.id, tagId); setTagMenuOpen(false); }} - onClearAll={() => { if (email) onSetTag?.(email.id, null); setTagMenuOpen(false); }} + onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }} />
)} @@ -3205,7 +3192,7 @@ export function EmailViewer({
)} {/* Overflow: tag - submenu */} - {emailKeywords.length > 0 && ( + {(emailKeywords.length > 0 || currentTagIds.length > 0) && (
setMoreMenuSub('tag')} onMouseLeave={() => setMoreMenuSub(null)} @@ -3222,8 +3209,7 @@ export function EmailViewer({
{ if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }} - onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }} + onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }} />
)} @@ -3368,7 +3354,7 @@ export function EmailViewer({ {isStarred ? t('tooltips.unstar') : t('tooltips.star')} {/* Tag (opens sub-view) */} - {emailKeywords.length > 0 && ( + {(emailKeywords.length > 0 || currentTagIds.length > 0) && (
@@ -3551,7 +3536,12 @@ export function EmailViewer({ {sortedTagIds.length > 0 && (
{sortedTagIds.map((tagId) => ( - + onSetTag(email.id, tagId) : undefined} + /> ))}
)} diff --git a/components/email/tag-badge.tsx b/components/email/tag-badge.tsx index 9c61f6d8..ca36f35f 100644 --- a/components/email/tag-badge.tsx +++ b/components/email/tag-badge.tsx @@ -1,5 +1,7 @@ "use client"; +import { useTranslations } from "next-intl"; +import { X } from "lucide-react"; import { cn } from "@/lib/utils"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { useShortenedText } from "@/hooks/use-shortened-text"; @@ -38,12 +40,19 @@ export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1"; export function TagBadge({ tagId, variant, + onRemove, className, }: { tagId: string; variant: TagBadgeVariant; + /** + * Takes the tag off the message. Only the named form offers it - a dot is the + * size of the control it would have to hold. + */ + onRemove?: () => void; className?: string; }) { + const t = useTranslations("email_viewer"); const { tagName, tagNameCandidates, tagColor } = useKeywordFormat(); const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId)); const color = tagColor(tagId); @@ -61,10 +70,9 @@ export function TagBadge({ return ( - {shortenedName} + + {shortenedName} + + {onRemove && ( + + )} ); } diff --git a/components/email/tag-picker.tsx b/components/email/tag-picker.tsx index e2df5e1d..0f0948ef 100644 --- a/components/email/tag-picker.tsx +++ b/components/email/tag-picker.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; -import { Check, Search, X } from "lucide-react"; +import { Check, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settings-store"; import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting"; @@ -26,12 +26,10 @@ const SEARCH_THRESHOLD = 10; export function TagPicker({ selectedIds, onToggle, - onClearAll, touch = false, }: { selectedIds: string[]; onToggle: (tagId: string) => void; - onClearAll?: () => void; /** Larger hit areas for the mobile sheet. */ touch?: boolean; }) { @@ -42,15 +40,32 @@ export function TagPicker({ const [query, setQuery] = useState(""); const trimmedQuery = query.trim().toLowerCase(); - const showSearch = keywords.length >= SEARCH_THRESHOLD; + + /** + * Tags on the message this client has no definition for - set from another + * client, or outliving the tag they were made with. Listing them is the only + * way to take one off, and they leave the list as they are deselected because + * nothing but the message itself records that they exist. + */ + const unknownIds = useMemo( + () => + selectedIds + .filter((id) => !keywords.some((keyword) => keyword.id === id)) + .sort((a, b) => tagName(a).localeCompare(tagName(b))), + // `tagName` is rebuilt whenever the definitions or the nesting setting change. + [selectedIds, keywords, tagName], + ); + + const showSearch = keywords.length + unknownIds.length >= SEARCH_THRESHOLD; const matches = useMemo( () => trimmedQuery - ? keywords.filter((keyword) => tagName(keyword.id).toLowerCase().includes(trimmedQuery)) + ? [...keywords.map((keyword) => keyword.id), ...unknownIds].filter((id) => + tagName(id).toLowerCase().includes(trimmedQuery), + ) : [], - // `tagName` is rebuilt whenever the definitions or the nesting setting change. - [keywords, trimmedQuery, tagName], + [keywords, unknownIds, trimmedQuery, tagName], ); const tree = useMemo( @@ -111,28 +126,22 @@ export function TagPicker({
{trimmedQuery ? ( matches.length > 0 ? ( - matches.map((keyword) => renderRow(keyword.id, tagName(keyword.id))) + matches.map((id) => renderRow(id, tagName(id))) ) : (

{t("tag_no_matches")}

) ) : ( - renderBranch(tree) + <> + {renderBranch(tree)} + {unknownIds.length > 0 && ( + <> + {keywords.length > 0 &&
} + {unknownIds.map((id) => renderRow(id, tagName(id)))} + + )} + )}
- - {onClearAll && selectedIds.length > 0 && ( - <> -
- - - )} ); } diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 2a412f7e..b37a5abe 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -16,6 +16,7 @@ import type { Email } from "@/lib/jmap/types"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { getQuoteBodies } from "@/lib/email-composer-utils"; import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; +import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; interface ProEmailTabBodyProps { tabId: string; @@ -57,7 +58,6 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { const moveToMailbox = useEmailStore((s) => s.moveToMailbox); const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal); const mailboxes = useEmailStore((s) => s.mailboxes); - const settingsKeywords = useSettingsStore((s) => s.emailKeywords); const identities = useIdentityStore((s) => s.identities); const multiAccountIdentities = useProMultiAccountIdentities(); @@ -238,18 +238,29 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { const handleSetTag = useCallback((emailId: string, tagId: string | null) => { if (!email || email.id !== emailId) return; - // Drop existing color keywords, optionally add the new one. Matches the - // mail page's local optimistic update. + // Toggle one tag, or clear them all. Matches the mail page's local + // optimistic update, down to reaching tags this client cannot name. const keywords = { ...(email.keywords ?? {}) }; - for (const kw of settingsKeywords) { - delete keywords[`$label:${kw.id}`]; - } - if (tagId) { - keywords[`$label:${tagId}`] = true; + if (tagId === null) { + for (const key of Object.keys(keywords)) { + if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) { + keywords[key] = false; + } + } + } else { + const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId] + .filter(key => keywords[key]); + if (activeKeys.length > 0) { + for (const key of activeKeys) { + keywords[key] = false; + } + } else { + keywords[KEYWORD_PREFIX + tagId] = true; + } } setEmailKeywordsLocal(emailId, keywords); setEmail({ ...email, keywords }); - }, [email, settingsKeywords, setEmailKeywordsLocal]); + }, [email, setEmailKeywordsLocal]); const handleMoveToMailbox = useCallback(async (mailboxId: string) => { if (!client || !email) return; diff --git a/lib/__tests__/thread-utils.test.ts b/lib/__tests__/thread-utils.test.ts index 5de19307..bc65d3e2 100644 --- a/lib/__tests__/thread-utils.test.ts +++ b/lib/__tests__/thread-utils.test.ts @@ -5,6 +5,7 @@ import { getThreadParticipants, mergeThreadEmails, getEmailTagId, + getEmailTagIds, getThreadTagId, getThreadTagIds, } from '../thread-utils'; @@ -246,6 +247,30 @@ describe('mergeThreadEmails', () => { }); }); +describe('getEmailTagIds', () => { + it('gathers every tag set on the message', () => { + expect(getEmailTagIds({ '$label:red': true, '$label:work': true, $seen: true })) + .toEqual(['red', 'work']); + }); + + it('reads the legacy prefix alongside the current one', () => { + expect(getEmailTagIds({ '$label:red': true, '$color:blue': true })).toEqual(['red', 'blue']); + }); + + it('reports a tag written under both prefixes once', () => { + expect(getEmailTagIds({ '$label:red': true, '$color:red': true })).toEqual(['red']); + }); + + it('ignores keywords set to false', () => { + expect(getEmailTagIds({ '$label:red': false, '$label:work': true })).toEqual(['work']); + }); + + it('is empty for an untagged message or none at all', () => { + expect(getEmailTagIds({ $seen: true })).toEqual([]); + expect(getEmailTagIds(undefined)).toEqual([]); + }); +}); + describe('getEmailTagId', () => { it('returns label from $label: keyword', () => { expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red'); diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index 4a152568..7719a2e5 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -170,20 +170,21 @@ export const KEYWORD_PREFIX_LEGACY = "$color:"; /** * Gets every tag id set on a message. * Reads both the current $label: prefix and the legacy $color: prefix. + * A tag written under both spellings is one tag, so it is returned once. */ export function getEmailTagIds(keywords: Record | undefined): string[] { if (!keywords) return []; - const tags: string[] = []; + const tags = new Set(); for (const key of Object.keys(keywords)) { if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) { - tags.push( + tags.add( key.startsWith(KEYWORD_PREFIX) ? key.slice(KEYWORD_PREFIX.length) : key.slice(KEYWORD_PREFIX_LEGACY.length) ); } } - return tags; + return [...tags]; } /** From f1e1ed1df76e9d9a7db19be1405fe67786bbd5af Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 15:03:51 +0200 Subject: [PATCH 31/42] fix: make the tint of selected rows work the same way in dark and light mode --- .../email/__tests__/thread-list-item.test.tsx | 45 +++++++++++++++++++ components/email/thread-list-item.tsx | 12 +++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 1f7bd498..c0935c26 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -243,3 +243,48 @@ describe('ThreadListItem shift-range checkbox', () => { expect(selected.has('e3')).toBe(true); }); }); + +describe('ThreadListItem row tint', () => { + const rowClasses = (container: HTMLElement) => + container.querySelector('[data-email-id="email-1"]')!.className.split(' '); + + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + tintListRowsByTag: true, + }); + useEmailStore.setState({ + selectedEmailIds: new Set(['email-1']), + selectedMailbox: 'inbox', + }); + }); + + it('keeps a checked row tinted, and says so to either theme', () => { + const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } })); + const classes = rowClasses(container); + + expect(classes).toContain('bg-red-50'); + expect(classes).toContain('dark:bg-red-950/30'); + expect(classes).not.toContain('bg-accent/40'); + expect(classes).toContain('ring-primary/20'); + }); + + it('washes a checked row that has no tint to keep', () => { + const { container } = renderRow(makeEmail({ keywords: { $seen: true } })); + const classes = rowClasses(container); + + expect(classes).toContain('bg-accent/40'); + expect(classes).toContain('ring-primary/20'); + }); + + it('leaves the tint alone when the setting is off', () => { + useSettingsStore.setState({ tintListRowsByTag: false }); + const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } })); + const classes = rowClasses(container); + + expect(classes).not.toContain('bg-red-50'); + expect(classes).toContain('bg-accent/40'); + }); +}); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 70a49f05..b5473341 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -202,9 +202,11 @@ const SingleEmailItem = React.forwardRef( !resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110", isUnread && !resolvedRowTint && "bg-accent/30", - isChecked && "ring-2 ring-primary/20 bg-accent/40", + isChecked && "ring-2 ring-primary/20", + isChecked && !resolvedRowTint && "bg-accent/40", isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30", - isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30" + isPressed && "scale-[0.98] ring-2 ring-primary/30", + isPressed && !resolvedRowTint && "bg-muted" )} onClick={handleClick} onDoubleClick={(e) => { @@ -640,8 +642,10 @@ export const ThreadListItem = React.forwardRef { From 7bb58f4f9f32464e154ed3f2b23fc6e5ed268198 Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 19:14:28 +0200 Subject: [PATCH 32/42] Add translations for new tag functionality --- locales/ar/common.json | 21 ++++++++++++++++++++- locales/ca/common.json | 21 ++++++++++++++++++++- locales/cs/common.json | 21 ++++++++++++++++++++- locales/da/common.json | 21 ++++++++++++++++++++- locales/de/common.json | 21 ++++++++++++++++++++- locales/es/common.json | 21 ++++++++++++++++++++- locales/fa/common.json | 21 ++++++++++++++++++++- locales/fr/common.json | 21 ++++++++++++++++++++- locales/he/common.json | 21 ++++++++++++++++++++- locales/hu/common.json | 21 ++++++++++++++++++++- locales/it/common.json | 21 ++++++++++++++++++++- locales/ja/common.json | 21 ++++++++++++++++++++- locales/ko/common.json | 21 ++++++++++++++++++++- locales/lv/common.json | 21 ++++++++++++++++++++- locales/pl/common.json | 21 ++++++++++++++++++++- locales/pt/common.json | 21 ++++++++++++++++++++- locales/ro/common.json | 21 ++++++++++++++++++++- locales/ru/common.json | 21 ++++++++++++++++++++- locales/sk/common.json | 21 ++++++++++++++++++++- locales/tr/common.json | 21 ++++++++++++++++++++- locales/uk/common.json | 21 ++++++++++++++++++++- locales/zh/common.json | 21 ++++++++++++++++++++- 22 files changed, 440 insertions(+), 22 deletions(-) diff --git a/locales/ar/common.json b/locales/ar/common.json index 23d15d86..3a870a64 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -130,6 +130,8 @@ "demo_reset": "إعادة تعيين", "demo_tour": "جولة", "tags": "الوسوم", + "show_all_tags": "إظهار الكل ({count})", + "show_fewer_tags": "إظهار أقل", "folders": "المجلدات", "shared": "مشترك", "mail": "البريد", @@ -332,6 +334,8 @@ "next": "التالي", "move_to": "نقل إلى...", "remove_tag": "إزالة الوسم", + "tag_filter_placeholder": "تصفية الوسوم", + "tag_no_matches": "لا توجد وسوم مطابقة", "more_count": "+{count} أخرى", "characters_count": "{count} حرفًا", "quick_reply_placeholder": "اكتب ردًا سريعًا...", @@ -1020,7 +1024,22 @@ "add": "إضافة", "cancel": "إلغاء", "migrating": "جارٍ تحديث الوسم على الرسائل الحالية…", - "migration_error": "فشل تحديث الوسم على الرسائل الحالية" + "migration_error": "فشل تحديث الوسم على الرسائل الحالية", + "nesting": { + "label": "وسوم متداخلة", + "description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي." + }, + "parent_field": "الوسم الأصل", + "no_parent": "بدون وسم أصل", + "too_long": "مسار الوسم طويل جدًا ({max} حرفًا على الأكثر)", + "has_children_locked": "توجد وسوم أخرى متداخلة تحت هذا الوسم، لذا فإن اسمه ووسمه الأصل مقفلان. انقلها أو احذفها أولًا.", + "has_children_delete": "احذف أولًا الوسوم المتداخلة تحت هذا الوسم", + "visibility_field": "الظهور في الشريط الجانبي", + "visibility": { + "show": "إظهار", + "unread": "إظهار عند وجود غير مقروء", + "hide": "إخفاء" + } }, "notifications": { "test_sound": "اختبار صوت الإشعار", diff --git a/locales/ca/common.json b/locales/ca/common.json index 97919417..11b15781 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -130,6 +130,8 @@ "demo_reset": "Reinicia", "demo_tour": "Visita guiada", "tags": "Etiquetes", + "show_all_tags": "Mostra-ho tot ({count})", + "show_fewer_tags": "Mostra'n menys", "folders": "Carpetes", "shared": "Compartit", "mail": "Correu", @@ -332,6 +334,8 @@ "next": "Següent", "move_to": "Mou a...", "remove_tag": "Elimina l'etiqueta", + "tag_filter_placeholder": "Filtra les etiquetes", + "tag_no_matches": "Cap etiqueta coincident", "more_count": "+{count} més", "characters_count": "{count} caràcters", "quick_reply_placeholder": "Escriviu una resposta ràpida...", @@ -988,7 +992,22 @@ "add": "Afegeix", "cancel": "Cancel·la", "migrating": "Actualitzant l'etiqueta als correus existents…", - "migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents" + "migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents", + "nesting": { + "label": "Etiquetes imbricades", + "description": "Imbrica etiquetes sota altres etiquetes i mostra-les com un arbre a la barra lateral." + }, + "parent_field": "Etiqueta principal", + "no_parent": "Sense etiqueta principal", + "too_long": "Aquest camí d'etiqueta és massa llarg (com a màxim {max} caràcters)", + "has_children_locked": "Hi ha altres etiquetes imbricades sota aquesta, per això el seu nom i la seva etiqueta principal estan bloquejats. Mou-les o elimina-les primer.", + "has_children_delete": "Elimina primer les etiquetes imbricades sota aquesta", + "visibility_field": "Visibilitat a la barra lateral", + "visibility": { + "show": "Mostra", + "unread": "Mostra si hi ha no llegits", + "hide": "Amaga" + } }, "notifications": { "test_sound": "Prova el so de notificació", diff --git a/locales/cs/common.json b/locales/cs/common.json index a8886132..7249e8f5 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -130,6 +130,8 @@ "demo_reset": "Resetovat", "demo_tour": "Průvodce", "tags": "Štítky", + "show_all_tags": "Zobrazit vše ({count})", + "show_fewer_tags": "Zobrazit méně", "folders": "Složky", "shared": "Sdílené", "mail": "Pošta", @@ -332,6 +334,8 @@ "next": "Další", "move_to": "Přesunout do...", "remove_tag": "Odebrat štítek", + "tag_filter_placeholder": "Filtrovat štítky", + "tag_no_matches": "Žádné odpovídající štítky", "more_count": "+{count} dalších", "characters_count": "{count} znaků", "quick_reply_placeholder": "Napsat rychlou odpověď...", @@ -1017,7 +1021,22 @@ "add": "Přidat", "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" + "migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech", + "nesting": { + "label": "Vnořené štítky", + "description": "Vnořujte štítky pod jiné štítky a zobrazujte je v postranním panelu jako strom." + }, + "parent_field": "Nadřazený štítek", + "no_parent": "Bez nadřazeného štítku", + "too_long": "Tato cesta štítku je příliš dlouhá (nejvýše {max} znaků)", + "has_children_locked": "Pod tímto štítkem jsou vnořeny další štítky, proto jsou jeho název a nadřazený štítek uzamčeny. Nejprve je přesuňte nebo odeberte.", + "has_children_delete": "Nejprve odeberte štítky vnořené pod tímto", + "visibility_field": "Viditelnost v postranním panelu", + "visibility": { + "show": "Zobrazit", + "unread": "Zobrazit při nepřečtených", + "hide": "Skrýt" + } }, "notifications": { "test_sound": "Otestovat zvuk oznámení", diff --git a/locales/da/common.json b/locales/da/common.json index 44aa3d90..d2f8c102 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -130,6 +130,8 @@ "demo_reset": "Nulstil", "demo_tour": "Rundvisning", "tags": "Tags", + "show_all_tags": "Vis alle ({count})", + "show_fewer_tags": "Vis færre", "folders": "Mapper", "shared": "Delt", "mail": "Mail", @@ -332,6 +334,8 @@ "next": "Næste", "move_to": "Flyt til...", "remove_tag": "Fjern tag", + "tag_filter_placeholder": "Filtrer tags", + "tag_no_matches": "Ingen matchende tags", "more_count": "+{count} mere", "characters_count": "{count} tegn", "quick_reply_placeholder": "Skriv et hurtigt svar...", @@ -1020,7 +1024,22 @@ "add": "Tilføj", "cancel": "Annuller", "migrating": "Opdaterer tag på eksisterende e-mails…", - "migration_error": "Kunne ikke opdatere tag på eksisterende e-mails" + "migration_error": "Kunne ikke opdatere tag på eksisterende e-mails", + "nesting": { + "label": "Indlejrede tags", + "description": "Indlejr tags under andre tags og vis dem som et træ i sidepanelet." + }, + "parent_field": "Overordnet tag", + "no_parent": "Intet overordnet tag", + "too_long": "Denne tagsti er for lang (højst {max} tegn)", + "has_children_locked": "Andre tags er indlejret under dette, så dets navn og overordnede tag er låst. Flyt eller fjern dem først.", + "has_children_delete": "Fjern først de tags, der er indlejret under dette", + "visibility_field": "Synlighed i sidepanel", + "visibility": { + "show": "Vis", + "unread": "Vis ved ulæste", + "hide": "Skjul" + } }, "notifications": { "test_sound": "Test notifikationslyd", diff --git a/locales/de/common.json b/locales/de/common.json index 20067cbc..c1311520 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -130,6 +130,8 @@ "demo_reset": "Zurücksetzen", "demo_tour": "Tour", "tags": "Tags", + "show_all_tags": "Alle anzeigen ({count})", + "show_fewer_tags": "Weniger anzeigen", "folders": "Ordner", "mail": "E-Mail", "nav_label": "Navigation", @@ -330,6 +332,8 @@ "more_actions": "Weitere Aktionen", "move_to": "Verschieben nach...", "remove_tag": "Label entfernen", + "tag_filter_placeholder": "Labels filtern", + "tag_no_matches": "Keine passenden Labels", "more_count": "+{count} weitere", "characters_count": "{count} Zeichen", "quick_reply_placeholder": "Eine kurze Antwort schreiben...", @@ -1017,7 +1021,22 @@ "add": "Hinzufügen", "cancel": "Abbrechen", "migrating": "Label auf vorhandenen E-Mails aktualisieren…", - "migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden" + "migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden", + "nesting": { + "label": "Verschachtelte Labels", + "description": "Labels unter anderen Labels verschachteln und als Baum in der Seitenleiste anzeigen." + }, + "parent_field": "Übergeordnetes Label", + "no_parent": "Kein übergeordnetes Label", + "too_long": "Dieser Label-Pfad ist zu lang (höchstens {max} Zeichen)", + "has_children_locked": "Unter diesem Label sind andere Labels verschachtelt, daher sind Name und übergeordnetes Label gesperrt. Verschieben oder entfernen Sie diese zuerst.", + "has_children_delete": "Entfernen Sie zuerst die Labels, die unter diesem verschachtelt sind", + "visibility_field": "Sichtbarkeit in der Seitenleiste", + "visibility": { + "show": "Anzeigen", + "unread": "Bei Ungelesenen anzeigen", + "hide": "Ausblenden" + } }, "notifications": { "test_sound": "Benachrichtigungston testen", diff --git a/locales/es/common.json b/locales/es/common.json index 4c2a3da9..ffeb9e38 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -130,6 +130,8 @@ "demo_reset": "Restablecer", "demo_tour": "Tour", "tags": "Etiquetas", + "show_all_tags": "Mostrar todo ({count})", + "show_fewer_tags": "Mostrar menos", "folders": "Carpetas", "mail": "Correo", "nav_label": "Navegación", @@ -330,6 +332,8 @@ "more_actions": "Más acciones", "move_to": "Mover a...", "remove_tag": "Eliminar etiqueta", + "tag_filter_placeholder": "Filtrar etiquetas", + "tag_no_matches": "No hay etiquetas coincidentes", "more_count": "+{count} más", "characters_count": "{count} caracteres", "quick_reply_placeholder": "Escriba una respuesta rápida...", @@ -1017,7 +1021,22 @@ "add": "Añadir", "cancel": "Cancelar", "migrating": "Actualizando etiqueta en correos existentes…", - "migration_error": "Error al actualizar la etiqueta en correos existentes" + "migration_error": "Error al actualizar la etiqueta en correos existentes", + "nesting": { + "label": "Etiquetas anidadas", + "description": "Anida etiquetas debajo de otras etiquetas y muéstralas como un árbol en la barra lateral." + }, + "parent_field": "Etiqueta principal", + "no_parent": "Sin etiqueta principal", + "too_long": "Esta ruta de etiqueta es demasiado larga (máximo {max} caracteres)", + "has_children_locked": "Hay otras etiquetas anidadas bajo esta, por lo que su nombre y su etiqueta principal están bloqueados. Muévelas o elimínalas primero.", + "has_children_delete": "Elimina primero las etiquetas anidadas bajo esta", + "visibility_field": "Visibilidad en la barra lateral", + "visibility": { + "show": "Mostrar", + "unread": "Mostrar si hay no leídos", + "hide": "Ocultar" + } }, "notifications": { "test_sound": "Probar sonido de notificación", diff --git a/locales/fa/common.json b/locales/fa/common.json index 4599c53b..466d8394 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -130,6 +130,8 @@ "demo_reset": "بازنشانی", "demo_tour": "تور", "tags": "برچسب‌ها", + "show_all_tags": "نمایش همه ({count})", + "show_fewer_tags": "نمایش کمتر", "folders": "پوشه‌ها", "shared": "اشتراکی", "mail": "ایمیل", @@ -332,6 +334,8 @@ "next": "بعدی", "move_to": "انتقال به...", "remove_tag": "حذف برچسب", + "tag_filter_placeholder": "فیلتر برچسب‌ها", + "tag_no_matches": "برچسب مطابقی یافت نشد", "more_count": "+{count} بیشتر", "characters_count": "{count} کاراکتر", "quick_reply_placeholder": "پاسخ سریع بنویسید...", @@ -1020,7 +1024,22 @@ "add": "افزودن", "cancel": "انصراف", "migrating": "در حال به‌روزرسانی برچسب روی ایمیل‌های موجود…", - "migration_error": "به‌روزرسانی برچسب ناموفق بود" + "migration_error": "به‌روزرسانی برچسب ناموفق بود", + "nesting": { + "label": "برچسب‌های تودرتو", + "description": "برچسب‌ها را زیر برچسب‌های دیگر قرار دهید و آن‌ها را به‌صورت درختی در نوار کناری نمایش دهید." + }, + "parent_field": "برچسب والد", + "no_parent": "بدون برچسب والد", + "too_long": "این مسیر برچسب خیلی طولانی است (حداکثر {max} نویسه)", + "has_children_locked": "برچسب‌های دیگری زیر این برچسب قرار دارند، بنابراین نام و برچسب والد آن قفل است. ابتدا آن‌ها را جابه‌جا یا حذف کنید.", + "has_children_delete": "ابتدا برچسب‌های زیرمجموعهٔ این برچسب را حذف کنید", + "visibility_field": "نمایش در نوار کناری", + "visibility": { + "show": "نمایش", + "unread": "نمایش در صورت وجود خوانده‌نشده", + "hide": "پنهان کردن" + } }, "notifications": { "test_sound": "تست صدای اعلان", diff --git a/locales/fr/common.json b/locales/fr/common.json index cf60e2df..06891007 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -130,6 +130,8 @@ "demo_reset": "Réinitialiser", "demo_tour": "Visite", "tags": "Étiquettes", + "show_all_tags": "Tout afficher ({count})", + "show_fewer_tags": "Afficher moins", "folders": "Dossiers", "mail": "Messagerie", "nav_label": "Navigation", @@ -330,6 +332,8 @@ "more_actions": "Plus d'actions", "move_to": "Déplacer vers...", "remove_tag": "Retirer l'étiquette", + "tag_filter_placeholder": "Filtrer les étiquettes", + "tag_no_matches": "Aucune étiquette correspondante", "more_count": "+{count} de plus", "characters_count": "{count} caractères", "quick_reply_placeholder": "Écrivez une réponse rapide...", @@ -1017,7 +1021,22 @@ "add": "Ajouter", "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" + "migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants", + "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." + }, + "parent_field": "Étiquette parente", + "no_parent": "Aucune étiquette parente", + "too_long": "Ce chemin d'étiquette est trop long ({max} caractères au maximum)", + "has_children_locked": "D'autres étiquettes sont imbriquées sous celle-ci, son nom et son étiquette parente sont donc verrouillés. Déplacez-les ou supprimez-les d'abord.", + "has_children_delete": "Retirez d'abord les étiquettes imbriquées sous celle-ci", + "visibility_field": "Visibilité dans la barre latérale", + "visibility": { + "show": "Afficher", + "unread": "Afficher si non lus", + "hide": "Masquer" + } }, "notifications": { "test_sound": "Tester le son de notification", diff --git a/locales/he/common.json b/locales/he/common.json index 918ccec8..a758742d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -122,6 +122,8 @@ "demo_reset": "אִתחוּל", "demo_tour": "סִיוּר", "tags": "תגים", + "show_all_tags": "הצג הכל ({count})", + "show_fewer_tags": "הצג פחות", "folders": "תיקיות", "mail": "דוֹאַר", "nav_label": "ניווט", @@ -279,6 +281,8 @@ "next": "הבא", "move_to": "העבר ל...", "remove_tag": "הסר תג", + "tag_filter_placeholder": "סינון תגים", + "tag_no_matches": "אין תגים תואמים", "more_count": "+{count}נוספים", "characters_count": "{count} תווים", "quick_reply_placeholder": "תשובה מהירה", @@ -982,7 +986,22 @@ "add": "לְהוֹסִיף", "cancel": "לְבַטֵל", "migrating": "מעדכן מילת מפתח באימיילים קיימים...", - "migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות" + "migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות", + "nesting": { + "label": "תגים מקוננים", + "description": "קנן תגים תחת תגים אחרים והצג אותם כעץ בסרגל הצד." + }, + "parent_field": "תג אב", + "no_parent": "ללא תג אב", + "too_long": "נתיב התג ארוך מדי (עד {max} תווים)", + "has_children_locked": "תגים אחרים מקוננים תחת תג זה, ולכן שמו ותג האב שלו נעולים. העבר או הסר אותם תחילה.", + "has_children_delete": "הסר תחילה את התגים המקוננים תחת תג זה", + "visibility_field": "הצגה בסרגל הצד", + "visibility": { + "show": "הצג", + "unread": "הצג כשיש שלא נקראו", + "hide": "הסתר" + } }, "notifications": { "test_sound": "צליל הודעת בדיקה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 790f4e8a..ed8bec08 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -130,6 +130,8 @@ "demo_reset": "Visszaállítás", "demo_tour": "Bemutató", "tags": "Címkék", + "show_all_tags": "Összes megjelenítése ({count})", + "show_fewer_tags": "Kevesebb megjelenítése", "folders": "Mappák", "shared": "Megosztott", "mail": "Levelek", @@ -332,6 +334,8 @@ "next": "Következő", "move_to": "Áthelyezés ide...", "remove_tag": "Címke eltávolítása", + "tag_filter_placeholder": "Címkék szűrése", + "tag_no_matches": "Nincs találat a címkék közt", "more_count": "+{count} további", "characters_count": "{count} karakter", "quick_reply_placeholder": "Gyors válasz írása...", @@ -1020,7 +1024,22 @@ "add": "Hozzáadás", "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" + "migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken", + "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." + }, + "parent_field": "Szülőcímke", + "no_parent": "Nincs szülőcímke", + "too_long": "Ez a címkeútvonal túl hosszú (legfeljebb {max} karakter)", + "has_children_locked": "Más címkék vannak beágyazva ez alá, ezért a neve és a szülőcímkéje zárolva van. Előbb helyezze át vagy távolítsa el őket.", + "has_children_delete": "Előbb távolítsa el az ez alá beágyazott címkéket", + "visibility_field": "Láthatóság az oldalsávon", + "visibility": { + "show": "Megjelenítés", + "unread": "Megjelenítés olvasatlanoknál", + "hide": "Elrejtés" + } }, "notifications": { "test_sound": "Értesítési hang tesztelése", diff --git a/locales/it/common.json b/locales/it/common.json index d43d911b..ee386f71 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -130,6 +130,8 @@ "demo_reset": "Reimposta", "demo_tour": "Tour", "tags": "Etichette", + "show_all_tags": "Mostra tutto ({count})", + "show_fewer_tags": "Mostra meno", "folders": "Cartelle", "mail": "Posta", "nav_label": "Navigazione", @@ -330,6 +332,8 @@ "more_actions": "Altre azioni", "move_to": "Sposta in...", "remove_tag": "Rimuovi etichetta", + "tag_filter_placeholder": "Filtra etichette", + "tag_no_matches": "Nessuna etichetta corrispondente", "more_count": "+{count} altri", "characters_count": "{count} caratteri", "quick_reply_placeholder": "Scrivi una risposta veloce...", @@ -1017,7 +1021,22 @@ "add": "Aggiungi", "cancel": "Annulla", "migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…", - "migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti" + "migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti", + "nesting": { + "label": "Etichette nidificate", + "description": "Nidifica le etichette sotto altre etichette e mostrale come un albero nella barra laterale." + }, + "parent_field": "Etichetta principale", + "no_parent": "Nessuna etichetta principale", + "too_long": "Questo percorso di etichetta è troppo lungo (al massimo {max} caratteri)", + "has_children_locked": "Altre etichette sono nidificate sotto questa, quindi il suo nome e la sua etichetta principale sono bloccati. Spostale o rimuovile prima.", + "has_children_delete": "Rimuovi prima le etichette nidificate sotto questa", + "visibility_field": "Visibilità nella barra laterale", + "visibility": { + "show": "Mostra", + "unread": "Mostra se non letti", + "hide": "Nascondi" + } }, "notifications": { "test_sound": "Testa il suono di notifica", diff --git a/locales/ja/common.json b/locales/ja/common.json index 2502dfa7..0501ef06 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -130,6 +130,8 @@ "demo_reset": "リセット", "demo_tour": "ツアー", "tags": "タグ", + "show_all_tags": "すべて表示({count})", + "show_fewer_tags": "表示を減らす", "folders": "フォルダ", "mail": "メール", "nav_label": "ナビゲーション", @@ -330,6 +332,8 @@ "more_actions": "その他の操作", "move_to": "移動...", "remove_tag": "ラベルを削除", + "tag_filter_placeholder": "ラベルを絞り込む", + "tag_no_matches": "一致するラベルがありません", "more_count": "他{count}件", "characters_count": "{count}文字", "quick_reply_placeholder": "クイック返信を入力...", @@ -1017,7 +1021,22 @@ "add": "追加", "cancel": "キャンセル", "migrating": "既存のメールのラベルを更新中…", - "migration_error": "既存のメールのラベルの更新に失敗しました" + "migration_error": "既存のメールのラベルの更新に失敗しました", + "nesting": { + "label": "ネストされたラベル", + "description": "ラベルを他のラベルの下にネストし、サイドバーにツリーとして表示します。" + }, + "parent_field": "親ラベル", + "no_parent": "親ラベルなし", + "too_long": "このラベルのパスが長すぎます(最大{max}文字)", + "has_children_locked": "このラベルの下に他のラベルがネストされているため、名前と親ラベルは変更できません。先に移動または削除してください。", + "has_children_delete": "先にこのラベルの下にネストされたラベルを削除してください", + "visibility_field": "サイドバーでの表示", + "visibility": { + "show": "表示する", + "unread": "未読がある場合に表示", + "hide": "表示しない" + } }, "notifications": { "test_sound": "通知音をテスト", diff --git a/locales/ko/common.json b/locales/ko/common.json index bfb65416..c6d9420a 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -130,6 +130,8 @@ "demo_reset": "초기화", "demo_tour": "둘러보기", "tags": "태그", + "show_all_tags": "전체 보기({count})", + "show_fewer_tags": "간략히 보기", "folders": "폴더", "mail": "메일", "nav_label": "내비게이션", @@ -332,6 +334,8 @@ "next": "다음", "move_to": "이동...", "remove_tag": "태그 제거", + "tag_filter_placeholder": "태그 검색", + "tag_no_matches": "일치하는 태그 없음", "more_count": "+{count}개 더보기", "characters_count": "{count}자", "quick_reply_placeholder": "간단하게 답장을 작성해 보세요...", @@ -1017,7 +1021,22 @@ "add": "추가", "cancel": "취소", "migrating": "기존 이메일의 태그 업데이트 중…", - "migration_error": "기존 이메일의 태그 업데이트에 실패했습니다" + "migration_error": "기존 이메일의 태그 업데이트에 실패했습니다", + "nesting": { + "label": "중첩 태그", + "description": "태그를 다른 태그 아래에 중첩하고 사이드바에 트리로 표시합니다." + }, + "parent_field": "상위 태그", + "no_parent": "상위 태그 없음", + "too_long": "이 태그 경로가 너무 깁니다(최대 {max}자)", + "has_children_locked": "이 태그 아래에 다른 태그가 중첩되어 있어 이름과 상위 태그가 잠겨 있습니다. 먼저 옮기거나 삭제하세요.", + "has_children_delete": "이 태그 아래에 중첩된 태그를 먼저 삭제하세요", + "visibility_field": "사이드바 표시", + "visibility": { + "show": "표시", + "unread": "읽지 않음이 있을 때 표시", + "hide": "숨기기" + } }, "notifications": { "test_sound": "알림음 테스트", diff --git a/locales/lv/common.json b/locales/lv/common.json index c742af88..746dbc88 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -130,6 +130,8 @@ "demo_reset": "Atiestatīt", "demo_tour": "Ekskursija", "tags": "Tagi", + "show_all_tags": "Rādīt visus ({count})", + "show_fewer_tags": "Rādīt mazāk", "folders": "Mapes", "mail": "Pasts", "nav_label": "Navigācija", @@ -332,6 +334,8 @@ "next": "Nāk.", "move_to": "Pārvietot uz...", "remove_tag": "Noņemt tagu", + "tag_filter_placeholder": "Filtrēt tagus", + "tag_no_matches": "Nav atbilstošu tagu", "more_count": "+vairāk {count}", "characters_count": "{count} rakstzīmes", "quick_reply_placeholder": "Rakstīt ātru atbildi...", @@ -1017,7 +1021,22 @@ "add": "Pievienot", "cancel": "Atcelt", "migrating": "Taga atjaunināšana esošajos e-pastos…", - "migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos" + "migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos", + "nesting": { + "label": "Ligzdoti tagi", + "description": "Ligzdojiet tagus zem citiem tagiem un rādiet tos sānjoslā kā koku." + }, + "parent_field": "Vecāktags", + "no_parent": "Nav vecāktaga", + "too_long": "Šis taga ceļš ir pārāk garš (ne vairāk kā {max} rakstzīmes)", + "has_children_locked": "Zem šī taga ir ligzdoti citi tagi, tāpēc tā nosaukums un vecāktags ir bloķēti. Vispirms pārvietojiet vai noņemiet tos.", + "has_children_delete": "Vispirms noņemiet zem šī ligzdotos tagus", + "visibility_field": "Redzamība sānjoslā", + "visibility": { + "show": "Rādīt", + "unread": "Rādīt, ja ir nelasīti", + "hide": "Slēpt" + } }, "notifications": { "test_sound": "Pārbaudīt paziņojuma skaņu", diff --git a/locales/pl/common.json b/locales/pl/common.json index 9378c706..9b0cf6ed 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -130,6 +130,8 @@ "demo_reset": "Resetuj", "demo_tour": "Przewodnik", "tags": "Etykiety", + "show_all_tags": "Pokaż wszystkie ({count})", + "show_fewer_tags": "Pokaż mniej", "folders": "Foldery", "mail": "Poczta", "nav_label": "Nawigacja", @@ -332,6 +334,8 @@ "next": "Nast.", "move_to": "Przenieś do...", "remove_tag": "Usuń etykietę", + "tag_filter_placeholder": "Filtruj etykiety", + "tag_no_matches": "Brak pasujących etykiet", "more_count": "+{count} więcej", "characters_count": "{count} znaków", "quick_reply_placeholder": "Napisz szybką odpowiedź...", @@ -1017,7 +1021,22 @@ "add": "Dodaj", "cancel": "Anuluj", "migrating": "Aktualizowanie etykiety w istniejących e-mailach…", - "migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach" + "migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach", + "nesting": { + "label": "Zagnieżdżone etykiety", + "description": "Zagnieżdżaj etykiety pod innymi etykietami i wyświetlaj je w panelu bocznym jako drzewo." + }, + "parent_field": "Etykieta nadrzędna", + "no_parent": "Brak etykiety nadrzędnej", + "too_long": "Ta ścieżka etykiety jest za długa (maksymalnie {max} znaków)", + "has_children_locked": "Pod tą etykietą zagnieżdżone są inne etykiety, więc jej nazwa i etykieta nadrzędna są zablokowane. Najpierw je przenieś lub usuń.", + "has_children_delete": "Najpierw usuń etykiety zagnieżdżone pod tą", + "visibility_field": "Widoczność w panelu bocznym", + "visibility": { + "show": "Pokaż", + "unread": "Pokaż przy nieprzeczytanych", + "hide": "Ukryj" + } }, "notifications": { "test_sound": "Przetestuj dźwięk powiadomienia", diff --git a/locales/pt/common.json b/locales/pt/common.json index 6bbd2b2e..1d226b70 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -130,6 +130,8 @@ "demo_reset": "Repor", "demo_tour": "Tour", "tags": "Etiquetas", + "show_all_tags": "Mostrar tudo ({count})", + "show_fewer_tags": "Mostrar menos", "folders": "Pastas", "mail": "E-mail", "nav_label": "Navegação", @@ -330,6 +332,8 @@ "more_actions": "Mais ações", "move_to": "Mover para...", "remove_tag": "Remover etiqueta", + "tag_filter_placeholder": "Filtrar etiquetas", + "tag_no_matches": "Nenhuma etiqueta correspondente", "more_count": "+{count} mais", "characters_count": "{count} caracteres", "quick_reply_placeholder": "Escreva uma resposta rápida...", @@ -1017,7 +1021,22 @@ "add": "Adicionar", "cancel": "Cancelar", "migrating": "A atualizar etiqueta nos e-mails existentes…", - "migration_error": "Falha ao atualizar etiqueta nos e-mails existentes" + "migration_error": "Falha ao atualizar etiqueta nos e-mails existentes", + "nesting": { + "label": "Etiquetas aninhadas", + "description": "Aninhe etiquetas sob outras etiquetas e mostre-as como uma árvore na barra lateral." + }, + "parent_field": "Etiqueta principal", + "no_parent": "Sem etiqueta principal", + "too_long": "Este caminho de etiqueta é demasiado longo (no máximo {max} caracteres)", + "has_children_locked": "Existem outras etiquetas aninhadas sob esta, por isso o seu nome e a sua etiqueta principal estão bloqueados. Mova-as ou remova-as primeiro.", + "has_children_delete": "Remova primeiro as etiquetas aninhadas sob esta", + "visibility_field": "Visibilidade na barra lateral", + "visibility": { + "show": "Mostrar", + "unread": "Mostrar se não lidas", + "hide": "Ocultar" + } }, "notifications": { "test_sound": "Testar som de notificação", diff --git a/locales/ro/common.json b/locales/ro/common.json index 12968735..7a3ceb82 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -130,6 +130,8 @@ "demo_reset": "Resetare", "demo_tour": "Tur de prezentare", "tags": "Etichete", + "show_all_tags": "Afișează tot ({count})", + "show_fewer_tags": "Afișează mai puține", "folders": "Dosare", "shared": "Partajat", "mail": "E-mail", @@ -332,6 +334,8 @@ "next": "Următorul", "move_to": "Mergi la...", "remove_tag": "Eliminați eticheta", + "tag_filter_placeholder": "Filtrează etichetele", + "tag_no_matches": "Nicio etichetă corespunzătoare", "more_count": "+{count} mai multe", "characters_count": "{count} caractere", "quick_reply_placeholder": "Scrie un răspuns rapid...", @@ -1020,7 +1024,22 @@ "add": "Adăugați", "cancel": "Anulează", "migrating": "Actualizarea etichetei pentru e-mailurile existente…", - "migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente" + "migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente", + "nesting": { + "label": "Etichete imbricate", + "description": "Imbricați etichete sub alte etichete și afișați-le ca un arbore în bara laterală." + }, + "parent_field": "Etichetă părinte", + "no_parent": "Fără etichetă părinte", + "too_long": "Această cale de etichetă este prea lungă (cel mult {max} caractere)", + "has_children_locked": "Alte etichete sunt imbricate sub aceasta, așa că numele și eticheta părinte sunt blocate. Mutați-le sau eliminați-le mai întâi.", + "has_children_delete": "Eliminați mai întâi etichetele imbricate sub aceasta", + "visibility_field": "Vizibilitate în bara laterală", + "visibility": { + "show": "Afișează", + "unread": "Afișează dacă sunt necitite", + "hide": "Ascunde" + } }, "notifications": { "test_sound": "Testați sunetul de notificare", diff --git a/locales/ru/common.json b/locales/ru/common.json index 9302e3a6..4dfe531f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -130,6 +130,8 @@ "demo_reset": "Сбросить", "demo_tour": "Тур", "tags": "Теги", + "show_all_tags": "Показать все ({count})", + "show_fewer_tags": "Показать меньше", "folders": "Папки", "mail": "Почта", "nav_label": "Навигация", @@ -332,6 +334,8 @@ "next": "След.", "move_to": "Переместить в...", "remove_tag": "Удалить тег", + "tag_filter_placeholder": "Фильтр тегов", + "tag_no_matches": "Подходящих тегов нет", "more_count": "+{count} ещё", "characters_count": "{count} символов", "quick_reply_placeholder": "Написать быстрый ответ...", @@ -1017,7 +1021,22 @@ "add": "Добавить", "cancel": "Отмена", "migrating": "Обновление тега в существующих письмах…", - "migration_error": "Не удалось обновить тег в существующих письмах" + "migration_error": "Не удалось обновить тег в существующих письмах", + "nesting": { + "label": "Вложенные теги", + "description": "Вкладывайте теги в другие теги и показывайте их в боковой панели в виде дерева." + }, + "parent_field": "Родительский тег", + "no_parent": "Без родительского тега", + "too_long": "Этот путь тега слишком длинный (не более {max} символов)", + "has_children_locked": "В этот тег вложены другие теги, поэтому его имя и родительский тег заблокированы. Сначала переместите или удалите их.", + "has_children_delete": "Сначала удалите теги, вложенные в этот", + "visibility_field": "Видимость в боковой панели", + "visibility": { + "show": "Показывать", + "unread": "Показывать при непрочитанных", + "hide": "Скрывать" + } }, "notifications": { "test_sound": "Проверить звук уведомления", diff --git a/locales/sk/common.json b/locales/sk/common.json index 0d2fba68..288868d1 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -130,6 +130,8 @@ "demo_reset": "Resetovať", "demo_tour": "Sprievodca", "tags": "Štítky", + "show_all_tags": "Zobraziť všetko ({count})", + "show_fewer_tags": "Zobraziť menej", "folders": "Priečinky", "shared": "Zdieľané", "mail": "Pošta", @@ -332,6 +334,8 @@ "next": "Ďalší", "move_to": "Presunúť do...", "remove_tag": "Odstrániť štítok", + "tag_filter_placeholder": "Filtrovať štítky", + "tag_no_matches": "Žiadne zodpovedajúce štítky", "more_count": "+{count} ďalších", "characters_count": "{count} znakov", "quick_reply_placeholder": "Napísať rýchlu odpoveď...", @@ -1020,7 +1024,22 @@ "add": "Pridať", "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" + "migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch", + "nesting": { + "label": "Vnorené štítky", + "description": "Vnorujte štítky pod iné štítky a zobrazujte ich v bočnom paneli ako strom." + }, + "parent_field": "Nadradený štítok", + "no_parent": "Bez nadradeného štítku", + "too_long": "Táto cesta štítku je príliš dlhá (najviac {max} znakov)", + "has_children_locked": "Pod týmto štítkom sú vnorené ďalšie štítky, preto sú jeho názov a nadradený štítok uzamknuté. Najprv ich presuňte alebo odstráňte.", + "has_children_delete": "Najprv odstráňte štítky vnorené pod týmto", + "visibility_field": "Viditeľnosť v bočnom paneli", + "visibility": { + "show": "Zobraziť", + "unread": "Zobraziť pri neprečítaných", + "hide": "Skryť" + } }, "notifications": { "test_sound": "Otestovať zvuk oznámenia", diff --git a/locales/tr/common.json b/locales/tr/common.json index 6e6ed5a0..849e7609 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -130,6 +130,8 @@ "demo_reset": "Sıfırla", "demo_tour": "Tur", "tags": "Etiketler", + "show_all_tags": "Tümünü göster ({count})", + "show_fewer_tags": "Daha az göster", "folders": "Klasörler", "shared": "Paylaşılan", "mail": "Posta", @@ -332,6 +334,8 @@ "next": "Sonraki", "move_to": "Şuraya taşı...", "remove_tag": "Etiketi kaldır", + "tag_filter_placeholder": "Etiketleri filtrele", + "tag_no_matches": "Eşleşen etiket yok", "more_count": "+{count} daha", "characters_count": "{count} karakter", "quick_reply_placeholder": "Hızlı yanıt yazın...", @@ -1017,7 +1021,22 @@ "add": "Ekle", "cancel": "İptal", "migrating": "Mevcut e-postalardaki etiket güncelleniyor…", - "migration_error": "Mevcut e-postalardaki etiket güncellenemedi" + "migration_error": "Mevcut e-postalardaki etiket güncellenemedi", + "nesting": { + "label": "İç içe etiketler", + "description": "Etiketleri başka etiketlerin altına yerleştirin ve kenar çubuğunda ağaç olarak gösterin." + }, + "parent_field": "Üst etiket", + "no_parent": "Üst etiket yok", + "too_long": "Bu etiket yolu çok uzun (en fazla {max} karakter)", + "has_children_locked": "Bunun altında başka etiketler var, bu nedenle adı ve üst etiketi kilitli. Önce onları taşıyın veya kaldırın.", + "has_children_delete": "Önce bunun altındaki etiketleri kaldırın", + "visibility_field": "Kenar çubuğunda görünürlük", + "visibility": { + "show": "Göster", + "unread": "Okunmamış varsa göster", + "hide": "Gizle" + } }, "notifications": { "test_sound": "Bildirim sesini test et", diff --git a/locales/uk/common.json b/locales/uk/common.json index 6da37126..54e41245 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -130,6 +130,8 @@ "demo_reset": "Скинути", "demo_tour": "Тур", "tags": "Теги", + "show_all_tags": "Показати всі ({count})", + "show_fewer_tags": "Показати менше", "folders": "Папки", "mail": "Пошта", "nav_label": "Навігація", @@ -332,6 +334,8 @@ "next": "Далі", "move_to": "Перейти до...", "remove_tag": "Видалити тег", + "tag_filter_placeholder": "Фільтр тегів", + "tag_no_matches": "Немає відповідних тегів", "more_count": "+ ще {count}", "characters_count": "{count} символів", "quick_reply_placeholder": "Напишіть швидку відповідь...", @@ -1017,7 +1021,22 @@ "add": "додати", "cancel": "Скасувати", "migrating": "Оновлення ключового слова в наявних електронних листах…", - "migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах" + "migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах", + "nesting": { + "label": "Вкладені теги", + "description": "Вкладайте теги в інші теги та показуйте їх на бічній панелі у вигляді дерева." + }, + "parent_field": "Батьківський тег", + "no_parent": "Без батьківського тега", + "too_long": "Цей шлях тега задовгий (щонайбільше {max} символів)", + "has_children_locked": "У цей тег вкладено інші теги, тому його назву та батьківський тег заблоковано. Спочатку перемістіть або видаліть їх.", + "has_children_delete": "Спочатку видаліть теги, вкладені в цей", + "visibility_field": "Видимість на бічній панелі", + "visibility": { + "show": "Показувати", + "unread": "Показувати за непрочитаних", + "hide": "Приховувати" + } }, "notifications": { "test_sound": "Тестовий звук сповіщення", diff --git a/locales/zh/common.json b/locales/zh/common.json index 54273d96..014116f7 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -130,6 +130,8 @@ "demo_reset": "重置", "demo_tour": "引导", "tags": "标签", + "show_all_tags": "显示全部({count})", + "show_fewer_tags": "收起", "folders": "文件夹", "mail": "邮件", "nav_label": "导航", @@ -332,6 +334,8 @@ "next": "下一封", "move_to": "移动到…", "remove_tag": "删除标签", + "tag_filter_placeholder": "筛选标签", + "tag_no_matches": "没有匹配的标签", "more_count": "+{count} 更多", "characters_count": "{count} 个字符", "quick_reply_placeholder": "快速回复...", @@ -1017,7 +1021,22 @@ "add": "添加", "cancel": "取消", "migrating": "正在更新现有邮件的标签…", - "migration_error": "更新现有邮件的标签失败" + "migration_error": "更新现有邮件的标签失败", + "nesting": { + "label": "嵌套标签", + "description": "将标签嵌套在其他标签之下,并在侧边栏中以树形显示。" + }, + "parent_field": "上级标签", + "no_parent": "无上级标签", + "too_long": "此标签路径过长(最多 {max} 个字符)", + "has_children_locked": "此标签下嵌套了其他标签,因此其名称和上级标签已锁定。请先移动或删除它们。", + "has_children_delete": "请先删除嵌套在此标签下的标签", + "visibility_field": "侧边栏显示", + "visibility": { + "show": "显示", + "unread": "有未读时显示", + "hide": "隐藏" + } }, "notifications": { "test_sound": "测试通知声音", From d52dfebad4a8e50ff59a0598f1c20471d9ada669 Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 19:16:31 +0200 Subject: [PATCH 33/42] Fix Catalan translation warnings --- locales/ca/common.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/locales/ca/common.json b/locales/ca/common.json index 11b15781..533247b5 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -680,6 +680,38 @@ "recipient_name_placeholder": "Nom mostrat", "autocomplete_search_server": "Cerca al servidor", "autocomplete_searching": "Cercant...", + "toolbar": { + "bold": "Negreta", + "italic": "Cursiva", + "underline": "Subratllat", + "strikethrough": "Ratllat", + "text_color": "Color del text", + "remove_color": "Elimina el color", + "heading_1": "Encapçalament 1", + "heading_2": "Encapçalament 2", + "bullet_list": "Llista de pics", + "ordered_list": "Llista numerada", + "quote": "Cita", + "code_block": "Bloc de codi", + "align_left": "Alinea a l'esquerra", + "align_center": "Centra", + "align_right": "Alinea a la dreta", + "text_direction": "Direcció del text (RTL/LTR)", + "link": "Enllaç", + "table": "Taula", + "clear_formatting": "Neteja el format", + "undo": "Desfés", + "redo": "Refés", + "add_row_above": "Afegeix una fila a sobre", + "add_row_below": "Afegeix una fila a sota", + "add_column_before": "Afegeix una columna abans", + "add_column_after": "Afegeix una columna després", + "delete_row": "Elimina la fila", + "delete_column": "Elimina la columna", + "toggle_header_row": "Commuta la fila de capçalera", + "delete_table": "Elimina la taula", + "pick_size": "Tria la mida" + }, "send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet." }, "confirm_dialog": { From ea7892b4974e4299ee48ee15ce1eeb27a21d04e5 Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 19:52:31 +0200 Subject: [PATCH 34/42] feat: Change the dev mode defaults to include nested tags --- app/api/dev-jmap/[...path]/route.ts | 12 ++++----- next.config.ts | 1 + .../__tests__/settings-store-keywords.test.ts | 27 ++++++++++++++++++- stores/settings-store.ts | 15 +++++++++-- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index faec6968..41b34ffa 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -120,7 +120,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1), + id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work/clients/acme': true }, size: 5100, receivedAt: daysAgo(1), from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }], @@ -152,7 +152,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:red': true }, size: 6200, receivedAt: daysAgo(0), + id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:work/clients': true, '$label:receipts': true }, size: 6200, receivedAt: daysAgo(0), from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: '[bulwark-webmail] New issue: Add dark mode toggle (#42)', @@ -181,7 +181,7 @@ const emails: MockEmail[] = [ }, // Newsletter with full HTML { - id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:purple': true }, size: 18200, receivedAt: daysAgo(0), + id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal/finance': true }, size: 18200, receivedAt: daysAgo(0), from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Launchpad Weekly #47 - The future of the open web', @@ -228,7 +228,7 @@ const emails: MockEmail[] = [ ], }, { - id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:green': true }, size: 4100, receivedAt: hoursAgo(3), + id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal': true }, size: 4100, receivedAt: hoursAgo(3), from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Code review request: JMAP-342 contact import', @@ -255,7 +255,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 4700, receivedAt: daysAgo(1), + id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work': true }, size: 4700, receivedAt: daysAgo(1), from: [{ name: 'Hetzner Cloud', email: 'billing@hetzner.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Your Hetzner invoice is available - February 2026', @@ -355,7 +355,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 4100, receivedAt: daysAgo(6), + id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$color:work/archived': true }, size: 4100, receivedAt: daysAgo(6), from: [{ name: 'Stripe Developer', email: 'developer-updates@stripe.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Action required: API v2023-10 deprecation on April 15, 2026', diff --git a/next.config.ts b/next.config.ts index eeef0a99..104b8186 100644 --- a/next.config.ts +++ b/next.config.ts @@ -59,6 +59,7 @@ const nextConfig: NextConfig = { NEXT_PUBLIC_GIT_COMMIT: gitCommitHash, NEXT_PUBLIC_APP_VERSION: appVersion, NEXT_PUBLIC_BASE_PATH: basePath, + NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "", }, }; diff --git a/stores/__tests__/settings-store-keywords.test.ts b/stores/__tests__/settings-store-keywords.test.ts index 822c8a72..40747543 100644 --- a/stores/__tests__/settings-store-keywords.test.ts +++ b/stores/__tests__/settings-store-keywords.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS, getKeywordVisibility } from '../settings-store'; +import { useSettingsStore, DEFAULT_KEYWORDS, DEV_KEYWORDS, KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS, getKeywordVisibility } from '../settings-store'; import type { KeywordDefinition } from '../settings-store'; describe('settings-store keywords', () => { @@ -24,6 +24,31 @@ describe('settings-store keywords', () => { const ids = DEFAULT_KEYWORDS.map((k) => k.id); expect(new Set(ids).size).toBe(ids.length); }); + + it('ships no nested tag, which is opt-in', () => { + DEFAULT_KEYWORDS.forEach((kw) => expect(kw.id).not.toContain('/')); + }); + }); + + describe('DEV_KEYWORDS', () => { + it('every nested tag has its parent defined, so the tree has no gaps', () => { + const ids = new Set(DEV_KEYWORDS.map((k) => k.id)); + DEV_KEYWORDS.forEach((kw) => { + const cut = kw.id.lastIndexOf('/'); + if (cut > 0) expect(ids, `orphan: ${kw.id}`).toContain(kw.id.slice(0, cut)); + }); + }); + + it('nests deeply enough to exercise the tree', () => { + const depths = DEV_KEYWORDS.map((k) => k.id.split('/').length); + expect(Math.max(...depths)).toBeGreaterThanOrEqual(3); + }); + + it('each dev keyword has a valid palette color and a unique id', () => { + const ids = DEV_KEYWORDS.map((k) => k.id); + expect(new Set(ids).size).toBe(ids.length); + DEV_KEYWORDS.forEach((kw) => expect(KEYWORD_PALETTE[kw.color]).toBeDefined()); + }); }); describe('KEYWORD_PALETTE', () => { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index e2ae45bf..2b628a57 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -215,6 +215,17 @@ export const DEFAULT_KEYWORDS: KeywordDefinition[] = [ { id: 'pink', label: 'Pink', color: 'pink' }, ]; +export const DEV_KEYWORDS: KeywordDefinition[] = [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'teal' }, + { id: 'work/clients/acme', label: 'Acme', color: 'green' }, + { id: 'personal', label: 'Personal', color: 'purple' }, + { id: 'personal/finance', label: 'Finance', color: 'amber' }, + { id: 'receipts', label: 'Receipts', color: 'gray' }, +]; + +const USING_MOCK_SERVER = process.env.NEXT_PUBLIC_DEV_MOCK_JMAP === 'true'; + interface SettingsState { // Appearance fontSize: FontSize; @@ -557,8 +568,8 @@ const DEFAULT_SETTINGS = { folderIcons: {} as Record, // Keywords - emailKeywords: DEFAULT_KEYWORDS, - nestedTags: false, + emailKeywords: USING_MOCK_SERVER ? DEV_KEYWORDS : DEFAULT_KEYWORDS, + nestedTags: USING_MOCK_SERVER, // Attachment Reminder attachmentReminderEnabled: true, From 1cdbf75270d77ca6d028dd6550cdd6db2d64827f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:39:49 +0200 Subject: [PATCH 35/42] fix: use full tag path in drag-drop toasts, fresh email in context menu markAsRead Nested tag toasts from drag-and-drop only showed the leaf name for non-root tags, contradicting the comment above it and making two same-named leaves under different parents (e.g. Personal/Receipts vs Work/Receipts) indistinguishable in the toast. The context menu's markAsRead handler was the one action left reading the stale contextMenu.data instead of the live-refreshed contextMenuEmail introduced alongside it, so it could act on outdated email state while every sibling handler was already updated. --- components/email/email-list.tsx | 2 +- components/layout/sidebar.tsx | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 4329a53d..9251f78c 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -598,7 +598,7 @@ export function EmailList({ onReplyAll={() => onReplyAll?.(contextMenuEmail!)} onForward={() => onForward?.(contextMenuEmail!)} onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)} - onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)} + onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)} onToggleStar={() => onToggleStar?.(contextMenuEmail!)} onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined} onDelete={() => onDelete?.(contextMenuEmail!)} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index f0c936a9..b3aa1078 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -609,21 +609,26 @@ function TagItem({ // Nested rows are placed by their indentation, so they show their own name. // A root spells out its path, which matters when an intermediate tag is // missing from this client's settings and the row would otherwise read as a - // bare leaf name. Toasts have the room for the whole thing. + // bare leaf name. const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label]; const label = labelCandidates[0]; + // Toasts have the room for the whole thing, and no indentation to lean on, + // so they always spell out the full path - otherwise two leaves with the + // same name in different branches (e.g. "Personal/Receipts" and + // "Work/Receipts") would read as the same tag. + const fullLabel = tagNameCandidates(node.id)[0]; const { isDragging: globalDragging } = useDragDropContext(); const { dropHandlers, isValidDropTarget } = useTagDrop({ tagId: node.id, onSuccess: (count) => { if (count === 1) { - toast.success(t('email_tagged'), label); + toast.success(t('email_tagged'), fullLabel); } else { - toast.success(t('emails_tagged', { count }), label); + toast.success(t('emails_tagged', { count }), fullLabel); } }, onError: () => { - toast.error(t('tag_failed'), label); + toast.error(t('tag_failed'), fullLabel); }, }); From aa7a814b8616241b6ce3d6791e924b67d8175d3d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:44:53 +0200 Subject: [PATCH 36/42] test: rewrite mock email content --- app/api/dev-jmap/[...path]/route.ts | 810 ++++++++++++++++++---------- 1 file changed, 511 insertions(+), 299 deletions(-) diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index faec6968..ac56ff8a 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -108,7 +108,7 @@ const emails: MockEmail[] = [ // ===================================================================== { id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0), - from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], + from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Willkommen bei Bulwark Webmail!', preview: 'Hallo! Welcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on JMAP.', @@ -121,77 +121,136 @@ const emails: MockEmail[] = [ }, { id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1), - from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }], + from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], - cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }], - subject: 'Project Update - Q1 Review', - preview: 'Salut team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.', + cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }], + subject: 'Q1 numbers, and the part I want to talk about', + preview: 'The deck is attached. Short version: we land on target, but not the way we planned it.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-003', size: 640, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-004', size: 820, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-003', size: 780, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-004', size: 1600, type: 'text/html' }], bodyValues: { - p1: { value: 'Salut team,\n\nI wanted to share the latest project numbers. We are on track to meet our targets for Q1.\n\nKey highlights:\n- Revenue up 12%\n- New signups increased by 8%\n- Customer satisfaction at 94%\n\nLet me know if you have questions.\n\nCordialement,\nPierre' }, - p2: { value: '

Salut team,

I wanted to share the latest project numbers. We are on track to meet our targets for Q1.

  • Revenue up 12%
  • New signups increased by 8%
  • Customer satisfaction at 94%

Let me know if you have questions.

Cordialement,
Pierre

' }, + p1: { value: 'Hello both,\n\nThe deck is attached. Short version: we land on target, but not the way we planned it.\n\nRevenue: +12% against a +9% forecast. Almost all of it comes from the two enterprise renewals in February, so it is two customers, not a trend.\n\nSignups: +8%, which is under plan. The self-serve funnel loses people at the payment step, and it has done so for three quarters now.\n\nSupport satisfaction: 94% across 1.240 tickets.\n\nI would like twenty minutes on Thursday for the funnel drop-off before we commit to Q2 targets. The rest of the deck can be read offline.\n\nBien à vous,\nPierre' }, + p2: { value: '

Hello both,

The deck is attached. Short version: we land on target, but not the way we planned it.

Revenue+12%forecast +9%
New signups+8%forecast +15%
Support satisfaction94%1.240 tickets

The revenue line is two enterprise renewals in February, so it is two customers, not a trend. The funnel loses people at the payment step and has done so for three quarters now.

I would like twenty minutes on Thursday for the drop-off before we commit to Q2 targets. The rest of the deck can be read offline.

Bien à vous,
Pierre

' }, }, attachments: [ { partId: 'att1', blobId: 'blob-att-001', size: 24500, name: 'Q1-Bericht.pdf', type: 'application/pdf' }, ], }, { - id: 'email-003', threadId: 'thread-003', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(2), + id: 'email-003', threadId: 'thread-003', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 2400, receivedAt: daysAgo(2), from: [{ name: 'Chiara Rossi', email: 'chiara@rossi.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Pranzo domani?', - preview: 'Ciao! Are you free for lunch tomorrow? I know a great trattoria near the Herengracht.', + subject: 'Lunch tomorrow?', + preview: 'Are you free around 12:30? There is a new place on the Herengracht that does a decent risotto.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-005', size: 180, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-006', size: 260, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-005', size: 240, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-006', size: 320, type: 'text/html' }], bodyValues: { - p1: { value: 'Ciao!\n\nAre you free for lunch tomorrow? I know a great trattoria near the Herengracht. They do an amazing risotto ai funghi porcini.\n\nFammi sapere!\nChiara' }, - p2: { value: '

Ciao!

Are you free for lunch tomorrow? I know a great trattoria near the Herengracht. They do an amazing risotto ai funghi porcini.

Fammi sapere!
Chiara

' }, + p1: { value: 'Are you free around 12:30? There is a new place on the Herengracht that does a decent risotto, which is a low bar in this city, but they clear it.\n\nI have a call at 14:00, so it has to be a short one.\n\nChiara' }, + p2: { value: '

Are you free around 12:30? There is a new place on the Herengracht that does a decent risotto, which is a low bar in this city, but they clear it.

I have a call at 14:00, so it has to be a short one.

Chiara

' }, }, }, { id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:red': true }, size: 6200, receivedAt: daysAgo(0), - from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }], + from: [{ name: 'GitHub', email: 'notifications@github.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '[bulwark-webmail] New issue: Add dark mode toggle (#42)', - preview: 'A new issue has been opened by @contributor. It would be great to have a dark mode toggle in the settings panel.', + subject: '[bulwark-webmail] Theme choice is ignored after an OS theme change (#42)', + preview: 'karel-devries opened issue #42: setting the theme to Dark explicitly, then switching the OS to light, drops back to the OS theme on reload.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-007', size: 350, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-008', size: 500, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-007', size: 620, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-008', size: 980, type: 'text/html' }], bodyValues: { - p1: { value: 'A new issue has been opened by @contributor.\n\nTitle: Add dark mode toggle\n\nIt would be great to have a dark mode toggle in the settings panel. Currently users have to rely on system preferences.\n\n-\nReply to this email directly or view it on GitHub.' }, - p2: { value: '

A new issue has been opened by @contributor.

Add dark mode toggle

It would be great to have a dark mode toggle in the settings panel. Currently users have to rely on system preferences.


Reply to this email directly or view it on GitHub.

' }, + p1: { value: '@karel-devries opened issue #42\n\nSteps:\n1. Settings > Appearance > Theme: Dark\n2. Switch the OS to its light theme\n3. Reload the page\n\nExpected: it stays dark, because the choice was explicit.\nActual: it follows the OS again.\n\nThe stored preference survives the reload (I can see it in localStorage), it just is not read before first paint. Firefox 128 on Fedora 41, reproduced in Chromium 133.\n\n-\nReply to this email directly, view it on GitHub, or unsubscribe.' }, + p2: { value: '

@karel-devries opened issue #42

Steps:

  1. Settings › Appearance › Theme: Dark
  2. Switch the OS to its light theme
  3. Reload the page

Expected: it stays dark, because the choice was explicit.
Actual: it follows the OS again.

The stored preference survives the reload (I can see it in localStorage), it just is not read before first paint.

Firefox 128 on Fedora 41, reproduced in Chromium 133.

Reply to this email directly, view it on GitHub, or unsubscribe.

' }, }, }, { - id: 'email-005', threadId: 'thread-005', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 2800, receivedAt: daysAgo(4), - from: [{ name: 'Newsletter', email: 'news@techdigest.example' }], - to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Your Weekly Tech Digest', - preview: 'This week in tech: new JavaScript runtime benchmarks, WebAssembly reaches 3.0, and more.', + id: 'email-005', threadId: 'thread-005', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 3300, receivedAt: daysAgo(4), + from: [{ name: 'Bram Kuipers', email: 'bram@ietf-lists.example' }], + to: [{ name: 'jmap', email: 'jmap@ietf-lists.example' }], cc: [], + subject: 'Re: [jmap] $seen on a shared mailbox: per account or per message?', + preview: 'Per message. The keyword lives on the Email object and the Email object is shared, so marking it read in one account marks it read in the other.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-009', size: 900, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-010', size: 1400, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-009', size: 800, type: 'text/plain' }], + htmlBody: [], bodyValues: { - p1: { value: 'This week in tech:\n\n1. New JavaScript runtime benchmarks show 30% improvement\n2. WebAssembly reaches version 3.0\n3. CSS container queries gain full browser support\n4. TypeScript 6.0 release candidate announced\n\nRead more at techdigest.example' }, - p2: { value: '

Your Weekly Tech Digest

  1. New JavaScript runtime benchmarks show 30% improvement
  2. WebAssembly reaches version 3.0
  3. CSS container queries gain full browser support
  4. TypeScript 6.0 release candidate announced

Read more at techdigest.example

' }, + p1: { value: 'On Tue, 24 Mar 2026 at 09:12, Astrid van der Berg wrote:\n> If two accounts have the same mailbox mapped, is $seen per account\n> or per message? We get bug reports either way.\n\nPer message. The keyword lives on the Email object, the Email object is shared, so marking it read in one account marks it read in the other. That is the reading most servers implement.\n\nIf you want per-account state you need per-account Email objects, which is what delegated mailboxes usually end up with anyway. RFC 8621 is quiet about the shared case, which is why your bug reports go both ways.\n\nWorth writing down in the interop notes before someone standardises the wrong half of it.\n\nBram\n--\njmap mailing list -- jmap@ietf-lists.example\nTo unsubscribe send an email to jmap-leave@ietf-lists.example' }, }, }, - // Newsletter with full HTML + // Newsletter with a full HTML body { id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:purple': true }, size: 18200, receivedAt: daysAgo(0), - from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }], + from: [{ name: 'Sidenote', email: 'post@sidenote.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Launchpad Weekly #47 - The future of the open web', - preview: 'This week: WebAssembly Components hit 1.0, a deep dive into privacy-first analytics, and 5 tools we can\'t stop using.', + subject: 'Sidenote 47: the Component Model shipped and nobody has to care yet', + preview: 'Wasm components reached 1.0 last week. The spec is done, the toolchain is not, and that gap is the whole story.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-020', size: 1200, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-021', size: 16000, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-020', size: 1900, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-021', size: 9400, type: 'text/html' }], bodyValues: { - p1: { value: 'LAUNCHPAD WEEKLY #47\nThe future of the open web\n\nWebAssembly Components hit 1.0\nThe Component Model spec has reached 1.0, unlocking language-agnostic modules that run anywhere.\n\nDeep dive: Privacy-first analytics\nCookie banners are on their way out. We explore the next generation of analytics tools that respect user privacy by design.\n\n5 tools we can\'t stop using\n1. Vite 7 - lightning-fast builds\n2. Biome - unified lint + format\n3. Deno 4 - batteries included runtime\n4. TailwindCSS 4 - zero config styling\n5. Playwright - end-to-end testing\n\nYou received this because you subscribed at launchpad.example.\nUnsubscribe: https://launchpad.example/unsubscribe' }, - p2: { value: '
◆ LAUNCHPAD WEEKLY
ISSUE #47 • MARCH 2026

The future of the open web

WebAssembly Components hit 1.0, privacy-first analytics take center stage, and 5 tools we can’t stop using.

FEATURED

WebAssembly Components hit 1.0

The Component Model specification has officially reached 1.0, unlocking language-agnostic modules that compose and run anywhere — from the browser to the edge. This is a watershed moment for portable computing.

Read the deep dive →
ANALYSIS

Deep dive: Privacy-first analytics

Cookie banners are on their way out. We explore the next generation of analytics platforms that respect user privacy by design — no consent dialogs required. From server-side aggregation to differential privacy, the landscape is shifting fast.

Explore the guide →
TOOLBOX

5 tools we can’t stop using

1Vite 7
Lightning-fast builds with zero-config ESM support.
2Biome
Unified linting and formatting in a single blazing-fast tool.
3Deno 4
Batteries-included runtime with native TypeScript & npm compat.
4TailwindCSS 4
Zero-config utility-first CSS that just works.
5Playwright
Reliable end-to-end testing across every browser.

You received this because you subscribed at launchpad.example

UnsubscribeManage preferencesView in browser

' }, + p1: { value: 'SIDENOTE 47\nA weekly letter about the web, from Berlin\n\n---\n\nTHE COMPONENT MODEL SHIPPED AND NOBODY HAS TO CARE YET\n\nWasm components reached 1.0 last week. The spec is done, the toolchain is not, and that gap is the whole story.\n\nWhat you get today: a Rust crate and a JS host that can pass a record across the boundary without hand-writing glue. What you do not get: a debugger that survives the boundary, or a bundler that treats a component as a first-class input. If you are shipping a WASM module today you will keep hand-writing the glue for another year, and that is fine.\n\nThe part that matters long term is the interface types, not the packaging. Once two languages agree on what a string is, the argument moves somewhere more interesting.\n\n---\n\nCOOKIE BANNERS ARE STILL LEGAL THEATRE\n\nEvery serious analytics tool now measures without setting an identifier: aggregate at the edge, drop the raw log, and answer at the level of a page and a day instead of a person.\n\nWe ran Plausible, Umami and a self-hosted Matomo against the same fortnight of traffic. Session counts landed within 4% of each other. Where they differ is what they cannot tell you: none of them will follow a visitor across two weeks, which is the point.\n\nIf your dashboard has a funnel with six steps and a cohort retention chart, you are still identifying people. Say so in the privacy notice and stop pretending the banner covers it.\n\n---\n\nFIVE LINKS\n\n1. A write-up of a Postgres index that got slower after ANALYZE, with the plan output.\n2. The CSS working group minutes on anchor positioning. Short, and it settles the popover argument.\n3. Someone rewrote git bisect as a 90-line shell script. Useful mainly as a reading exercise.\n4. Notes from a team that moved 40 services off Kubernetes and back onto three machines.\n5. A tiny font renderer in 500 lines of C. The hinting section is worth the read on its own.\n\n---\n\nSidenote UG, Torstraße 12, 10119 Berlin\nYou get this because you signed up at sidenote.example.\nUnsubscribe: sidenote.example/unsubscribe' }, + p2: { value: `
+
+ + + + + + + + + + + + + + + +
+ + + +
SidenoteNo. 47 · 26 March
+

A weekly letter about the web, from Berlin

+
+

The Component Model shipped and nobody has to care yet

+

Wasm components reached 1.0 last week. The spec is done, the toolchain is not, and that gap is the whole story.

+

What you get today is a Rust crate and a JS host that can pass a record across the boundary without hand-written glue. What you do not get is a debugger that survives that boundary, or a bundler that treats a component as a first-class input. If you ship a WASM module this year, you will keep writing the glue by hand, and that is a reasonable place to be.

+

The durable part is the interface types, not the packaging. Once two languages agree on what a string is, the argument moves somewhere more interesting.

+ Read the full piece +
+

Cookie banners are still legal theatre

+

Every serious analytics tool now measures without setting an identifier: aggregate at the edge, drop the raw log, answer at the level of a page and a day instead of a person.

+

We pointed three of them at the same fortnight of traffic.

+ + + + + + + + + +
ToolSessionsDelta
Plausible41.208
Umami40.114−2,7%
Matomo (self-hosted)42.760+3,8%
+

Where they agree is the count. Where they differ is what they refuse to do: none of them will follow a visitor across two weeks. If your dashboard has a six-step funnel and a cohort retention chart, you are identifying people. Put that in the privacy notice and stop asking the banner to carry it.

+
+

Five links

+ + + + + + +
1A Postgres index that got slower after ANALYZE, with the plan output.
2CSSWG minutes on anchor positioning. Short, and it settles the popover argument.
3git bisect in 90 lines of shell. Useful mainly as a reading exercise.
4Forty services off Kubernetes, onto three machines, with the bill before and after.
5A font renderer in 500 lines of C. The hinting section earns the read on its own.
+
+
+ Sidenote UG, Torstraße 12, 10119 Berlin
+ You get this because you signed up at sidenote.example. + Unsubscribe · Read in the browser +
+
+
` }, }, }, // --- Additional inbox emails --- @@ -199,105 +258,213 @@ const emails: MockEmail[] = [ id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2), from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], - cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], - subject: 'Sprint planning - next week priorities', - preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.', + cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], + subject: 'Sprint priorities for next week', + preview: 'Five items, in order. If something here is wrong, say so before the meeting rather than in it.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-030', size: 450, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-031', size: 600, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-030', size: 620, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-031', size: 820, type: 'text/html' }], bodyValues: { - p1: { value: 'Hej team,\n\nHere are the priorities for next sprint:\n\n1. Finish JMAP calendar integration\n2. Fix email threading bug (#187)\n3. Implement contact group management\n4. Performance optimization for large mailboxes\n5. Accessibility audit follow-ups\n\nPlease review before our planning meeting tomorrow at 10:00.\n\nTack,\nLars' }, - p2: { value: '

Hej team,

Here are the priorities for next sprint:

  1. Finish JMAP calendar integration
  2. Fix email threading bug (#187)
  3. Implement contact group management
  4. Performance optimization for large mailboxes
  5. Accessibility audit follow-ups

Please review before our planning meeting tomorrow at 10:00.

Tack,
Lars

' }, + p1: { value: 'Hej,\n\nFive items for next sprint, in order:\n\n1. Calendar: finish CalendarEvent/set so editing one occurrence stops dropping the other overrides\n2. Threading bug #187: messages with a rewritten Message-ID land in a thread of their own\n3. Contact groups: create, rename, membership\n4. Large mailboxes: the list view still fetches full Email objects to render a preview line\n5. Accessibility follow-ups, focus order in the composer first\n\nPlanning is tomorrow at 10:00 in room A. If something here is wrong, say so before the meeting rather than in it.\n\nLars' }, + p2: { value: '

Hej,

Five items for next sprint, in order:

  1. Calendar: finish CalendarEvent/set so editing one occurrence stops dropping the other overrides
  2. Threading bug #187: messages with a rewritten Message-ID land in a thread of their own
  3. Contact groups: create, rename, membership
  4. Large mailboxes: the list view still fetches full Email objects to render a preview line
  5. Accessibility follow-ups, focus order in the composer first

Planning is tomorrow at 10:00 in room A. If something here is wrong, say so before the meeting rather than in it.

Lars

' }, }, }, { - id: 'email-015', threadId: 'thread-014', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 5800, receivedAt: hoursAgo(5), - from: [{ name: 'Booking.com', email: 'automated@booking.example' }], + id: 'email-015', threadId: 'thread-014', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 9800, receivedAt: hoursAgo(5), + from: [{ name: 'Lago Stays', email: 'reservations@lagostays.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Prenotazione Confermata - Lake Como, Mar 28–30', - preview: 'Your reservation has been confirmed. Check-in: March 28, 2026. Check-out: March 30, 2026.', + subject: 'Booking confirmed: Villa sul Lago, Bellagio (28–30 March)', + preview: 'Reservation LS-4419-BG is confirmed. Check-in Saturday 28 March from 15:00, check-out Monday 30 March by 11:00.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-032', size: 500, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-033', size: 900, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-032', size: 700, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-033', size: 5200, type: 'text/html' }], bodyValues: { - p1: { value: 'Your reservation has been confirmed!\n\nProperty: Villa sul Lago, Bellagio, Lake Como\nCheck-in: March 28, 2026 (15:00)\nCheck-out: March 30, 2026 (11:00)\nGuests: 2\nTotal: €385,00\n\nConfirmation code: EU42GDPR\n\nHouse rules and directions are in the attached PDF.' }, - p2: { value: '

Prenotazione Confermata!

Your reservation at Villa sul Lago, Bellagio, Lake Como is confirmed.

Check-inMarch 28, 2026 (15:00)
Check-outMarch 30, 2026 (11:00)
Guests2
Total€385,00

Confirmation code: EU42GDPR

' }, + p1: { value: 'Reservation LS-4419-BG is confirmed.\n\nVilla sul Lago, Via Roma 8, 22021 Bellagio (CO), Italy\n\nCheck-in: Saturday 28 March, from 15:00\nCheck-out: Monday 30 March, by 11:00\nGuests: 2\n\n2 nights x €175,00 ... €350,00\nCleaning fee ......... €25,00\nTassa di soggiorno ... €10,00\nTotal ................ €385,00\n\nPaid in full. Free cancellation until 21 March, 23:59 CET.\n\nThe key box code is in the attached voucher. Parking is behind the building, the gate remote is on the kitchen table.\n\nMarco, your host, reads messages between 08:00 and 21:00: +39 031 950 118.' }, + p2: { value: `
+
+ + + + + + + + + + + + + + +
+ + + +
LAGO STAYSReservation LS-4419-BG
+
+

Confirmed

+

Villa sul Lago

+

Via Roma 8, 22021 Bellagio (CO), Italy

+
+ + + + + + +
+

Check-in

+

Sat 28 March

+

from 15:00

+
+

Check-out

+

Mon 30 March

+

by 11:00

+
2 guests · 2 nights · whole apartment, first floor
+
+ + + + + +
2 nights × €175,00€350,00
Cleaning fee€25,00
Tassa di soggiorno (2 × €2,50 per night)€10,00
Total, paid in full€385,00
+
+
+ The key box code is in the attached voucher. Parking is behind the building; the gate remote is on the kitchen table. +
+
+ Free cancellation until 21 March, 23:59 CET.
+ Marco, your host, reads messages between 08:00 and 21:00: +39 031 950 118. +
+ Lago Stays S.r.l., Via Statale 42, 22021 Bellagio (CO) · P.IVA IT03948210131
+ Manage this booking · Invoice +
+
` }, }, attachments: [ - { partId: 'att2', blobId: 'blob-att-002', size: 18200, name: 'conferma-prenotazione.pdf', type: 'application/pdf' }, + { partId: 'att2', blobId: 'blob-att-002', size: 18200, name: 'voucher-LS-4419-BG.pdf', type: 'application/pdf' }, ], }, { id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:green': true }, size: 4100, receivedAt: hoursAgo(3), from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Code review request: JMAP-342 contact import', - preview: 'Salut, I just pushed the contact vCard import feature. Could you review when you get a chance?', + subject: 'JMAP-342 is up: vCard import', + preview: 'Contact import from vCard is ready for review. The part I would like you to look at is the merge UI.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-034', size: 380, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-035', size: 520, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-034', size: 640, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-035', size: 860, type: 'text/html' }], bodyValues: { - p1: { value: 'Salut,\n\nI just pushed the contact vCard import feature (JMAP-342). Could you review when you get a chance?\n\nPR: https://github.example/bulwark-webmail/pull/342\n\nKey changes:\n- New vCard parser with v3/v4 support\n- Batch import with progress indicator\n- Duplicate detection and merge UI\n- Unit tests for edge cases\n\nMerci d\'avance,\nÉlise' }, - p2: { value: '

Salut,

I just pushed the contact vCard import feature (JMAP-342). Could you review when you get a chance?

PR: bulwark-webmail/pull/342

Key changes:

  • New vCard parser with v3/v4 support
  • Batch import with progress indicator
  • Duplicate detection and merge UI
  • Unit tests for edge cases

Merci d\'avance,
Élise

' }, + p1: { value: 'Salut,\n\nJMAP-342 is up: https://github.example/bulwark-webmail/pull/342\n\nWhat is in it: a parser for vCard 3.0 and 4.0, batch import with a progress bar, and duplicate detection that matches on UID first and falls back to the email address.\n\nWhat I would like you to look at: the merge dialogue when a duplicate has conflicting fields. I went with "keep both, mark one primary" and I am not convinced that is the right call. The alternative is a field-by-field picker, which is more clicks but less surprising.\n\nThe 3.0 tests are thin. I will add the line-folding cases before it merges.\n\nÉlise' }, + p2: { value: '

Salut,

JMAP-342 is up: bulwark-webmail/pull/342

What is in it: a parser for vCard 3.0 and 4.0, batch import with a progress bar, and duplicate detection that matches on UID first and falls back to the email address.

What I would like you to look at: the merge dialogue when a duplicate has conflicting fields. I went with “keep both, mark one primary” and I am not convinced that is the right call. The alternative is a field-by-field picker, which is more clicks but less surprising.

The 3.0 tests are thin. I will add the line-folding cases before it merges.

Élise

' }, }, }, { id: 'email-017', threadId: 'thread-016', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 2900, receivedAt: daysAgo(1), - from: [{ name: 'GitHub', email: 'noreply@github.com' }], + from: [{ name: 'GitHub', email: 'noreply@github.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '[GitHub] A new sign-in from Firefox on Linux', - preview: 'We noticed a new sign-in to your account from Firefox on Linux. If this was you, no action is needed.', + subject: 'A new sign-in from Firefox on Linux', + preview: 'Your account was signed in to from a browser we have not seen before. If this was you, there is nothing to do.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-036', size: 350, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hi dev,\n\nWe noticed a new sign-in to your GitHub account.\n\nBrowser: Firefox 128\nOS: Linux (Fedora)\nLocation: Amsterdam, NL\nIP: 42.42.42.42\nTime: March 10, 2026 at 14:15 CET\n\nIf this was you, no action is needed. Don\'t Panic.\n\nIf you don\'t recognize this activity, please review your security settings.\n\nGitHub Security' }, + p1: { value: 'Your account was signed in to from a browser we have not seen before.\n\nBrowser: Firefox 128\nOperating system: Linux (Fedora 41)\nLocation: Amsterdam, Netherlands\nIP address: 145.94.12.208\nWhen: 10 March 2026 at 14:15 CET\n\nIf this was you, there is nothing to do. If it was not, change your password and review your active sessions.\n\nGitHub Security' }, }, }, { - id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 4700, receivedAt: daysAgo(1), - from: [{ name: 'Hetzner Cloud', email: 'billing@hetzner.example' }], + id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 8900, receivedAt: daysAgo(1), + from: [{ name: 'Nordhost GmbH', email: 'rechnung@nordhost.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Your Hetzner invoice is available - February 2026', - preview: 'Your Hetzner Cloud invoice for February 2026 is now available. Total: €1.337,42.', + subject: 'Invoice NH-2026-0284 for February', + preview: 'Your February invoice comes to €137,35 including VAT. It will be collected by SEPA direct debit on 12 March.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-037', size: 400, type: 'text/plain' }], - htmlBody: [], + textBody: [{ partId: 'p1', blobId: 'blob-037', size: 720, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-070', size: 4600, type: 'text/html' }], bodyValues: { - p1: { value: 'Guten Tag,\n\nYour Hetzner Cloud invoice for February 2026 is now available.\n\nKundennummer: DE-4242-1337\nBilling period: Feb 1 – Feb 28, 2026\nTotal charges: €1.337,42\n\nService breakdown:\n- CX41 Dedicated: €41,20\n- Storage Box: €11,30\n- Managed Database: €47,10\n- Load Balancer: €7,43\n- Floating IPs: €8,39\n\nView your full invoice at console.hetzner.example/billing' }, + p1: { value: 'Guten Tag,\n\nInvoice NH-2026-0284 covers 1 to 28 February 2026 for customer 4181-2260.\n\nDedicated server AX41 ........ €41,20\nStorage box BX11 ............. €11,30\nManaged PostgreSQL ........... €47,10\nLoad balancer LB11 ........... €7,43\nFloating IPv4 (3) ............ €8,39\n\nNet ......................... €115,42\nVAT 19% ...................... €21,93\nTotal ....................... €137,35\n\nThe amount will be collected from IBAN DE** **** **** **** **60 01 on 12 March 2026, mandate NH-M-77213.\n\nThe PDF is attached and stays available in the console for ten years.\n\nMit freundlichen Grüßen\nNordhost GmbH' }, + p2: { value: `
+
+ + + + + + + + + + + + +
+ + + +
NORDHOSTInvoice NH-2026-0284
+
+

February 2026

+

Billing period 1–28 February · Customer 4181-2260

+
+ + + + + + + + + + + + + +
ServiceNet
Dedicated server AX41€41,20
Storage box BX11€11,30
Managed PostgreSQL€47,10
Load balancer LB11€7,43
Floating IPv4 × 3€8,39
Net€115,42
VAT 19%€21,93
Total€137,35
+
+
+ Collected by SEPA direct debit on 12 March 2026 from IBAN DE** **** **** **** **60 01, mandate NH-M-77213. No action needed. +
+
+ Open billing console +
+ Nordhost GmbH, Speicherstraße 14, 20457 Hamburg · Amtsgericht Hamburg HRB 118420
+ Geschäftsführerin: Ines Kalb · USt-IdNr. DE297441022
+ The PDF stays available in the console for ten years. +
+
` }, }, attachments: [ - { partId: 'att3', blobId: 'blob-att-003', size: 32100, name: 'Hetzner-Rechnung-Feb-2026.pdf', type: 'application/pdf' }, + { partId: 'att3', blobId: 'blob-att-003', size: 32100, name: 'Rechnung-NH-2026-0284.pdf', type: 'application/pdf' }, ], }, { id: 'email-019', threadId: 'thread-018', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 3600, receivedAt: daysAgo(2), from: [{ name: 'Astrid van der Berg', email: 'astrid@berglabs.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], cc: [], - subject: 'Meeting notes - API design review', - preview: 'Here are the notes from today\'s API design review session. Key decisions: REST for public API, gRPC for internal services.', + subject: 'Notes from the API design review', + preview: 'Four decisions and three action items. Correct me where I have written down the wrong thing.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-038', size: 600, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-039', size: 800, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-038', size: 780, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-039', size: 1000, type: 'text/html' }], bodyValues: { - p1: { value: 'Hoi allemaal,\n\nHere are the notes from today\'s API design review:\n\nDecisions:\n1. REST for public-facing APIs (OpenAPI 3.1 spec)\n2. gRPC for internal service communication\n3. GraphQL only for the dashboard BFF\n4. Rate limiting: 100 req/min for free tier, 1000 for pro\n\nAction items:\n- Dev: Draft OpenAPI spec by Friday\n- Lars: Set up gRPC proto repository\n- Astrid: Update architecture diagrams\n\nNext review: March 18, 2026\n\nGroetjes,\nAstrid' }, - p2: { value: '

Hoi allemaal,

Here are the notes from today\'s API design review:

Decisions:

  1. REST for public-facing APIs (OpenAPI 3.1 spec)
  2. gRPC for internal service communication
  3. GraphQL only for the dashboard BFF
  4. Rate limiting: 100 req/min for free tier, 1000 for pro

Action items:

  • Dev: Draft OpenAPI spec by Friday
  • Lars: Set up gRPC proto repository
  • Astrid: Update architecture diagrams

Next review: March 18, 2026

Groetjes,
Astrid

' }, + p1: { value: 'Hoi,\n\nNotes from this morning. Correct me where I have written down the wrong thing.\n\nDecisions:\n1. REST for anything a customer touches, described in OpenAPI 3.1. Nobody wanted to hand a partner a proto file.\n2. gRPC between our own services, because the calendar sync is chatty and the payload is ours.\n3. GraphQL only in the dashboard BFF. It stays behind our own login.\n4. Rate limits: 100 requests per minute on free, 1.000 on pro, per token rather than per account.\n\nActions:\n- Dev: OpenAPI draft by Friday\n- Lars: proto repository and CI for it\n- Astrid: redraw the service diagram, the old one has two services that no longer exist\n\nNext review 18 March.\n\nGroeten,\nAstrid' }, + p2: { value: '

Hoi,

Notes from this morning. Correct me where I have written down the wrong thing.

Decisions

  1. REST for anything a customer touches, described in OpenAPI 3.1. Nobody wanted to hand a partner a proto file.
  2. gRPC between our own services, because the calendar sync is chatty and the payload is ours.
  3. GraphQL only in the dashboard BFF. It stays behind our own login.
  4. Rate limits: 100 req/min on free, 1.000 on pro, per token rather than per account.

Actions

  • Dev: OpenAPI draft by Friday
  • Lars: proto repository and CI for it
  • Astrid: redraw the service diagram, the old one has two services that no longer exist

Next review 18 March.

Groeten,
Astrid

' }, }, }, { id: 'email-020', threadId: 'thread-019', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 5200, receivedAt: daysAgo(3), from: [{ name: 'Jacques Lefèvre', email: 'jacques@lefevre-avocats.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Re: Partnership agreement - feedback', - preview: 'I reviewed the draft agreement. A few points need clarification around intellectual property clauses.', + subject: 'Re: Partnership agreement, three clauses to change', + preview: 'The draft is workable. Three clauses need to change before you sign anything.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-040', size: 700, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-040', size: 820, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Bonjour,\n\nI reviewed the draft partnership agreement. Overall it looks good, but a few points need clarification:\n\n1. Article 4.2 - IP ownership clause is ambiguous. Should specify that pre-existing IP remains with original owner.\n2. Article 7.1 - Non-compete period of 24 months may be too restrictive under EU law. Suggest 12 months.\n3. Article 9.3 - Liability cap should be tied to contract value, not a fixed amount.\n\nI\'ve marked up the document with detailed comments (attached).\n\nLet me know when you\'d like to discuss.\n\nBien cordialement,\nJacques Lefèvre\nLefèvre & Associés' }, + p1: { value: 'Bonjour,\n\nThe draft is workable. Three clauses need to change before you sign anything.\n\nArticle 4.2, intellectual property. As written, anything created during the partnership belongs to both parties, including work that predates it. Add a sentence that pre-existing IP stays with its owner and name your repositories in an annex.\n\nArticle 7.1, non-compete. Twenty-four months across the whole EU will not hold up in a French court and probably not in a German one either. Twelve months, limited to the two named market segments, survives.\n\nArticle 9.3, liability. A fixed cap of 50.000 euros is generous to them today and ruinous to you in year three. Tie it to the fees paid in the preceding twelve months.\n\nMy comments are in the attached document. I have left the rest alone, it is standard.\n\nCall me before you reply to them.\n\nBien cordialement,\nJacques Lefèvre\nLefèvre & Associés' }, }, attachments: [ - { partId: 'att4', blobId: 'blob-att-004', size: 45000, name: 'Contrat-de-Partenariat-Annoté.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, + { partId: 'att4', blobId: 'blob-att-004', size: 45000, name: 'Contrat-de-Partenariat-annote.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, ], }, { @@ -305,80 +472,80 @@ const emails: MockEmail[] = [ from: [{ name: 'Katrin Bauer', email: 'katrin.bauer@charite.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }, { name: 'Chiara Rossi', email: 'chiara@rossi.example' }], - subject: 'Team outing - voting on activity', - preview: 'Hey everyone! Time to vote on next month\'s team outing. Options: Biergarten, Eurovision watch party, or cooking class.', + subject: 'Team evening: pick one', + preview: 'Three options for the team evening on 24 April. Reply with a letter, voting closes Friday.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-041', size: 300, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-041', size: 340, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hallo zusammen!\n\nTime to vote on next month\'s team outing. Please reply with your preference:\n\nA) Biergarten evening + Bretzel buffet\nB) Eurovision watch party (with scorecards!)\nC) Cooking class (Italian cuisine - pasta fresca)\n\nVoting closes Friday. Most votes wins!\n\nKatrin' }, + p1: { value: 'Three options for the team evening on 24 April. Reply with a letter.\n\nA) Dinner at the Portuguese place near the office. Set menu, they can do vegetarian if we say so in advance.\nB) Boat tour, two hours, with something to drink on board. Cancelled if it rains.\nC) Pasta course, three hours, you eat what you make.\n\nVoting closes Friday. If we tie I will pick the cheapest.\n\nKatrin' }, }, }, { id: 'email-022', threadId: 'thread-021', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3200, receivedAt: hoursAgo(1), - from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }], + from: [{ name: 'GitHub', email: 'notifications@github.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '[vcard-parser] PR merged: Add support for FBURL property (#89)', - preview: 'Your pull request #89 has been merged into main. Thanks for contributing!', + subject: '[vcard-parser] Pull request #89 merged: FBURL property', + preview: 'Your pull request was merged into main by @maintainer.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-042', size: 280, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-042', size: 300, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Your pull request has been merged.\n\nRepository: vcard-parser\nPR #89: Add support for FBURL property\nMerged by: @maintainer\nBranch: feature/fburl → main\n\nCommits merged:\n- feat: parse FBURL property from vCard 4.0\n- test: add FBURL round-trip tests\n- docs: update README with FBURL example\n\n-\nReply to this email directly or view it on GitHub.' }, + p1: { value: 'Merged #89 into main.\n\nvcard-parser: add support for the FBURL property\nfeature/fburl -> main, merged by @maintainer\n\n feat: parse FBURL from vCard 4.0\n test: FBURL round-trip cases\n docs: FBURL example in the README\n\nThe release workflow picked it up, 2.4.0 is on the registry.\n\n-\nReply to this email directly, view it on GitHub, or unsubscribe.' }, }, }, { id: 'email-023', threadId: 'thread-022', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3800, receivedAt: hoursAgo(4), - from: [{ name: 'Support Team', email: 'support@saas-platform.example' }], + from: [{ name: 'Support', email: 'support@saas-platform.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '[Ticket #4521] Escalation: API rate limit exceeded for enterprise account', - preview: 'A customer reported hitting rate limits despite being on the enterprise plan. This has been escalated to engineering.', + subject: 'Ticket #4521 escalated: enterprise account hitting the rate limit', + preview: 'EuroTech GmbH is on the enterprise plan and still getting 429s. Their bursts go over the limit, their average is well under it.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-043', size: 500, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-043', size: 620, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hallo Dev,\n\nTicket #4521 has been escalated to engineering.\n\nCustomer: EuroTech GmbH (Enterprise plan)\nIssue: API rate limit exceeded\nImpact: Production integration failing intermittently\n\nDetails:\n- Customer is hitting the 1000 req/min limit\n- Their usage pattern shows bursts of 2000+ req/min during peak hours\n- They\'re requesting a temporary increase to 5000 req/min\n\nCan you review the rate limiting config and advise?\n\nPriority: High\nSLA: 4 hours\n\nDanke,\nSupport Team' }, + p1: { value: 'Ticket #4521 is with engineering now.\n\nCustomer: EuroTech GmbH, enterprise plan\nSymptom: 429 responses during their nightly sync, roughly 03:00 to 03:40 CET\n\nWhat the logs show: their average is 340 requests per minute, well under the 1.000 limit. The bursts hit 2.100 for about ninety seconds while the sync opens every mailbox at once.\n\nThey have asked for 5.000 per minute. I would rather we let them burst than raise the ceiling for everyone, but that is your call.\n\nSLA on this one is four hours and it started at 11:20.\n\nDanke,\nMirjam' }, }, }, { id: 'email-024', threadId: 'thread-023', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 7200, receivedAt: daysAgo(5), - from: [{ name: 'DEV Community', email: 'digest@dev.to.example' }], + from: [{ name: 'DEV Community', email: 'digest@dev-community.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'DEV Digest - Top posts this week', - preview: 'This week\'s top posts: "Why I switched from React to Solid", "Building a CLI tool in Rust", and more.', + subject: 'Most read this week', + preview: 'Why I moved off React and what it cost, a CLI in Rust without clap, and the state of CSS in 2026.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-044', size: 800, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-045', size: 1200, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-044', size: 780, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-045', size: 1300, type: 'text/html' }], bodyValues: { - p1: { value: 'DEV Digest - Top posts this week\n\n1. "Why I switched from React to Solid" by @webdev - 342 reactions\n2. "Building a CLI tool in Rust from scratch" by @rustacean - 289 reactions\n3. "The state of CSS in 2026" by @cssmaster - 256 reactions\n4. "Microservices are dead, long live modular monoliths" by @architect - 234 reactions\n5. "A beginner\'s guide to WebAssembly Components" by @wasmdev - 198 reactions\n\nHappy coding!\nThe DEV Team' }, - p2: { value: '

DEV Digest

Top posts this week:

  1. "Why I switched from React to Solid" - 342 reactions
  2. "Building a CLI tool in Rust from scratch" - 289 reactions
  3. "The state of CSS in 2026" - 256 reactions
  4. "Microservices are dead, long live modular monoliths" - 234 reactions
  5. "A beginner\'s guide to WebAssembly Components" - 198 reactions

Happy coding!
The DEV Team

' }, + p1: { value: 'Most read this week\n\n1. Why I moved off React and what it cost, by @webdev (342 reactions)\n2. Writing a CLI in Rust without clap, by @rustacean (289)\n3. The state of CSS in 2026, by @cssmaster (256)\n4. Microservices are dead, long live the modular monolith, by @architect (234)\n5. WebAssembly components for people who write JavaScript, by @wasmdev (198)\n\nManage what lands in your inbox: dev-community.example/settings' }, + p2: { value: '' }, }, }, { id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 4100, receivedAt: daysAgo(6), - from: [{ name: 'Stripe Developer', email: 'developer-updates@stripe.example' }], + from: [{ name: 'Mollie Developers', email: 'developers@mollie.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Action required: API v2023-10 deprecation on April 15, 2026', - preview: 'Stripe API version 2023-10 will be deprecated on April 15, 2026. Please upgrade to v2025-01 before then.', + subject: 'API version 2023-10 stops working on 15 April', + preview: 'Two of your API keys still send version 2023-10. After 15 April those calls return 410.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-046', size: 550, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-046', size: 640, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Important: API Deprecation Notice\n\nStripe API version 2023-10 will be deprecated on April 15, 2026.\n\nWhat you need to do:\n1. Review the migration guide: https://stripe.example/docs/upgrades\n2. Update your API version to 2025-01\n3. Test your integration in test mode\n4. Deploy changes before April 15\n\nBreaking changes in v2025-01:\n- Payment Intent confirmation flow updated\n- Webhook event structure changes\n- Deprecated parameters removed\n\nQuestions? Contact developer-support@stripe.example\n\nStripe Developer Relations' }, + p1: { value: 'Two of your API keys still send version 2023-10:\n\n live_k4m...9tz last used 2 hours ago\n test_p1q...44b last used yesterday\n\nAfter 15 April 2026 those calls return 410 Gone.\n\nWhat changes in 2025-01, for the endpoints you use:\n\n- Payment confirmation is a single call. The separate confirm step is gone.\n- Webhook payloads wrap the object in "data" and add "eventId".\n- The deprecated "metadata_json" parameter has been removed. Use "metadata".\n\nMigration guide: mollie.example/docs/upgrades/2025-01\nTest mode accepts the new version today, so you can switch one key and watch it.\n\nMollie Developer Relations' }, }, }, { id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1), - from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], + from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], - subject: 'Re: Sprint planning - next week priorities', - preview: 'Looks good! I\'d also suggest we add the email signature editor to the list. I can take that one.', + subject: 'Re: Sprint priorities for next week', + preview: 'The order looks right. Can we add the signature editor? It is half done and it keeps coming back in support tickets.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-047', size: 200, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-047', size: 260, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Sieht gut aus! I\'d also suggest we add the email signature editor to the list. I can take that one.\n\nAlso, can we move the planning meeting to 10:30? I have a conflict at 10.\n\nSophie' }, + p1: { value: 'The order looks right.\n\nCan we add the signature editor? It is half done and it keeps coming back in support tickets. I can take it, it is two days at most.\n\nAlso: 10:30 instead of 10:00 for planning? I have a call that runs to the hour.\n\nSophie' }, }, }, { @@ -386,13 +553,13 @@ const emails: MockEmail[] = [ from: [{ name: 'Liam Ó Donaill', email: 'liam.odonaill@finanz.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'Nils Andersson', email: 'nils@digitaal.example' }], - subject: 'Q1 Budget Review - Meeting this Thursday', - preview: 'Hi, let\'s meet Thursday at 14:00 to review the Q1 engineering budget. Please bring your team\'s actuals.', + subject: 'Q1 budget review, Thursday 14:00', + preview: 'Bring your actual spend. The forecast column in the sheet is mine, the actuals column is yours and it is empty.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-062', size: 350, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-062', size: 480, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Dia duit,\n\nLet\'s meet Thursday at 14:00 to review the Q1 engineering budget.\n\nAgenda:\n1. Actuals vs. forecast (see attached)\n2. Cloud infrastructure cost optimization\n3. Headcount planning for Q2\n4. Software license renewals\n\nPlease bring your team\'s actual spend numbers.\n\nMeeting room: Konferenzsaal B / Zoom link in calendar invite\n\nLiam' }, + p1: { value: 'Thursday at 14:00, room B, one hour. Zoom link is in the calendar invitation.\n\nAgenda:\n1. Actuals against forecast\n2. Cloud spend, which is 18% over and I would like to know why before I ask upstairs\n3. Headcount for Q2\n4. Licence renewals, three of them run out in May\n\nBring your actual spend. The forecast column in the attached sheet is mine, the actuals column is yours and it is empty.\n\nLiam' }, }, attachments: [ { partId: 'att9', blobId: 'blob-att-009', size: 54000, name: 'Q1-Budget-Vorlage.xlsx', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, @@ -402,13 +569,13 @@ const emails: MockEmail[] = [ id: 'email-041', threadId: 'thread-036', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 2600, receivedAt: daysAgo(7), from: [{ name: 'María García', email: 'maria@garcia-design.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Updated brand guidelines and component library', - preview: 'Hola! The new brand guidelines are finalized. I\'ve also updated the Figma component library.', + subject: 'Brand guidelines v2 and what it means for the app', + preview: 'The guidelines are final. Two changes touch the app: the primary colour and the heading font.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-063', size: 350, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-063', size: 520, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: '¡Hola!\n\nThe new brand guidelines are finalized. Key updates:\n\n- Primary color shifted to #7c3aed (from #6366f1)\n- New typography scale (Inter for body, Cal Sans for headings)\n- Updated icon set (Lucide → custom icon font)\n- Dark mode color tokens added\n\nI\'ve also updated the Figma component library. Link: figma.example/bulwark-webmail-v2\n\nBrand guidelines PDF attached.\n\nMaría' }, + p1: { value: 'The guidelines are final. Two changes touch the app:\n\nPrimary colour moves from #6366f1 to #7c3aed. It passes AA on white at 14px, which the old one did not, so the small print in the composer stops being a problem.\n\nHeadings move to Cal Sans, body stays Inter. Only headings, so the change is two font faces, not twelve.\n\nThe icons and the dark mode tokens are in the Figma library, same file, page "App v2". Nothing there is new, I only named the tokens properly so they can be read by a script.\n\nPDF attached. Ask before you improvise a shade, I will say yes to most things.\n\nUn saludo,\nMaría' }, }, attachments: [ { partId: 'att10', blobId: 'blob-att-010', size: 3200000, name: 'Markenrichtlinien-v2.pdf', type: 'application/pdf' }, @@ -419,26 +586,71 @@ const emails: MockEmail[] = [ from: [{ name: 'Nils Andersson', email: 'nils@digitaal.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Fika next week?', - preview: 'Hej! Haven\'t caught up in a while. Free for a fika next week? Tuesday or Wednesday work best for me.', + preview: 'Tuesday or Wednesday work for me. There is a place on the Prinsengracht that has finally learned to make a kanelbulle.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-064', size: 150, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-064', size: 180, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hej!\n\nHaven\'t caught up in a while. Free for a fika next week? Tuesday or Wednesday work best for me.\n\nThere\'s a great new café on the Prinsengracht I\'ve been wanting to try - they do a wonderful kanelbulle.\n\nNils' }, + p1: { value: 'Tuesday or Wednesday work for me, after 15:00 either day.\n\nThere is a place on the Prinsengracht that has finally learned to make a kanelbulle. Low bar, met.\n\nNils' }, }, }, { - id: 'email-039', threadId: 'thread-034', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(0.25), - from: [{ name: 'CI/CD Pipeline', email: 'ci@github.example' }], + id: 'email-039', threadId: 'thread-034', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 6400, receivedAt: hoursAgo(0.25), + from: [{ name: 'CI', email: 'ci@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '❌ Build failed: main - bulwark-webmail #1337', - preview: 'Build #1337 on branch main failed. 2 test(s) failed in email-sanitization.test.ts.', + subject: '❌ bulwark-webmail #482 failed on main', + preview: 'Two tests failed in email-sanitization.test.ts. Both of them are about style attributes.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-060', size: 450, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-061', size: 600, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-060', size: 620, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-061', size: 3900, type: 'text/html' }], bodyValues: { - p1: { value: '❌ Build #1337 FAILED\n\nRepository: bulwark-webmail\nBranch: main\nCommit: a3f9c21 "fix: sanitize CSS in email body"\nTriggered by: @elise-moreau\n\nFailed tests:\n ✗ email-sanitization.test.ts > should strip javascript: URLs\n ✗ email-sanitization.test.ts > should handle nested style tags\n\nPassed: 247 | Failed: 2 | Skipped: 0\nDuration: 42.0s\n\nView full logs: https://github.example/bulwark-webmail/actions/runs/1337' }, - p2: { value: '

❌ Build #1337 FAILED

Repositorybulwark-webmail
Branchmain
Commita3f9c21 "fix: sanitize CSS in email body"

Failed tests:

  • email-sanitization.test.ts > should strip javascript: URLs
  • email-sanitization.test.ts > should handle nested style tags

Passed: 247 | Failed: 2 | Skipped: 0

' }, + p1: { value: 'Build #482 failed on main.\n\nCommit a3f9c21 fix: sanitize CSS in email bodies\nAuthor Élise Moreau\nRan 42s on ubuntu-24.04, node 22\n\n247 passed, 2 failed, 0 skipped\n\nFAIL lib/__tests__/email-sanitization.test.ts\n x strips javascript: URLs from style attributes\n expected:
\n received:
\n x drops @import inside a nested style tag\n expected 0 matches for /@import/, got 1\n\nLogs: https://ci.fjord-systems.example/runs/482' }, + p2: { value: `
+
+ + + + + + + + + + +
+ + + +
Build #482 failedmain · 42s
+
+ + + + + +
Repositorybulwark-webmail
Commita3f9c21 fix: sanitize CSS in email bodies
AuthorÉlise Moreau
Runnerubuntu-24.04, node 22
+
+ + + + + + +
247 passed2 failed0 skipped
+
+
+ FAIL lib/__tests__/email-sanitization.test.ts
+   × strips javascript: URLs from style attributes
+     expected <div style="">
+     received <div style="background:url(javascript:alert(1))">
+   × drops @import inside a nested style tag
+     expected 0 matches for /@import/, got 1 +
+
+ View the run + Re-run failed tests +
+
` }, }, }, // ===================================================================== @@ -448,92 +660,92 @@ const emails: MockEmail[] = [ id: 'email-006', threadId: 'thread-003', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1800, receivedAt: daysAgo(2), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Chiara Rossi', email: 'chiara@rossi.example' }], cc: [], - subject: 'Re: Pranzo domani?', - preview: 'Perfetto! Let\'s meet at noon.', + subject: 'Re: Lunch tomorrow?', + preview: '12:30 works. I will be the one already sitting down.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-011', size: 80, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-011', size: 110, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Perfetto! Let\'s meet at noon by the Herengracht.\n\n- Dev User' }, + p1: { value: '12:30 works. I will be the one already sitting down.\n\nIf the risotto is bad we are never speaking of this again.' }, }, }, { id: 'email-007', threadId: 'thread-006', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 2200, receivedAt: daysAgo(3), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }], cc: [], - subject: 'Re: Project Update - Q1 Review', - preview: 'Merci Pierre, the numbers look great. I\'ll prepare the board presentation.', + subject: 'Re: Q1 numbers, and the part I want to talk about', + preview: 'Twenty minutes is fine. Can you send the funnel numbers per step beforehand?', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-012', size: 150, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-012', size: 210, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Merci Pierre, the numbers look great. I\'ll prepare the board presentation.\n\nCheers,\nDev User' }, + p1: { value: 'Twenty minutes is fine, put it at the front of the call.\n\nCan you send the funnel numbers per step beforehand? If the drop is at payment details I would like to know whether it is the form or the card.\n\nDev' }, }, }, { id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5), from: [{ name: 'Dev User', email: 'dev@localhost' }], - to: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], cc: [], - subject: 'Design review feedback', - preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.', + to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [], + subject: 'Mockup feedback', + preview: 'Three notes on the new screens, none of them blocking.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-013', size: 300, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-013', size: 380, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hallo Sophie,\n\nI reviewed the new mockups and have a few suggestions:\n\n1. The sidebar could use more contrast\n2. Consider adding breadcrumbs to the settings page\n3. The compose button placement looks good\n\nOverall great work!\n\nDev User' }, + p1: { value: 'Three notes, none of them blocking.\n\nThe sidebar labels sit at about 3:1 against the background. On my laptop outdoors they disappear.\n\nSettings needs a way back out. Breadcrumbs or a title with the section name, either is fine.\n\nThe compose button where it is now is right. I was wrong about that in the last round.\n\nDev' }, }, }, { id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], - cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], - subject: 'Re: Sprint planning - next week priorities', - preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.', + cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], + subject: 'Re: Sprint priorities for next week', + preview: '10:30 works. Signature editor goes in as item six, below the accessibility follow-ups.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-048', size: 150, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-048', size: 220, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.\n\nLars - let\'s also add a stretch goal for the email template system if we finish early.\n\n- Dev User' }, + p1: { value: '10:30 works, I moved the invitation.\n\nSignature editor goes in as item six, below the accessibility follow-ups. Sophie, if it really is two days, it lands. If it turns into four, it comes back out.\n\nDev' }, }, }, { id: 'email-028', threadId: 'thread-015', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 2100, receivedAt: daysAgo(1), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], cc: [], - subject: 'Re: Code review request: JMAP-342 contact import', - preview: 'Nice work on the vCard parser! Left a few comments on the PR. Main concern is memory usage for large imports.', + subject: 'Re: JMAP-342 is up: vCard import', + preview: 'Comments are on the PR. Keep the merge dialogue as it is, but the import needs to stream.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-049', size: 280, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-049', size: 340, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Nice work on the vCard parser! Left a few comments on the PR.\n\nMain concern: memory usage for large imports (1000+ contacts). Consider using a streaming parser instead of loading the entire file.\n\nAlso, the duplicate detection logic looks solid. Approved with minor changes.\n\n- Dev User' }, + p1: { value: 'Comments are on the PR.\n\nKeep the merge dialogue as it is. "Keep both, mark one primary" is recoverable, a wrong field pick is not, and people will click through the picker without reading it.\n\nThe import reads the whole file into memory first. At 1.200 contacts my tab used 380 MB. Stream it and I will approve.\n\nDev' }, }, }, { id: 'email-029', threadId: 'thread-018', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1600, receivedAt: daysAgo(2), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Astrid van der Berg', email: 'astrid@berglabs.example' }], cc: [], - subject: 'Re: Meeting notes - API design review', - preview: 'Thanks for the thorough notes Astrid. I\'ll have the OpenAPI spec draft ready by Friday.', + subject: 'Re: Notes from the API design review', + preview: 'One correction: the rate limit is per token, not per key. Draft lands Friday.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-050', size: 120, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-050', size: 190, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Bedankt for the thorough notes Astrid. I\'ll have the OpenAPI spec draft ready by Friday.\n\n- Dev User' }, + p1: { value: 'One correction: we said per token, and a customer can hold several. That matters for the enterprise ticket that is open right now.\n\nOpenAPI draft lands Friday.\n\nDev' }, }, }, { id: 'email-030', threadId: 'thread-025', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 4800, receivedAt: daysAgo(4), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Team', email: 'team@fjord-systems.example' }], cc: [], - subject: 'Proposal: Migrate from REST to JMAP for mail backend', - preview: 'I\'ve been researching JMAP as a replacement for our current REST-based mail backend. Here\'s the proposal.', + subject: 'Proposal: move the mail backend to JMAP', + preview: 'Our REST layer is a worse version of a protocol that already exists. The proposal is to stop maintaining it.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-051', size: 900, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-051', size: 1100, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hej team,\n\nI\'ve been researching JMAP (RFC 8620/8621) as a replacement for our current REST-based mail backend. Here\'s a summary:\n\nWhy JMAP?\n- Eliminates N+1 query problems with batch requests\n- Built-in push notifications via EventSource\n- Efficient delta sync reduces bandwidth by 60-80%\n- Standardized protocol with growing ecosystem\n\nProposed timeline:\n- Phase 1 (Mar): Proof of concept with mock server\n- Phase 2 (Apr): Core email operations\n- Phase 3 (May): Calendar & contacts integration\n- Phase 4 (Jun): Migration from legacy API\n\nFull proposal document attached.\n\n- Dev User' }, + p1: { value: 'Our REST layer is a worse version of a protocol that already exists. The proposal is to stop maintaining it and speak JMAP (RFC 8620 and 8621) directly.\n\nWhat we get:\n\n- One request instead of the current fan-out. Opening a 40-message thread costs us 41 calls today.\n- Push over EventSource, so we can delete the polling worker and the Redis key it uses to deduplicate.\n- Delta sync. In the prototype, a warm sync of a 12.000-message mailbox moved 240 KB instead of 3,1 MB.\n\nWhat it costs:\n\n- Two people for roughly ten weeks.\n- A migration path for the three integrations that read our REST endpoints. Two are internal, one is a customer and needs notice.\n\nRough plan: prototype in March against a mock server, core mail in April, calendar and contacts in May, cut over in June with the old endpoints kept read-only until September.\n\nFull write-up attached, including the numbers behind the sync figure.\n\nDev' }, }, attachments: [ { partId: 'att5', blobId: 'blob-att-005', size: 67000, name: 'JMAP-Migration-Proposal.pdf', type: 'application/pdf' }, @@ -546,39 +758,39 @@ const emails: MockEmail[] = [ id: 'email-009', threadId: 'thread-008', mailboxIds: { 'mb-drafts': true }, keywords: { $draft: true }, size: 1200, receivedAt: daysAgo(0), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Team', email: 'team@fjord-systems.example' }], cc: [], - subject: 'Meeting notes (draft)', - preview: 'Notes from today\'s standup meeting...', + subject: 'Standup notes', + preview: 'Blocked on the CalendarEvent/set override question...', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-014', size: 200, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-014', size: 220, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Notes from today\'s standup meeting:\n\n- TODO: fill in details\n- Action items: ...' }, + p1: { value: 'Yesterday: threading bug, no cause yet\nToday: CalendarEvent/set overrides\nBlocked on: whether we keep the old override when the recurrence rule changes\n\nTODO: ask Lars before sending this' }, }, }, { id: 'email-031', threadId: 'thread-026', mailboxIds: { 'mb-drafts': true }, keywords: { $draft: true }, size: 2400, receivedAt: hoursAgo(6), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [], cc: [], - subject: 'Blog post: Building a JMAP client from scratch (draft)', - preview: 'Introduction: JMAP is a modern, efficient protocol for email, calendar, and contacts...', + subject: 'Blog post: a JMAP client in 200 lines', + preview: 'IMAP makes you ask twelve times. JMAP lets you ask once. That is most of the difference...', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-052', size: 500, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-052', size: 560, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Building a JMAP Client from Scratch\n\nIntroduction:\nJMAP is a modern, efficient protocol for email, calendar, and contacts. Unlike IMAP, it uses JSON over HTTP, making it much easier to work with in web applications.\n\nIn this post, we\'ll build a minimal JMAP client in TypeScript that can:\n- Authenticate and discover capabilities\n- List mailboxes and messages\n- Send emails\n\n[TODO: Add code examples]\n[TODO: Add section on error handling]\n[TODO: Conclusion]' }, + p1: { value: 'A JMAP client in 200 lines\n\nIMAP makes you ask twelve times. JMAP lets you ask once, and that is most of the difference. The rest is JSON over HTTP, which means the whole thing fits in a file you can read on a train.\n\nWe will get a session, list mailboxes, page through an inbox and send one message.\n\n[TODO: session discovery, mention the .well-known redirect trap]\n[TODO: back-references, this is the part people miss]\n[TODO: error handling, the "notCreated" shape is unusual]\n[TODO: closing, do not turn it into a manifesto]' }, }, }, { id: 'email-032', threadId: 'thread-027', mailboxIds: { 'mb-drafts': true }, keywords: { $draft: true }, size: 1800, receivedAt: daysAgo(1), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'CFP Committee', email: 'cfp@fosdem.example' }], cc: [], - subject: 'Talk proposal: Modern email clients with JMAP', - preview: 'Title: Modern Email Clients with JMAP - From Protocol to Production...', + subject: 'Talk proposal: webmail on JMAP', + preview: 'Title: What a webmail client looks like when the protocol is on your side...', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-053', size: 400, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-053', size: 460, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Title: Modern Email Clients with JMAP - From Protocol to Production\n\nAbstract:\nThis talk explores building a full-featured webmail client using the JMAP protocol. We\'ll cover session negotiation, efficient data sync, real-time push notifications, and lessons learned.\n\nConference: FOSDEM 2027\nFormat: 30-minute talk\nLevel: Intermediate\n\n[TODO: Add speaker bio]\n[TODO: Complete outline]' }, + p1: { value: 'Title: What a webmail client looks like when the protocol is on your side\n\nAbstract:\nWe built a webmail client on JMAP instead of an IMAP bridge. This talk covers what got easier (sync, push, search), what got harder (nothing else speaks it yet), and the three places where the spec left us to decide for ourselves.\n\nTrack: Modern Email\nFormat: 30 minutes\nLevel: intermediate\n\n[TODO: speaker bio, keep it to three lines]\n[TODO: outline, five bullets is enough]' }, }, }, // ===================================================================== @@ -586,41 +798,41 @@ const emails: MockEmail[] = [ // ===================================================================== { id: 'email-010', threadId: 'thread-009', mailboxIds: { 'mb-junk': true }, keywords: {}, size: 4500, receivedAt: daysAgo(1), - from: [{ name: 'Totally Real Prince', email: 'prince@scam.example' }], + from: [{ name: 'EuroMillions Claims Dept', email: 'claims@euro-lotto-payout.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'You have won €1.000.000!!!', - preview: 'Congratulations! You have been selected as the winner of our international lottery.', + subject: 'FINAL NOTICE: your prize of €1.000.000 is waiting', + preview: 'Your email address was drawn in our international promotional draw. To release the funds we require your bank details.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-015', size: 500, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-015', size: 520, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Congratulations!\n\nYou have been selected as the winner of our international lottery. To claim your prize, please send your IBAN details to...\n\nCeci n\'est pas un spam.' }, + p1: { value: 'ATTENTION BENEFICIARY,\n\nYour email address was drawn in our international promotional draw held in Madrid. Prize: ONE MILLION EURO (€1.000.000,00).\n\nTo release the funds our processing office requires:\n1. Full name and address\n2. Copy of passport\n3. IBAN and BIC\n4. Processing fee of €450 (refundable)\n\nReply within 72 hours or the prize passes to the next beneficiary.\n\nMrs. Elizabeth Okon\nClaims Officer' }, }, }, { id: 'email-033', threadId: 'thread-028', mailboxIds: { 'mb-junk': true }, keywords: {}, size: 2200, receivedAt: hoursAgo(8), from: [{ name: 'HTCPCP Service', email: 'noreply@teapot.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '418 I\'m a Teapot - Your coffee request was denied', - preview: 'Per RFC 2324, this server is a teapot and cannot brew coffee. Please try a coffee pot instead.', + subject: '418 I am a teapot', + preview: 'Per RFC 2324 this server is a teapot. Your BREW request has been declined.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-054', size: 250, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'HTTP/1.1 418 I\'m a Teapot\n\nPer RFC 2324 (Hyper Text Coffee Pot Control Protocol), this server is, in fact, a teapot. It is short and stout. It cannot brew coffee.\n\nPlease redirect your BREW request to a proper coffee pot.\n\nContent-Type: message/coffeepot\n\nThe teapot abides.' }, + p1: { value: 'HTTP/1.1 418 I am a teapot\nContent-Type: message/coffeepot\n\nPer RFC 2324, this server is a teapot. It is short and stout. Your BREW request has been declined.\n\nPlease direct it at a device that can actually make coffee.' }, }, }, { id: 'email-034', threadId: 'thread-029', mailboxIds: { 'mb-junk': true }, keywords: {}, size: 1900, receivedAt: daysAgo(2), from: [{ name: 'CryptoTrader Pro', email: 'earn@crypto-gains.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Turn €100 into €10.000 in just 7 days! 🚀', - preview: 'Our AI trading bot has a 99.9% success rate. Start earning today!', + subject: 'Turn €100 into €10.000 in 7 days 🚀', + preview: 'Our trading bot closed 99,9% of positions in profit last month. Places are limited.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-055', size: 300, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'LIMITED TIME OFFER!\n\nOur revolutionary AI trading bot:\n- 99.9% success rate\n- Guaranteed returns\n- No experience needed\n\nSign up now at crypto-gains.example!' }, + p1: { value: 'LIMITED PLACES!!!\n\nOur trading bot closed 99,9% of positions in profit last month.\n\n- No experience needed\n- Withdraw any time*\n- Start with only €100\n\nSign up today at crypto-gains.example\n\n*after the 90 day qualifying period' }, }, }, // ===================================================================== @@ -628,41 +840,41 @@ const emails: MockEmail[] = [ // ===================================================================== { id: 'email-011', threadId: 'thread-010', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true }, size: 3800, receivedAt: daysAgo(14), - from: [{ name: 'HR Department', email: 'hr@fjord-systems.example' }], + from: [{ name: 'People & Culture', email: 'hr@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Updated Holiday Policy - EU Directive Compliance', - preview: 'Please review the updated paid leave policy effective next month, now with 30 days minimum.', + subject: 'Leave policy from 1 April', + preview: 'Annual leave goes to 30 days for everyone, and approval moves out of email and into the HR system.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-016', size: 600, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-016', size: 620, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hej team,\n\nPlease review the updated paid leave policy effective next month. Key changes include:\n\n- Minimum annual leave: 30 days (EU directive compliance)\n- New flexible Friday policy - Freitags um 14:00 Schluss\n- Simplified approval workflow\n- Fika breaks are now officially protected time\n\nFull details in the employee handbook.\n\nBästa hälsningar,\nHR Department' }, + p1: { value: 'The leave policy changes on 1 April.\n\nAnnual leave goes to 30 days for everyone, including the two contracts that were on 25. Days already booked keep their approval.\n\nCarry-over is capped at 10 days and expires on 31 March of the following year. This is new, and it is the part people will read too late.\n\nApproval moves out of email and into the HR system. Your manager gets a notification, you get a calendar entry when it is approved.\n\nThe handbook has the full text. Questions go to hr@, not to your manager, we would rather answer once.\n\nPeople & Culture' }, }, }, { id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30), - from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], + from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Conference talk accepted!', - preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!', + subject: 'Your talk was accepted', + preview: 'Day one, 14:00, main hall. Thirty minutes plus ten for questions.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-017', size: 350, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-017', size: 380, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Toll!\n\nYour talk proposal "Building Modern Webmail with JMAP" for the JMAP Conf in Amsterdam has been accepted!\n\nThe conference is scheduled for next month at the RAI. More details to follow.\n\nHerzlichen Glückwunsch!\nSophie' }, + p1: { value: 'The committee took "Building modern webmail with JMAP" for the Amsterdam conference.\n\nDay one, 14:00, main hall. Thirty minutes plus ten for questions. They want slides by the Friday before, in PDF, because the last speaker who brought Keynote cost them twenty minutes.\n\nTravel is booked, hotel is not. Tell me if you want the one next to the RAI or the quiet one twenty minutes away.\n\nSophie' }, }, }, { id: 'email-035', threadId: 'thread-030', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true }, size: 4200, receivedAt: daysAgo(60), - from: [{ name: 'IT Abteilung', email: 'it@fjord-systems.example' }], + from: [{ name: 'IT', email: 'it@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Välkommen! Your development environment setup guide', - preview: 'Welcome to the team! Here\'s everything you need to set up your development environment.', + subject: 'Your development setup', + preview: 'Everything you need for the first day, in the order that works.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-056', size: 800, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-056', size: 820, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Välkommen till laget!\n\nHere\'s your development environment setup guide:\n\n1. Clone the monorepo: git clone git@gitlab.example:fjord/monorepo.git\n2. Install dependencies: npm install\n3. Set up local database: docker-compose up -d\n4. Configure environment variables (see .env.example)\n5. Run the test suite: npm test\n\nAccess credentials:\n- Jira: your-email (SSO)\n- GitLab: your-email (SSO)\n- Hetzner Console: IAM user created, check Bitwarden\n\nQuestions? Reach out on #dev-onboarding in Mattermost.\n\nBästa hälsningar,\nIT Abteilung' }, + p1: { value: 'Welcome. Everything you need for the first day, in the order that works:\n\n1. git clone git@gitlab.example:fjord/monorepo.git\n2. npm install (node 22, the repo pins it)\n3. docker compose up -d for Postgres and the mail server\n4. cp .env.example .env, then ask in #dev-onboarding for the two secrets that are not in it\n5. npm test, which should be green before you change anything\n\nAccounts: GitLab and Jira are behind SSO, so your login already works. The hosting console needs an IAM user, it is in your Bitwarden collection.\n\nThe setup guide is attached. It is a year old and mostly right. Where it is wrong, edit it, that is what it is for.\n\nIT' }, }, attachments: [ { partId: 'att6', blobId: 'blob-att-006', size: 125000, name: 'Entwicklung-Setup-Guide.pdf', type: 'application/pdf' }, @@ -672,13 +884,13 @@ const emails: MockEmail[] = [ id: 'email-036', threadId: 'thread-031', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 3100, receivedAt: daysAgo(45), from: [{ name: 'ELSTER Online', email: 'noreply@elster.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Ihre Steuererklärung 2025 - Dokumente bereit', - preview: 'Ihre Lohnsteuerbescheinigung und Steuerbescheid sind zum Download bereit.', + subject: 'Ihre Dokumente für die Steuererklärung 2025 stehen bereit', + preview: 'Lohnsteuerbescheinigung und Bescheinigung über gezahlte Kirchensteuer liegen im Postfach bereit.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-057', size: 300, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-057', size: 380, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Sehr geehrte/r Steuerpflichtige/r,\n\nIhre Steuerdokumente für 2025 sind jetzt verfügbar:\n\n- Lohnsteuerbescheinigung\n- Steuerbescheid\n- Bescheinigung über Kirchensteuer\n\nAbgabefrist: 31. Juli 2026\n\nMelden Sie sich bei elster.example an, um Ihre Erklärung einzureichen.\n\nMit freundlichen Grüßen,\nFinanzamt' }, + p1: { value: 'Sehr geehrte Steuerpflichtige, sehr geehrter Steuerpflichtiger,\n\nfolgende Dokumente stehen in Ihrem Postfach bereit:\n\n- Lohnsteuerbescheinigung 2025\n- Bescheinigung über gezahlte Kirchensteuer\n- Vorausgefüllte Steuererklärung (Entwurf)\n\nAbgabefrist ohne steuerliche Beratung: 31. Juli 2026.\n\nBitte melden Sie sich mit Ihrem Zertifikat unter elster.example an. Wir fordern Sie niemals per E-Mail zur Eingabe Ihrer Zugangsdaten auf.\n\nMit freundlichen Grüßen\nIhr Finanzamt' }, }, attachments: [ { partId: 'att7', blobId: 'blob-att-007', size: 89000, name: 'Steuerdokumente-2025.pdf', type: 'application/pdf' }, @@ -689,16 +901,16 @@ const emails: MockEmail[] = [ from: [{ name: 'Chiara Rossi', email: 'chiara@rossi.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }], - subject: 'Team building photos from last Friday', - preview: 'Che bella serata! Sharing the photos from our team building event at the Biergarten.', + subject: 'Photos from Friday', + preview: 'Everything from Friday evening, unsorted. Tell me if you want one taken down.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-058', size: 150, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-058', size: 200, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Che bella serata! 🎉\n\nSharing the photos from our team building event at the Biergarten am Prinsengracht. The Bretzel eating contest was legendary!\n\nPhotos attached. Feel free to share.\n\nChiara' }, + p1: { value: 'Everything from Friday evening, unsorted, 84 of them.\n\nThere are four where Pierre is mid-sentence and looks furious. I kept them.\n\nTell me if you want one taken down before I put the album anywhere else.\n\nChiara' }, }, attachments: [ - { partId: 'att8', blobId: 'blob-att-008', size: 2400000, name: 'teambuilding-fotos.zip', type: 'application/zip' }, + { partId: 'att8', blobId: 'blob-att-008', size: 2400000, name: 'fotos-vrijdag.zip', type: 'application/zip' }, ], }, // ===================================================================== @@ -706,15 +918,16 @@ const emails: MockEmail[] = [ // ===================================================================== { id: 'email-038', threadId: 'thread-033', mailboxIds: { 'mb-trash': true }, keywords: { $seen: true }, size: 3500, receivedAt: daysAgo(1), - from: [{ name: 'SaaS Product', email: 'marketing@saas-product.example' }], + from: [{ name: 'Kanbanist', email: 'hello@kanbanist.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '🎉 50% off annual plans - limited time!', - preview: 'Upgrade to our annual plan and save 50%. Offer expires this Sunday.', + subject: 'Your trial ends Sunday', + preview: 'Annual plans are 30% off until Sunday. After that your workspace goes read-only.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-059', size: 400, type: 'text/plain' }], - htmlBody: [], + textBody: [{ partId: 'p1', blobId: 'blob-059', size: 420, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-065', size: 1200, type: 'text/html' }], bodyValues: { - p1: { value: 'Spring sale is here!\n\nUpgrade to our annual plan and save 50%.\n\nWhat you get:\n- Unlimited users\n- Priority support\n- Advanced analytics\n- Custom integrations\n\nOffer expires Sunday, March 15, 2026.\n\nUpgrade now at saas-product.example/pricing' }, + p1: { value: 'Your trial ends on Sunday 15 March.\n\nAnnual plans are 30% off until then: €84 per user per year instead of €120.\n\nAfter Sunday your workspace stays readable for 30 days, then it is deleted. Exports are in Settings > Data.\n\nkanbanist.example/billing' }, + p2: { value: '

Your trial ends on Sunday 15 March.

Annual plans are 30% off until then: €84 per user per year instead of €120.

After Sunday your workspace stays readable for 30 days, then it is deleted. Exports live in Settings › Data.

Choose a plan

Kanbanist BV, Keizersgracht 62, 1015 CS Amsterdam · Unsubscribe

' }, }, }, ]; @@ -752,8 +965,8 @@ const IDENTITIES: MockIdentity[] = [ // --------------------------------------------------------------------------- const addressBooks = [ - { id: 'ab-1', name: 'Persönlich', isDefault: true }, - { id: 'ab-2', name: 'Arbeit / Work', isDefault: false }, + { id: 'ab-1', name: 'Personal', isDefault: true }, + { id: 'ab-2', name: 'Work', isDefault: false }, ]; // Profile photos served straight from randomuser.me's CDN; the API at @@ -770,7 +983,7 @@ const contacts = [ phones: { p1: { number: '+49 30 8844 2200' } }, organizations: { o1: { name: 'EuroTech GmbH' } }, addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } }, - notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } }, + notes: { n1: { note: 'Frontend lead at EuroTech. Reviews quickly, comments at length.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 14), mediaType: 'image/jpeg' } }, }, { id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual', @@ -779,7 +992,7 @@ const contacts = [ phones: { p1: { number: '+33 1 42 68 53 00' } }, organizations: { o1: { name: 'Dubois Consulting' } }, addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } }, - notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } }, + notes: { n1: { note: 'Product manager. Would rather have a call than a thread.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 23), mediaType: 'image/jpeg' } }, }, { id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual', @@ -788,7 +1001,7 @@ const contacts = [ phones: { p1: { number: '+39 02 7634 5678' } }, organizations: { o1: { name: 'Rossi Design Studio' } }, addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } }, - notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } }, + notes: { n1: { note: 'UX designer. Sends mockups as PDFs and will not be talked out of it.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 40), mediaType: 'image/jpeg' } }, }, { id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual', @@ -796,7 +1009,7 @@ const contacts = [ emails: { e1: { address: 'karel@devries.example' } }, phones: { p1: { number: '+31 20 555 0142' } }, addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } }, - notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } }, + notes: { n1: { note: 'Backend developer. Filed half of our open issues, most of them valid.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 45), mediaType: 'image/jpeg' } }, }, { id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual', @@ -805,7 +1018,7 @@ const contacts = [ phones: { p1: { number: '+46 8 123 456 78' } }, organizations: { o1: { name: 'Fjord Systems AB' } }, addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } }, - notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } }, + notes: { n1: { note: 'Tech lead in Stockholm. Nothing after 15:00 his time.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 61), mediaType: 'image/jpeg' } }, }, { id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual', @@ -814,7 +1027,7 @@ const contacts = [ phones: { p1: { number: '+33 6 12 34 56 78' } }, organizations: { o1: { name: 'Fjord Systems AB' } }, addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } }, - notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } }, + notes: { n1: { note: 'Backend developer, remote from Paris. Overlaps with Stockholm until 17:00.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 29), mediaType: 'image/jpeg' } }, }, { id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual', @@ -822,7 +1035,7 @@ const contacts = [ emails: { e1: { address: 'francesco@bianchi.example' } }, phones: { p1: { number: '+39 06 9876 5432' } }, addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } }, - notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } }, + notes: { n1: { note: 'Old university friend. Runs a bookshop in Rome and still argues about type systems.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 72), mediaType: 'image/jpeg' } }, }, { id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual', @@ -831,7 +1044,7 @@ const contacts = [ phones: { p1: { number: '+31 70 362 4242' } }, organizations: { o1: { name: 'BergLabs' } }, addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } }, - notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } }, + notes: { n1: { note: 'Solutions architect. Keeps the service diagram, ask her before drawing another one.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 58), mediaType: 'image/jpeg' } }, }, { id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual', @@ -840,7 +1053,7 @@ const contacts = [ phones: { p1: { number: '+45 33 42 42 42' } }, organizations: { o1: { name: 'Nielsen Konsult' } }, addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } }, - notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } }, + notes: { n1: { note: 'Freelance SRE. On call for our deployment windows, invoices monthly.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 35), mediaType: 'image/jpeg' } }, }, { id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual', @@ -849,7 +1062,7 @@ const contacts = [ phones: { p1: { number: '+33 1 44 27 42 42' } }, organizations: { o1: { name: 'Sorbonne Université' } }, addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } }, - notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } }, + notes: { n1: { note: 'Professor of computer science. Works on formal verification of mail protocols.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } }, }, // --- Work address book --- @@ -859,7 +1072,7 @@ const contacts = [ phones: { p1: { number: '+33 1 53 67 42 00' } }, organizations: { o1: { name: 'Lefèvre & Associés' } }, addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } }, - notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } }, + notes: { n1: { note: 'Contract and IP law. Bills in six-minute units, so keep the email short.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 81), mediaType: 'image/jpeg' } }, }, { id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual', @@ -868,7 +1081,7 @@ const contacts = [ phones: { p1: { number: '+49 30 450 570 000' } }, organizations: { o1: { name: 'Charité Klinik Berlin' } }, addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } }, - notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } }, + notes: { n1: { note: 'Organises the Berlin team evenings. Books everything three months ahead.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 26), mediaType: 'image/jpeg' } }, }, { id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual', @@ -877,7 +1090,7 @@ const contacts = [ phones: { p1: { number: '+353 1 677 4242' } }, organizations: { o1: { name: 'Finanz Dublin' } }, addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } }, - notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } }, + notes: { n1: { note: 'Finance lead in Dublin. Wants the numbers before the meeting, not during it.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 19), mediaType: 'image/jpeg' } }, }, { id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual', @@ -886,16 +1099,16 @@ const contacts = [ phones: { p1: { number: '+34 91 420 4242' } }, organizations: { o1: { name: 'García Design Studio' } }, addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } }, - notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } }, + notes: { n1: { note: 'Brand designer. Owns the Figma library, ask before inventing a shade.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 50), mediaType: 'image/jpeg' } }, }, { id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] }, emails: { e1: { address: 'nils@digitaal.example' } }, - phones: { p1: { number: '+31 20 624 1337' } }, + phones: { p1: { number: '+31 20 624 8815' } }, organizations: { o1: { name: 'Digitaal BV' } }, addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } }, - notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } }, + notes: { n1: { note: 'Platform engineer. Knows where the old DNS records are buried.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 57), mediaType: 'image/jpeg' } }, }, { id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual', @@ -904,7 +1117,7 @@ const contacts = [ phones: { p1: { number: '+48 22 505 4242' } }, organizations: { o1: { name: 'Kowalska Marketing' } }, addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } }, - notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } }, + notes: { n1: { note: 'Marketing strategist. Runs the campaign reporting.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 71), mediaType: 'image/jpeg' } }, }, { id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual', @@ -913,7 +1126,7 @@ const contacts = [ phones: { p1: { number: '+353 86 123 4242' } }, organizations: { o1: { name: 'Murphy Bau GmbH' } }, addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } }, - notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } }, + notes: { n1: { note: 'Runs the Dublin office fit-out. Reachable by phone, not by email.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 93), mediaType: 'image/jpeg' } }, }, { id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual', @@ -922,7 +1135,7 @@ const contacts = [ phones: { p1: { number: '+351 21 342 4242' } }, organizations: { o1: { name: 'Ferreira Media' } }, addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } }, - notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } }, + notes: { n1: { note: 'Media consultant. Handles press for the Lisbon launch.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 82), mediaType: 'image/jpeg' } }, }, { id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual', @@ -931,7 +1144,7 @@ const contacts = [ phones: { p1: { number: '+32 2 555 4242' } }, organizations: { o1: { name: 'Dumont Conseil' } }, addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } }, - notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } }, + notes: { n1: { note: 'Strategy consultant in Brussels. Good on procurement questions.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 4), mediaType: 'image/jpeg' } }, }, { id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual', @@ -941,7 +1154,7 @@ const contacts = [ organizations: { o1: { name: 'Lindgren Consulting' } }, addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } }, nicknames: { n1: { name: 'Anni' } }, - notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } }, + notes: { n1: { note: 'Data protection consultant. Reviewed our privacy notice in January.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 36), mediaType: 'image/jpeg' } }, }, // --- Groups --- @@ -967,11 +1180,11 @@ const contacts = [ // --------------------------------------------------------------------------- const mockCalendars = [ - { id: 'cal-1', name: 'Persönlich', color: '#4285f4', isVisible: true, isDefault: true }, - { id: 'cal-2', name: 'Arbeit', color: '#0b8043', isVisible: true, isDefault: false }, + { id: 'cal-1', name: 'Personal', color: '#4285f4', isVisible: true, isDefault: true }, + { id: 'cal-2', name: 'Work', color: '#0b8043', isVisible: true, isDefault: false }, { id: 'cal-3', name: 'Team', color: '#8e24aa', isVisible: true, isDefault: false }, - { id: 'cal-4', name: 'Feiertage (EU)', color: '#f4511e', isVisible: true, isDefault: false }, - { id: 'cal-5', name: 'Geburtstage', color: '#e67c73', isVisible: true, isDefault: false }, + { id: 'cal-4', name: 'Public holidays', color: '#f4511e', isVisible: true, isDefault: false }, + { id: 'cal-5', name: 'Birthdays', color: '#e67c73', isVisible: true, isDefault: false }, ]; function makeEvent( @@ -1015,41 +1228,41 @@ const calendarEvents = [ participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), - p3: participant('Sophie Example', 'sophie@eurotech.example'), + p3: participant('Sophie Müller', 'sophie@eurotech.example'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), }, alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } }, }), makeEvent('evt-002', 'cal-2', 'Sprint Planning', localDateTime(1, 10, 30), 'PT1H30M', { - location: 'Konferenzsaal A', + location: 'Room A', participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), - p3: participant('Sophie Example', 'sophie@eurotech.example'), + p3: participant('Sophie Müller', 'sophie@eurotech.example'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), p5: participant('Astrid van der Berg', 'astrid@berglabs.example'), }, recurrence: [{ frequency: 'weekly', byDay: [{ day: 'mo' }], interval: 2 }], alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' }, action: 'display' } }, }), - makeEvent('evt-003', 'cal-2', '1:1 with Lars', localDateTime(0, 14, 0), 'PT42M', { + makeEvent('evt-003', 'cal-2', '1:1 with Lars', localDateTime(0, 14, 0), 'PT30M', { virtualLocations: { vl1: { uri: 'https://meet.example/lars-dev', name: 'Zoom' } }, participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), }, - description: 'Weekly catch-up. Duration: exactly 42 minutes - the answer to everything.', + description: 'Weekly catch-up.', }), makeEvent('evt-004', 'cal-2', 'Code Review Session', localDateTime(0, 16, 0), 'PT1H', { - location: 'Konferenzsaal B', + location: 'Room B', participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), }, - description: 'Review JMAP-342 contact import PR.', + description: 'Walk through the vCard import PR, mainly the merge dialogue.', }), makeEvent('evt-005', 'cal-2', 'Architecture Review', localDateTime(2, 11, 0), 'PT1H30M', { - location: 'Konferenzsaal A', + location: 'Room A', participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Astrid van der Berg', 'astrid@berglabs.example'), @@ -1063,13 +1276,13 @@ const calendarEvents = [ virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } }, participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), - p2: participant('Sophie Example', 'sophie@eurotech.example'), + p2: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Pierre Dubois', 'pierre@dubois.example'), }, - description: 'Discuss API rate limit escalation for EuroTech enterprise account.', + description: 'Rate limit escalation on the EuroTech account, ticket #4521.', }), makeEvent('evt-007', 'cal-2', 'Deployment Window', localDateTime(3, 22, 0), 'PT2H', { - description: 'Production deployment: JMAP calendar integration v2.3.\nRollback plan in Confluence.\nOn-call: Henrik Nielsen.', + description: 'Calendar integration v2.3 goes to production. The rollback plan is in the runbook, Henrik is on call.', participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Henrik Nielsen', 'henrik@nielsen-konsult.example'), @@ -1080,7 +1293,7 @@ const calendarEvents = [ }, }), makeEvent('evt-008', 'cal-2', 'Q1 Budget Review', localDateTime(3, 14, 0), 'PT1H', { - location: 'Konferenzsaal B', + location: 'Room B', participants: { p1: participant('Liam Ó Donaill', 'liam.odonaill@finanz.example', 'owner'), p2: participant('Dev User', 'dev@localhost'), @@ -1088,12 +1301,12 @@ const calendarEvents = [ }, }), makeEvent('evt-009', 'cal-2', 'Retro & Demo', localDateTime(4, 15, 0), 'PT1H30M', { - location: 'Konferenzsaal A', + location: 'Room A', virtualLocations: { vl1: { uri: 'https://meet.example/retro', name: 'Google Meet' } }, participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), - p3: participant('Sophie Example', 'sophie@eurotech.example'), + p3: participant('Sophie Müller', 'sophie@eurotech.example'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), p5: participant('Astrid van der Berg', 'astrid@berglabs.example'), p6: participant('Pierre Dubois', 'pierre@dubois.example'), @@ -1105,25 +1318,25 @@ const calendarEvents = [ participants: { p1: participant('Dev User', 'dev@localhost'), p2: participant('María García', 'maria@garcia-design.example', 'owner'), - p3: participant('Sophie Example', 'sophie@eurotech.example'), + p3: participant('Sophie Müller', 'sophie@eurotech.example'), }, }), makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', { showWithoutTime: true, - description: 'Stripe API v2023-10 deprecated. Must be on v2025-01 by today.', + description: 'Payment API v2023-10 stops answering today. Both keys have to be on v2025-01.', color: '#d50000', }), // ===== Team calendar (cal-3) - social & team ===== - makeEvent('evt-012', 'cal-3', 'Biergarten Abend 🍺', localDateTime(5, 18, 0), 'PT3H', { - location: 'Biergarten am Prinsengracht, Amsterdam', - description: 'Monthly team social. Bretzel buffet included.\nVegetarian options: Käsespätzle, Kartoffelsalat.\nBring your own Dirndl/Lederhosen (optional but encouraged).', + makeEvent('evt-012', 'cal-3', 'Team evening', localDateTime(5, 18, 0), 'PT3H', { + location: 'Restaurante Fado, Zeedijk 62, Amsterdam', + description: 'Set menu, paid by the company. Vegetarian option has to be flagged by Wednesday.', participants: { p1: participant('Katrin Bauer', 'katrin.bauer@charite.example', 'owner'), p2: participant('Dev User', 'dev@localhost'), p3: participant('Pierre Dubois', 'pierre@dubois.example'), p4: participant('Chiara Rossi', 'chiara@rossi.example'), - p5: participant('Sophie Example', 'sophie@eurotech.example'), + p5: participant('Sophie Müller', 'sophie@eurotech.example'), }, }), makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', { @@ -1132,23 +1345,23 @@ const calendarEvents = [ p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), - p4: participant('Sophie Example', 'sophie@eurotech.example'), + p4: participant('Sophie Müller', 'sophie@eurotech.example'), }, }), - makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', { - location: 'Kantine, 2. OG', - description: 'Presenter: Dev User\nTopic: How JMAP solves the N+1 problem and why it\'s better than IMAP for modern clients.\nPizza will be provided.', + makeEvent('evt-014', 'cal-3', 'Lunch & learn: how JMAP batches requests', localDateTime(4, 12, 0), 'PT1H', { + location: 'Canteen, second floor', + description: 'Dev User walks through batching and back-references, with the numbers from the prototype. Pizza at 12:00, talk at 12:15.', participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Astrid van der Berg', 'astrid@berglabs.example'), p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'), }, }), - makeEvent('evt-015', 'cal-3', 'Eurovision Watch Party 🎤✨', localDateTime(60, 20, 0), 'PT4H', { - location: 'Sophie\'s apartment, Kreuzberg, Berlin', - description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.', + makeEvent('evt-015', 'cal-3', 'Quarterly all-hands', localDateTime(60, 15, 0), 'PT1H30M', { + location: 'Room A, and streamed', + description: 'Numbers, roadmap, questions. Send questions in advance if you want an answer that has been thought about.', participants: { - p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'), + p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'), p2: participant('Dev User', 'dev@localhost'), p3: participant('Pierre Dubois', 'pierre@dubois.example'), p4: participant('Chiara Rossi', 'chiara@rossi.example'), @@ -1156,9 +1369,9 @@ const calendarEvents = [ p6: participant('Nils Andersson', 'nils@digitaal.example'), }, }), - makeEvent('evt-016', 'cal-3', 'Cooking Class - Pasta Fresca', localDateTime(12, 18, 30), 'PT2H30M', { - location: 'La Cucina Cooking School, Jordaan, Amsterdam', - description: 'Team cooking class: fresh pasta from scratch.\nMenu: tagliatelle al ragù, ravioli ricotta e spinaci.\nChef: Chiara Rossi (guest instructor)', + makeEvent('evt-016', 'cal-3', 'Pasta course', localDateTime(12, 18, 30), 'PT2H30M', { + location: 'La Cucina, Jordaan, Amsterdam', + description: 'Three hours, you eat what you make. Chiara is teaching, which she volunteered for and may come to regret.', participants: { p1: participant('Chiara Rossi', 'chiara@rossi.example', 'owner'), p2: participant('Dev User', 'dev@localhost'), @@ -1169,27 +1382,27 @@ const calendarEvents = [ // ===== Personal calendar (cal-1) ===== makeEvent('evt-017', 'cal-1', 'Fika with Nils', localDateTime(2, 15, 30), 'PT1H', { - location: 'Café de Flore, Prinsengracht, Amsterdam', - description: 'Catch-up over coffee and kanelbullar.', + location: 'Koffiehuis Prinsengracht, Amsterdam', + description: 'Catch-up over coffee.', }), makeEvent('evt-018', 'cal-1', 'Lake Como Weekend', localDateTime(14, 10, 0), 'P2D', { location: 'Villa sul Lago, Bellagio, Lake Como', - description: 'Weekend getaway.\nConfirmation: EU42GDPR\nCheck-in: 15:00\nCheck-out: 11:00', + description: 'Reservation LS-4419-BG.\nCheck-in from 15:00, check-out by 11:00.\nThe key box code is in the voucher.', showWithoutTime: true, }), makeEvent('evt-019', 'cal-1', 'Tandarts (Dentist)', localDateTime(7, 9, 30), 'PT45M', { location: 'Tandartspraktijk Centrum, Reguliersgracht 12, Amsterdam', - description: 'Regular check-up. Don\'t forget to floss!', + description: 'Six-month check-up.', alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' }, action: 'display' } }, }), makeEvent('evt-020', 'cal-1', 'Albert Cuyp Markt', localDateTime(6, 10, 0), 'PT2H', { location: 'Albert Cuypstraat, Amsterdam', - description: 'Saturday market run.\nShopping list: stroopwafels, Gouda, tulips, fresh bread, olives.', + description: 'Market run. Bread, cheese, olives, and whatever looks good.', showWithoutTime: false, }), makeEvent('evt-021', 'cal-1', 'Cycling to Vondelpark', localDateTime(6, 14, 0), 'PT1H30M', { location: 'Vondelpark, Amsterdam', - description: 'Afternoon bike ride. Meet at the main entrance.', + description: 'Meet at the main entrance.', }), makeEvent('evt-022', 'cal-1', 'Yoga Class', localDateTime(0, 7, 0), 'PT1H', { location: 'De Nieuwe Yogaschool, Laurierstraat, Amsterdam', @@ -1198,7 +1411,7 @@ const calendarEvents = [ makeEvent('evt-023', 'cal-1', 'Dutch Language Lesson', localDateTime(1, 19, 0), 'PT1H30M', { location: 'Taleninstituut, Plantage Middenlaan, Amsterdam', recurrence: [{ frequency: 'weekly', byDay: [{ day: 'tu' }] }], - description: 'Semester 3 - past tense and separable verbs. Ik heb geprobeerd...', + description: 'Semester 3: past tense and separable verbs.', }), makeEvent('evt-024', 'cal-1', 'Call with Mum', localDateTime(0, 18, 30), 'PT30M', { recurrence: [{ frequency: 'weekly', byDay: [{ day: 'su' }] }], @@ -1207,65 +1420,64 @@ const calendarEvents = [ // Overlapping personal events makeEvent('evt-025', 'cal-1', 'Haircut', localDateTime(6, 14, 30), 'PT45M', { location: 'Kapper de Luxe, Utrechtsestraat, Amsterdam', - description: 'Overlaps with Vondelpark bike ride - need to reschedule one!', + description: 'Overlaps with the bike ride. One of them has to move.', }), // ===== Holiday calendar (cal-4) - all-day events ===== - makeEvent('evt-026', 'cal-4', 'Koningsdag 🧡', localDateTime(42, 0, 0), 'P1D', { + makeEvent('evt-026', 'cal-4', 'Koningsdag', localDateTime(42, 0, 0), 'P1D', { showWithoutTime: true, - description: 'King\'s Day - national holiday in the Netherlands.\nWear orange! Visit a vrijmarkt. Eat tompouce.', + description: 'Public holiday in the Netherlands. Shops shut, the city centre is closed to cars.', color: '#ff6d00', }), makeEvent('evt-027', 'cal-4', 'Tag der Arbeit', localDateTime(48, 0, 0), 'P1D', { showWithoutTime: true, - description: 'Labour Day - public holiday in most EU countries.', + description: 'Public holiday in most of Europe. Stockholm and Berlin are closed.', }), - makeEvent('evt-028', 'cal-4', 'Europe Day 🇪🇺', localDateTime(55, 0, 0), 'P1D', { + makeEvent('evt-028', 'cal-4', 'Hemelvaartsdag', localDateTime(55, 0, 0), 'P1D', { showWithoutTime: true, - description: 'Anniversary of the Schuman Declaration (1950). The foundation of European integration.', - color: '#003399', + description: 'Public holiday in the Netherlands. Most people take the Friday as well.', }), makeEvent('evt-029', 'cal-4', 'Bevrijdingsdag', localDateTime(52, 0, 0), 'P1D', { showWithoutTime: true, - description: 'Liberation Day - Dutch national holiday commemorating the end of WWII occupation.', + description: 'Liberation Day. A holiday for us, not for every employer in the country.', }), // ===== Birthday calendar (cal-5) ===== - makeEvent('evt-030', 'cal-5', '🎂 Sophie Example', localDateTime(8, 0, 0), 'P1D', { + makeEvent('evt-030', 'cal-5', '🎂 Sophie Müller', localDateTime(8, 0, 0), 'P1D', { showWithoutTime: true, recurrence: [{ frequency: 'yearly' }], - description: 'Don\'t forget to bring Kuchen!', + description: 'She has said twice that she wants nothing. Bring cake anyway.', }), makeEvent('evt-031', 'cal-5', '🎂 Chiara Rossi', localDateTime(21, 0, 0), 'P1D', { showWithoutTime: true, recurrence: [{ frequency: 'yearly' }], - description: 'She prefers tiramisu over cake.', + description: 'Tiramisu, not cake.', }), makeEvent('evt-032', 'cal-5', '🎂 Pierre Dubois', localDateTime(45, 0, 0), 'P1D', { showWithoutTime: true, recurrence: [{ frequency: 'yearly' }], - description: 'Likes a good Bordeaux.', + description: 'Wine, and he will notice which one.', }), makeEvent('evt-033', 'cal-5', '🎂 Lars Johansson', localDateTime(-3, 0, 0), 'P1D', { showWithoutTime: true, recurrence: [{ frequency: 'yearly' }], - description: 'Just passed! Hope you remembered.', + description: 'Was last week. It was not remembered.', }), // ===== JMAP Conf & travel (work calendar) ===== makeEvent('evt-034', 'cal-2', 'JMAP Conf Amsterdam', localDateTime(28, 9, 0), 'P2D', { location: 'RAI Amsterdam Convention Centre', showWithoutTime: true, - description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!', + description: 'Your talk is day one, 14:00, main hall. Slides as PDF to the organisers by the Friday before.', participants: { p1: participant('Dev User', 'dev@localhost'), - p2: participant('Sophie Example', 'sophie@eurotech.example'), + p2: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'), }, }), makeEvent('evt-035', 'cal-2', 'FOSDEM Talk Prep', localDateTime(10, 13, 0), 'PT2H', { virtualLocations: { vl1: { uri: 'https://meet.example/fosdem-prep', name: 'Meet' } }, - description: 'Rehearse FOSDEM 2027 talk proposal.\nTitle: "Modern Email Clients with JMAP - From Protocol to Production"', + description: 'Run through the FOSDEM proposal end to end and cut it down to 30 minutes.', }), ]; From 59bc7fd64cdfe00ac72dc01b377acbfb17aedf9d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:02:39 +0200 Subject: [PATCH 37/42] fix: reply to own thread message addresses original recipients #703 --- app/(main)/[locale]/page.tsx | 40 ++- .../email/__tests__/reply-addressing.test.tsx | 228 ++++++++++++++++++ components/email/email-composer.tsx | 42 ++-- lib/__tests__/reply-recipients.test.ts | 114 +++++++++ lib/reply-recipients.ts | 108 +++++++++ 5 files changed, 501 insertions(+), 31 deletions(-) create mode 100644 components/email/__tests__/reply-addressing.test.tsx create mode 100644 lib/__tests__/reply-recipients.test.ts create mode 100644 lib/reply-recipients.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 7345bb80..f7230321 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -61,7 +61,8 @@ import { isFilePreviewable } from "@/lib/file-preview"; import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; -import { findDraftIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; +import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity"; +import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; import { ResizeHandle } from "@/components/layout/resize-handle"; @@ -2400,8 +2401,20 @@ export default function Home() { const handleQuickReply = async (body: string) => { if (!client || !selectedEmail) return; - const sender = selectedEmail.from?.[0]; - if (!sender?.email) { + // Quick reply follows the same addressing rules as the composer: Reply-To + // over From, and for our own messages in a thread the original recipients + // instead of ourselves (#703). + const ownIdentityEmails = identities.map(i => i.email).filter(Boolean); + const replySource = { + from: selectedEmail.from, + replyToAddresses: selectedEmail.replyTo, + to: selectedEmail.to, + cc: selectedEmail.cc, + }; + const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to + .map(r => r.email) + .filter((email): email is string => Boolean(email)); + if (recipients.length === 0) { throw new Error("No sender email found"); } @@ -2410,14 +2423,21 @@ export default function Home() { // Decide the sending identity and (for domain-catch-all) an optional // header From override that matches the address the message was sent to. + // Our own message keeps the identity it was sent from - the recipients are + // the other party, so resolving from them would send as their address. // When the setting is off, fall through to primary-identity behavior. - const resolved = autoSelectReplyIdentity - ? resolveReplyFrom(identities, { - to: selectedEmail.to, - cc: selectedEmail.cc, - bcc: selectedEmail.bcc, - }) + const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails) + ? findDraftIdentityId(identities, selectedEmail.from?.[0]) : null; + const resolved: ReplyFromResolution | null = !autoSelectReplyIdentity + ? null + : selfSentIdentityId + ? { identityId: selfSentIdentityId } + : resolveReplyFrom(identities, { + to: selectedEmail.to, + cc: selectedEmail.cc, + bcc: selectedEmail.bcc, + }); const sendingIdentity = resolved ? (identities.find((i) => i.id === resolved.identityId) || primaryIdentity) : primaryIdentity; @@ -2464,7 +2484,7 @@ export default function Home() { // Send reply with just the body text const result = await sendEmail( client, - [sender.email], + recipients, buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')), finalBody, undefined, diff --git a/components/email/__tests__/reply-addressing.test.tsx b/components/email/__tests__/reply-addressing.test.tsx new file mode 100644 index 00000000..f009b310 --- /dev/null +++ b/components/email/__tests__/reply-addressing.test.tsx @@ -0,0 +1,228 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// ─── Heavy component mocks (mirrors recipient-paste.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: () => ({ ref: { 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' }, + { id: 'id-info', email: 'info@example.com', name: 'Info' }, + ], + 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: [], + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + }; + 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', () => ({ + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onSend: { call: async () => [] }, + beforeSend: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + }, + contactHooks: { + search: { call: async () => [] }, + }, +})); + +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: () => '', +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: () => {} })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +const RECEIVED = { + from: [{ email: 'bob@other.com', name: 'Bob' }], + to: [{ email: 'me@example.com', name: 'Me' }, { email: 'carol@other.com', name: 'Carol' }], + cc: [{ email: 'dave@other.com', name: 'Dave' }], + subject: 'Hello', +}; + +/** The same conversation, but the message opened is the one we sent back. */ +const SELF_SENT = { + from: [{ email: 'me@example.com', name: 'Me' }], + to: [{ email: 'bob@other.com', name: 'Bob' }], + cc: [{ email: 'carol@other.com', name: 'Carol' }], + subject: 'Re: Hello', +}; + +/** Chip labels currently shown in a recipient row, in order. Chips are the + * draggable spans inside the row; next-intl is mocked to return the key, so + * the Cc row is found via its "cc_label" caption. */ +const chipsIn = (row: HTMLElement) => + Array.from(row.querySelectorAll('[draggable]')).map((el) => el.textContent?.trim()); + +const toChips = () => chipsIn(screen.getByTestId('composer-to')); +const ccChips = () => chipsIn(screen.getByText('cc_label').parentElement as HTMLElement); + +const identitySelect = () => screen.getByTestId('composer-from') as HTMLSelectElement; + +describe('composer reply addressing', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('addresses a reply to the sender of a received message', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)']); + }); + + it('reply-all keeps the other recipients but not our own address', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)', 'Carol (carol@other.com)']); + expect(ccChips()).toEqual(['Dave (dave@other.com)']); + }); + + // #703: replying to our own message inside a thread used to address the + // reply back to ourselves instead of continuing the conversation. + it('addresses a reply to our own message to the original recipient', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)']); + }); + + it('reply-all on our own message restores the original To and Cc', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)']); + expect(ccChips()).toEqual(['Carol (carol@other.com)']); + }); + + it('sends the reply to our own message from the identity that sent it', () => { + render(); + expect(identitySelect().value).toBe('id-info'); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index f5abe5d5..0479f183 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -36,7 +36,8 @@ import { TemplatePicker } from "@/components/templates/template-picker"; import { TemplateForm } from "@/components/templates/template-form"; import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; -import { findComposeIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; +import { findComposeIdentityId, findDraftIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; +import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { rewriteCidImagesForEditor, @@ -322,31 +323,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)); + // Initialize with reply/forward data if provided const getInitialTo = (): Recipient[] => { - if (!replyTo) return []; - // RFC 5322: use Reply-To header if present, otherwise fall back to From - const replyTarget = replyTo.replyToAddresses?.length - ? replyTo.replyToAddresses.filter(r => r.email).map(toRecipient) - : (replyTo.from?.[0]?.email ? [toRecipient(replyTo.from[0])] : []); - if (mode === 'reply') { - return replyTarget; - } else if (mode === 'replyAll') { - const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean)); - const originalTo = (replyTo.to ?? []) - .filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase())) - .map(toRecipient); - return [...replyTarget, ...originalTo]; - } - return []; + if (mode !== 'reply' && mode !== 'replyAll') return []; + return buildReplyRecipients(replyTo, mode, ownIdentityEmails).to.map(toRecipient); }; const getInitialCc = (): Recipient[] => { - if (!replyTo || mode !== 'replyAll') return []; - const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean)); - return (replyTo.cc ?? []) - .filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase())) - .map(toRecipient); + if (mode !== 'replyAll') return []; + return buildReplyRecipients(replyTo, mode, ownIdentityEmails).cc.map(toRecipient); }; const getInitialSubject = () => { @@ -716,6 +703,18 @@ export function EmailComposer({ if (mode !== 'reply' && mode !== 'replyAll') return; + // Replying to our own message in a thread (#703): keep sending as the + // 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. + if (isSelfSent({ from: replyTo?.from }, identities.map(i => i.email).filter(Boolean))) { + const senderIdentityId = findDraftIdentityId(identities, replyTo?.from?.[0]); + if (senderIdentityId) { + setSelectedIdentityId(senderIdentityId); + return; + } + } + const resolved = resolveReplyFrom(identities, { to: replyTo?.to, cc: replyTo?.cc, @@ -755,6 +754,7 @@ export function EmailComposer({ replyTo?.accountId, replyTo?.bcc, replyTo?.cc, + replyTo?.from, replyTo?.to, selectedIdentityId, ]); diff --git a/lib/__tests__/reply-recipients.test.ts b/lib/__tests__/reply-recipients.test.ts new file mode 100644 index 00000000..2a50ed49 --- /dev/null +++ b/lib/__tests__/reply-recipients.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import { buildReplyRecipients, isSelfSent } from '@/lib/reply-recipients'; + +const OWN = ['me@example.com', 'info@example.com']; + +const emails = (list: { email?: string }[]) => list.map((r) => r.email); + +describe('buildReplyRecipients', () => { + describe('received message', () => { + const received = { + from: [{ email: 'bob@other.com', name: 'Bob' }], + to: [{ email: 'me@example.com' }, { email: 'carol@other.com' }], + cc: [{ email: 'dave@other.com' }], + }; + + it('replies to the sender', () => { + const { to, cc } = buildReplyRecipients(received, 'reply', OWN); + expect(emails(to)).toEqual(['bob@other.com']); + expect(cc).toEqual([]); + }); + + it('prefers the Reply-To header over From', () => { + const { to } = buildReplyRecipients( + { ...received, replyToAddresses: [{ email: 'list@other.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['list@other.com']); + }); + + it('reply-all keeps the other recipients and drops our own address', () => { + const { to, cc } = buildReplyRecipients(received, 'replyAll', OWN); + expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']); + expect(emails(cc)).toEqual(['dave@other.com']); + }); + + it('reply-all drops our own address even with +tag sub-addressing', () => { + const { to } = buildReplyRecipients( + { ...received, to: [{ email: 'me+newsletter@example.com' }, { email: 'carol@other.com' }] }, + 'replyAll', + OWN, + ); + expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']); + }); + }); + + describe('self-sent message (#703)', () => { + const sent = { + from: [{ email: 'me@example.com', name: 'Me' }], + to: [{ email: 'bob@other.com', name: 'Bob' }], + cc: [{ email: 'carol@other.com' }], + }; + + it('replies to the original recipient, not to ourselves', () => { + const { to, cc } = buildReplyRecipients(sent, 'reply', OWN); + expect(emails(to)).toEqual(['bob@other.com']); + expect(cc).toEqual([]); + }); + + it('reply-all restores the original To and Cc', () => { + const { to, cc } = buildReplyRecipients(sent, 'replyAll', OWN); + expect(emails(to)).toEqual(['bob@other.com']); + expect(emails(cc)).toEqual(['carol@other.com']); + }); + + it('recognises the sending identity through +tag sub-addressing', () => { + const { to } = buildReplyRecipients( + { ...sent, from: [{ email: 'me+project@example.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['bob@other.com']); + }); + + it('ignores our own Reply-To header so the reply leaves our mailbox', () => { + const { to } = buildReplyRecipients( + { ...sent, replyToAddresses: [{ email: 'info@example.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['bob@other.com']); + }); + + it('keeps a self-addressed recipient we chose ourselves', () => { + const { to } = buildReplyRecipients( + { ...sent, to: [{ email: 'info@example.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['info@example.com']); + }); + + it('falls back to the sender when there is no visible recipient (Bcc-only)', () => { + const { to } = buildReplyRecipients({ ...sent, to: [], cc: [] }, 'reply', OWN); + expect(emails(to)).toEqual(['me@example.com']); + }); + + it('keeps the display names of the original recipients', () => { + const { to } = buildReplyRecipients(sent, 'reply', OWN); + expect(to[0]).toEqual({ email: 'bob@other.com', name: 'Bob' }); + }); + }); + + it('returns nothing without a source message', () => { + expect(buildReplyRecipients(undefined, 'replyAll', OWN)).toEqual({ to: [], cc: [] }); + }); + + it('treats a message as foreign when no identity matches', () => { + expect(isSelfSent({ from: [{ email: 'bob@other.com' }] }, OWN)).toBe(false); + expect(isSelfSent({ from: [{ email: 'ME@Example.com ' }] }, OWN)).toBe(true); + expect(isSelfSent({ from: [] }, OWN)).toBe(false); + expect(isSelfSent(undefined, OWN)).toBe(false); + }); +}); diff --git a/lib/reply-recipients.ts b/lib/reply-recipients.ts new file mode 100644 index 00000000..46d3d560 --- /dev/null +++ b/lib/reply-recipients.ts @@ -0,0 +1,108 @@ +export interface ReplyAddress { + email?: string; + name?: string; +} + +export interface ReplySource { + from?: ReplyAddress[]; + /** Addresses from the original message's Reply-To header. */ + replyToAddresses?: ReplyAddress[]; + to?: ReplyAddress[]; + cc?: ReplyAddress[]; +} + +export interface ReplyRecipientsResult { + to: ReplyAddress[]; + cc: ReplyAddress[]; +} + +function normalize(email: string): string { + return email.trim().toLowerCase(); +} + +function normalizeBase(email: string): string { + const normalized = normalize(email); + const at = normalized.indexOf('@'); + if (at <= 0) return normalized; + + const local = normalized.slice(0, at); + const domain = normalized.slice(at + 1); + const plus = local.indexOf('+'); + + return `${plus >= 0 ? local.slice(0, plus) : local}@${domain}`; +} + +/** + * Does `email` belong to the user? Matches exactly first, then with `+tag` + * sub-addressing stripped (info+news@ is still info@). + */ +function isOwnAddress(email: string | undefined, ownEmails: string[]): boolean { + if (!email?.trim()) return false; + const exact = normalize(email); + if (ownEmails.some((own) => normalize(own) === exact)) return true; + const base = normalizeBase(email); + return ownEmails.some((own) => normalizeBase(own) === base); +} + +/** + * 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 + * replying to your own last message. + */ +export function isSelfSent(source: ReplySource | undefined, ownEmails: string[]): boolean { + return isOwnAddress(source?.from?.[0]?.email, ownEmails); +} + +/** + * Work out the To/Cc a reply should open with. + * + * Normal case: reply goes to the Reply-To header if the original carried one, + * else to From (RFC 5322). Reply-all adds the other original recipients, + * minus the user's own addresses. + * + * Self-sent case (#703): replying to your own message inside a thread must + * continue the conversation, not mail yourself. Gmail and Thunderbird address + * the reply to the message's original recipients instead, so that's what we do + * - the original To for reply, plus the original Cc for reply-all. Those + * addresses were the user's own choice, so they're kept verbatim (no self- + * filtering) and the Reply-To header is ignored, since answering your own + * Reply-To would land the mail back in your inbox again. + * + * A self-sent message with no visible recipients (Bcc-only) has nothing to + * continue to, so it falls back to the normal behaviour. + */ +export function buildReplyRecipients( + source: ReplySource | undefined, + mode: 'reply' | 'replyAll', + ownEmails: string[], +): ReplyRecipientsResult { + if (!source) return { to: [], cc: [] }; + + const withEmail = (list: ReplyAddress[] | undefined) => (list ?? []).filter((r) => Boolean(r.email)); + + if (isSelfSent(source, ownEmails)) { + const originalTo = withEmail(source.to); + if (originalTo.length > 0) { + return { + to: originalTo, + cc: mode === 'replyAll' ? withEmail(source.cc) : [], + }; + } + } + + const replyTarget = withEmail(source.replyToAddresses).length + ? withEmail(source.replyToAddresses) + : (source.from?.[0]?.email ? [source.from[0]] : []); + + if (mode === 'reply') { + return { to: replyTarget, cc: [] }; + } + + const others = (list: ReplyAddress[] | undefined) => + withEmail(list).filter((r) => !isOwnAddress(r.email, ownEmails)); + + return { + to: [...replyTarget, ...others(source.to)], + cc: others(source.cc), + }; +} From a6c15b8ad68976694e5ad76418ddba5e05a57332 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:40:25 +0200 Subject: [PATCH 38/42] fix: empty folder stopped after 500 emails #711 --- lib/__tests__/jmap-empty-mailbox.test.ts | 133 +++++++++++++++++++++++ lib/jmap/client.ts | 18 ++- 2 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 lib/__tests__/jmap-empty-mailbox.test.ts diff --git a/lib/__tests__/jmap-empty-mailbox.test.ts b/lib/__tests__/jmap-empty-mailbox.test.ts new file mode 100644 index 00000000..a41d90c1 --- /dev/null +++ b/lib/__tests__/jmap-empty-mailbox.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +function makeSession() { + return { + capabilities: { 'urn:ietf:params:jmap:core': {} }, + accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' }, + apiUrl: 'https://mail.example.com/jmap/api', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: 'https://mail.example.com/jmap/eventsource', + }; +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** + * Stand-in for a Stalwart mailbox: Email/query returns one page of remaining + * ids, Email/set destroys them. `includeTotal` mirrors the server's freedom to + * omit `total` when the query did not ask for `calculateTotal` (RFC 8620 5.5). + */ +function makeMailboxServer(opts: { + count: number; + includeTotal?: boolean; + destroyFails?: boolean; +}) { + let remaining = Array.from({ length: opts.count }, (_, i) => `email-${i}`); + const requests: number[] = []; + + const handler = async (_url: string, init: RequestInit): Promise => { + const body = JSON.parse(init.body as string); + const [, queryArgs] = body.methodCalls[0]; + const limit: number = queryArgs.limit; + const page = remaining.slice(0, limit); + requests.push(page.length); + + const destroyed = opts.destroyFails ? [] : page; + remaining = remaining.slice(destroyed.length); + + return jsonResponse({ + methodResponses: [ + ['Email/query', { ids: page, ...(opts.includeTotal ? { total: page.length } : {}) }, '0'], + ['Email/set', { destroyed, notDestroyed: {} }, '1'], + ], + }); + }; + + return { handler, requests, remainingCount: () => remaining.length }; +} + +describe('JMAPClient.emptyMailbox', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + async function connectedClient(): Promise { + fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession())); + const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com'); + await client.connect(); + fetchSpy.mockReset(); + return client; + } + + it('destroys every email in a mailbox larger than one batch', async () => { + const client = await connectedClient(); + const server = makeMailboxServer({ count: 1200 }); + fetchSpy.mockImplementation(server.handler as never); + + const destroyed = await client.emptyMailbox('mailbox-1'); + + expect(destroyed).toBe(1200); + expect(server.remainingCount()).toBe(0); + expect(server.requests).toEqual([500, 500, 200]); + }); + + // Regression for #711: the loop used to stop after one batch when the server + // omitted `total`, leaving folders with thousands of emails nearly full. + it('keeps paging when the server omits Email/query total', async () => { + const client = await connectedClient(); + const server = makeMailboxServer({ count: 2300, includeTotal: false }); + fetchSpy.mockImplementation(server.handler as never); + + const destroyed = await client.emptyMailbox('mailbox-1'); + + expect(destroyed).toBe(2300); + expect(server.remainingCount()).toBe(0); + }); + + it('issues a final confirming query when the count is an exact multiple of the batch size', async () => { + const client = await connectedClient(); + const server = makeMailboxServer({ count: 1000 }); + fetchSpy.mockImplementation(server.handler as never); + + const destroyed = await client.emptyMailbox('mailbox-1'); + + expect(destroyed).toBe(1000); + expect(server.requests).toEqual([500, 500, 0]); + }); + + it('stops instead of looping forever when the server refuses to destroy', async () => { + const client = await connectedClient(); + const server = makeMailboxServer({ count: 1200, destroyFails: true }); + fetchSpy.mockImplementation(server.handler as never); + + const destroyed = await client.emptyMailbox('mailbox-1'); + + expect(destroyed).toBe(0); + expect(server.requests).toEqual([500]); + }); + + it('returns zero without extra requests for an already empty mailbox', async () => { + const client = await connectedClient(); + const server = makeMailboxServer({ count: 0 }); + fetchSpy.mockImplementation(server.handler as never); + + const destroyed = await client.emptyMailbox('mailbox-1'); + + expect(destroyed).toBe(0); + expect(server.requests).toEqual([0]); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 771eaed2..d97ab234 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1758,15 +1758,19 @@ export class JMAPClient implements IJMAPClient { async emptyMailbox(mailboxId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; + const batchSize = 500; let totalDestroyed = 0; - let hasMore = true; - while (hasMore) { + // Destroy in batches until the mailbox is empty. Never gate the loop on + // Email/query's `total`: it is only guaranteed when `calculateTotal` is + // requested, and Stalwart omits it otherwise, which used to stop the loop + // after the first batch and leave folders with >500 emails mostly intact. + while (true) { const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter: { inMailbox: mailboxId }, - limit: 500, + limit: batchSize, }, "0"], ["Email/set", { accountId: targetAccountId, @@ -1776,10 +1780,16 @@ export class JMAPClient implements IJMAPClient { const queryResult = response.methodResponses?.[0]?.[1]; const setResult = response.methodResponses?.[1]?.[1]; + const found: string[] = queryResult?.ids || []; const destroyed = setResult?.destroyed?.length || 0; totalDestroyed += destroyed; - hasMore = destroyed > 0 && (queryResult?.total || 0) > destroyed; + // Nothing left, or the server refused everything in this batch (missing + // permission, immutable mail) — stop instead of looping forever on the + // same ids. + if (found.length === 0 || destroyed === 0) break; + // A short page means we just handled the tail of the mailbox. + if (found.length < batchSize) break; } return totalDestroyed; From 348e032dce57bd60b1d1a85d5dffe63d4e2bd6a8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:41:02 +0200 Subject: [PATCH 39/42] fix: files show creation date instead of modification date #700 --- lib/demo/demo-client.ts | 8 ++--- lib/demo/fixtures/files.ts | 16 ++++----- lib/jmap/client.ts | 2 +- lib/jmap/types.ts | 6 +++- stores/__tests__/file-store.test.ts | 50 +++++++++++++++++++++++++---- stores/file-store.ts | 2 +- 6 files changed, 63 insertions(+), 21 deletions(-) diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 79b5fc67..f066c7fe 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -1046,7 +1046,7 @@ export class DemoJMAPClient implements IJMAPClient { const node: FileNode = { id: generateDemoId('file'), parentId, name, type: 'd', blobId: null, size: 0, - created: new Date().toISOString(), updated: new Date().toISOString(), + created: new Date().toISOString(), modified: new Date().toISOString(), }; this.data.fileNodes.push(node); return node; @@ -1056,7 +1056,7 @@ export class DemoJMAPClient implements IJMAPClient { const node: FileNode = { id: generateDemoId('file'), parentId, name, type, blobId, size, - created: new Date().toISOString(), updated: new Date().toISOString(), + created: new Date().toISOString(), modified: new Date().toISOString(), }; this.data.fileNodes.push(node); return node; @@ -1064,7 +1064,7 @@ export class DemoJMAPClient implements IJMAPClient { async updateFileNode(id: string, updates: Partial>): Promise { const node = this.data.fileNodes.find(n => n.id === id); - if (node) Object.assign(node, updates, { updated: new Date().toISOString() }); + if (node) Object.assign(node, updates, { modified: new Date().toISOString() }); } async updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }> { @@ -1072,7 +1072,7 @@ export class DemoJMAPClient implements IJMAPClient { for (const [id, patch] of Object.entries(updates)) { const node = this.data.fileNodes.find(n => n.id === id); if (node) { - Object.assign(node, patch, { updated: new Date().toISOString() }); + Object.assign(node, patch, { modified: new Date().toISOString() }); updated.push(id); } } diff --git a/lib/demo/fixtures/files.ts b/lib/demo/fixtures/files.ts index e4b3734c..59720d15 100644 --- a/lib/demo/fixtures/files.ts +++ b/lib/demo/fixtures/files.ts @@ -12,7 +12,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: null, size: 0, created: demoDate(-30), - updated: demoDate(-2), + modified: demoDate(-2), }, { id: 'demo-file-photos', @@ -22,7 +22,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: null, size: 0, created: demoDate(-30), - updated: demoDate(-5), + modified: demoDate(-5), }, // Documents contents @@ -34,7 +34,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-1', size: 2150, created: demoDate(-7), - updated: demoDate(-2), + modified: demoDate(-2), }, { id: 'demo-file-quarterly-report', @@ -44,7 +44,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-2', size: 148480, created: demoDate(-14), - updated: demoDate(-14), + modified: demoDate(-14), }, { id: 'demo-file-todo', @@ -54,7 +54,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-3', size: 410, created: demoDate(-3), - updated: demoDate(-1), + modified: demoDate(-1), }, // Photos contents @@ -66,7 +66,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-4', size: 1258291, created: demoDate(-10), - updated: demoDate(-10), + modified: demoDate(-10), }, { id: 'demo-file-team-photo', @@ -76,7 +76,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-5', size: 911360, created: demoDate(-21), - updated: demoDate(-21), + modified: demoDate(-21), }, // Root-level file @@ -88,7 +88,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-6', size: 68608, created: demoDate(-5), - updated: demoDate(-1), + modified: demoDate(-1), }, ]; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d97ab234..cacef5eb 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -5534,7 +5534,7 @@ export class JMAPClient implements IJMAPClient { } private static FILE_NODE_PROPERTIES = [ - "id", "parentId", "name", "type", "blobId", "size", "created", "updated", + "id", "parentId", "name", "type", "blobId", "size", "created", "modified", // Stalwart omits shareWith/myRights from FileNode/get unless requested // explicitly, so the share dialog and indicators can't see existing // shares without naming them here (same as CALENDAR_PROPERTIES). diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index e94dfba6..7ebdbfff 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -835,7 +835,11 @@ export interface FileNode { blobId: string | null; size: number; created: string; - updated: string; + // Last content/metadata change, server-maintained. The property is named + // `modified` in draft-ietf-jmap-filenode and in Stalwart - there is no + // `updated` on a FileNode. Asking for the wrong name silently yields + // undefined, which made the UI show the creation date forever (#700). + modified: string; // JMAP Sharing (RFC 9670). Populated only when the server advertises the // filenode capability and the properties are explicitly requested. A node is // shared-out when `shareWith` has entries; `myRights` describes what the diff --git a/stores/__tests__/file-store.test.ts b/stores/__tests__/file-store.test.ts index b22a4da1..7bf6151e 100644 --- a/stores/__tests__/file-store.test.ts +++ b/stores/__tests__/file-store.test.ts @@ -25,12 +25,12 @@ function makeMockClient(initial: FileNode[] = []) { }, async createFileDirectory(name: string, parentId: string | null) { // A real folder has no content blob (this is how Stalwart marks a container). - const node: FileNode = { id: `n${++seq}`, parentId, name, type: '', blobId: null, size: 0, created: now(), updated: now() }; + const node: FileNode = { id: `n${++seq}`, parentId, name, type: '', blobId: null, size: 0, created: now(), modified: now() }; nodes.push(node); return { ...node }; }, async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null) { - const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: now(), updated: now() }; + const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: now(), modified: now() }; nodes.push(node); return { ...node }; }, @@ -92,15 +92,15 @@ function makeMockClient(initial: FileNode[] = []) { } const dir = (id: string, name: string, parentId: string | null): FileNode => ({ - id, parentId, name, type: 'd', blobId: null, size: 0, created: '', updated: '', + id, parentId, name, type: 'd', blobId: null, size: 0, created: '', modified: '', }); // An old build's "folder": a directory-typed node that is actually a blob-backed // file, so the server won't let anything be parented under it. const marker = (id: string, name: string, parentId: string | null): FileNode => ({ - id, parentId, name, type: 'd', blobId: `b-${id}`, size: 0, created: '', updated: '', + id, parentId, name, type: 'd', blobId: `b-${id}`, size: 0, created: '', modified: '', }); const file = (id: string, name: string, parentId: string | null): FileNode => ({ - id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 10, created: '', updated: '', + id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 10, created: '', modified: '', }); describe('file-store hierarchy (issue #379)', () => { @@ -343,7 +343,7 @@ describe('file-store hierarchy (issue #379)', () => { // containers). The migration must detect this and undo its marker rename. (client as unknown as { createFileDirectory: typeof client.createFileDirectory }).createFileDirectory = async (name: string, parentId: string | null) => - ({ id: 'bad', parentId, name, type: 'd', blobId: 'b-bad', size: 0, created: '', updated: '' }); + ({ id: 'bad', parentId, name, type: 'd', blobId: 'b-bad', size: 0, created: '', modified: '' }); useFileStore.getState().initClient(client); expect(await useFileStore.getState().migrateLegacyFlatNodes()).toBe(false); @@ -373,3 +373,41 @@ describe('file-store hierarchy (issue #379)', () => { expect(client._nodes()).toHaveLength(0); }); }); + +describe('file-store modification date (issue #700)', () => { + beforeEach(() => { + useFileStore.setState({ + client: null, + currentParentId: null, + currentPath: '/', + pathStack: [{ id: null, name: '' }], + resources: [], + selectedResources: new Set(), + clipboard: null, + lastAction: null, + }); + }); + + it('shows the server-maintained `modified` date, not `created`', async () => { + // Stalwart names the property `modified` (draft-ietf-jmap-filenode); there + // is no `updated`. Asking for the wrong name used to leave lastModified + // pinned to the creation date, so replacing a file looked unmodified. + const client = makeMockClient([ + { ...file('notes', 'Notes.md', null), created: '2025-01-01T00:00:00Z', modified: '2026-07-30T12:00:00Z' }, + ]); + useFileStore.getState().initClient(client); + + await useFileStore.getState().navigate(null); + expect(useFileStore.getState().resources[0].lastModified).toBe('2026-07-30T12:00:00Z'); + }); + + it('falls back to `created` when the server sends no `modified`', async () => { + const client = makeMockClient([ + { ...file('notes', 'Notes.md', null), created: '2025-01-01T00:00:00Z', modified: '' }, + ]); + useFileStore.getState().initClient(client); + + await useFileStore.getState().navigate(null); + expect(useFileStore.getState().resources[0].lastModified).toBe('2025-01-01T00:00:00Z'); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index 28c255d7..ddd92314 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -174,7 +174,7 @@ function nodeToResource(node: FileNode): FileResource { isDirectory: isDir, contentType: isDir ? '' : node.type, contentLength: node.size, - lastModified: node.updated || node.created, + lastModified: node.modified || node.created, blobId: node.blobId, parentId: node.parentId, myRights: node.myRights, From e659fe3d3813437c5fd2367a9edb0bb3bcded928 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:44:36 +0200 Subject: [PATCH 40/42] feat: allow contact cards for organizations #701 --- .../contacts/__tests__/contact-form.test.tsx | 94 ++++++++++ components/contacts/contact-detail.tsx | 4 +- components/contacts/contact-form.tsx | 161 +++++++++++++----- lib/__tests__/vcard.test.ts | 42 +++++ lib/vcard.ts | 10 +- 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/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/common.json | 5 +- 29 files changed, 360 insertions(+), 71 deletions(-) diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index 75f4375b..6977cd68 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -102,4 +102,98 @@ describe('ContactForm', () => { expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })]) ); }); + + it('saves an organization-only card when the organization type is selected', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByText('type_organization')); + fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } }); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledOnce(); + }); + + const savedData = onSave.mock.calls[0][0]; + expect(savedData.kind).toBe('org'); + expect(savedData.organizations.o0.name).toBe('Acme Corp'); + // No personal name components; the org name carries the display name instead. + expect(savedData.name.components).toBeUndefined(); + expect(savedData.name.full).toBe('Acme Corp'); + }); + + it('hides the personal name fields in organization mode', () => { + render(); + expect(screen.getByPlaceholderText('given_name')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('type_organization')); + + expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('surname')).not.toBeInTheDocument(); + // The organization field moves into the identity section, so it appears once. + expect(screen.getAllByPlaceholderText('organization_placeholder')).toHaveLength(1); + }); + + it('still requires a name in organization mode', async () => { + const onSave = vi.fn(); + render(); + + fireEvent.click(screen.getByText('type_organization')); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => { + expect(screen.getByText('name_required')).toBeInTheDocument(); + }); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('accepts an organization instead of a personal name in person mode', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByText('section_work')); + fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } }); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledOnce(); + }); + expect(onSave.mock.calls[0][0].name.full).toBe('Acme Corp'); + }); + + it('opens an existing org card in organization mode', () => { + const orgContact: ContactCard = { + id: '2', + addressBookIds: {}, + kind: 'org', + name: { full: 'Acme Corp' }, + organizations: { o0: { name: 'Acme Corp' } }, + }; + render(); + + expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument(); + expect(screen.getByDisplayValue('Acme Corp')).toBeInTheDocument(); + }); + + it('switches an org card back to a person', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const orgContact: ContactCard = { + id: '2', + addressBookIds: {}, + kind: 'org', + name: { full: 'Acme Corp' }, + organizations: { o0: { name: 'Acme Corp' } }, + }; + render(); + + fireEvent.click(screen.getByText('type_person')); + fireEvent.change(screen.getByPlaceholderText('given_name'), { target: { value: 'Jane' } }); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledOnce(); + }); + expect(onSave.mock.calls[0][0].kind).toBe('individual'); + }); }); diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index c4160b7e..1b07d86e 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -171,7 +171,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli const hasNickname = nicknames.length > 0; const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined; - const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[]; + // On an organization card the org name is already the heading; don't repeat it. + const orgName = orgs[0]?.name; + const subtitleParts = [titleLine, orgName === name ? undefined : orgName].filter(Boolean) as string[]; const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0; const hasWork = titles.length > 0 || orgs.length > 0; const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns)); diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index a2e898eb..566d5301 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -266,6 +266,16 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : "" ); + // A card may describe an organization instead of a person (RFC 9553 kind "org"). + // Older cards predate the explicit kind, so fall back to "has an org name but no + // personal name". + const [isOrg, setIsOrg] = useState(() => { + if (!contact) return false; + if (contact.kind) return contact.kind === "org"; + const hasPersonName = !!(findComponent("given") || findComponent("surname")); + return !hasPersonName && !!Object.values(contact.organizations || {})[0]?.name; + }); + const [jobTitle, setJobTitle] = useState(() => { if (contact?.titles) { const t = Object.values(contact.titles).find(t => t.kind !== "role"); @@ -424,7 +434,10 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress e.preventDefault(); setError(null); - if (!givenName.trim() && !surname.trim()) { + // An organization name identifies the card just as well as a personal name. + const orgName = organization.trim(); + const hasPersonName = !!(givenName.trim() || surname.trim()); + if (isOrg ? !orgName : (!hasPersonName && !orgName)) { setError(t("name_required")); return; } @@ -461,11 +474,19 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress // Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly. const nameComponents = []; - if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() }); - if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() }); - if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() }); - if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() }); - if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() }); + if (!isOrg) { + if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() }); + if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() }); + if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() }); + if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() }); + if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() }); + } + + // Without personal name components, carry the organization name in `name.full` + // so servers and other clients have something to display. + const nameValue: ContactCard["name"] = nameComponents.length > 0 + ? { components: nameComponents, isOrdered: true } + : { full: orgName }; const titlesMap: Record = {}; if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" }; @@ -530,14 +551,20 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const mediaValue: Record | null | undefined = Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? null : undefined); + // Only send `kind` when this form owns the answer: switching a card between + // person and organization. Leave other kinds (group, location, ...) untouched. + const kindValue: ContactCard["kind"] | undefined = + isOrg ? "org" : (contact?.kind === "org" ? "individual" : undefined); + const data: Partial = { - name: { components: nameComponents, isOrdered: true }, + name: nameValue, + ...(kindValue ? { kind: kindValue } : {}), nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined, emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined, phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined, titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined, - organizations: organization.trim() - ? { o0: { name: organization.trim(), units: orgUnits } } + organizations: orgName + ? { o0: { name: orgName, units: orgUnits } } : undefined, addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined, onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined, @@ -570,7 +597,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress } }; - const previewName = [givenName, surname].filter(Boolean).join(" ").trim(); + const previewName = (isOrg ? "" : [givenName, surname].filter(Boolean).join(" ").trim()) || organization.trim(); const previewEmail = emails.find(e => e.address.trim())?.address.trim() || ""; return ( @@ -661,38 +688,81 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress )} -
-
- - setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" /> -
-
- - setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus /> -
-
- - setSurname(e.target.value)} placeholder={t("surname")} /> -
-
- - setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" /> +
+ {t("contact_type")} +
+ {[ + { org: false, label: t("type_person"), icon: User }, + { org: true, label: t("type_organization"), icon: Building }, + ].map(({ org, label, icon: Icon }) => ( + + ))}
-
-
- - setAdditionalName(e.target.value)} placeholder={t("middle_name")} /> + {isOrg ? ( +
+
+ + setOrganization(e.target.value)} placeholder={t("organization_placeholder")} autoFocus /> +
+
+ + setNickname(e.target.value)} placeholder={t("nickname_placeholder")} /> +
-
- - setNickname(e.target.value)} placeholder={t("nickname_placeholder")} /> -
-
+ ) : ( + <> +
+
+ + setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" /> +
+
+ + setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus /> +
+
+ + setSurname(e.target.value)} placeholder={t("surname")} /> +
+
+ + setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" /> +
+
+
+
+ + setAdditionalName(e.target.value)} placeholder={t("middle_name")} /> +
+
+ + setNickname(e.target.value)} placeholder={t("nickname_placeholder")} /> +
+
+ + )} {/* Email */} @@ -806,12 +876,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress {/* Work & Organization */} - +
-
- - setOrganization(e.target.value)} placeholder={t("organization_placeholder")} /> -
+ {/* In organization mode the org name is the card's identity, edited above. */} + {!isOrg && ( +
+ + setOrganization(e.target.value)} placeholder={t("organization_placeholder")} /> +
+ )}
setDepartment(e.target.value)} placeholder={t("department_placeholder")} /> diff --git a/lib/__tests__/vcard.test.ts b/lib/__tests__/vcard.test.ts index bc502023..a42cf63c 100644 --- a/lib/__tests__/vcard.test.ts +++ b/lib/__tests__/vcard.test.ts @@ -445,6 +445,48 @@ describe("generateVCard", () => { const vcf = generateVCard([contact]); expect(vcf).toContain("NOTE:Has comma\\, semicolon\\; and newline\\nhere"); }); + + it("uses the organization name as FN for organization cards (issue #701)", () => { + const contact: ContactCard = { + id: "c4", + addressBookIds: {}, + kind: "org", + name: { full: "Acme Corp" }, + organizations: { o0: { name: "Acme Corp" } }, + }; + + const vcf = generateVCard([contact]); + expect(vcf).toContain("KIND:org"); + expect(vcf).toContain("FN:Acme Corp"); + expect(vcf).toContain("ORG:Acme Corp"); + }); + + it("falls back to ORG for FN when the card has no name at all", () => { + const contact: ContactCard = { + id: "c5", + addressBookIds: {}, + kind: "org", + organizations: { o0: { name: "Acme Corp" } }, + }; + + expect(generateVCard([contact])).toContain("FN:Acme Corp"); + }); +}); + +describe("organization-only cards (issue #701)", () => { + it("keeps a vCard that has only an organization name", () => { + const parsed = parseVCard([ + "BEGIN:VCARD", + "VERSION:4.0", + "KIND:org", + "ORG:Acme Corp", + "END:VCARD", + ].join("\r\n")); + + expect(parsed).toHaveLength(1); + expect(parsed[0].kind).toBe("org"); + expect(parsed[0].organizations?.o0.name).toBe("Acme Corp"); + }); }); describe("round-trip: parse → generate → parse", () => { diff --git a/lib/vcard.ts b/lib/vcard.ts index efd592d4..96d5701a 100644 --- a/lib/vcard.ts +++ b/lib/vcard.ts @@ -844,7 +844,9 @@ function buildContact(raw: Record): ContactCard | null { const hasName = card.name && (card.name.components?.length ?? 0) > 0 || !!card.name?.full; const hasEmail = card.emails && Object.keys(card.emails).length > 0; - if (!hasName && !hasEmail && card.kind !== "group") return null; + // An organization name identifies the card just as well as a personal name. + const hasOrg = !!Object.values(card.organizations || {})[0]?.name; + if (!hasName && !hasEmail && !hasOrg && card.kind !== "group") return null; return card; } @@ -882,7 +884,11 @@ function generateSingleVCard(contact: ContactCard): string { const suffix = findKind("generation", "suffix"); const additional = findKind("given2", "additional", "middle"); - const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || ""; + // FN is mandatory in vCard, so fall back to the organization name for org cards. + const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") + || contact.name?.full + || Object.values(contact.organizations || {})[0]?.name + || ""; if (fn) { lines.push(`FN:${encodeValue(fn)}`); lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`); diff --git a/locales/ar/common.json b/locales/ar/common.json index 3a870a64..67c7a25f 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2376,6 +2376,9 @@ "section_address_book": "الدليل", "select_address_book": "اختر دليلًا...", "section_identity": "الاسم والهوية", + "contact_type": "نوع جهة الاتصال", + "type_person": "شخص", + "type_organization": "المؤسسة", "section_work": "العمل والمؤسسة", "prefix": "اللقب", "prefix_placeholder": "د.، أ.، السيدة", @@ -2460,7 +2463,7 @@ "cancel": "إلغاء", "creating": "جارٍ الإنشاء...", "updating": "جارٍ التحديث...", - "name_required": "يلزم إدخال الاسم الأول أو اسم العائلة على الأقل", + "name_required": "أدخل اسمًا أول أو اسم عائلة أو مؤسسة", "email_invalid": "يرجى إدخال عنوان بريد إلكتروني صالح", "email_error_inline": "تنسيق البريد الإلكتروني غير صالح", "save_failed": "فشل حفظ جهة الاتصال", diff --git a/locales/ca/common.json b/locales/ca/common.json index 533247b5..a2da0f2f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2376,6 +2376,9 @@ "section_address_book": "Directori", "select_address_book": "Seleccioneu un directori...", "section_identity": "Nom i identitat", + "contact_type": "Tipus de contacte", + "type_person": "Persona", + "type_organization": "Organització", "section_work": "Feina i organització", "prefix": "Prefix", "prefix_placeholder": "Dr., Sr., Sra.", @@ -2460,7 +2463,7 @@ "cancel": "Cancel·la", "creating": "Creant...", "updating": "Actualitzant...", - "name_required": "Cal com a mínim un nom o un cognom", + "name_required": "Introduïu un nom, un cognom o una organització", "email_invalid": "Introduïu una adreça electrònica vàlida", "email_error_inline": "Format de correu electrònic no vàlid", "save_failed": "No s'ha pogut desar el contacte", diff --git a/locales/cs/common.json b/locales/cs/common.json index 7249e8f5..3025a07d 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Adresář", "select_address_book": "Vyberte adresář...", "section_identity": "Jméno a identita", + "contact_type": "Typ kontaktu", + "type_person": "Osoba", + "type_organization": "Organizace", "section_work": "Práce a organizace", "prefix": "Titul", "prefix_placeholder": "Dr., Pan, Paní", @@ -2459,7 +2462,7 @@ "cancel": "Zrušit", "creating": "Vytváření...", "updating": "Aktualizování...", - "name_required": "Je vyžadováno alespoň jméno nebo příjmení", + "name_required": "Zadejte jméno, příjmení nebo organizaci", "email_invalid": "Zadejte platnou e-mailovou adresu", "email_error_inline": "Neplatný formát e-mailové adresy", "save_failed": "Uložení kontaktu selhalo", diff --git a/locales/da/common.json b/locales/da/common.json index d2f8c102..8503e48b 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Adressebog", "select_address_book": "Vælg en adressebog...", "section_identity": "Navn & identitet", + "contact_type": "Kontakttype", + "type_person": "Person", + "type_organization": "Organisation", "section_work": "Arbejde & organisation", "prefix": "Præfiks", "prefix_placeholder": "Dr., hr., fru", @@ -2459,7 +2462,7 @@ "cancel": "Annuller", "creating": "Opretter...", "updating": "Opdaterer...", - "name_required": "Mindst et fornavn eller efternavn er påkrævet", + "name_required": "Angiv et fornavn, efternavn eller en organisation", "email_invalid": "Indtast en gyldig e-mailadresse", "email_error_inline": "Ugyldigt e-mailformat", "save_failed": "Kunne ikke gemme kontakt", diff --git a/locales/de/common.json b/locales/de/common.json index c1311520..bfa1814d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Verzeichnis", "select_address_book": "Verzeichnis auswählen...", "section_identity": "Name & Identität", + "contact_type": "Kontakttyp", + "type_person": "Person", + "type_organization": "Organisation", "section_work": "Beruf & Organisation", "prefix": "Anrede", "prefix_placeholder": "Dr., Herr, Frau", @@ -2459,7 +2462,7 @@ "cancel": "Abbrechen", "creating": "Wird erstellt...", "updating": "Wird aktualisiert...", - "name_required": "Mindestens ein Vor- oder Nachname ist erforderlich", + "name_required": "Bitte Vorname, Nachname oder Organisation angeben", "email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein", "email_error_inline": "Ungültiges E-Mail-Format", "save_failed": "Kontakt konnte nicht gespeichert werden", diff --git a/locales/en/common.json b/locales/en/common.json index 96e19eb7..c05a71cb 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2376,6 +2376,9 @@ "section_address_book": "Directory", "select_address_book": "Select a directory...", "section_identity": "Name & Identity", + "contact_type": "Contact type", + "type_person": "Person", + "type_organization": "Organization", "section_work": "Work & Organization", "prefix": "Prefix", "prefix_placeholder": "Dr., Mr., Mrs.", @@ -2460,7 +2463,7 @@ "cancel": "Cancel", "creating": "Creating...", "updating": "Updating...", - "name_required": "At least a first name or last name is required", + "name_required": "Enter a first name, last name, or organization", "email_invalid": "Please enter a valid email address", "email_error_inline": "Invalid email format", "save_failed": "Failed to save contact", diff --git a/locales/es/common.json b/locales/es/common.json index ffeb9e38..25a53a64 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Directorio", "select_address_book": "Seleccionar un directorio...", "section_identity": "Nombre e identidad", + "contact_type": "Tipo de contacto", + "type_person": "Persona", + "type_organization": "Organización", "section_work": "Trabajo y organización", "prefix": "Prefijo", "prefix_placeholder": "Dr., Sr., Sra.", @@ -2459,7 +2462,7 @@ "cancel": "Cancelar", "creating": "Creando...", "updating": "Actualizando...", - "name_required": "Se requiere al menos un nombre o apellido", + "name_required": "Introduce un nombre, un apellido o una organización", "email_invalid": "Introduce una dirección de correo válida", "email_error_inline": "Formato de correo inválido", "save_failed": "Error al guardar el contacto", diff --git a/locales/fa/common.json b/locales/fa/common.json index 466d8394..c8f472bf 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2376,6 +2376,9 @@ "section_address_book": "دفترچه", "select_address_book": "انتخاب دفترچه...", "section_identity": "نام و هویت", + "contact_type": "نوع مخاطب", + "type_person": "شخص", + "type_organization": "سازمان", "section_work": "کار و سازمان", "prefix": "پیشوند", "prefix_placeholder": "دکتر، مهندس", @@ -2460,7 +2463,7 @@ "cancel": "انصراف", "creating": "در حال ایجاد...", "updating": "در حال به‌روزرسانی...", - "name_required": "حداقل نام یا نام خانوادگی الزامی است", + "name_required": "نام، نام خانوادگی یا سازمان را وارد کنید", "email_invalid": "لطفاً یک آدرس ایمیل معتبر وارد کنید", "email_error_inline": "فرمت ایمیل نامعتبر است", "save_failed": "ذخیره مخاطب ناموفق بود", diff --git a/locales/fr/common.json b/locales/fr/common.json index 06891007..e174bbc9 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Répertoire", "select_address_book": "Sélectionner un répertoire...", "section_identity": "Nom et identité", + "contact_type": "Type de contact", + "type_person": "Personne", + "type_organization": "Organisation", "section_work": "Travail et organisation", "prefix": "Préfixe", "prefix_placeholder": "Dr., M., Mme", @@ -2459,7 +2462,7 @@ "cancel": "Annuler", "creating": "Création...", "updating": "Mise à jour...", - "name_required": "Un prénom ou un nom est requis", + "name_required": "Saisissez un prénom, un nom ou une organisation", "email_invalid": "Veuillez saisir une adresse e-mail valide", "email_error_inline": "Format d'e-mail invalide", "save_failed": "Échec de l'enregistrement du contact", diff --git a/locales/he/common.json b/locales/he/common.json index a758742d..b250cd92 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2289,6 +2289,9 @@ "section_address_book": "ספרייה", "select_address_book": "בחר ספרייה...", "section_identity": "שם וזהות", + "contact_type": "סוג איש קשר", + "type_person": "אדם", + "type_organization": "ארגון", "section_work": "עבודה וארגון", "prefix": "קידומת", "prefix_placeholder": "ד\"ר, מר, גברת.", @@ -2373,7 +2376,7 @@ "cancel": "לְבַטֵל", "creating": "יוצר...", "updating": "מעדכן...", - "name_required": "נדרש לפחות שם פרטי או שם משפחה", + "name_required": "יש להזין שם פרטי, שם משפחה או ארגון", "email_invalid": "נא להזין כתובת אימייל חוקית", "email_error_inline": "פורמט אימייל לא חוקי", "save_failed": "שמירת איש הקשר נכשלה", diff --git a/locales/hu/common.json b/locales/hu/common.json index ed8bec08..f1320a7b 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2376,6 +2376,9 @@ "section_address_book": "Címtár", "select_address_book": "Címtár kiválasztása...", "section_identity": "Név és azonosság", + "contact_type": "Névjegy típusa", + "type_person": "Személy", + "type_organization": "Szervezet", "section_work": "Munka és szervezet", "prefix": "Előtag", "prefix_placeholder": "Dr., Úr., Mrs.", @@ -2460,7 +2463,7 @@ "cancel": "Mégse", "creating": "Létrehozás...", "updating": "Frissítés...", - "name_required": "Legalább a keresztnév vagy vezetéknév megadása kötelező", + "name_required": "Adjon meg egy keresztnevet, vezetéknevet vagy szervezetet", "email_invalid": "Kérjük, adj meg egy érvényes e-mail címet", "email_error_inline": "Érvénytelen e-mail formátum", "save_failed": "Nem sikerült menteni a névjegyet", diff --git a/locales/it/common.json b/locales/it/common.json index ee386f71..d469e1f8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Rubrica", "select_address_book": "Seleziona una rubrica...", "section_identity": "Nome e identità", + "contact_type": "Tipo di contatto", + "type_person": "Persona", + "type_organization": "Organizzazione", "section_work": "Lavoro e organizzazione", "prefix": "Prefisso", "prefix_placeholder": "Dott., Sig., Sig.ra", @@ -2459,7 +2462,7 @@ "cancel": "Annulla", "creating": "Creazione...", "updating": "Aggiornamento...", - "name_required": "È richiesto almeno un nome o cognome", + "name_required": "Inserisci un nome, un cognome o un'organizzazione", "email_invalid": "Inserisci un indirizzo email valido", "email_error_inline": "Formato email non valido", "save_failed": "Impossibile salvare il contatto", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0501ef06..36054845 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "ディレクトリ", "select_address_book": "ディレクトリを選択...", "section_identity": "名前と識別情報", + "contact_type": "連絡先の種類", + "type_person": "個人", + "type_organization": "組織", "section_work": "職業と組織", "prefix": "敬称", "prefix_placeholder": "博士、氏", @@ -2459,7 +2462,7 @@ "cancel": "キャンセル", "creating": "作成中...", "updating": "更新中...", - "name_required": "名前は必須です", + "name_required": "名、姓、または組織を入力してください", "email_invalid": "有効なメールアドレスを入力してください", "email_error_inline": "メールアドレスの形式が正しくありません", "save_failed": "連絡先の保存に失敗しました", diff --git a/locales/ko/common.json b/locales/ko/common.json index c6d9420a..85c57ab6 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "디렉터리", "select_address_book": "디렉터리 선택...", "section_identity": "이름 및 신원", + "contact_type": "연락처 유형", + "type_person": "개인", + "type_organization": "소속(회사)", "section_work": "직장 및 소속", "prefix": "호칭", "prefix_placeholder": "예: Dr., Mr., Mrs.", @@ -2459,7 +2462,7 @@ "cancel": "취소", "creating": "만드는 중...", "updating": "업데이트 중...", - "name_required": "이름이나 성 중에 하나는 꼭 필요해요", + "name_required": "이름, 성 또는 조직을 입력하세요", "email_invalid": "올바른 이메일 주소를 입력해 주세요", "email_error_inline": "이메일 형식이 잘못되었어요", "save_failed": "연락처를 저장하지 못했어요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 746dbc88..ab3f02b3 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2371,6 +2371,9 @@ "section_address_book": "Katalogs", "select_address_book": "Izvēlieties katalogu...", "section_identity": "Vārds un identitāte", + "contact_type": "Kontakta veids", + "type_person": "Persona", + "type_organization": "Organizācija", "section_work": "Darbs un organizācija", "prefix": "Prefikss", "prefix_placeholder": "Dr., kungs, kundze", @@ -2455,7 +2458,7 @@ "cancel": "Atcelt", "creating": "Izveido...", "updating": "Atjaunina...", - "name_required": "Nepieciešams vismaz vārds vai uzvārds", + "name_required": "Ievadiet vārdu, uzvārdu vai organizāciju", "email_invalid": "Ievadiet derīgu e-pasta adresi", "email_error_inline": "Nederīgs e-pasta formāts", "save_failed": "Neizdevās saglabāt kontaktu", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8048229b..6d6e1587 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Adresboek", "select_address_book": "Selecteer een adresboek...", "section_identity": "Naam en identiteit", + "contact_type": "Contacttype", + "type_person": "Persoon", + "type_organization": "Organisatie", "section_work": "Werk en organisatie", "prefix": "Voorvoegsel", "prefix_placeholder": "Dr., Dhr., Mevr.", @@ -2459,7 +2462,7 @@ "cancel": "Annuleren", "creating": "Aanmaken...", "updating": "Bijwerken...", - "name_required": "Ten minste een voor- of achternaam is vereist", + "name_required": "Voer een voornaam, achternaam of organisatie in", "email_invalid": "Voer een geldig e-mailadres in", "email_error_inline": "Ongeldig e-mailformaat", "save_failed": "Kon contact niet opslaan", diff --git a/locales/pl/common.json b/locales/pl/common.json index 9b0cf6ed..7d66f712 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Katalog", "select_address_book": "Wybierz katalog...", "section_identity": "Imię i tożsamość", + "contact_type": "Typ kontaktu", + "type_person": "Osoba", + "type_organization": "Organizacja", "section_work": "Praca i organizacja", "prefix": "Tytuł", "prefix_placeholder": "Dr, Pan, Pani", @@ -2459,7 +2462,7 @@ "cancel": "Anuluj", "creating": "Tworzenie...", "updating": "Aktualizowanie...", - "name_required": "Wymagane jest przynajmniej imię lub nazwisko", + "name_required": "Podaj imię, nazwisko lub organizację", "email_invalid": "Wprowadź prawidłowy adres e-mail", "email_error_inline": "Nieprawidłowy format adresu e-mail", "save_failed": "Nie udało się zapisać kontaktu", diff --git a/locales/pt/common.json b/locales/pt/common.json index 1d226b70..85512608 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Diretório", "select_address_book": "Selecionar um diretório...", "section_identity": "Nome e identidade", + "contact_type": "Tipo de contacto", + "type_person": "Pessoa", + "type_organization": "Organização", "section_work": "Trabalho e organização", "prefix": "Prefixo", "prefix_placeholder": "Dr., Sr., Sra.", @@ -2459,7 +2462,7 @@ "cancel": "Cancelar", "creating": "Criando...", "updating": "Atualizando...", - "name_required": "É necessário pelo menos um nome ou sobrenome", + "name_required": "Introduza um nome próprio, apelido ou organização", "email_invalid": "Por favor, insira um endereço de e-mail válido", "email_error_inline": "Formato de e-mail inválido", "save_failed": "Falha ao salvar contato", diff --git a/locales/ro/common.json b/locales/ro/common.json index 7a3ceb82..30ff81d0 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2376,6 +2376,9 @@ "section_address_book": "Director", "select_address_book": "Selectați un director...", "section_identity": "Nume și identitate", + "contact_type": "Tip de contact", + "type_person": "Persoană", + "type_organization": "Organizare", "section_work": "Muncă și organizare", "prefix": "Prefix", "prefix_placeholder": "Dr., Dl., Dna.", @@ -2460,7 +2463,7 @@ "cancel": "Anulează", "creating": "Se creează...", "updating": "Se actualizează...", - "name_required": "Este necesar cel puțin un prenume sau un nume de familie", + "name_required": "Introduceți un prenume, un nume sau o organizație", "email_invalid": "Vă rugăm să introduceți o adresă de e-mail validă", "email_error_inline": "Format de e-mail nevalid", "save_failed": "Nu s-a putut salva contactul", diff --git a/locales/ru/common.json b/locales/ru/common.json index 4dfe531f..15b160f1 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Каталог", "select_address_book": "Выберите каталог...", "section_identity": "Имя и личность", + "contact_type": "Тип контакта", + "type_person": "Человек", + "type_organization": "Организация", "section_work": "Работа и организация", "prefix": "Префикс", "prefix_placeholder": "Д-р., Г-н., Г-жа.", @@ -2459,7 +2462,7 @@ "cancel": "Отмена", "creating": "Создание...", "updating": "Обновление...", - "name_required": "Требуется хотя бы имя или фамилия", + "name_required": "Укажите имя, фамилию или организацию", "email_invalid": "Введите корректный адрес электронной почты", "email_error_inline": "Неверный формат email", "save_failed": "Не удалось сохранить контакт", diff --git a/locales/sk/common.json b/locales/sk/common.json index 288868d1..65ebcefe 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2376,6 +2376,9 @@ "section_address_book": "Adresár", "select_address_book": "Vyberte adresár...", "section_identity": "Meno a identita", + "contact_type": "Typ kontaktu", + "type_person": "Osoba", + "type_organization": "Organizácia", "section_work": "Práca a organizácia", "prefix": "Titul", "prefix_placeholder": "Dr., Pán, Pani", @@ -2460,7 +2463,7 @@ "cancel": "Zrušiť", "creating": "Vytváranie...", "updating": "Aktualizovanie...", - "name_required": "Je potrebné aspoň meno alebo priezvisko", + "name_required": "Zadajte meno, priezvisko alebo organizáciu", "email_invalid": "Zadajte platnú e-mailovú adresu", "email_error_inline": "Neplatný formát e-mailovej adresy", "save_failed": "Uloženie kontaktu zlyhalo", diff --git a/locales/tr/common.json b/locales/tr/common.json index 849e7609..9b0b1785 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Dizin", "select_address_book": "Bir dizin seçin...", "section_identity": "Ad ve Kimlik", + "contact_type": "Kişi türü", + "type_person": "Kişi", + "type_organization": "Kuruluş", "section_work": "İş ve Kuruluş", "prefix": "Ön Ek", "prefix_placeholder": "Dr., Bay, Bayan", @@ -2459,7 +2462,7 @@ "cancel": "İptal", "creating": "Oluşturuluyor...", "updating": "Güncelleniyor...", - "name_required": "En az bir ad veya soyadı gereklidir", + "name_required": "Bir ad, soyad veya kuruluş girin", "email_invalid": "Lütfen geçerli bir e-posta adresi girin", "email_error_inline": "Geçersiz e-posta biçimi", "save_failed": "Kişi kaydedilemedi", diff --git a/locales/uk/common.json b/locales/uk/common.json index 54e41245..bed8f2ed 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "Довідник", "select_address_book": "Виберіть каталог...", "section_identity": "Ім'я та ідентифікація", + "contact_type": "Тип контакту", + "type_person": "Людина", + "type_organization": "організація", "section_work": "Робота та організація", "prefix": "Префікс", "prefix_placeholder": "доктор, пан, місіс", @@ -2459,7 +2462,7 @@ "cancel": "Скасувати", "creating": "Створення...", "updating": "Оновлення...", - "name_required": "Потрібне принаймні ім’я або прізвище", + "name_required": "Вкажіть ім'я, прізвище або організацію", "email_invalid": "Введіть дійсну електронну адресу", "email_error_inline": "Недійсний формат електронної пошти", "save_failed": "Не вдалося зберегти контакт", diff --git a/locales/zh/common.json b/locales/zh/common.json index 014116f7..54475a38 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2375,6 +2375,9 @@ "section_address_book": "地址簿", "select_address_book": "选择地址簿...", "section_identity": "姓名和身份", + "contact_type": "联系人类型", + "type_person": "个人", + "type_organization": "组织", "section_work": "工作与组织", "prefix": "前缀", "prefix_placeholder": "博士、先生、女士", @@ -2459,7 +2462,7 @@ "cancel": "取消", "creating": "创建中...", "updating": "更新中...", - "name_required": "至少需要名字或姓氏", + "name_required": "请输入名字、姓氏或组织", "email_invalid": "请输入有效的邮箱地址", "email_error_inline": "邮箱地址格式无效", "save_failed": "保存联系人失败", From 1652a0ec62dc3054396bb05c780f3de4d384e062 Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Thu, 30 Jul 2026 22:11:40 +0200 Subject: [PATCH 41/42] fix: make bulwark respect server limits If you have a large number of tags, getTagCounts would not be able to get the unread counts because it did not respect maxCallsInRequest, even though the value was actually read out, it was just ignored. There are also other places where the limits were not respected. Batching is now generalized in a helper that also takes maxObjectsInSet, which also was ignored, into account and is applied to all functions. In addition, the dev mock now also advertises and enforces the limits, so these issues can get picked up during development. Possible closes #699 Possibly closes #399 --- app/api/dev-jmap/[...path]/route.ts | 49 +- components/layout/sidebar.tsx | 13 +- lib/__tests__/dev-jmap-mock.test.ts | 47 ++ lib/__tests__/jmap-request-limits.test.ts | 231 +++++++++ lib/demo/demo-client.ts | 3 +- lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 576 ++++++++++++---------- lib/jmap/request-limits.ts | 26 + 8 files changed, 685 insertions(+), 261 deletions(-) create mode 100644 lib/__tests__/jmap-request-limits.test.ts create mode 100644 lib/jmap/request-limits.ts diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index b39f9448..423fb0e8 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -2038,6 +2038,35 @@ function resolveBackReferences( }); } +// --------------------------------------------------------------------------- +// Request limits +// --------------------------------------------------------------------------- + +// Stalwart's defaults. Too many method calls fails the request whole +// (RFC 8620 §3.6.1), an over-sized /get or /set fails that call +// (`requestTooLarge`, §5.1 and §5.3). The mock enforces what it advertises so a +// client that sends an unsplit batch fails here the way it fails in production. +const MAX_CALLS_IN_REQUEST = 16; +const MAX_OBJECTS_IN_GET = 500; +const MAX_OBJECTS_IN_SET = 500; + +/** Objects a /set call touches, across all three of its maps (RFC 8620 §5.3). */ +function setObjectCount(args: MethodArgs): number { + const size = (value: unknown) => (Array.isArray(value) ? value.length : Object.keys(value || {}).length); + return size(args.create) + size(args.update) + size(args.destroy); +} + +/** The method-level error a server returns for an over-sized /get or /set. */ +function tooLargeFor(method: string, args: MethodArgs, callId: string): MethodResult | null { + if (method.endsWith('/get') && Array.isArray(args.ids) && args.ids.length > MAX_OBJECTS_IN_GET) { + return ['error', { type: 'requestTooLarge', description: `More than ${MAX_OBJECTS_IN_GET} ids in ${method}` }, callId]; + } + if (method.endsWith('/set') && setObjectCount(args) > MAX_OBJECTS_IN_SET) { + return ['error', { type: 'requestTooLarge', description: `More than ${MAX_OBJECTS_IN_SET} objects in ${method}` }, callId]; + } + return null; +} + // --------------------------------------------------------------------------- // Route handlers // --------------------------------------------------------------------------- @@ -2070,9 +2099,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, - maxCallsInRequest: 16, - maxObjectsInGet: 500, - maxObjectsInSet: 500, + maxCallsInRequest: MAX_CALLS_IN_REQUEST, + maxObjectsInGet: MAX_OBJECTS_IN_GET, + maxObjectsInSet: MAX_OBJECTS_IN_SET, collationAlgorithms: ['i;ascii-casemap', 'i;ascii-numeric', 'i;unicode-casemap'], }, 'urn:ietf:params:jmap:mail': {}, @@ -2218,6 +2247,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Invalid request: missing methodCalls' }, { status: 400 }); } + if (methodCalls.length > MAX_CALLS_IN_REQUEST) { + return NextResponse.json({ + type: 'urn:ietf:params:jmap:error:limit', + status: 400, + limit: 'maxCallsInRequest', + detail: `This request contains ${methodCalls.length} method calls, the maximum is ${MAX_CALLS_IN_REQUEST}.`, + }, { status: 400 }); + } + const responses: MethodResult[] = []; // Process method calls sequentially (to support back-references) @@ -2227,8 +2265,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Use resolved args if available, otherwise original const args = i < resolved.length ? resolved[i][1] : methodCalls[i][1]; + const tooLarge = tooLargeFor(method, args, callId); const handler = METHOD_HANDLERS[method]; - if (handler) { + if (tooLarge) { + responses.push(tooLarge); + } else if (handler) { const result = handler(args, callId); responses.push(result); } else { diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index b3aa1078..e85b42b0 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -949,15 +949,18 @@ export function Sidebar({ ? buildKeywordTree(emailKeywords) : emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 })); - // Counts arrive from a separate JMAP round trip; until they land, treat every - // "show if unread" tag as visible rather than blanking the section and - // filling it back in. - const tagCountsLoaded = Object.keys(tagCounts).length > 0; + // Counts arrive from a separate JMAP round trip, one batch per group of tags; + // a tag with no count yet is treated as visible rather than blanking it and + // filling it back in. A tag the server answered for with zero unread hides, + // which is the point of the setting. const isTagVisible = (node: KeywordNode) => { if (showAllTags || node.id === selectedKeyword) return true; const visibility = getKeywordVisibility(node); if (visibility === 'hide') return false; - if (visibility === 'unread') return !tagCountsLoaded || (tagCounts[node.id]?.unread ?? 0) > 0; + if (visibility === 'unread') { + const count = tagCounts[node.id]; + return !count || count.unread > 0; + } return true; }; const visibleTagTree = filterKeywordTree(tagTree, isTagVisible); diff --git a/lib/__tests__/dev-jmap-mock.test.ts b/lib/__tests__/dev-jmap-mock.test.ts index 75ba078b..0de022dd 100644 --- a/lib/__tests__/dev-jmap-mock.test.ts +++ b/lib/__tests__/dev-jmap-mock.test.ts @@ -232,6 +232,53 @@ describe('dev-jmap mock server', () => { }); }); + describe('POST /api - request limits', () => { + it('should refuse a request with more method calls than it advertises', async () => { + const methodCalls = Array.from({ length: 17 }, (_, i) => [ + 'Email/query', + { accountId: 'dev-account-001', limit: 0, calculateTotal: true }, + `c${i}`, + ]); + const req = makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ methodCalls }), + }); + const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) }); + const data = await res.json(); + expect(res.status).toBe(400); + expect(data.type).toBe('urn:ietf:params:jmap:error:limit'); + expect(data.limit).toBe('maxCallsInRequest'); + }); + + it('should reject an over-sized /set with requestTooLarge', async () => { + const destroy = Array.from({ length: 501 }, (_, i) => `email-${i}`); + const req = makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ methodCalls: [['Email/set', { accountId: 'dev-account-001', destroy }, '0']] }), + }); + const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) }); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data.methodResponses[0][0]).toBe('error'); + expect(data.methodResponses[0][1].type).toBe('requestTooLarge'); + }); + + it('should reject an over-sized /get with requestTooLarge', async () => { + const ids = Array.from({ length: 501 }, (_, i) => `email-${i}`); + const req = makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ methodCalls: [['Email/get', { accountId: 'dev-account-001', ids }, '0']] }), + }); + const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) }); + const data = await res.json(); + expect(data.methodResponses[0][0]).toBe('error'); + expect(data.methodResponses[0][1].type).toBe('requestTooLarge'); + }); + }); + describe('POST /upload', () => { it('should return a fake blob response', async () => { const req = makeRequest('http://localhost:3000/api/dev-jmap/upload/dev-account-001/', { diff --git a/lib/__tests__/jmap-request-limits.test.ts b/lib/__tests__/jmap-request-limits.test.ts new file mode 100644 index 00000000..61dc2170 --- /dev/null +++ b/lib/__tests__/jmap-request-limits.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; +import { batched, itemsPerRequest } from '../jmap/request-limits'; + +// Stalwart allows 16 method calls and 500 objects per request by default. A +// batch built from a list the user controls - tags, a multi-select, an import - +// reaches those ceilings with ordinary use, and going over fails the *whole* +// request: nine tags used to blank every tag badge in the sidebar. + +function makeSession(core: Record = {}) { + return { + capabilities: { 'urn:ietf:params:jmap:core': core }, + accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' }, + apiUrl: 'https://mail.example.com/jmap/api', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: 'https://mail.example.com/jmap/eventsource', + }; +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** RFC 8620 §3.6.1: an over-sized request is refused whole, before any method runs. */ +function limitErrorResponse(limit: string): Response { + return new Response( + JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', status: 400, limit }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); +} + +describe('batched', () => { + it('returns one batch when everything fits', () => { + expect(batched([1, 2, 3], 5)).toEqual([[1, 2, 3]]); + }); + + it('splits into consecutive batches of at most `size`', () => { + expect(batched([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); + + it('returns nothing for an empty list', () => { + expect(batched([], 10)).toEqual([]); + }); + + it('never produces an empty batch for a nonsensical size', () => { + expect(batched([1, 2], 0)).toEqual([[1], [2]]); + expect(batched([1, 2], -5)).toEqual([[1], [2]]); + }); +}); + +describe('itemsPerRequest', () => { + it('divides the call budget by the cost of one item', () => { + expect(itemsPerRequest(16, 2)).toBe(8); + expect(itemsPerRequest(16, 1)).toBe(16); + expect(itemsPerRequest(50, 3)).toBe(16); + }); + + it('always allows at least one item, however expensive', () => { + expect(itemsPerRequest(1, 2)).toBe(1); + }); +}); + +describe('JMAPClient request limits', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + async function connectedClient(core?: Record): Promise { + fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(core))); + const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com'); + await client.connect(); + fetchSpy.mockReset(); + return client; + } + + /** Records the method calls of every request the client makes. */ + function recordRequests(reply: (methodCalls: Array<[string, Record, string]>) => unknown) { + const sent: Array, string]>> = []; + fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => { + const body = JSON.parse(init.body as string); + sent.push(body.methodCalls); + return jsonResponse(reply(body.methodCalls)); + }) as never); + return sent; + } + + describe('getTagCounts', () => { + // Two Email/query calls per tag: nine tags is 18 calls against a ceiling of 16. + const tags = Array.from({ length: 9 }, (_, i) => `tag-${i}`); + + it('splits the tags so no request exceeds maxCallsInRequest', async () => { + const client = await connectedClient({ maxCallsInRequest: 16 }); + const sent = recordRequests((methodCalls) => ({ + methodResponses: methodCalls.map(([, , callId], i) => [ + 'Email/query', + { total: i + 1 }, + callId, + ]), + })); + + const counts = await client.getTagCounts(tags); + + expect(sent.map(calls => calls.length)).toEqual([16, 2]); + expect(Object.keys(counts)).toEqual(tags); + expect(counts['tag-8']).toEqual({ total: 1, unread: 2 }); + }); + + it('keeps the tags of the batches that did succeed when one is refused', async () => { + const client = await connectedClient({ maxCallsInRequest: 16 }); + let call = 0; + fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => { + const body = JSON.parse(init.body as string); + if (call++ === 0) return limitErrorResponse('maxCallsInRequest'); + return jsonResponse({ + methodResponses: body.methodCalls.map(([, , callId]: [string, unknown, string]) => [ + 'Email/query', { total: 7 }, callId, + ]), + }); + }) as never); + + const counts = await client.getTagCounts(tags); + + expect(Object.keys(counts)).toEqual(['tag-8']); + expect(counts['tag-8']).toEqual({ total: 7, unread: 7 }); + }); + + it('honours a lower ceiling advertised by the server', async () => { + const client = await connectedClient({ maxCallsInRequest: 4 }); + const sent = recordRequests((methodCalls) => ({ + methodResponses: methodCalls.map(([, , callId]) => ['Email/query', { total: 0 }, callId]), + })); + + await client.getTagCounts(tags); + + expect(sent.map(calls => calls.length)).toEqual([4, 4, 4, 4, 2]); + }); + }); + + describe('getCategoryUnreadCounts', () => { + it('splits the tabs across requests and keeps every tab id', async () => { + const client = await connectedClient({ maxCallsInRequest: 16 }); + const tabs = Array.from({ length: 20 }, (_, i) => ({ id: `tab-${i}`, filter: null })); + const sent = recordRequests((methodCalls) => ({ + methodResponses: methodCalls.map(([, , callId]) => ['Email/query', { total: 3 }, callId]), + })); + + const counts = await client.getCategoryUnreadCounts('inbox', tabs); + + expect(sent.map(calls => calls.length)).toEqual([16, 4]); + expect(Object.keys(counts)).toHaveLength(20); + expect(counts['tab-19']).toBe(3); + }); + }); + + describe('Email/set batches', () => { + const ids = Array.from({ length: 1200 }, (_, i) => `email-${i}`); + + it('splits batchDeleteEmails at maxObjectsInSet', async () => { + const client = await connectedClient({ maxObjectsInSet: 500 }); + const sent = recordRequests(() => ({ methodResponses: [['Email/set', { destroyed: [] }, '0']] })); + + await client.batchDeleteEmails(ids); + + expect(sent.map(calls => (calls[0][1].destroy as string[]).length)).toEqual([500, 500, 200]); + }); + + it('splits batchMarkAsRead at maxObjectsInSet', async () => { + const client = await connectedClient({ maxObjectsInSet: 500 }); + const sent = recordRequests(() => ({ methodResponses: [['Email/set', { updated: {} }, '0']] })); + + await client.batchMarkAsRead(ids, true); + + const updated = sent.flatMap(calls => Object.keys(calls[0][1].update as object)); + expect(sent).toHaveLength(3); + expect(updated).toEqual(ids); + }); + + it('splits batchMoveEmails at a ceiling the server lowered', async () => { + const client = await connectedClient({ maxObjectsInSet: 100 }); + const sent = recordRequests(() => ({ methodResponses: [['Email/set', { updated: {} }, '0']] })); + + await client.batchMoveEmails(ids, 'mailbox-2'); + + expect(sent).toHaveLength(12); + expect(Object.keys(sent[0][0][1].update as object)).toHaveLength(100); + }); + }); + + describe('Email/get batches', () => { + it('splits getSomeEmails at maxObjectsInGet and returns every message', async () => { + const client = await connectedClient({ maxObjectsInGet: 500 }); + const sent = recordRequests((methodCalls) => ({ + methodResponses: [[ + 'Email/get', + { + list: (methodCalls[0][1].ids as string[]).map(id => ({ + id, + receivedAt: '2026-03-14T10:00:00Z', + })), + }, + '0', + ]], + })); + + const emails = await client.getSomeEmails(Array.from({ length: 1100 }, (_, i) => `email-${i}`)); + + expect(sent.map(calls => (calls[0][1].ids as string[]).length)).toEqual([500, 500, 100]); + expect(emails).toHaveLength(1100); + }); + }); + + it('falls back to the documented defaults when the session advertises no limits', async () => { + const client = await connectedClient(); + + expect(client.getMaxObjectsInGet()).toBe(500); + expect(client.getMaxObjectsInSet()).toBe(500); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index f066c7fe..e7b42fc5 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -56,7 +56,7 @@ export class DemoJMAPClient implements IJMAPClient { getCapabilities(): Record { return { - 'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 }, + 'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500 }, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } }, 'urn:ietf:params:jmap:vacationresponse': {}, @@ -71,6 +71,7 @@ export class DemoJMAPClient implements IJMAPClient { getMaxSizeUpload(): number { return 50_000_000; } getMaxCallsInRequest(): number { return 16; } getMaxObjectsInGet(): number { return 500; } + getMaxObjectsInSet(): number { return 500; } getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; } hasDelayedSend(): boolean { return true; } getEventSourceUrl(): string | null { return null; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 35e6f1ad..2693f3d8 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -31,6 +31,7 @@ export interface IJMAPClient { getMaxSizeUpload(): number; getMaxCallsInRequest(): number; getMaxObjectsInGet(): number; + getMaxObjectsInSet(): number; getMaxDelayedSend(accountId?: string): number; hasDelayedSend(accountId?: string): boolean; getEventSourceUrl(): string | null; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index cacef5eb..63e368fa 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2,6 +2,7 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, Emai import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; +import { batched, itemsPerRequest } from "./request-limits"; import { debug } from "@/lib/debug"; import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization"; @@ -606,31 +607,32 @@ export class JMAPClient implements IJMAPClient { return []; } - const response = await this.request([ - ["Email/get", { - accountId: targetAccountId, - ids: emailsId, - properties: [...EMAIL_LIST_PROPERTIES], - }, "0"], - ]); + const emails: Email[] = []; - const getResponse = response.methodResponses?.[0]?.[1]; + for (const batchIds of batched(emailsId, this.getMaxObjectsInGet())) { + const response = await this.request([ + ["Email/get", { + accountId: targetAccountId, + ids: batchIds, + properties: [...EMAIL_LIST_PROPERTIES], + }, "0"], + ]); - if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) { - const emails = (getResponse.list || []) as Email[]; - - emails.sort((a: Email, b: Email) => - new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() - ); - - if (accountId && accountId !== this.accountId) { - namespaceMailboxIds(emails, accountId); + const getResponse = response.methodResponses?.[0]?.[1]; + if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) { + emails.push(...((getResponse.list || []) as Email[])); } - - return emails; } - return []; + emails.sort((a: Email, b: Email) => + new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); + + if (accountId && accountId !== this.accountId) { + namespaceMailboxIds(emails, accountId); + } + + return emails; } catch (error) { console.error('Failed to get specific emails:', error); return []; @@ -1266,56 +1268,62 @@ export class JMAPClient implements IJMAPClient { async getTagCounts(tagIds: string[]): Promise> { if (tagIds.length === 0) return {}; - try { - const methodCalls: JMAPMethodCall[] = []; - for (let i = 0; i < tagIds.length; i++) { - const keyword = `$label:${tagIds[i]}`; - // Total count for this tag - methodCalls.push(["Email/query", { - accountId: this.accountId, - filter: { hasKeyword: keyword }, - limit: 0, - calculateTotal: true, - }, `total_${i}`]); - // Unread count for this tag - methodCalls.push(["Email/query", { - accountId: this.accountId, - filter: { - operator: "AND", - conditions: [ - { hasKeyword: keyword }, - { notKeyword: "$seen" }, - ], - }, - limit: 0, - calculateTotal: true, - }, `unread_${i}`]); + const result: Record = {}; + + const CALLS_PER_TAG = 2; + const perRequest = itemsPerRequest(this.getMaxCallsInRequest(), CALLS_PER_TAG); + + for (const batch of batched(tagIds, perRequest)) { + try { + const methodCalls: JMAPMethodCall[] = []; + for (let i = 0; i < batch.length; i++) { + const keyword = `$label:${batch[i]}`; + // Total count for this tag + methodCalls.push(["Email/query", { + accountId: this.accountId, + filter: { hasKeyword: keyword }, + limit: 0, + calculateTotal: true, + }, `total_${i}`]); + // Unread count for this tag + methodCalls.push(["Email/query", { + accountId: this.accountId, + filter: { + operator: "AND", + conditions: [ + { hasKeyword: keyword }, + { notKeyword: "$seen" }, + ], + }, + limit: 0, + calculateTotal: true, + }, `unread_${i}`]); + } + + const response = await this.request(methodCalls); + + for (let i = 0; i < batch.length; i++) { + const totalResp = response.methodResponses?.[i * 2]?.[1]; + const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1]; + result[batch[i]] = { + total: totalResp?.total ?? 0, + unread: unreadResp?.total ?? 0, + }; + } + } catch (error) { + console.error('Failed to get tag counts:', error); } - - const response = await this.request(methodCalls); - const result: Record = {}; - - for (let i = 0; i < tagIds.length; i++) { - const totalResp = response.methodResponses?.[i * 2]?.[1]; - const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1]; - result[tagIds[i]] = { - total: totalResp?.total ?? 0, - unread: unreadResp?.total ?? 0, - }; - } - - return result; - } catch (error) { - console.error('Failed to get tag counts:', error); - return {}; } + + return result; } /** * Per-tab unread counts for message-list category tabs. One Email/query - * (limit 0, calculateTotal) per tab, batched in a single request. Each - * entry's `filter` is the tab's resolved FilterCondition/FilterOperator - * (null = no extra condition, i.e. all unread in the mailbox). + * (limit 0, calculateTotal) per tab, batched into as few requests as the + * server's method-call ceiling allows. Each entry's `filter` is the tab's + * resolved FilterCondition/FilterOperator (null = no extra condition, i.e. + * all unread in the mailbox). */ async getCategoryUnreadCounts( mailboxId: string, @@ -1324,31 +1332,34 @@ export class JMAPClient implements IJMAPClient { ): Promise> { if (tabs.length === 0) return {}; const targetAccountId = accountId || this.accountId; - try { - const methodCalls: JMAPMethodCall[] = tabs.map((tab, i) => { - const conditions: Record[] = [ - { inMailbox: mailboxId }, - { notKeyword: "$seen" }, - ]; - if (tab.filter) conditions.push(tab.filter); - return ["Email/query", { - accountId: targetAccountId, - filter: { operator: "AND", conditions }, - limit: 0, - calculateTotal: true, - }, `tab_${i}`]; - }); + const result: Record = {}; - const response = await this.request(methodCalls); - const result: Record = {}; - for (let i = 0; i < tabs.length; i++) { - result[tabs[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0; + for (const batch of batched(tabs, this.getMaxCallsInRequest())) { + try { + const methodCalls: JMAPMethodCall[] = batch.map((tab, i) => { + const conditions: Record[] = [ + { inMailbox: mailboxId }, + { notKeyword: "$seen" }, + ]; + if (tab.filter) conditions.push(tab.filter); + return ["Email/query", { + accountId: targetAccountId, + filter: { operator: "AND", conditions }, + limit: 0, + calculateTotal: true, + }, `tab_${i}`]; + }); + + const response = await this.request(methodCalls); + for (let i = 0; i < batch.length; i++) { + result[batch[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0; + } + } catch (error) { + console.error('Failed to get category tab counts:', error); } - return result; - } catch (error) { - console.error('Failed to get category tab counts:', error); - return {}; } + + return result; } async getEmail(emailId: string, accountId?: string): Promise { @@ -1464,10 +1475,12 @@ export class JMAPClient implements IJMAPClient { async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise { if (emailIds.length === 0) return; - const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }])); - await this.request([ - ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"], - ]); + for (const batch of batched(emailIds, this.getMaxObjectsInSet())) { + const updates = Object.fromEntries(batch.map(id => [id, { "keywords/$seen": read }])); + await this.request([ + ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"], + ]); + } } async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise { @@ -1529,10 +1542,12 @@ export class JMAPClient implements IJMAPClient { */ async batchUpdateKeywords(emailIds: string[], patch: Record, accountId?: string): Promise { if (emailIds.length === 0 || Object.keys(patch).length === 0) return; - const update = Object.fromEntries(emailIds.map(id => [id, { ...patch }])); - await this.request([ - ["Email/set", { accountId: accountId || this.accountId, update }, "0"], - ]); + for (const batch of batched(emailIds, this.getMaxObjectsInSet())) { + const update = Object.fromEntries(batch.map(id => [id, { ...patch }])); + await this.request([ + ["Email/set", { accountId: accountId || this.accountId, update }, "0"], + ]); + } } async migrateKeyword(oldKeyword: string, newKeyword: string): Promise { @@ -1562,9 +1577,7 @@ export class JMAPClient implements IJMAPClient { if (allIds.length === 0) return 0; // Batch update: remove old keyword, add new keyword using per-property patches - const updateBatchSize = 50; - for (let i = 0; i < allIds.length; i += updateBatchSize) { - const batch = allIds.slice(i, i + updateBatchSize); + for (const batch of batched(allIds, this.getMaxObjectsInSet())) { const update: Record> = {}; for (const id of batch) { update[id] = { @@ -1608,12 +1621,14 @@ export class JMAPClient implements IJMAPClient { async batchDeleteEmails(emailIds: string[], accountId?: string): Promise { if (emailIds.length === 0) return; - await this.request([ - ["Email/set", { - accountId: accountId || this.accountId, - destroy: emailIds, - }, "0"], - ]); + for (const batch of batched(emailIds, this.getMaxObjectsInSet())) { + await this.request([ + ["Email/set", { + accountId: accountId || this.accountId, + destroy: batch, + }, "0"], + ]); + } } async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise { @@ -1624,10 +1639,12 @@ export class JMAPClient implements IJMAPClient { if (markAsRead) patch["keywords/$seen"] = true; return patch; }; - const updates = Object.fromEntries(emailIds.map(id => [id, buildPatch()])); - await this.request([ - ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"], - ]); + for (const batch of batched(emailIds, this.getMaxObjectsInSet())) { + const updates = Object.fromEntries(batch.map(id => [id, buildPatch()])); + await this.request([ + ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"], + ]); + } } async batchArchiveEmails( @@ -1705,35 +1722,59 @@ export class JMAPClient implements IJMAPClient { updates[emailId] = { mailboxIds: { [destId]: true } }; } - const methodCalls: JMAPMethodCall[] = []; + // Creation ids are scoped to the request that introduced them (RFC 8620 + // §3.3), so "#" only resolves in the request carrying the Mailbox/set: + // the folders are created alongside the first batch of messages, and the + // ids they were assigned are substituted into every later batch. + const updateBatches = batched(Object.entries(updates), this.getMaxObjectsInSet()); const hasCreates = Object.keys(createEntries).length > 0; - if (hasCreates) { - methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']); - } - methodCalls.push(['Email/set', { accountId: targetAccountId, update: updates }, String(methodCalls.length)]); + let createdIdFor: Record = {}; - const response = await this.request(methodCalls); + for (let i = 0; i < updateBatches.length; i++) { + const batch: Array<[string, { mailboxIds: Record }]> = i === 0 + ? updateBatches[i] + : updateBatches[i].map(([emailId, patch]) => { + const [destId] = Object.keys(patch.mailboxIds); + const resolved = createdIdFor[destId]; + return [emailId, resolved ? { mailboxIds: { [resolved]: true } as Record } : patch]; + }); - if (hasCreates) { - const mailboxResult = response.methodResponses?.[0]?.[1]; - const notCreated = mailboxResult?.notCreated as Record | undefined; - const failures = notCreated ? Object.entries(notCreated) : []; - if (failures.length > 0) { - const [cid, err] = failures[0]; - const parts = [err.type || 'unknown']; - if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`); - if (err.description) parts.push(err.description); - throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`); + const methodCalls: JMAPMethodCall[] = []; + const withCreates = hasCreates && i === 0; + if (withCreates) { + methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']); } - } + methodCalls.push(['Email/set', { accountId: targetAccountId, update: Object.fromEntries(batch) }, String(methodCalls.length)]); - const emailIdx = hasCreates ? 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})` : ''}`); + const response = await this.request(methodCalls); + + if (withCreates) { + const mailboxResult = response.methodResponses?.[0]?.[1]; + const notCreated = mailboxResult?.notCreated as Record | undefined; + const failures = notCreated ? Object.entries(notCreated) : []; + if (failures.length > 0) { + const [cid, err] = failures[0]; + const parts = [err.type || 'unknown']; + if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`); + if (err.description) parts.push(err.description); + throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`); + } + const created = (mailboxResult?.created || {}) as Record; + createdIdFor = Object.fromEntries( + Object.entries(created) + .filter(([, mailbox]) => !!mailbox?.id) + .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})` : ''}`); + } } } @@ -1758,7 +1799,7 @@ export class JMAPClient implements IJMAPClient { async emptyMailbox(mailboxId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; - const batchSize = 500; + const batchSize = Math.min(500, this.getMaxObjectsInSet()); let totalDestroyed = 0; // Destroy in batches until the mailbox is empty. Never gate the loop on @@ -1797,6 +1838,7 @@ export class JMAPClient implements IJMAPClient { async markMailboxAsRead(mailboxId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; + const pageSize = Math.min(500, this.getMaxObjectsInSet()); let totalMarked = 0; let hasMore = true; @@ -1811,7 +1853,7 @@ export class JMAPClient implements IJMAPClient { { notKeyword: "$seen" }, ], }, - limit: 500, + limit: pageSize, }, "0"], ]); @@ -1827,7 +1869,7 @@ export class JMAPClient implements IJMAPClient { ]); totalMarked += ids.length; - hasMore = ids.length === 500; + hasMore = ids.length === pageSize; } return totalMarked; @@ -1836,6 +1878,7 @@ export class JMAPClient implements IJMAPClient { async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise { const targetAccountId = accountId || this.accountId; const excludeSet = new Set(excludeMailboxIds); + const pageSize = Math.min(500, this.getMaxObjectsInGet(), this.getMaxObjectsInSet()); let totalMarked = 0; let hasMore = true; let position = 0; @@ -1845,7 +1888,7 @@ export class JMAPClient implements IJMAPClient { ["Email/query", { accountId: targetAccountId, filter: { notKeyword: "$seen" }, - limit: 500, + limit: pageSize, position, }, "0"], ["Email/get", { @@ -1881,7 +1924,7 @@ export class JMAPClient implements IJMAPClient { totalMarked += targetIds.length; } - hasMore = ids.length === 500; + hasMore = ids.length === pageSize; position += ids.length; } @@ -2193,14 +2236,19 @@ export class JMAPClient implements IJMAPClient { if (threadIds.length === 0) return []; try { const targetAccountId = accountId || this.accountId; - const response = await this.request([ - ["Thread/get", { accountId: targetAccountId, ids: threadIds }, "0"], - ]); + const threads: Thread[] = []; - if (response.methodResponses?.[0]?.[0] === "Thread/get") { - return (response.methodResponses[0][1].list || []) as Thread[]; + for (const batchIds of batched(threadIds, this.getMaxObjectsInGet())) { + const response = await this.request([ + ["Thread/get", { accountId: targetAccountId, ids: batchIds }, "0"], + ]); + + if (response.methodResponses?.[0]?.[0] === "Thread/get") { + threads.push(...((response.methodResponses[0][1].list || []) as Thread[])); + } } - return []; + + return threads; } catch (error) { console.error('Failed to get threads:', error); return []; @@ -2215,26 +2263,32 @@ export class JMAPClient implements IJMAPClient { return []; } - const response = await this.request([ - ["Email/get", { - accountId: targetAccountId, - ids: thread.emailIds, - properties: [ - ...EMAIL_LIST_PROPERTIES, - "textBody", "htmlBody", "bodyValues", - "attachments", "blobId", "sentAt", "bcc", "replyTo", - "messageId", "inReplyTo", "references", "headers", "bodyStructure", - ], - fetchTextBodyValues: true, - fetchHTMLBodyValues: true, - fetchAllBodyValues: true, - maxBodyValueBytes: 256000, - }, "0"], - ]); + const emails: Email[] = []; - if (response.methodResponses?.[0]?.[0] === "Email/get") { - const emails = response.methodResponses[0][1].list || []; + for (const batchIds of batched(thread.emailIds, this.getMaxObjectsInGet())) { + const response = await this.request([ + ["Email/get", { + accountId: targetAccountId, + ids: batchIds, + properties: [ + ...EMAIL_LIST_PROPERTIES, + "textBody", "htmlBody", "bodyValues", + "attachments", "blobId", "sentAt", "bcc", "replyTo", + "messageId", "inReplyTo", "references", "headers", "bodyStructure", + ], + fetchTextBodyValues: true, + fetchHTMLBodyValues: true, + fetchAllBodyValues: true, + maxBodyValueBytes: 256000, + }, "0"], + ]); + if (response.methodResponses?.[0]?.[0] === "Email/get") { + emails.push(...(response.methodResponses[0][1].list || [])); + } + } + + if (emails.length > 0) { if (accountId && accountId !== this.accountId) { namespaceMailboxIds(emails, accountId); } @@ -3606,6 +3660,11 @@ export class JMAPClient implements IJMAPClient { return coreCapability?.maxObjectsInGet || 500; } + getMaxObjectsInSet(): number { + const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInSet?: number } | undefined; + return coreCapability?.maxObjectsInSet || 500; + } + getMaxDelayedSend(accountId?: string): number { const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend; return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0; @@ -5060,38 +5119,41 @@ export class JMAPClient implements IJMAPClient { const accountId = targetAccountId || this.getCalendarsAccountId(); - // Build the create map: { "new-0": event0, "new-1": event1, ... } - const createMap: Record> = {}; - for (let i = 0; i < events.length; i++) { - const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent; - cleanRecurrenceRules(clean as unknown as Record); - createMap[`new-${i}`] = clean; - } - debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId }); - // Never emit iMIP scheduling messages when importing. Imported events often - // carry an organizer/participants where the current user is the organizer; - // without this, Stalwart tries to send invitation emails to every attendee - // synchronously during CalendarEvent/set, which is both wrong (importing a - // calendar should not spam invites) and can block the request indefinitely, - // leaving the import spinner spinning forever (#411). - const response = await this.request([ - ["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"] - ], this.calendarUsing()); - const createdIds: string[] = []; const failed: string[] = []; + const indexed = events.map((event, index) => ({ event, index })); - if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { - const result = response.methodResponses[0][1]; - for (let i = 0; i < events.length; i++) { - const key = `new-${i}`; - if (result.created?.[key]?.id) { - createdIds.push(result.created[key].id); - } else if (result.notCreated?.[key]) { - debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]); - failed.push(key); + for (const batch of batched(indexed, this.getMaxObjectsInSet())) { + // Build the create map: { "new-0": event0, "new-1": event1, ... } + const createMap: Record> = {}; + for (const { event, index } of batch) { + const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = event as CalendarEvent; + cleanRecurrenceRules(clean as unknown as Record); + createMap[`new-${index}`] = clean; + } + + // Never emit iMIP scheduling messages when importing. Imported events often + // carry an organizer/participants where the current user is the organizer; + // without this, Stalwart tries to send invitation emails to every attendee + // synchronously during CalendarEvent/set, which is both wrong (importing a + // calendar should not spam invites) and can block the request indefinitely, + // leaving the import spinner spinning forever (#411). + const response = await this.request([ + ["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"] + ], this.calendarUsing()); + + if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { + const result = response.methodResponses[0][1]; + for (const { index } of batch) { + const key = `new-${index}`; + if (result.created?.[key]?.id) { + createdIds.push(result.created[key].id); + } else if (result.notCreated?.[key]) { + debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]); + failed.push(key); + } } } } @@ -5100,21 +5162,24 @@ export class JMAPClient implements IJMAPClient { return { created: [], failed }; } - // Fetch all created events in a single CalendarEvent/get + // Fetch the created events back for their server-assigned properties const refetchTimeZone = getUserTimeZone(); - const getResponse = await this.request([ - ["CalendarEvent/get", { - accountId, - properties: [...CALENDAR_EVENT_PROPERTIES], - ids: createdIds, - ...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}), - }, "0"] - ], this.calendarUsing()); + const createdEvents: CalendarEvent[] = []; - let createdEvents: CalendarEvent[] = []; - if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { - const list = getResponse.methodResponses[0][1].list || []; - createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e)); + for (const batchIds of batched(createdIds, this.getMaxObjectsInGet())) { + const getResponse = await this.request([ + ["CalendarEvent/get", { + accountId, + properties: [...CALENDAR_EVENT_PROPERTIES], + ids: batchIds, + ...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}), + }, "0"] + ], this.calendarUsing()); + + if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { + const list = getResponse.methodResponses[0][1].list || []; + createdEvents.push(...list.map((e: CalendarEvent) => normalizeCalendarEventLike(e))); + } } debug.log('calendar', 'CalendarEvent/batchCreate result', { @@ -5265,17 +5330,19 @@ export class JMAPClient implements IJMAPClient { if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] }; const accountId = targetAccountId || this.getCalendarsAccountId(); - const response = await this.request([ - ["CalendarEvent/set", { accountId, destroy: eventIds }, "0"] - ], this.calendarUsing()); - const destroyed: string[] = []; const notDestroyed: string[] = []; - if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { - const result = response.methodResponses[0][1]; - if (result.destroyed) destroyed.push(...result.destroyed); - if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed)); + for (const batch of batched(eventIds, this.getMaxObjectsInSet())) { + const response = await this.request([ + ["CalendarEvent/set", { accountId, destroy: batch }, "0"] + ], this.calendarUsing()); + + if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { + const result = response.methodResponses[0][1]; + if (result.destroyed) destroyed.push(...result.destroyed); + if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed)); + } } return { destroyed, notDestroyed }; @@ -5788,62 +5855,69 @@ export class JMAPClient implements IJMAPClient { * throws for per-node failures (only for a whole-method error). */ async updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }> { - const ids = Object.keys(updates); - if (ids.length === 0) return { updated: [], notUpdated: {} }; + const entries = Object.entries(updates); + if (entries.length === 0) return { updated: [], notUpdated: {} }; const accountId = this.getFilesAccountId(); - const response = await this.request( - [["FileNode/set", { accountId, update: updates }, "fns0"]], - this.fileUsing(), - ); - - const result = response.methodResponses?.[0]; - if (!result || result[0] === "error") { - throw new Error(result?.[1]?.description || "FileNode/set update failed"); - } - - const updatedMap: Record = result[1].updated || {}; - const notUpdatedMap: Record = result[1].notUpdated || {}; + const updated: string[] = []; const notUpdated: Record = {}; - for (const id of Object.keys(notUpdatedMap)) { - notUpdated[id] = notUpdatedMap[id]?.description || 'not updated'; + + for (const batch of batched(entries, this.getMaxObjectsInSet())) { + const response = await this.request( + [["FileNode/set", { accountId, update: Object.fromEntries(batch) }, "fns0"]], + this.fileUsing(), + ); + + const result = response.methodResponses?.[0]; + if (!result || result[0] === "error") { + throw new Error(result?.[1]?.description || "FileNode/set update failed"); + } + + const updatedMap: Record = result[1].updated || {}; + const notUpdatedMap: Record = result[1].notUpdated || {}; + for (const id of Object.keys(notUpdatedMap)) { + notUpdated[id] = notUpdatedMap[id]?.description || 'not updated'; + } + // Servers may omit the `updated` map; treat anything not rejected as updated. + updated.push(...(Object.keys(updatedMap).length > 0 + ? Object.keys(updatedMap) + : batch.map(([id]) => id).filter(id => !(id in notUpdated)))); } - // Servers may omit the `updated` map; treat anything not rejected as updated. - const updated = Object.keys(updatedMap).length > 0 - ? Object.keys(updatedMap) - : ids.filter(id => !(id in notUpdated)); + return { updated, notUpdated }; } async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> { const accountId = this.getFilesAccountId(); + const destroyed: string[] = []; - const response = await this.request( - [["FileNode/set", { - accountId, - destroy: ids, - onDestroyRemoveChildren: true, - }, "fns0"]], - this.fileUsing(), - ); + for (const batch of batched(ids, this.getMaxObjectsInSet())) { + const response = await this.request( + [["FileNode/set", { + accountId, + destroy: batch, + onDestroyRemoveChildren: true, + }, "fns0"]], + this.fileUsing(), + ); - const result = response.methodResponses?.[0]; - if (!result || result[0] === "error") { - throw new Error(result?.[1]?.description || "FileNode/set destroy failed"); + const result = response.methodResponses?.[0]; + if (!result || result[0] === "error") { + throw new Error(result?.[1]?.description || "FileNode/set destroy failed"); + } + + const notDestroyedMap: Record = result[1].notDestroyed || {}; + const notDestroyedIds = Object.keys(notDestroyedMap); + + if (notDestroyedIds.length > 0) { + const firstError = notDestroyedMap[notDestroyedIds[0]]; + throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`); + } + + destroyed.push(...(result[1].destroyed || [])); } - const notDestroyedMap: Record = result[1].notDestroyed || {}; - const notDestroyedIds = Object.keys(notDestroyedMap); - - if (notDestroyedIds.length > 0) { - const firstError = notDestroyedMap[notDestroyedIds[0]]; - throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`); - } - - return { - destroyed: result[1].destroyed || [], - notDestroyed: [], - }; + return { destroyed, notDestroyed: [] }; } async copyFileNode(id: string, newName: string, parentId: string | null): Promise { diff --git a/lib/jmap/request-limits.ts b/lib/jmap/request-limits.ts new file mode 100644 index 00000000..92fd1d61 --- /dev/null +++ b/lib/jmap/request-limits.ts @@ -0,0 +1,26 @@ +/** + * A JMAP session advertises hard ceilings on what one request may carry: how + * many method calls it holds (`maxCallsInRequest`) and how many objects a + * single /get or /set may touch (`maxObjectsInGet`, `maxObjectsInSet`). Going + * over any of them fails the *whole* request, not the surplus, so a batch built + * from a list the user controls - tags, category tabs, a multi-select, an + * import - is split against the advertised limit before it is sent. + * + * Stalwart defaults to 16 method calls and 500 objects, so the ceilings are low + * enough to reach with ordinary use: nine tags is already 18 calls. + */ + +/** Split `items` into consecutive batches of at most `size` entries. */ +export function batched(items: T[], size: number): T[][] { + const step = Math.max(1, Math.floor(size)); + const result: T[][] = []; + for (let i = 0; i < items.length; i += step) { + result.push(items.slice(i, i + step)); + } + return result; +} + +/** How many items fit in one request when each item costs `callsPerItem` method calls. */ +export function itemsPerRequest(maxCalls: number, callsPerItem: number): number { + return Math.max(1, Math.floor(maxCalls / callsPerItem)); +} From 1890cade084f240dcea33a9336f3bdd5823419d0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:47:16 +0200 Subject: [PATCH 42/42] fix: surface underlying network error cause in JMAP passthrough failures --- app/api/account/stalwart/jmap/route.ts | 16 +++++++++++++++- package-lock.json | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/api/account/stalwart/jmap/route.ts b/app/api/account/stalwart/jmap/route.ts index 150e5e2a..76c9a37d 100644 --- a/app/api/account/stalwart/jmap/route.ts +++ b/app/api/account/stalwart/jmap/route.ts @@ -55,9 +55,23 @@ export async function POST(request: NextRequest) { logger.error('Stalwart JMAP passthrough redirect error', { error: error.message }); return NextResponse.json({ error: error.message }, { status: 502 }); } + // `fetch failed` from undici is too generic to debug — the real reason + // (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`. + const err = error as Error & { cause?: { code?: string; message?: string } }; logger.error('Stalwart JMAP passthrough error', { - error: error instanceof Error ? error.message : 'Unknown', + error: err?.message ?? 'Unknown', + causeCode: err?.cause?.code, + causeMessage: err?.cause?.message, }); + // The server this process failed to reach is the user's own mail server, + // so the reason is worth surfacing: an opaque 500 leaves operators with + // nothing to act on. + if (err?.cause?.code) { + return NextResponse.json( + { error: `Cannot reach the JMAP server (${err.cause.code})` }, + { status: 502 }, + ); + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } diff --git a/package-lock.json b/package-lock.json index bce5d6b7..5b47f334 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6280,6 +6280,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7976,6 +7977,17 @@ } } }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",