From 61e59386bb0cf8f4ceb89b8e3e8c177dcaacda78 Mon Sep 17 00:00:00 2001 From: wmcparla <183443443+wmcparla@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:31:18 -0500 Subject: [PATCH] feat: manage masked addresses under Settings > Security, as in the webmail #143 Port of the webmail's masked addresses (lib/stalwart/masked-email.ts, components/settings/masked-addresses-section.tsx). On a Stalwart server that offers them, Settings > Security lists the account's masked addresses ten to a page. One can be copied, given a description, turned off so new mail to it is rejected, or deleted for good after a confirm, and a new one created with an optional website and description. The section stays hidden where the server has none (another server, or Stalwart Community before 1.1.1) and for a sign-in the server gives no account settings. A delete whose answer never came is not sent again until the list has been read afresh. The strings are the webmail's masked_addresses block, in every locale. --- locales/ar/common.json | 51 ++ locales/ca/common.json | 51 ++ locales/cs/common.json | 51 ++ locales/da/common.json | 51 ++ locales/de/common.json | 51 ++ locales/en/common.json | 51 ++ locales/es/common.json | 51 ++ locales/et/common.json | 51 ++ locales/fa/common.json | 51 ++ locales/fr/common.json | 51 ++ locales/he/common.json | 51 ++ locales/hu/common.json | 51 ++ locales/it/common.json | 51 ++ locales/ja/common.json | 51 ++ locales/ko/common.json | 51 ++ locales/lv/common.json | 51 ++ locales/mn/common.json | 51 ++ locales/nb/common.json | 51 ++ locales/nl/common.json | 51 ++ locales/pl/common.json | 51 ++ locales/pt/common.json | 51 ++ locales/ro/common.json | 51 ++ locales/ru/common.json | 51 ++ locales/sk/common.json | 51 ++ locales/th/common.json | 51 ++ locales/tr/common.json | 51 ++ locales/uk/common.json | 51 ++ locales/zh-TW/common.json | 51 ++ locales/zh/common.json | 51 ++ src/api/__tests__/masked-email.test.ts | 92 ++++ src/api/masked-email.ts | 46 ++ .../settings/AccountSecuritySettings.tsx | 3 + .../settings/MaskedAddressesSection.tsx | 494 ++++++++++++++++++ .../__tests__/masked-addresses.test.ts | 54 ++ src/components/settings/masked-addresses.ts | 41 ++ src/lib/__tests__/masked-email.test.ts | 367 +++++++++++++ src/lib/masked-email.ts | 239 +++++++++ 37 files changed, 2815 insertions(+) create mode 100644 src/api/__tests__/masked-email.test.ts create mode 100644 src/api/masked-email.ts create mode 100644 src/components/settings/MaskedAddressesSection.tsx create mode 100644 src/components/settings/__tests__/masked-addresses.test.ts create mode 100644 src/components/settings/masked-addresses.ts create mode 100644 src/lib/__tests__/masked-email.test.ts create mode 100644 src/lib/masked-email.ts diff --git a/locales/ar/common.json b/locales/ar/common.json index f5c7b2c7d..244165ca5 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2142,6 +2142,57 @@ "managing": "الإدارة: {name}" }, "security": { + "masked_addresses": { + "address": "العنوان", + "description_column": "الوصف", + "website_column": "الموقع", + "state": "الحالة", + "actions": "الإجراءات", + "no_description": "بلا وصف", + "edit": "تعديل", + "close": "إغلاق", + "pagination": "صفحات العناوين المقنّعة", + "page": "الصفحة {page} من {pages}", + "previous": "السابق", + "next": "التالي", + "delete": "حذف", + "delete_title": "حذف العنوان المقنّع نهائيًا؟", + "delete_warning": "حذف {address}؟ لا يمكن التراجع عن هذا الإجراء. لن تُسلَّم الرسائل المرسلة إلى هذا العنوان بعد الآن.", + "delete_confirm": "حذف نهائيًا", + "cancel": "إلغاء", + "deleted": "تم حذف العنوان نهائيًا.", + "deleted_refresh_failed": "تم تأكيد الحذف، لكن تعذّر تحديث القائمة. حدّث الصفحة لتحميل القائمة الحالية.", + "delete_forbidden": "جلستك الحالية غير مسموح لها بحذف العناوين المقنّعة. تبقى الإجراءات المسموح بها الأخرى متاحة.", + "delete_failed": "لم يُحذف العنوان. حدّث القائمة قبل المحاولة مرة أخرى.", + "delete_unconfirmed": "تعذّر تأكيد الحذف ولن تتم إعادة المحاولة. حدّث القائمة وتحقق من العنوان قبل أي حذف آخر.", + "title": "العناوين المقنّعة", + "intro": "استقبل الرسائل دون مشاركة عنوانك الرئيسي. تعطيل عنوان يرفض الرسائل الجديدة المرسلة إليه. لا يمكن استخدام هذه العناوين لإرسال الرسائل.", + "loading": "جارٍ تحميل العناوين المقنّعة…", + "count": "{count, plural, zero {لا توجد عناوين} one {عنوان واحد} two {عنوانان} few {# عناوين} many {# عنوانًا} other {# عنوان}}", + "empty": "لا توجد عناوين مقنّعة بعد.", + "website": "الموقع (اختياري)", + "description": "الوصف (اختياري)", + "no_website": "غير محدد", + "enabled": "مفعّل", + "disabled": "معطّل", + "enable": "تفعيل", + "disable": "تعطيل", + "copy": "نسخ", + "copied": "تم نسخ العنوان.", + "copy_failed": "تعذّر النسخ. حدّد العنوان أعلاه وانسخه.", + "save": "حفظ الوصف", + "create": "إنشاء عنوان مقنّع", + "refresh": "تحديث", + "saved": "تم حفظ التغيير.", + "forbidden": "جلستك الحالية غير مسموح لها بعرض العناوين المقنّعة.", + "unavailable": "العناوين المقنّعة غير متاحة لهذا الحساب أو الخادم.", + "load_failed": "تعذّر تحميل قائمة العناوين كاملة. حدّث القائمة للمحاولة مرة أخرى.", + "save_failed": "تعذّر تأكيد التغيير. حدّث القائمة قبل المحاولة مرة أخرى.", + "create_forbidden": "لم يسمح الخادم بإنشاء عنوان مقنّع بهذه الجلسة.", + "update_forbidden": "لم يسمح الخادم بتعديل العناوين المقنّعة بهذه الجلسة. لا يزال بإمكانك نسخ العناوين.", + "over_quota": "تم بلوغ الحد الأقصى للعناوين المقنّعة على الخادم. قد تُحتسب العناوين المعطّلة ضمن هذا الحد.", + "rate_limit": "تم بلوغ حد معدل الإنشاء على الخادم. حاول مرة أخرى لاحقًا." + }, "title": "أمان الحساب", "description": "إدارة كلمة المرور والمصادقة الثنائية وإعدادات الأمان", "detecting": "جارٍ اكتشاف قدرات الخادم...", diff --git a/locales/ca/common.json b/locales/ca/common.json index c5e8f90dc..ff196e925 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2142,6 +2142,57 @@ "managing": "Gestionant: {name}" }, "security": { + "masked_addresses": { + "address": "Adreça", + "description_column": "Descripció", + "website_column": "Lloc web", + "state": "Estat", + "actions": "Accions", + "no_description": "Sense descripció", + "edit": "Edita", + "close": "Tanca", + "pagination": "Pàgines d'adreces emmascarades", + "page": "Pàgina {page} de {pages}", + "previous": "Anterior", + "next": "Següent", + "delete": "Suprimeix", + "delete_title": "Voleu suprimir definitivament l'adreça emmascarada?", + "delete_warning": "Voleu suprimir {address}? Això no es pot desfer. El correu enviat a aquesta adreça ja no es lliurarà.", + "delete_confirm": "Suprimeix definitivament", + "cancel": "Cancel·la", + "deleted": "S'ha suprimit l'adreça definitivament.", + "deleted_refresh_failed": "S'ha confirmat la supressió, però no s'ha pogut actualitzar la llista. Actualitzeu-la per carregar la llista actual.", + "delete_forbidden": "La sessió actual no té permís per suprimir adreces emmascarades. Les altres accions permeses continuen disponibles.", + "delete_failed": "No s'ha suprimit l'adreça. Actualitzeu la llista abans de tornar-ho a provar.", + "delete_unconfirmed": "No s'ha pogut confirmar la supressió. No es tornarà a intentar. Actualitzeu la llista per comprovar l'adreça abans de fer cap altra supressió.", + "title": "Adreces emmascarades", + "intro": "Rebeu correu sense compartir la vostra adreça principal. Si desactiveu una adreça, es rebutjarà el correu nou que s'hi enviï. Aquestes adreces no es poden fer servir per enviar correu.", + "loading": "S'estan carregant les adreces emmascarades…", + "count": "{count, plural, one {# adreça} other {# adreces}}", + "empty": "Encara no hi ha cap adreça emmascarada.", + "website": "Lloc web (opcional)", + "description": "Descripció (opcional)", + "no_website": "No especificat", + "enabled": "Activada", + "disabled": "Desactivada", + "enable": "Activa", + "disable": "Desactiva", + "copy": "Copia", + "copied": "S'ha copiat l'adreça.", + "copy_failed": "No s'ha pogut copiar. Seleccioneu i copieu l'adreça de més amunt.", + "save": "Desa la descripció", + "create": "Crea una adreça emmascarada", + "refresh": "Actualitza", + "saved": "S'ha desat el canvi.", + "forbidden": "La sessió actual no té permís per mostrar les adreces emmascarades.", + "unavailable": "Les adreces emmascarades no estan disponibles per a aquest compte o servidor.", + "load_failed": "No s'ha pogut carregar la llista completa d'adreces. Actualitzeu-la per tornar-ho a provar.", + "save_failed": "No s'ha pogut confirmar el canvi. Actualitzeu la llista abans de tornar-ho a provar.", + "create_forbidden": "El servidor no ha permès crear una adreça emmascarada amb aquesta sessió.", + "update_forbidden": "El servidor no ha permès editar adreces emmascarades amb aquesta sessió. Encara podeu copiar les adreces.", + "over_quota": "S'ha arribat a la quota d'adreces emmascarades del servidor. És possible que les adreces desactivades també comptin per al límit.", + "rate_limit": "S'ha arribat al límit de creació del servidor. Torneu-ho a provar més tard." + }, "title": "Seguretat del compte", "description": "Gestioneu la contrasenya, l'autenticació de dos factors i la configuració de seguretat", "detecting": "Detectant les capacitats del servidor...", diff --git a/locales/cs/common.json b/locales/cs/common.json index 470fff17a..a9939a229 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Adresa", + "description_column": "Popis", + "website_column": "Web", + "state": "Stav", + "actions": "Akce", + "no_description": "Bez popisu", + "edit": "Upravit", + "close": "Zavřít", + "pagination": "Stránky maskovaných adres", + "page": "Stránka {page} z {pages}", + "previous": "Předchozí", + "next": "Další", + "delete": "Odstranit", + "delete_title": "Trvale odstranit maskovanou adresu?", + "delete_warning": "Odstranit {address}? Tuto akci nelze vrátit zpět. Pošta odeslaná na tuto adresu už nebude doručena.", + "delete_confirm": "Trvale odstranit", + "cancel": "Zrušit", + "deleted": "Adresa byla trvale odstraněna.", + "deleted_refresh_failed": "Odstranění bylo potvrzeno, ale seznam se nepodařilo obnovit. Obnovte ho, abyste načetli aktuální seznam.", + "delete_forbidden": "Vaše aktuální relace nemá oprávnění odstraňovat maskované adresy. Ostatní povolené akce zůstávají dostupné.", + "delete_failed": "Adresa nebyla odstraněna. Než to zkusíte znovu, obnovte seznam.", + "delete_unconfirmed": "Odstranění se nepodařilo potvrdit. Nebude se opakovat. Před dalším odstraňováním obnovte seznam a zkontrolujte adresu.", + "title": "Maskované adresy", + "intro": "Přijímejte poštu, aniž byste sdíleli svou hlavní adresu. Vypnutá adresa odmítá novou poštu. Z těchto adres nelze poštu odesílat.", + "loading": "Načítání maskovaných adres…", + "count": "{count, plural, one {# adresa} few {# adresy} many {# adresy} other {# adres}}", + "empty": "Zatím žádné maskované adresy.", + "website": "Web (nepovinné)", + "description": "Popis (nepovinné)", + "no_website": "Neuvedeno", + "enabled": "Zapnuto", + "disabled": "Vypnuto", + "enable": "Zapnout", + "disable": "Vypnout", + "copy": "Kopírovat", + "copied": "Adresa zkopírována.", + "copy_failed": "Kopírování se nezdařilo. Označte a zkopírujte adresu výše.", + "save": "Uložit popis", + "create": "Vytvořit maskovanou adresu", + "refresh": "Obnovit", + "saved": "Změna uložena.", + "forbidden": "Vaše aktuální relace nemá oprávnění zobrazit maskované adresy.", + "unavailable": "Maskované adresy nejsou pro tento účet nebo server k dispozici.", + "load_failed": "Úplný seznam adres se nepodařilo načíst. Obnovte ho a zkuste to znovu.", + "save_failed": "Změnu se nepodařilo potvrdit. Než to zkusíte znovu, obnovte seznam.", + "create_forbidden": "Server v této relaci nepovolil vytvoření maskované adresy.", + "update_forbidden": "Server v této relaci nepovolil úpravy maskovaných adres. Adresy můžete dál kopírovat.", + "over_quota": "Byla dosažena kvóta maskovaných adres na serveru. Vypnuté adresy se do limitu mohou stále počítat.", + "rate_limit": "Byl dosažen limit serveru pro vytváření adres. Zkuste to později." + }, "title": "Zabezpečení účtu", "description": "Správa hesla, dvoufázového ověření a nastavení zabezpečení", "detecting": "Zjišťování možností serveru...", diff --git a/locales/da/common.json b/locales/da/common.json index 7a3de8339..a5657fdfe 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2138,6 +2138,57 @@ } }, "security": { + "masked_addresses": { + "address": "Adresse", + "description_column": "Beskrivelse", + "website_column": "Websted", + "state": "Status", + "actions": "Handlinger", + "no_description": "Ingen beskrivelse", + "edit": "Rediger", + "close": "Luk", + "pagination": "Sider med maskerede adresser", + "page": "Side {page} af {pages}", + "previous": "Forrige", + "next": "Næste", + "delete": "Slet", + "delete_title": "Slet den maskerede adresse permanent?", + "delete_warning": "Slet {address}? Det kan ikke fortrydes. Mail, der sendes til denne adresse, bliver ikke længere leveret.", + "delete_confirm": "Slet permanent", + "cancel": "Annuller", + "deleted": "Adressen er slettet permanent.", + "deleted_refresh_failed": "Sletningen er bekræftet, men listen kunne ikke opdateres. Opdater for at indlæse den aktuelle liste.", + "delete_forbidden": "Din nuværende session har ikke tilladelse til at slette maskerede adresser. Andre tilladte handlinger er stadig tilgængelige.", + "delete_failed": "Adressen blev ikke slettet. Opdater listen, før du prøver igen.", + "delete_unconfirmed": "Sletningen kunne ikke bekræftes. Den bliver ikke forsøgt igen. Opdater listen, og kontrollér adressen, før du sletter mere.", + "title": "Maskerede adresser", + "intro": "Modtag mail uden at dele din primære adresse. Når en adresse deaktiveres, afvises ny mail til den. Disse adresser kan ikke bruges til at sende mail.", + "loading": "Indlæser maskerede adresser…", + "count": "{count, plural, one {# adresse} other {# adresser}}", + "empty": "Ingen maskerede adresser endnu.", + "website": "Websted (valgfrit)", + "description": "Beskrivelse (valgfrit)", + "no_website": "Ikke angivet", + "enabled": "Aktiveret", + "disabled": "Deaktiveret", + "enable": "Aktivér", + "disable": "Deaktivér", + "copy": "Kopier", + "copied": "Adressen er kopieret.", + "copy_failed": "Kunne ikke kopiere. Markér og kopier adressen ovenfor.", + "save": "Gem beskrivelse", + "create": "Opret maskeret adresse", + "refresh": "Opdater", + "saved": "Ændringen er gemt.", + "forbidden": "Din nuværende session har ikke tilladelse til at vise maskerede adresser.", + "unavailable": "Maskerede adresser er ikke tilgængelige for denne konto eller server.", + "load_failed": "Den fulde adresseliste kunne ikke indlæses. Opdater for at prøve igen.", + "save_failed": "Ændringen kunne ikke bekræftes. Opdater listen, før du prøver igen.", + "create_forbidden": "Serveren tillod ikke at oprette en maskeret adresse med denne session.", + "update_forbidden": "Serveren tillod ikke at redigere maskerede adresser med denne session. Du kan stadig kopiere adresser.", + "over_quota": "Serverens kvote for maskerede adresser er nået. Deaktiverede adresser kan stadig tælle med i grænsen.", + "rate_limit": "Serverens grænse for oprettelser er nået. Prøv igen senere." + }, "title": "Kontosikkerhed", "description": "Administrér din adgangskode, to-faktor-godkendelse og sikkerhedsindstillinger", "detecting": "Registrerer serverfunktioner...", diff --git a/locales/de/common.json b/locales/de/common.json index 86879832b..86adca7bd 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Adresse", + "description_column": "Beschreibung", + "website_column": "Website", + "state": "Status", + "actions": "Aktionen", + "no_description": "Keine Beschreibung", + "edit": "Bearbeiten", + "close": "Schließen", + "pagination": "Seiten der maskierten Adressen", + "page": "Seite {page} von {pages}", + "previous": "Zurück", + "next": "Weiter", + "delete": "Löschen", + "delete_title": "Maskierte Adresse endgültig löschen?", + "delete_warning": "{address} löschen? Das kann nicht rückgängig gemacht werden. E-Mails an diese Adresse werden nicht mehr zugestellt.", + "delete_confirm": "Endgültig löschen", + "cancel": "Abbrechen", + "deleted": "Adresse endgültig gelöscht.", + "deleted_refresh_failed": "Löschen bestätigt, aber die Liste konnte nicht aktualisiert werden. Aktualisieren Sie, um die aktuelle Liste zu laden.", + "delete_forbidden": "Ihre aktuelle Sitzung darf keine maskierten Adressen löschen. Andere erlaubte Aktionen bleiben verfügbar.", + "delete_failed": "Die Adresse wurde nicht gelöscht. Aktualisieren Sie die Liste, bevor Sie es erneut versuchen.", + "delete_unconfirmed": "Das Löschen konnte nicht bestätigt werden und wird nicht wiederholt. Aktualisieren Sie die Liste und prüfen Sie die Adresse, bevor Sie weitere Adressen löschen.", + "title": "Maskierte Adressen", + "intro": "Empfangen Sie E-Mails, ohne Ihre Hauptadresse preiszugeben. Wenn Sie eine Adresse deaktivieren, werden neue E-Mails an sie abgelehnt. Mit diesen Adressen können keine E-Mails gesendet werden.", + "loading": "Maskierte Adressen werden geladen…", + "count": "{count, plural, one {# Adresse} other {# Adressen}}", + "empty": "Noch keine maskierten Adressen.", + "website": "Website (optional)", + "description": "Beschreibung (optional)", + "no_website": "Nicht angegeben", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "enable": "Aktivieren", + "disable": "Deaktivieren", + "copy": "Kopieren", + "copied": "Adresse kopiert.", + "copy_failed": "Kopieren fehlgeschlagen. Markieren und kopieren Sie die Adresse oben.", + "save": "Beschreibung speichern", + "create": "Maskierte Adresse erstellen", + "refresh": "Aktualisieren", + "saved": "Änderung gespeichert.", + "forbidden": "Ihre aktuelle Sitzung darf keine maskierten Adressen auflisten.", + "unavailable": "Maskierte Adressen sind für dieses Konto oder diesen Server nicht verfügbar.", + "load_failed": "Die vollständige Adressliste konnte nicht geladen werden. Aktualisieren Sie, um es erneut zu versuchen.", + "save_failed": "Die Änderung konnte nicht bestätigt werden. Aktualisieren Sie die Liste, bevor Sie es erneut versuchen.", + "create_forbidden": "Der Server hat das Erstellen einer maskierten Adresse mit dieser Sitzung nicht erlaubt.", + "update_forbidden": "Der Server hat das Bearbeiten maskierter Adressen mit dieser Sitzung nicht erlaubt. Sie können Adressen weiterhin kopieren.", + "over_quota": "Das Kontingent des Servers für maskierte Adressen ist erreicht. Deaktivierte Adressen zählen möglicherweise weiterhin zum Limit.", + "rate_limit": "Das Limit des Servers für neue Adressen ist erreicht. Versuchen Sie es später erneut." + }, "title": "Kontosicherheit", "description": "Verwalten Sie Ihr Passwort, Zwei-Faktor-Authentifizierung und Sicherheitseinstellungen", "detecting": "Serverfunktionen werden erkannt...", diff --git a/locales/en/common.json b/locales/en/common.json index c164315f3..49f3aa0ec 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2142,6 +2142,57 @@ "managing": "Managing: {name}" }, "security": { + "masked_addresses": { + "address": "Address", + "description_column": "Description", + "website_column": "Website", + "state": "State", + "actions": "Actions", + "no_description": "No description", + "edit": "Edit", + "close": "Close", + "pagination": "Masked address pages", + "page": "Page {page} of {pages}", + "previous": "Previous", + "next": "Next", + "delete": "Delete", + "delete_title": "Permanently delete masked address?", + "delete_warning": "Delete {address}? This cannot be undone. Mail sent to this address will no longer be delivered.", + "delete_confirm": "Delete permanently", + "cancel": "Cancel", + "deleted": "Address permanently deleted.", + "deleted_refresh_failed": "Deletion confirmed, but the list could not be refreshed. Refresh to load the current list.", + "delete_forbidden": "Your current session is not allowed to delete masked addresses. Other permitted actions remain available.", + "delete_failed": "The address was not deleted. Refresh the list before trying again.", + "delete_unconfirmed": "Deletion could not be confirmed. It will not be retried. Refresh the list to check the address before any further deletion.", + "title": "Masked addresses", + "intro": "Receive mail without sharing your main address. Disabling an address rejects new mail to it. These addresses cannot be used to send mail.", + "loading": "Loading masked addresses…", + "count": "{count, plural, one {# address} other {# addresses}}", + "empty": "No masked addresses yet.", + "website": "Website (optional)", + "description": "Description (optional)", + "no_website": "Not specified", + "enabled": "Enabled", + "disabled": "Disabled", + "enable": "Enable", + "disable": "Disable", + "copy": "Copy", + "copied": "Address copied.", + "copy_failed": "Could not copy. Select and copy the address above.", + "save": "Save description", + "create": "Create masked address", + "refresh": "Refresh", + "saved": "Change saved.", + "forbidden": "Your current session is not allowed to list masked addresses.", + "unavailable": "Masked addresses aren't available for this account or server.", + "load_failed": "Could not load the complete address list. Refresh to try again.", + "save_failed": "The change could not be confirmed. Refresh the list before trying again.", + "create_forbidden": "The server did not allow creating a masked address with this session.", + "update_forbidden": "The server did not allow editing masked addresses with this session. You can still copy addresses.", + "over_quota": "The server's masked address quota has been reached. Disabled addresses may still count towards the limit.", + "rate_limit": "The server's creation rate limit has been reached. Try again later." + }, "title": "Account security", "description": "Manage your password, two-factor authentication, and security settings", "detecting": "Detecting server capabilities...", diff --git a/locales/es/common.json b/locales/es/common.json index 6752de642..63ca17da3 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Dirección", + "description_column": "Descripción", + "website_column": "Sitio web", + "state": "Estado", + "actions": "Acciones", + "no_description": "Sin descripción", + "edit": "Editar", + "close": "Cerrar", + "pagination": "Páginas de direcciones enmascaradas", + "page": "Página {page} de {pages}", + "previous": "Anterior", + "next": "Siguiente", + "delete": "Eliminar", + "delete_title": "¿Eliminar definitivamente la dirección enmascarada?", + "delete_warning": "¿Eliminar {address}? Esta acción no se puede deshacer. El correo enviado a esta dirección dejará de entregarse.", + "delete_confirm": "Eliminar definitivamente", + "cancel": "Cancelar", + "deleted": "Dirección eliminada definitivamente.", + "deleted_refresh_failed": "Eliminación confirmada, pero no se pudo actualizar la lista. Actualice para cargar la lista actual.", + "delete_forbidden": "Su sesión actual no tiene permiso para eliminar direcciones enmascaradas. Las demás acciones permitidas siguen disponibles.", + "delete_failed": "No se eliminó la dirección. Actualice la lista antes de volver a intentarlo.", + "delete_unconfirmed": "No se pudo confirmar la eliminación. No se volverá a intentar. Actualice la lista para comprobar la dirección antes de eliminar nada más.", + "title": "Direcciones enmascaradas", + "intro": "Reciba correo sin compartir su dirección principal. Al desactivar una dirección, se rechaza el correo nuevo enviado a ella. Estas direcciones no se pueden usar para enviar correo.", + "loading": "Cargando direcciones enmascaradas…", + "count": "{count, plural, one {# dirección} other {# direcciones}}", + "empty": "Todavía no hay direcciones enmascaradas.", + "website": "Sitio web (opcional)", + "description": "Descripción (opcional)", + "no_website": "Sin especificar", + "enabled": "Activada", + "disabled": "Desactivada", + "enable": "Activar", + "disable": "Desactivar", + "copy": "Copiar", + "copied": "Dirección copiada.", + "copy_failed": "No se pudo copiar. Seleccione y copie la dirección de arriba.", + "save": "Guardar descripción", + "create": "Crear dirección enmascarada", + "refresh": "Actualizar", + "saved": "Cambio guardado.", + "forbidden": "Su sesión actual no tiene permiso para mostrar las direcciones enmascaradas.", + "unavailable": "Las direcciones enmascaradas no están disponibles para esta cuenta o servidor.", + "load_failed": "No se pudo cargar la lista completa de direcciones. Actualice para volver a intentarlo.", + "save_failed": "No se pudo confirmar el cambio. Actualice la lista antes de volver a intentarlo.", + "create_forbidden": "El servidor no permitió crear una dirección enmascarada con esta sesión.", + "update_forbidden": "El servidor no permitió editar direcciones enmascaradas con esta sesión. Todavía puede copiar las direcciones.", + "over_quota": "Se ha alcanzado la cuota de direcciones enmascaradas del servidor. Es posible que las direcciones desactivadas sigan contando para el límite.", + "rate_limit": "Se ha alcanzado el límite de creación del servidor. Inténtelo de nuevo más tarde." + }, "title": "Seguridad de la cuenta", "description": "Administre su contraseña, autenticación de dos factores y configuración de seguridad", "detecting": "Detectando capacidades del servidor...", diff --git a/locales/et/common.json b/locales/et/common.json index f1c378c57..8940c95d7 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -2142,6 +2142,57 @@ "managing": "Haldan: {name}" }, "security": { + "masked_addresses": { + "address": "Aadress", + "description_column": "Kirjeldus", + "website_column": "Veebisait", + "state": "Olek", + "actions": "Toimingud", + "no_description": "Kirjeldus puudub", + "edit": "Muuda", + "close": "Sulge", + "pagination": "Maskeeritud aadresside lehed", + "page": "Leht {page}/{pages}", + "previous": "Eelmine", + "next": "Järgmine", + "delete": "Kustuta", + "delete_title": "Kas kustutada maskeeritud aadress jäädavalt?", + "delete_warning": "Kas kustutada {address}? Seda ei saa tagasi võtta. Sellele aadressile saadetud kirju enam ei edastata.", + "delete_confirm": "Kustuta jäädavalt", + "cancel": "Tühista", + "deleted": "Aadress kustutati jäädavalt.", + "deleted_refresh_failed": "Kustutamine on kinnitatud, kuid loendit ei õnnestunud värskendada. Värskenda, et laadida praegune loend.", + "delete_forbidden": "Sinu praegusel seansil pole lubatud maskeeritud aadresse kustutada. Muud lubatud toimingud on endiselt saadaval.", + "delete_failed": "Aadressi ei kustutatud. Värskenda loendit enne uuesti proovimist.", + "delete_unconfirmed": "Kustutamist ei õnnestunud kinnitada. Seda ei proovita uuesti. Värskenda loendit ja kontrolli aadressi enne järgmist kustutamist.", + "title": "Maskeeritud aadressid", + "intro": "Võta kirju vastu ilma oma põhiaadressi jagamata. Keelatud aadressile saadetud uued kirjad lükatakse tagasi. Nende aadressidega ei saa kirju saata.", + "loading": "Maskeeritud aadresside laadimine…", + "count": "{count, plural, one {# aadress} other {# aadressi}}", + "empty": "Maskeeritud aadresse veel pole.", + "website": "Veebisait (valikuline)", + "description": "Kirjeldus (valikuline)", + "no_website": "Määramata", + "enabled": "Lubatud", + "disabled": "Keelatud", + "enable": "Luba", + "disable": "Keela", + "copy": "Kopeeri", + "copied": "Aadress kopeeriti.", + "copy_failed": "Kopeerimine ebaõnnestus. Vali ülal olev aadress ja kopeeri see.", + "save": "Salvesta kirjeldus", + "create": "Loo maskeeritud aadress", + "refresh": "Värskenda", + "saved": "Muudatus salvestati.", + "forbidden": "Sinu praegusel seansil pole lubatud maskeeritud aadresse kuvada.", + "unavailable": "Maskeeritud aadressid pole selle konto või serveri jaoks saadaval.", + "load_failed": "Kogu aadressiloendit ei õnnestunud laadida. Värskenda ja proovi uuesti.", + "save_failed": "Muudatust ei õnnestunud kinnitada. Värskenda loendit enne uuesti proovimist.", + "create_forbidden": "Server ei lubanud selle seansiga maskeeritud aadressi luua.", + "update_forbidden": "Server ei lubanud selle seansiga maskeeritud aadresse muuta. Aadresse saad endiselt kopeerida.", + "over_quota": "Serveri maskeeritud aadresside kvoot on täis. Keelatud aadressid võivad endiselt piirangu hulka arvestuda.", + "rate_limit": "Serveri loomise kiiruspiirang on täis. Proovi hiljem uuesti." + }, "title": "Konto turvalisus", "description": "Halda oma parooli, kaheastmelist autentimist ja turvasätteid", "detecting": "Serveri võimekuste tuvastamine...", diff --git a/locales/fa/common.json b/locales/fa/common.json index 2da9a6e5a..f1b6a73ef 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2142,6 +2142,57 @@ "managing": "مدیریت: {name}" }, "security": { + "masked_addresses": { + "address": "آدرس", + "description_column": "توضیحات", + "website_column": "وب‌سایت", + "state": "وضعیت", + "actions": "اقدامات", + "no_description": "بدون توضیحات", + "edit": "ویرایش", + "close": "بستن", + "pagination": "صفحه‌های آدرس‌های پوشیده", + "page": "صفحه {page} از {pages}", + "previous": "قبلی", + "next": "بعدی", + "delete": "حذف", + "delete_title": "آدرس پوشیده برای همیشه حذف شود؟", + "delete_warning": "{address} حذف شود؟ این کار برگشت‌پذیر نیست. ایمیل‌هایی که به این آدرس فرستاده شوند دیگر تحویل داده نمی‌شوند.", + "delete_confirm": "حذف برای همیشه", + "cancel": "انصراف", + "deleted": "آدرس برای همیشه حذف شد.", + "deleted_refresh_failed": "حذف تأیید شد، اما فهرست تازه‌سازی نشد. برای بارگیری فهرست فعلی، آن را تازه‌سازی کنید.", + "delete_forbidden": "نشست فعلی شما اجازه حذف آدرس‌های پوشیده را ندارد. سایر اقدامات مجاز همچنان در دسترس هستند.", + "delete_failed": "آدرس حذف نشد. پیش از تلاش دوباره، فهرست را تازه‌سازی کنید.", + "delete_unconfirmed": "حذف تأیید نشد و دوباره تلاش نخواهد شد. پیش از هر حذف دیگری، فهرست را تازه‌سازی کنید و آدرس را بررسی کنید.", + "title": "آدرس‌های پوشیده", + "intro": "بدون اشتراک‌گذاری آدرس اصلی خود ایمیل دریافت کنید. با غیرفعال کردن یک آدرس، ایمیل‌های جدید به آن رد می‌شوند. با این آدرس‌ها نمی‌توان ایمیل فرستاد.", + "loading": "در حال بارگیری آدرس‌های پوشیده…", + "count": "{count, plural, one {# آدرس} other {# آدرس}}", + "empty": "هنوز آدرس پوشیده‌ای وجود ندارد.", + "website": "وب‌سایت (اختیاری)", + "description": "توضیحات (اختیاری)", + "no_website": "مشخص نشده", + "enabled": "فعال", + "disabled": "غیرفعال", + "enable": "فعال‌سازی", + "disable": "غیرفعال کردن", + "copy": "کپی", + "copied": "آدرس کپی شد.", + "copy_failed": "کپی نشد. آدرس بالا را انتخاب و کپی کنید.", + "save": "ذخیره توضیحات", + "create": "ایجاد آدرس پوشیده", + "refresh": "تازه‌سازی", + "saved": "تغییر ذخیره شد.", + "forbidden": "نشست فعلی شما اجازه نمایش آدرس‌های پوشیده را ندارد.", + "unavailable": "آدرس‌های پوشیده برای این حساب یا سرور در دسترس نیستند.", + "load_failed": "فهرست کامل آدرس‌ها بارگیری نشد. برای تلاش دوباره، تازه‌سازی کنید.", + "save_failed": "تغییر تأیید نشد. پیش از تلاش دوباره، فهرست را تازه‌سازی کنید.", + "create_forbidden": "سرور اجازه ایجاد آدرس پوشیده با این نشست را نداد.", + "update_forbidden": "سرور اجازه ویرایش آدرس‌های پوشیده با این نشست را نداد. همچنان می‌توانید آدرس‌ها را کپی کنید.", + "over_quota": "سهمیه آدرس‌های پوشیده سرور پر شده است. آدرس‌های غیرفعال ممکن است همچنان جزو این محدودیت حساب شوند.", + "rate_limit": "به محدودیت سرعت ایجاد سرور رسیده‌اید. بعداً دوباره تلاش کنید." + }, "title": "امنیت حساب", "description": "مدیریت امنیت", "detecting": "شناسایی قابلیت‌ها...", diff --git a/locales/fr/common.json b/locales/fr/common.json index c41f6a286..d416249cc 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Adresse", + "description_column": "Description", + "website_column": "Site web", + "state": "État", + "actions": "Actions", + "no_description": "Aucune description", + "edit": "Modifier", + "close": "Fermer", + "pagination": "Pages des adresses masquées", + "page": "Page {page} sur {pages}", + "previous": "Précédent", + "next": "Suivant", + "delete": "Supprimer", + "delete_title": "Supprimer définitivement l'adresse masquée ?", + "delete_warning": "Supprimer {address} ? Cette action est irréversible. Les e-mails envoyés à cette adresse ne seront plus distribués.", + "delete_confirm": "Supprimer définitivement", + "cancel": "Annuler", + "deleted": "Adresse supprimée définitivement.", + "deleted_refresh_failed": "Suppression confirmée, mais la liste n'a pas pu être actualisée. Actualisez pour charger la liste à jour.", + "delete_forbidden": "Votre session actuelle n'est pas autorisée à supprimer des adresses masquées. Les autres actions autorisées restent disponibles.", + "delete_failed": "L'adresse n'a pas été supprimée. Actualisez la liste avant de réessayer.", + "delete_unconfirmed": "La suppression n'a pas pu être confirmée. Elle ne sera pas relancée. Actualisez la liste pour vérifier l'adresse avant toute nouvelle suppression.", + "title": "Adresses masquées", + "intro": "Recevez des e-mails sans communiquer votre adresse principale. Une adresse désactivée refuse les nouveaux e-mails. Ces adresses ne permettent pas d'envoyer des e-mails.", + "loading": "Chargement des adresses masquées…", + "count": "{count, plural, one {# adresse} other {# adresses}}", + "empty": "Aucune adresse masquée pour l'instant.", + "website": "Site web (facultatif)", + "description": "Description (facultatif)", + "no_website": "Non renseigné", + "enabled": "Activée", + "disabled": "Désactivée", + "enable": "Activer", + "disable": "Désactiver", + "copy": "Copier", + "copied": "Adresse copiée.", + "copy_failed": "Impossible de copier. Sélectionnez et copiez l'adresse ci-dessus.", + "save": "Enregistrer la description", + "create": "Créer une adresse masquée", + "refresh": "Actualiser", + "saved": "Modification enregistrée.", + "forbidden": "Votre session actuelle n'est pas autorisée à afficher les adresses masquées.", + "unavailable": "Les adresses masquées ne sont pas disponibles pour ce compte ou ce serveur.", + "load_failed": "Impossible de charger la liste complète des adresses. Actualisez pour réessayer.", + "save_failed": "La modification n'a pas pu être confirmée. Actualisez la liste avant de réessayer.", + "create_forbidden": "Le serveur n'a pas autorisé la création d'une adresse masquée avec cette session.", + "update_forbidden": "Le serveur n'a pas autorisé la modification des adresses masquées avec cette session. Vous pouvez toujours copier les adresses.", + "over_quota": "Le quota d'adresses masquées du serveur est atteint. Les adresses désactivées peuvent encore compter dans la limite.", + "rate_limit": "La limite de création du serveur est atteinte. Réessayez plus tard." + }, "title": "Sécurité du compte", "description": "Gérez votre mot de passe, l'authentification à deux facteurs et les paramètres de sécurité", "detecting": "Détection des capacités du serveur...", diff --git a/locales/he/common.json b/locales/he/common.json index dee262172..d31463617 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2100,6 +2100,57 @@ } }, "security": { + "masked_addresses": { + "address": "כתובת", + "description_column": "תיאור", + "website_column": "אתר", + "state": "מצב", + "actions": "פעולות", + "no_description": "אין תיאור", + "edit": "עריכה", + "close": "סגירה", + "pagination": "עמודי כתובות מוסוות", + "page": "עמוד {page} מתוך {pages}", + "previous": "הקודם", + "next": "הבא", + "delete": "מחיקה", + "delete_title": "למחוק את הכתובת המוסווית לצמיתות?", + "delete_warning": "למחוק את {address}? לא ניתן לבטל פעולה זו. דואר שיישלח לכתובת הזו לא יימסר עוד.", + "delete_confirm": "מחיקה לצמיתות", + "cancel": "ביטול", + "deleted": "הכתובת נמחקה לצמיתות.", + "deleted_refresh_failed": "המחיקה אושרה, אבל לא ניתן היה לרענן את הרשימה. רענן כדי לטעון את הרשימה העדכנית.", + "delete_forbidden": "להפעלה הנוכחית שלך אין הרשאה למחוק כתובות מוסוות. שאר הפעולות המותרות עדיין זמינות.", + "delete_failed": "הכתובת לא נמחקה. רענן את הרשימה לפני שתנסה שוב.", + "delete_unconfirmed": "לא ניתן היה לאשר את המחיקה, והיא לא תנוסה שוב. רענן את הרשימה ובדוק את הכתובת לפני כל מחיקה נוספת.", + "title": "כתובות מוסוות", + "intro": "קבל דואר בלי למסור את הכתובת הראשית שלך. השבתת כתובת דוחה דואר חדש שנשלח אליה. לא ניתן לשלוח דואר מכתובות אלה.", + "loading": "טוען כתובות מוסוות…", + "count": "{count, plural, one {כתובת אחת} two {שתי כתובות} other {# כתובות}}", + "empty": "אין עדיין כתובות מוסוות.", + "website": "אתר (אופציונלי)", + "description": "תיאור (אופציונלי)", + "no_website": "לא צוין", + "enabled": "מופעלת", + "disabled": "מושבתת", + "enable": "הפעל", + "disable": "השבת", + "copy": "העתק", + "copied": "הכתובת הועתקה.", + "copy_failed": "ההעתקה נכשלה. סמן את הכתובת שלמעלה והעתק אותה.", + "save": "שמור תיאור", + "create": "יצירת כתובת מוסווית", + "refresh": "רענון", + "saved": "השינוי נשמר.", + "forbidden": "להפעלה הנוכחית שלך אין הרשאה להציג כתובות מוסוות.", + "unavailable": "כתובות מוסוות אינן זמינות לחשבון או לשרת הזה.", + "load_failed": "לא ניתן היה לטעון את רשימת הכתובות המלאה. רענן כדי לנסות שוב.", + "save_failed": "לא ניתן היה לאשר את השינוי. רענן את הרשימה לפני שתנסה שוב.", + "create_forbidden": "השרת לא התיר ליצור כתובת מוסווית בהפעלה הזו.", + "update_forbidden": "השרת לא התיר לערוך כתובות מוסוות בהפעלה הזו. עדיין אפשר להעתיק כתובות.", + "over_quota": "הגעת למכסת הכתובות המוסוות של השרת. ייתכן שכתובות מושבתות עדיין נספרות במגבלה.", + "rate_limit": "הגעת למגבלת קצב היצירה של השרת. נסה שוב מאוחר יותר." + }, "title": "אבטחת חשבון", "description": "נהל את הסיסמה שלך, אימות דו-שלבי והגדרות אבטחה", "detecting": "מזהה יכולות שרת...", diff --git a/locales/hu/common.json b/locales/hu/common.json index 2acb340f5..23dbc4fe3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2138,6 +2138,57 @@ } }, "security": { + "masked_addresses": { + "address": "Cím", + "description_column": "Leírás", + "website_column": "Webhely", + "state": "Állapot", + "actions": "Műveletek", + "no_description": "Nincs leírás", + "edit": "Szerkesztés", + "close": "Bezárás", + "pagination": "Maszkolt címek oldalai", + "page": "{page}. oldal / {pages}", + "previous": "Előző", + "next": "Következő", + "delete": "Törlés", + "delete_title": "Véglegesen törlöd a maszkolt címet?", + "delete_warning": "Törlöd ezt: {address}? Ez nem vonható vissza. Az erre a címre küldött leveleket a rendszer többé nem kézbesíti.", + "delete_confirm": "Végleges törlés", + "cancel": "Mégse", + "deleted": "A cím véglegesen törölve.", + "deleted_refresh_failed": "A törlés megerősítve, de a listát nem sikerült frissíteni. Frissíts az aktuális lista betöltéséhez.", + "delete_forbidden": "A jelenlegi munkameneted nem törölhet maszkolt címeket. A többi engedélyezett művelet továbbra is elérhető.", + "delete_failed": "A cím nem lett törölve. Frissítsd a listát, mielőtt újra próbálkozol.", + "delete_unconfirmed": "A törlést nem sikerült megerősíteni, és nem próbáljuk meg újra. Frissítsd a listát, és ellenőrizd a címet, mielőtt bármit törölnél.", + "title": "Maszkolt címek", + "intro": "Fogadj leveleket anélkül, hogy megadnád a fő címedet. Egy letiltott cím elutasítja az új leveleket. Ezekről a címekről nem lehet levelet küldeni.", + "loading": "Maszkolt címek betöltése…", + "count": "{count, plural, one {# cím} other {# cím}}", + "empty": "Még nincsenek maszkolt címek.", + "website": "Webhely (nem kötelező)", + "description": "Leírás (nem kötelező)", + "no_website": "Nincs megadva", + "enabled": "Engedélyezve", + "disabled": "Letiltva", + "enable": "Engedélyezés", + "disable": "Letiltás", + "copy": "Másolás", + "copied": "Cím másolva.", + "copy_failed": "Nem sikerült másolni. Jelöld ki és másold ki a fenti címet.", + "save": "Leírás mentése", + "create": "Maszkolt cím létrehozása", + "refresh": "Frissítés", + "saved": "Módosítás mentve.", + "forbidden": "A jelenlegi munkameneted nem listázhatja a maszkolt címeket.", + "unavailable": "Maszkolt címek nem érhetők el ehhez a fiókhoz vagy szerverhez.", + "load_failed": "Nem sikerült betölteni a teljes címlistát. Frissíts az újrapróbáláshoz.", + "save_failed": "A módosítást nem sikerült megerősíteni. Frissítsd a listát, mielőtt újra próbálkozol.", + "create_forbidden": "A szerver nem engedélyezte maszkolt cím létrehozását ezzel a munkamenettel.", + "update_forbidden": "A szerver nem engedélyezte a maszkolt címek szerkesztését ezzel a munkamenettel. A címeket továbbra is másolhatod.", + "over_quota": "Elérted a szerver maszkoltcím-kvótáját. A letiltott címek is beleszámíthatnak a korlátba.", + "rate_limit": "Elérted a szerver létrehozási korlátját. Próbáld újra később." + }, "title": "Fiók biztonság", "description": "Jelszó, kétfaktoros hitelesítés és biztonsági beállítások kezelése", "detecting": "Szerver képességek érzékelése...", diff --git a/locales/it/common.json b/locales/it/common.json index 3c24fa252..b2ee1dbd6 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Indirizzo", + "description_column": "Descrizione", + "website_column": "Sito web", + "state": "Stato", + "actions": "Azioni", + "no_description": "Nessuna descrizione", + "edit": "Modifica", + "close": "Chiudi", + "pagination": "Pagine degli indirizzi mascherati", + "page": "Pagina {page} di {pages}", + "previous": "Precedente", + "next": "Successiva", + "delete": "Elimina", + "delete_title": "Eliminare definitivamente l'indirizzo mascherato?", + "delete_warning": "Eliminare {address}? L'operazione non può essere annullata. La posta inviata a questo indirizzo non verrà più recapitata.", + "delete_confirm": "Elimina definitivamente", + "cancel": "Annulla", + "deleted": "Indirizzo eliminato definitivamente.", + "deleted_refresh_failed": "Eliminazione confermata, ma non è stato possibile aggiornare l'elenco. Aggiorna per caricare l'elenco attuale.", + "delete_forbidden": "La sessione attuale non è autorizzata a eliminare indirizzi mascherati. Le altre azioni consentite restano disponibili.", + "delete_failed": "L'indirizzo non è stato eliminato. Aggiorna l'elenco prima di riprovare.", + "delete_unconfirmed": "Non è stato possibile confermare l'eliminazione. Non verrà ritentata. Aggiorna l'elenco per controllare l'indirizzo prima di eliminare altro.", + "title": "Indirizzi mascherati", + "intro": "Ricevi posta senza condividere il tuo indirizzo principale. Disattivando un indirizzo, la nuova posta indirizzata a esso viene rifiutata. Questi indirizzi non possono essere usati per inviare posta.", + "loading": "Caricamento degli indirizzi mascherati…", + "count": "{count, plural, one {# indirizzo} other {# indirizzi}}", + "empty": "Ancora nessun indirizzo mascherato.", + "website": "Sito web (facoltativo)", + "description": "Descrizione (facoltativa)", + "no_website": "Non specificato", + "enabled": "Attivo", + "disabled": "Disattivato", + "enable": "Attiva", + "disable": "Disattiva", + "copy": "Copia", + "copied": "Indirizzo copiato.", + "copy_failed": "Impossibile copiare. Seleziona e copia l'indirizzo qui sopra.", + "save": "Salva descrizione", + "create": "Crea indirizzo mascherato", + "refresh": "Aggiorna", + "saved": "Modifica salvata.", + "forbidden": "La sessione attuale non è autorizzata a elencare gli indirizzi mascherati.", + "unavailable": "Gli indirizzi mascherati non sono disponibili per questo account o server.", + "load_failed": "Impossibile caricare l'elenco completo degli indirizzi. Aggiorna per riprovare.", + "save_failed": "Non è stato possibile confermare la modifica. Aggiorna l'elenco prima di riprovare.", + "create_forbidden": "Il server non ha consentito di creare un indirizzo mascherato con questa sessione.", + "update_forbidden": "Il server non ha consentito di modificare gli indirizzi mascherati con questa sessione. Puoi comunque copiare gli indirizzi.", + "over_quota": "È stata raggiunta la quota di indirizzi mascherati del server. Anche gli indirizzi disattivati potrebbero contare ai fini del limite.", + "rate_limit": "È stato raggiunto il limite di creazione del server. Riprova più tardi." + }, "title": "Sicurezza dell'account", "description": "Gestisci la tua password, l'autenticazione a due fattori e le impostazioni di sicurezza", "detecting": "Rilevamento delle funzionalità del server...", diff --git a/locales/ja/common.json b/locales/ja/common.json index a192cfc9b..1c5999bd1 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "アドレス", + "description_column": "説明", + "website_column": "ウェブサイト", + "state": "状態", + "actions": "操作", + "no_description": "説明なし", + "edit": "編集", + "close": "閉じる", + "pagination": "マスクアドレスのページ", + "page": "{page} / {pages} ページ", + "previous": "前へ", + "next": "次へ", + "delete": "削除", + "delete_title": "マスクアドレスを完全に削除しますか?", + "delete_warning": "{address} を削除しますか?この操作は取り消せません。このアドレス宛てのメールは今後配信されなくなります。", + "delete_confirm": "完全に削除", + "cancel": "キャンセル", + "deleted": "アドレスを完全に削除しました。", + "deleted_refresh_failed": "削除は確認されましたが、一覧を更新できませんでした。更新して最新の一覧を読み込んでください。", + "delete_forbidden": "現在のセッションにはマスクアドレスを削除する権限がありません。その他の許可された操作は引き続き使用できます。", + "delete_failed": "アドレスは削除されませんでした。一覧を更新してから、もう一度お試しください。", + "delete_unconfirmed": "削除を確認できませんでした。再試行は行われません。さらに削除する前に、一覧を更新してアドレスを確認してください。", + "title": "マスクアドレス", + "intro": "メインのアドレスを教えずにメールを受信できます。アドレスを無効にすると、そのアドレス宛ての新しいメールは拒否されます。これらのアドレスからメールを送信することはできません。", + "loading": "マスクアドレスを読み込んでいます…", + "count": "{count, plural, other {# 件のアドレス}}", + "empty": "マスクアドレスはまだありません。", + "website": "ウェブサイト(任意)", + "description": "説明(任意)", + "no_website": "指定なし", + "enabled": "有効", + "disabled": "無効", + "enable": "有効にする", + "disable": "無効にする", + "copy": "コピー", + "copied": "アドレスをコピーしました。", + "copy_failed": "コピーできませんでした。上のアドレスを選択してコピーしてください。", + "save": "説明を保存", + "create": "マスクアドレスを作成", + "refresh": "更新", + "saved": "変更を保存しました。", + "forbidden": "現在のセッションにはマスクアドレスを一覧表示する権限がありません。", + "unavailable": "このアカウントまたはサーバーではマスクアドレスを利用できません。", + "load_failed": "アドレスの一覧をすべて読み込めませんでした。更新してもう一度お試しください。", + "save_failed": "変更を確認できませんでした。一覧を更新してから、もう一度お試しください。", + "create_forbidden": "このセッションでは、サーバーがマスクアドレスの作成を許可しませんでした。", + "update_forbidden": "このセッションでは、サーバーがマスクアドレスの編集を許可しませんでした。アドレスのコピーは引き続き可能です。", + "over_quota": "サーバーのマスクアドレスの上限に達しました。無効にしたアドレスも上限に含まれる場合があります。", + "rate_limit": "サーバーの作成回数の制限に達しました。しばらくしてからもう一度お試しください。" + }, "title": "アカウントセキュリティ", "description": "パスワード、二要素認証、セキュリティ設定を管理します", "detecting": "サーバー機能を検出中...", diff --git a/locales/ko/common.json b/locales/ko/common.json index 42a1e7baf..721556c43 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "주소", + "description_column": "설명", + "website_column": "웹사이트", + "state": "상태", + "actions": "작업", + "no_description": "설명 없음", + "edit": "편집", + "close": "닫기", + "pagination": "마스킹된 주소 페이지", + "page": "{pages}페이지 중 {page}페이지", + "previous": "이전", + "next": "다음", + "delete": "삭제", + "delete_title": "마스킹된 주소를 영구 삭제할까요?", + "delete_warning": "{address} 주소를 삭제할까요? 되돌릴 수 없어요. 이 주소로 보낸 메일은 더 이상 전달되지 않아요.", + "delete_confirm": "영구 삭제", + "cancel": "취소", + "deleted": "주소를 영구 삭제했어요.", + "deleted_refresh_failed": "삭제가 확인됐지만 목록을 새로 고치지 못했어요. 새로 고쳐서 최신 목록을 불러오세요.", + "delete_forbidden": "현재 세션에는 마스킹된 주소를 삭제할 권한이 없어요. 허용된 다른 작업은 계속 사용할 수 있어요.", + "delete_failed": "주소가 삭제되지 않았어요. 다시 시도하기 전에 목록을 새로 고치세요.", + "delete_unconfirmed": "삭제를 확인할 수 없어요. 다시 시도하지 않아요. 추가로 삭제하기 전에 목록을 새로 고쳐 주소를 확인하세요.", + "title": "마스킹된 주소", + "intro": "기본 주소를 알려주지 않고 메일을 받을 수 있어요. 주소를 비활성화하면 그 주소로 오는 새 메일은 거부돼요. 이 주소로는 메일을 보낼 수 없어요.", + "loading": "마스킹된 주소를 불러오는 중…", + "count": "{count, plural, other {주소 #개}}", + "empty": "아직 마스킹된 주소가 없어요.", + "website": "웹사이트(선택 사항)", + "description": "설명(선택 사항)", + "no_website": "지정되지 않음", + "enabled": "사용 중", + "disabled": "사용 안 함", + "enable": "사용", + "disable": "비활성화", + "copy": "복사", + "copied": "주소를 복사했어요.", + "copy_failed": "복사하지 못했어요. 위의 주소를 선택해서 복사하세요.", + "save": "설명 저장", + "create": "마스킹된 주소 만들기", + "refresh": "새로 고침", + "saved": "변경 사항을 저장했어요.", + "forbidden": "현재 세션에는 마스킹된 주소 목록을 볼 권한이 없어요.", + "unavailable": "이 계정이나 서버에서는 마스킹된 주소를 사용할 수 없어요.", + "load_failed": "전체 주소 목록을 불러오지 못했어요. 새로 고쳐서 다시 시도하세요.", + "save_failed": "변경 사항을 확인할 수 없어요. 다시 시도하기 전에 목록을 새로 고치세요.", + "create_forbidden": "서버가 이 세션으로 마스킹된 주소를 만드는 것을 허용하지 않았어요.", + "update_forbidden": "서버가 이 세션으로 마스킹된 주소를 편집하는 것을 허용하지 않았어요. 주소 복사는 계속할 수 있어요.", + "over_quota": "서버의 마스킹된 주소 할당량에 도달했어요. 비활성화한 주소도 한도에 포함될 수 있어요.", + "rate_limit": "서버의 생성 속도 제한에 도달했어요. 나중에 다시 시도하세요." + }, "title": "계정 보안", "description": "비밀번호, 2단계 인증 및 보안 설정을 관리해요", "detecting": "서버 기능을 확인하는 중...", diff --git a/locales/lv/common.json b/locales/lv/common.json index 9b30806e2..2c0446fe8 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Adrese", + "description_column": "Apraksts", + "website_column": "Vietne", + "state": "Stāvoklis", + "actions": "Darbības", + "no_description": "Nav apraksta", + "edit": "Rediģēt", + "close": "Aizvērt", + "pagination": "Maskēto adrešu lapas", + "page": "{page}. lapa no {pages}", + "previous": "Iepriekšējā", + "next": "Nākamā", + "delete": "Dzēst", + "delete_title": "Neatgriezeniski dzēst maskēto adresi?", + "delete_warning": "Dzēst {address}? To nevar atsaukt. Uz šo adresi sūtītās vēstules vairs netiks piegādātas.", + "delete_confirm": "Dzēst neatgriezeniski", + "cancel": "Atcelt", + "deleted": "Adrese ir neatgriezeniski izdzēsta.", + "deleted_refresh_failed": "Dzēšana apstiprināta, bet sarakstu neizdevās atsvaidzināt. Atsvaidziniet, lai ielādētu pašreizējo sarakstu.", + "delete_forbidden": "Jūsu pašreizējai sesijai nav atļauts dzēst maskētās adreses. Citas atļautās darbības joprojām ir pieejamas.", + "delete_failed": "Adrese netika izdzēsta. Pirms mēģināt vēlreiz, atsvaidziniet sarakstu.", + "delete_unconfirmed": "Dzēšanu neizdevās apstiprināt. Tā netiks atkārtota. Pirms turpmākas dzēšanas atsvaidziniet sarakstu un pārbaudiet adresi.", + "title": "Maskētās adreses", + "intro": "Saņemiet vēstules, neizpaužot savu galveno adresi. Atspējota adrese noraida jaunas vēstules. Šīs adreses nevar izmantot vēstuļu sūtīšanai.", + "loading": "Ielādē maskētās adreses…", + "count": "{count, plural, zero {# adrešu} one {# adrese} other {# adreses}}", + "empty": "Vēl nav maskēto adrešu.", + "website": "Vietne (neobligāti)", + "description": "Apraksts (neobligāti)", + "no_website": "Nav norādīta", + "enabled": "Iespējota", + "disabled": "Atspējota", + "enable": "Iespējot", + "disable": "Atspējot", + "copy": "Kopēt", + "copied": "Adrese nokopēta.", + "copy_failed": "Neizdevās nokopēt. Atlasiet un nokopējiet adresi augstāk.", + "save": "Saglabāt aprakstu", + "create": "Izveidot maskēto adresi", + "refresh": "Atsvaidzināt", + "saved": "Izmaiņas saglabātas.", + "forbidden": "Jūsu pašreizējai sesijai nav atļauts skatīt maskēto adrešu sarakstu.", + "unavailable": "Maskētās adreses šim kontam vai serverim nav pieejamas.", + "load_failed": "Neizdevās ielādēt pilnu adrešu sarakstu. Atsvaidziniet, lai mēģinātu vēlreiz.", + "save_failed": "Izmaiņas neizdevās apstiprināt. Pirms mēģināt vēlreiz, atsvaidziniet sarakstu.", + "create_forbidden": "Serveris neatļāva ar šo sesiju izveidot maskēto adresi.", + "update_forbidden": "Serveris neatļāva ar šo sesiju rediģēt maskētās adreses. Adreses joprojām varat kopēt.", + "over_quota": "Sasniegta servera maskēto adrešu kvota. Atspējotās adreses joprojām var tikt ieskaitītas limitā.", + "rate_limit": "Sasniegts servera izveides ātruma limits. Mēģiniet vēlāk vēlreiz." + }, "title": "Konta drošība", "description": "Pārvaldiet paroli, 2FA un drošības iestatījumus", "detecting": "Nosaka servera iespējas...", diff --git a/locales/mn/common.json b/locales/mn/common.json index 5cb6a933f..c502f56ee 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2142,6 +2142,57 @@ "managing": "Удирдлага: {name}" }, "security": { + "masked_addresses": { + "address": "Хаяг", + "description_column": "Тайлбар", + "website_column": "Вэбсайт", + "state": "Төлөв", + "actions": "Үйлдэл", + "no_description": "Тайлбаргүй", + "edit": "Засах", + "close": "Хаах", + "pagination": "Нуусан хаягийн хуудсууд", + "page": "{pages}-с {page}-р хуудас", + "previous": "Өмнөх", + "next": "Дараах", + "delete": "Устгах", + "delete_title": "Нуусан хаягийг бүрмөсөн устгах уу?", + "delete_warning": "{address}-г устгах уу? Үүнийг буцаах боломжгүй. Энэ хаяг руу илгээсэн захидал цаашид хүргэгдэхгүй.", + "delete_confirm": "Бүрмөсөн устгах", + "cancel": "Цуцлах", + "deleted": "Хаягийг бүрмөсөн устгалаа.", + "deleted_refresh_failed": "Устгалт баталгаажсан ч жагсаалтыг шинэчилж чадсангүй. Одоогийн жагсаалтыг ачаалахын тулд шинэчилнэ үү.", + "delete_forbidden": "Таны одоогийн сесс нуусан хаяг устгах эрхгүй. Бусад зөвшөөрөгдсөн үйлдлүүд боломжтой хэвээр байна.", + "delete_failed": "Хаяг устгагдсангүй. Дахин оролдохоосоо өмнө жагсаалтыг шинэчилнэ үү.", + "delete_unconfirmed": "Устгалтыг баталгаажуулж чадсангүй. Дахин оролдохгүй. Цааш устгахаасаа өмнө жагсаалтыг шинэчилж хаягийг шалгана уу.", + "title": "Нуусан хаягууд", + "intro": "Үндсэн хаягаа хуваалцахгүйгээр захидал хүлээн аваарай. Хаягийг идэвхгүй болговол түүн рүү ирэх шинэ захидлыг татгалзана. Эдгээр хаягаар захидал илгээх боломжгүй.", + "loading": "Нуусан хаягуудыг ачаалж байна…", + "count": "{count, plural, one {# хаяг} other {# хаяг}}", + "empty": "Одоогоор нуусан хаяг алга.", + "website": "Вэбсайт (заавал биш)", + "description": "Тайлбар (заавал биш)", + "no_website": "Заагаагүй", + "enabled": "Идэвхтэй", + "disabled": "Идэвхгүй", + "enable": "Идэвхжүүлэх", + "disable": "Идэвхгүй болгох", + "copy": "Хуулах", + "copied": "Хаягийг хууллаа.", + "copy_failed": "Хуулж чадсангүй. Дээрх хаягийг сонгоод хуулна уу.", + "save": "Тайлбар хадгалах", + "create": "Нуусан хаяг үүсгэх", + "refresh": "Сэргээх", + "saved": "Өөрчлөлтийг хадгаллаа.", + "forbidden": "Таны одоогийн сесс нуусан хаягуудыг жагсаах эрхгүй.", + "unavailable": "Энэ бүртгэл эсвэл серверт нуусан хаяг боломжгүй.", + "load_failed": "Хаягийн бүрэн жагсаалтыг ачаалж чадсангүй. Дахин оролдохын тулд шинэчилнэ үү.", + "save_failed": "Өөрчлөлтийг баталгаажуулж чадсангүй. Дахин оролдохоосоо өмнө жагсаалтыг шинэчилнэ үү.", + "create_forbidden": "Сервер энэ сессээр нуусан хаяг үүсгэхийг зөвшөөрсөнгүй.", + "update_forbidden": "Сервер энэ сессээр нуусан хаяг засахыг зөвшөөрсөнгүй. Та хаягуудыг хуулах боломжтой хэвээр байна.", + "over_quota": "Серверийн нуусан хаягийн квот дүүрсэн. Идэвхгүй хаягууд хязгаарт тооцогдсоор байж магадгүй.", + "rate_limit": "Серверийн үүсгэх давтамжийн хязгаарт хүрлээ. Дараа дахин оролдоно уу." + }, "title": "Дансны аюулгүй байдал", "description": "Нууц үг, хоёр хүчин зүйлийн баталгаажуулалт, аюулгүй байдлын тохиргоогоо удирдах", "detecting": "Серверийн чадварыг илрүүлж байна...", diff --git a/locales/nb/common.json b/locales/nb/common.json index 708cf47ab..5ab67e3bf 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2142,6 +2142,57 @@ "managing": "Innstillinger for: {name}" }, "security": { + "masked_addresses": { + "address": "Adresse", + "description_column": "Beskrivelse", + "website_column": "Nettsted", + "state": "Status", + "actions": "Handlinger", + "no_description": "Ingen beskrivelse", + "edit": "Rediger", + "close": "Lukk", + "pagination": "Sider med maskerte adresser", + "page": "Side {page} av {pages}", + "previous": "Forrige", + "next": "Neste", + "delete": "Slett", + "delete_title": "Slette den maskerte adressen permanent?", + "delete_warning": "Slette {address}? Dette kan ikke angres. E-post som sendes til denne adressen, blir ikke lenger levert.", + "delete_confirm": "Slett permanent", + "cancel": "Avbryt", + "deleted": "Adressen er slettet permanent.", + "deleted_refresh_failed": "Slettingen er bekreftet, men listen kunne ikke oppdateres. Oppdater for å laste inn den gjeldende listen.", + "delete_forbidden": "Den gjeldende økten din har ikke tillatelse til å slette maskerte adresser. Andre tillatte handlinger er fortsatt tilgjengelige.", + "delete_failed": "Adressen ble ikke slettet. Oppdater listen før du prøver igjen.", + "delete_unconfirmed": "Slettingen kunne ikke bekreftes. Den blir ikke forsøkt på nytt. Oppdater listen og sjekk adressen før du sletter noe mer.", + "title": "Maskerte adresser", + "intro": "Motta e-post uten å dele hovedadressen din. Når en adresse deaktiveres, avvises ny e-post til den. Disse adressene kan ikke brukes til å sende e-post.", + "loading": "Laster inn maskerte adresser…", + "count": "{count, plural, one {# adresse} other {# adresser}}", + "empty": "Ingen maskerte adresser ennå.", + "website": "Nettsted (valgfritt)", + "description": "Beskrivelse (valgfritt)", + "no_website": "Ikke angitt", + "enabled": "Aktivert", + "disabled": "Deaktivert", + "enable": "Aktiver", + "disable": "Deaktiver", + "copy": "Kopier", + "copied": "Adressen er kopiert.", + "copy_failed": "Kunne ikke kopiere. Merk og kopier adressen ovenfor.", + "save": "Lagre beskrivelse", + "create": "Opprett maskert adresse", + "refresh": "Oppdater", + "saved": "Endringen er lagret.", + "forbidden": "Den gjeldende økten din har ikke tillatelse til å vise maskerte adresser.", + "unavailable": "Maskerte adresser er ikke tilgjengelige for denne kontoen eller serveren.", + "load_failed": "Kunne ikke laste inn hele adresselisten. Oppdater for å prøve igjen.", + "save_failed": "Endringen kunne ikke bekreftes. Oppdater listen før du prøver igjen.", + "create_forbidden": "Serveren tillot ikke å opprette en maskert adresse med denne økten.", + "update_forbidden": "Serveren tillot ikke å redigere maskerte adresser med denne økten. Du kan fortsatt kopiere adresser.", + "over_quota": "Serverens kvote for maskerte adresser er nådd. Deaktiverte adresser kan fortsatt telle med i grensen.", + "rate_limit": "Serverens grense for oppretting er nådd. Prøv igjen senere." + }, "title": "Kontosikkerhet", "description": "Administrer passordet ditt, tofaktorautentisering og sikkerhetsinnstillinger", "detecting": "Kontrollerer hvilke funksjoner serveren støtter...", diff --git a/locales/nl/common.json b/locales/nl/common.json index 0290e9e32..3deddca7f 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Adres", + "description_column": "Beschrijving", + "website_column": "Website", + "state": "Status", + "actions": "Acties", + "no_description": "Geen beschrijving", + "edit": "Bewerken", + "close": "Sluiten", + "pagination": "Pagina's met gemaskeerde adressen", + "page": "Pagina {page} van {pages}", + "previous": "Vorige", + "next": "Volgende", + "delete": "Verwijderen", + "delete_title": "Gemaskeerd adres definitief verwijderen?", + "delete_warning": "{address} verwijderen? Dit kan niet ongedaan worden gemaakt. E-mail naar dit adres wordt niet meer bezorgd.", + "delete_confirm": "Definitief verwijderen", + "cancel": "Annuleren", + "deleted": "Adres definitief verwijderd.", + "deleted_refresh_failed": "Verwijdering bevestigd, maar de lijst kon niet worden vernieuwd. Vernieuw om de actuele lijst te laden.", + "delete_forbidden": "Uw huidige sessie mag geen gemaskeerde adressen verwijderen. Andere toegestane acties blijven beschikbaar.", + "delete_failed": "Het adres is niet verwijderd. Vernieuw de lijst voordat u het opnieuw probeert.", + "delete_unconfirmed": "De verwijdering kon niet worden bevestigd en wordt niet opnieuw geprobeerd. Vernieuw de lijst en controleer het adres voordat u verder iets verwijdert.", + "title": "Gemaskeerde adressen", + "intro": "Ontvang e-mail zonder uw hoofdadres te delen. Als u een adres uitschakelt, wordt nieuwe e-mail naar dat adres geweigerd. Met deze adressen kunt u geen e-mail versturen.", + "loading": "Gemaskeerde adressen laden…", + "count": "{count, plural, one {# adres} other {# adressen}}", + "empty": "Nog geen gemaskeerde adressen.", + "website": "Website (optioneel)", + "description": "Beschrijving (optioneel)", + "no_website": "Niet opgegeven", + "enabled": "Ingeschakeld", + "disabled": "Uitgeschakeld", + "enable": "Inschakelen", + "disable": "Uitschakelen", + "copy": "Kopiëren", + "copied": "Adres gekopieerd.", + "copy_failed": "Kopiëren mislukt. Selecteer en kopieer het adres hierboven.", + "save": "Beschrijving opslaan", + "create": "Gemaskeerd adres maken", + "refresh": "Vernieuwen", + "saved": "Wijziging opgeslagen.", + "forbidden": "Uw huidige sessie mag geen gemaskeerde adressen weergeven.", + "unavailable": "Gemaskeerde adressen zijn niet beschikbaar voor dit account of deze server.", + "load_failed": "De volledige adreslijst kon niet worden geladen. Vernieuw om het opnieuw te proberen.", + "save_failed": "De wijziging kon niet worden bevestigd. Vernieuw de lijst voordat u het opnieuw probeert.", + "create_forbidden": "De server stond niet toe dat met deze sessie een gemaskeerd adres werd gemaakt.", + "update_forbidden": "De server stond niet toe dat met deze sessie gemaskeerde adressen werden bewerkt. U kunt adressen nog wel kopiëren.", + "over_quota": "Het quotum voor gemaskeerde adressen op de server is bereikt. Uitgeschakelde adressen kunnen nog meetellen voor de limiet.", + "rate_limit": "De aanmaaklimiet van de server is bereikt. Probeer het later opnieuw." + }, "title": "Accountbeveiliging", "description": "Beheer uw wachtwoord, tweefactorauthenticatie en beveiligingsinstellingen", "detecting": "Servermogelijkheden worden gedetecteerd...", diff --git a/locales/pl/common.json b/locales/pl/common.json index 897ef6039..5337d6ff6 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Adres", + "description_column": "Opis", + "website_column": "Witryna", + "state": "Stan", + "actions": "Działania", + "no_description": "Brak opisu", + "edit": "Edytuj", + "close": "Zamknij", + "pagination": "Strony maskowanych adresów", + "page": "Strona {page} z {pages}", + "previous": "Poprzednia", + "next": "Następna", + "delete": "Usuń", + "delete_title": "Trwale usunąć maskowany adres?", + "delete_warning": "Usunąć {address}? Tej operacji nie można cofnąć. Poczta wysłana na ten adres nie będzie już dostarczana.", + "delete_confirm": "Usuń trwale", + "cancel": "Anuluj", + "deleted": "Adres został trwale usunięty.", + "deleted_refresh_failed": "Usunięcie potwierdzone, ale nie udało się odświeżyć listy. Odśwież, aby wczytać aktualną listę.", + "delete_forbidden": "Twoja bieżąca sesja nie ma uprawnień do usuwania maskowanych adresów. Pozostałe dozwolone działania są nadal dostępne.", + "delete_failed": "Adres nie został usunięty. Odśwież listę, zanim spróbujesz ponownie.", + "delete_unconfirmed": "Nie udało się potwierdzić usunięcia. Nie zostanie ono ponowione. Odśwież listę i sprawdź adres, zanim cokolwiek usuniesz.", + "title": "Maskowane adresy", + "intro": "Odbieraj pocztę bez udostępniania swojego głównego adresu. Wyłączony adres odrzuca nową pocztę. Z tych adresów nie można wysyłać poczty.", + "loading": "Wczytywanie maskowanych adresów…", + "count": "{count, plural, one {# adres} few {# adresy} many {# adresów} other {# adresu}}", + "empty": "Nie ma jeszcze maskowanych adresów.", + "website": "Witryna (opcjonalnie)", + "description": "Opis (opcjonalnie)", + "no_website": "Nie podano", + "enabled": "Włączony", + "disabled": "Wyłączony", + "enable": "Włącz", + "disable": "Wyłącz", + "copy": "Kopiuj", + "copied": "Skopiowano adres.", + "copy_failed": "Nie udało się skopiować. Zaznacz i skopiuj adres powyżej.", + "save": "Zapisz opis", + "create": "Utwórz maskowany adres", + "refresh": "Odśwież", + "saved": "Zapisano zmianę.", + "forbidden": "Twoja bieżąca sesja nie ma uprawnień do wyświetlania maskowanych adresów.", + "unavailable": "Maskowane adresy nie są dostępne dla tego konta lub serwera.", + "load_failed": "Nie udało się wczytać pełnej listy adresów. Odśwież, aby spróbować ponownie.", + "save_failed": "Nie udało się potwierdzić zmiany. Odśwież listę, zanim spróbujesz ponownie.", + "create_forbidden": "Serwer nie zezwolił na utworzenie maskowanego adresu w tej sesji.", + "update_forbidden": "Serwer nie zezwolił na edycję maskowanych adresów w tej sesji. Nadal możesz kopiować adresy.", + "over_quota": "Osiągnięto limit maskowanych adresów na serwerze. Wyłączone adresy mogą nadal wliczać się do limitu.", + "rate_limit": "Osiągnięto limit tworzenia adresów na serwerze. Spróbuj ponownie później." + }, "title": "Bezpieczeństwo konta", "description": "Zarządzaj hasłem, uwierzytelnianiem dwuskładnikowym i ustawieniami bezpieczeństwa", "detecting": "Wykrywanie możliwości serwera...", diff --git a/locales/pt/common.json b/locales/pt/common.json index 73f060dbd..7bddb6857 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Endereço", + "description_column": "Descrição", + "website_column": "Site", + "state": "Estado", + "actions": "Ações", + "no_description": "Sem descrição", + "edit": "Editar", + "close": "Fechar", + "pagination": "Páginas de endereços mascarados", + "page": "Página {page} de {pages}", + "previous": "Anterior", + "next": "Seguinte", + "delete": "Eliminar", + "delete_title": "Eliminar definitivamente o endereço mascarado?", + "delete_warning": "Eliminar {address}? Esta ação não pode ser anulada. O correio enviado para este endereço deixará de ser entregue.", + "delete_confirm": "Eliminar definitivamente", + "cancel": "Cancelar", + "deleted": "Endereço eliminado definitivamente.", + "deleted_refresh_failed": "Eliminação confirmada, mas não foi possível atualizar a lista. Atualize para carregar a lista atual.", + "delete_forbidden": "A sua sessão atual não tem permissão para eliminar endereços mascarados. As restantes ações permitidas continuam disponíveis.", + "delete_failed": "O endereço não foi eliminado. Atualize a lista antes de tentar novamente.", + "delete_unconfirmed": "Não foi possível confirmar a eliminação. Não será repetida. Atualize a lista para verificar o endereço antes de qualquer nova eliminação.", + "title": "Endereços mascarados", + "intro": "Receba correio sem partilhar o seu endereço principal. Ao desativar um endereço, o correio novo enviado para ele é rejeitado. Estes endereços não podem ser utilizados para enviar correio.", + "loading": "A carregar endereços mascarados…", + "count": "{count, plural, one {# endereço} other {# endereços}}", + "empty": "Ainda não existem endereços mascarados.", + "website": "Site (opcional)", + "description": "Descrição (opcional)", + "no_website": "Não especificado", + "enabled": "Ativado", + "disabled": "Desativado", + "enable": "Ativar", + "disable": "Desativar", + "copy": "Copiar", + "copied": "Endereço copiado.", + "copy_failed": "Não foi possível copiar. Selecione e copie o endereço acima.", + "save": "Guardar descrição", + "create": "Criar endereço mascarado", + "refresh": "Atualizar", + "saved": "Alteração guardada.", + "forbidden": "A sua sessão atual não tem permissão para listar endereços mascarados.", + "unavailable": "Os endereços mascarados não estão disponíveis para esta conta ou servidor.", + "load_failed": "Não foi possível carregar a lista completa de endereços. Atualize para tentar novamente.", + "save_failed": "Não foi possível confirmar a alteração. Atualize a lista antes de tentar novamente.", + "create_forbidden": "O servidor não permitiu criar um endereço mascarado com esta sessão.", + "update_forbidden": "O servidor não permitiu editar endereços mascarados com esta sessão. Ainda pode copiar os endereços.", + "over_quota": "Foi atingida a quota de endereços mascarados do servidor. Os endereços desativados podem continuar a contar para o limite.", + "rate_limit": "Foi atingido o limite de criação do servidor. Tente novamente mais tarde." + }, "title": "Segurança da conta", "description": "Gerencie sua senha, autenticação de dois fatores e configurações de segurança", "detecting": "Detectando recursos do servidor...", diff --git a/locales/ro/common.json b/locales/ro/common.json index 26633ecc4..7582e5724 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2142,6 +2142,57 @@ "managing": "Administrare: {name}" }, "security": { + "masked_addresses": { + "address": "Adresă", + "description_column": "Descriere", + "website_column": "Site web", + "state": "Stare", + "actions": "Acțiuni", + "no_description": "Fără descriere", + "edit": "Editare", + "close": "Închidere", + "pagination": "Pagini cu adrese mascate", + "page": "Pagina {page} din {pages}", + "previous": "Anterioară", + "next": "Următoare", + "delete": "Ștergere", + "delete_title": "Ștergeți definitiv adresa mascată?", + "delete_warning": "Ștergeți {address}? Acțiunea nu poate fi anulată. E-mailurile trimise la această adresă nu vor mai fi livrate.", + "delete_confirm": "Ștergeți definitiv", + "cancel": "Anulează", + "deleted": "Adresa a fost ștearsă definitiv.", + "deleted_refresh_failed": "Ștergerea a fost confirmată, dar lista nu a putut fi reîmprospătată. Reîmprospătați pentru a încărca lista actuală.", + "delete_forbidden": "Sesiunea curentă nu are permisiunea de a șterge adrese mascate. Celelalte acțiuni permise rămân disponibile.", + "delete_failed": "Adresa nu a fost ștearsă. Reîmprospătați lista înainte de a încerca din nou.", + "delete_unconfirmed": "Ștergerea nu a putut fi confirmată și nu va fi reîncercată. Reîmprospătați lista pentru a verifica adresa înainte de orice altă ștergere.", + "title": "Adrese mascate", + "intro": "Primiți e-mailuri fără să vă dezvăluiți adresa principală. Dezactivarea unei adrese respinge e-mailurile noi trimise la ea. Aceste adrese nu pot fi folosite pentru trimiterea de e-mailuri.", + "loading": "Se încarcă adresele mascate…", + "count": "{count, plural, one {# adresă} few {# adrese} other {# de adrese}}", + "empty": "Nu există încă adrese mascate.", + "website": "Site web (opțional)", + "description": "Descriere (opțional)", + "no_website": "Nespecificat", + "enabled": "Activată", + "disabled": "Dezactivată", + "enable": "Activați", + "disable": "Dezactivați", + "copy": "Copiere", + "copied": "Adresa a fost copiată.", + "copy_failed": "Copierea nu a reușit. Selectați și copiați adresa de mai sus.", + "save": "Salvați descrierea", + "create": "Creați o adresă mascată", + "refresh": "Reîmprospătare", + "saved": "Modificarea a fost salvată.", + "forbidden": "Sesiunea curentă nu are permisiunea de a lista adresele mascate.", + "unavailable": "Adresele mascate nu sunt disponibile pentru acest cont sau server.", + "load_failed": "Lista completă de adrese nu a putut fi încărcată. Reîmprospătați pentru a încerca din nou.", + "save_failed": "Modificarea nu a putut fi confirmată. Reîmprospătați lista înainte de a încerca din nou.", + "create_forbidden": "Serverul nu a permis crearea unei adrese mascate cu această sesiune.", + "update_forbidden": "Serverul nu a permis editarea adreselor mascate cu această sesiune. Puteți copia în continuare adresele.", + "over_quota": "Cota de adrese mascate a serverului a fost atinsă. Adresele dezactivate pot fi luate în calcul în continuare.", + "rate_limit": "Limita de creare a serverului a fost atinsă. Încercați din nou mai târziu." + }, "title": "Securitatea contului", "description": "Gestionați-vă parola, autentificarea în doi pași și setările de securitate", "detecting": "Se detectează capacitățile serverului...", diff --git a/locales/ru/common.json b/locales/ru/common.json index b0d758057..239223000 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Адрес", + "description_column": "Описание", + "website_column": "Сайт", + "state": "Состояние", + "actions": "Действия", + "no_description": "Без описания", + "edit": "Изменить", + "close": "Закрыть", + "pagination": "Страницы замаскированных адресов", + "page": "Страница {page} из {pages}", + "previous": "Назад", + "next": "Далее", + "delete": "Удалить", + "delete_title": "Удалить замаскированный адрес навсегда?", + "delete_warning": "Удалить {address}? Это действие нельзя отменить. Письма на этот адрес больше не будут доставляться.", + "delete_confirm": "Удалить навсегда", + "cancel": "Отмена", + "deleted": "Адрес удалён навсегда.", + "deleted_refresh_failed": "Удаление подтверждено, но список не удалось обновить. Обновите его, чтобы загрузить актуальный список.", + "delete_forbidden": "Текущему сеансу не разрешено удалять замаскированные адреса. Другие разрешённые действия по-прежнему доступны.", + "delete_failed": "Адрес не удалён. Обновите список, прежде чем пробовать снова.", + "delete_unconfirmed": "Не удалось подтвердить удаление. Повторной попытки не будет. Обновите список и проверьте адрес, прежде чем удалять что-либо ещё.", + "title": "Замаскированные адреса", + "intro": "Получайте почту, не раскрывая свой основной адрес. Отключённый адрес отклоняет новые письма. С этих адресов нельзя отправлять почту.", + "loading": "Загрузка замаскированных адресов…", + "count": "{count, plural, one {# адрес} few {# адреса} many {# адресов} other {# адреса}}", + "empty": "Замаскированных адресов пока нет.", + "website": "Сайт (необязательно)", + "description": "Описание (необязательно)", + "no_website": "Не указан", + "enabled": "Включён", + "disabled": "Отключён", + "enable": "Включить", + "disable": "Отключить", + "copy": "Копировать", + "copied": "Адрес скопирован.", + "copy_failed": "Не удалось скопировать. Выделите и скопируйте адрес выше.", + "save": "Сохранить описание", + "create": "Создать замаскированный адрес", + "refresh": "Обновить", + "saved": "Изменение сохранено.", + "forbidden": "Текущему сеансу не разрешено просматривать замаскированные адреса.", + "unavailable": "Замаскированные адреса недоступны для этой учётной записи или сервера.", + "load_failed": "Не удалось загрузить полный список адресов. Обновите, чтобы попробовать снова.", + "save_failed": "Не удалось подтвердить изменение. Обновите список, прежде чем пробовать снова.", + "create_forbidden": "Сервер не разрешил создать замаскированный адрес в этом сеансе.", + "update_forbidden": "Сервер не разрешил изменять замаскированные адреса в этом сеансе. Копировать адреса по-прежнему можно.", + "over_quota": "Достигнута квота сервера на замаскированные адреса. Отключённые адреса тоже могут учитываться в лимите.", + "rate_limit": "Достигнут лимит сервера на создание адресов. Попробуйте позже." + }, "title": "Безопасность аккаунта", "description": "Управляйте паролем, двухфакторной аутентификацией и настройками безопасности", "detecting": "Определение возможностей сервера...", diff --git a/locales/sk/common.json b/locales/sk/common.json index 46e9c0440..844a778e4 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2142,6 +2142,57 @@ "managing": "Správa: {name}" }, "security": { + "masked_addresses": { + "address": "Adresa", + "description_column": "Popis", + "website_column": "Web", + "state": "Stav", + "actions": "Akcie", + "no_description": "Bez popisu", + "edit": "Upraviť", + "close": "Zavrieť", + "pagination": "Stránky maskovaných adries", + "page": "Strana {page} z {pages}", + "previous": "Predchádzajúca", + "next": "Ďalšia", + "delete": "Odstrániť", + "delete_title": "Natrvalo odstrániť maskovanú adresu?", + "delete_warning": "Odstrániť {address}? Túto akciu nie je možné vrátiť späť. Pošta odoslaná na túto adresu už nebude doručená.", + "delete_confirm": "Natrvalo odstrániť", + "cancel": "Zrušiť", + "deleted": "Adresa bola natrvalo odstránená.", + "deleted_refresh_failed": "Odstránenie bolo potvrdené, ale zoznam sa nepodarilo obnoviť. Obnovte ho, aby sa načítal aktuálny zoznam.", + "delete_forbidden": "Vaša aktuálna relácia nemá povolenie odstraňovať maskované adresy. Ostatné povolené akcie zostávajú dostupné.", + "delete_failed": "Adresa nebola odstránená. Skôr ako to skúsite znova, obnovte zoznam.", + "delete_unconfirmed": "Odstránenie sa nepodarilo potvrdiť. Nebude sa opakovať. Pred ďalším odstraňovaním obnovte zoznam a skontrolujte adresu.", + "title": "Maskované adresy", + "intro": "Prijímajte poštu bez toho, aby ste zdieľali svoju hlavnú adresu. Vypnutá adresa odmieta novú poštu. Z týchto adries nie je možné poštu odosielať.", + "loading": "Načítavajú sa maskované adresy…", + "count": "{count, plural, one {# adresa} few {# adresy} many {# adresy} other {# adries}}", + "empty": "Zatiaľ žiadne maskované adresy.", + "website": "Web (voliteľné)", + "description": "Popis (voliteľné)", + "no_website": "Neuvedené", + "enabled": "Zapnutá", + "disabled": "Vypnutá", + "enable": "Zapnúť", + "disable": "Vypnúť", + "copy": "Kopírovať", + "copied": "Adresa bola skopírovaná.", + "copy_failed": "Kopírovanie zlyhalo. Označte a skopírujte adresu vyššie.", + "save": "Uložiť popis", + "create": "Vytvoriť maskovanú adresu", + "refresh": "Obnoviť", + "saved": "Zmena bola uložená.", + "forbidden": "Vaša aktuálna relácia nemá povolenie zobraziť maskované adresy.", + "unavailable": "Maskované adresy nie sú pre tento účet alebo server k dispozícii.", + "load_failed": "Úplný zoznam adries sa nepodarilo načítať. Obnovte ho a skúste to znova.", + "save_failed": "Zmenu sa nepodarilo potvrdiť. Skôr ako to skúsite znova, obnovte zoznam.", + "create_forbidden": "Server v tejto relácii nepovolil vytvoriť maskovanú adresu.", + "update_forbidden": "Server v tejto relácii nepovolil upravovať maskované adresy. Adresy môžete naďalej kopírovať.", + "over_quota": "Dosiahli ste kvótu maskovaných adries na serveri. Vypnuté adresy sa do limitu môžu stále započítavať.", + "rate_limit": "Dosiahli ste limit servera na vytváranie adries. Skúste to neskôr." + }, "title": "Zabezpečenie účtu", "description": "Správa hesla, dvojfaktorového overenia a nastavení zabezpečenia", "detecting": "Zisťovanie možností servera...", diff --git a/locales/th/common.json b/locales/th/common.json index 98dff5de8..78d8feb2b 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4724,6 +4724,57 @@ "error_role": "อัปเดตบทบาทของโฟลเดอร์ไม่สำเร็จ" }, "security": { + "masked_addresses": { + "address": "ที่อยู่", + "description_column": "คำอธิบาย", + "website_column": "เว็บไซต์", + "state": "สถานะ", + "actions": "การดำเนินการ", + "no_description": "ไม่มีคำอธิบาย", + "edit": "แก้ไข", + "close": "ปิด", + "pagination": "หน้าของที่อยู่แฝง", + "page": "หน้า {page} จาก {pages}", + "previous": "ก่อนหน้า", + "next": "ถัดไป", + "delete": "ลบ", + "delete_title": "ลบที่อยู่แฝงอย่างถาวรไหม", + "delete_warning": "ลบ {address} ไหม การดำเนินการนี้ย้อนกลับไม่ได้ อีเมลที่ส่งมายังที่อยู่นี้จะไม่ถูกส่งถึงอีกต่อไป", + "delete_confirm": "ลบอย่างถาวร", + "cancel": "ยกเลิก", + "deleted": "ลบที่อยู่อย่างถาวรแล้ว", + "deleted_refresh_failed": "ยืนยันการลบแล้ว แต่รีเฟรชรายการไม่ได้ รีเฟรชเพื่อโหลดรายการปัจจุบัน", + "delete_forbidden": "เซสชันปัจจุบันของคุณไม่ได้รับอนุญาตให้ลบที่อยู่แฝง การดำเนินการอื่นที่ได้รับอนุญาตยังคงใช้ได้", + "delete_failed": "ไม่ได้ลบที่อยู่ รีเฟรชรายการก่อนลองอีกครั้ง", + "delete_unconfirmed": "ยืนยันการลบไม่ได้ และจะไม่ลองซ้ำ รีเฟรชรายการเพื่อตรวจสอบที่อยู่ก่อนลบเพิ่มเติม", + "title": "ที่อยู่แฝง", + "intro": "รับอีเมลได้โดยไม่ต้องเปิดเผยที่อยู่หลักของคุณ เมื่อปิดใช้งานที่อยู่ใด อีเมลใหม่ที่ส่งถึงที่อยู่นั้นจะถูกปฏิเสธ ที่อยู่เหล่านี้ใช้ส่งอีเมลไม่ได้", + "loading": "กำลังโหลดที่อยู่แฝง…", + "count": "{count, plural, other {# ที่อยู่}}", + "empty": "ยังไม่มีที่อยู่แฝง", + "website": "เว็บไซต์ (ไม่บังคับ)", + "description": "คำอธิบาย (ไม่บังคับ)", + "no_website": "ไม่ได้ระบุ", + "enabled": "เปิดใช้งาน", + "disabled": "ปิดใช้งาน", + "enable": "เปิดใช้งาน", + "disable": "ปิดใช้งาน", + "copy": "คัดลอก", + "copied": "คัดลอกที่อยู่แล้ว", + "copy_failed": "คัดลอกไม่ได้ เลือกและคัดลอกที่อยู่ด้านบน", + "save": "บันทึกคำอธิบาย", + "create": "สร้างที่อยู่แฝง", + "refresh": "รีเฟรช", + "saved": "บันทึกการเปลี่ยนแปลงแล้ว", + "forbidden": "เซสชันปัจจุบันของคุณไม่ได้รับอนุญาตให้แสดงรายการที่อยู่แฝง", + "unavailable": "ที่อยู่แฝงไม่พร้อมใช้งานสำหรับบัญชีหรือเซิร์ฟเวอร์นี้", + "load_failed": "โหลดรายการที่อยู่ทั้งหมดไม่ได้ รีเฟรชเพื่อลองอีกครั้ง", + "save_failed": "ยืนยันการเปลี่ยนแปลงไม่ได้ รีเฟรชรายการก่อนลองอีกครั้ง", + "create_forbidden": "เซิร์ฟเวอร์ไม่อนุญาตให้สร้างที่อยู่แฝงด้วยเซสชันนี้", + "update_forbidden": "เซิร์ฟเวอร์ไม่อนุญาตให้แก้ไขที่อยู่แฝงด้วยเซสชันนี้ คุณยังคัดลอกที่อยู่ได้", + "over_quota": "ถึงโควตาที่อยู่แฝงของเซิร์ฟเวอร์แล้ว ที่อยู่ที่ปิดใช้งานอาจยังนับรวมในขีดจำกัด", + "rate_limit": "ถึงขีดจำกัดอัตราการสร้างของเซิร์ฟเวอร์แล้ว ลองอีกครั้งภายหลัง" + }, "title": "ความปลอดภัยของบัญชี", "description": "จัดการรหัสผ่าน การยืนยันตัวตนแบบสองขั้นตอน และการตั้งค่าความปลอดภัยของคุณ", "detecting": "กำลังตรวจสอบความสามารถของเซิร์ฟเวอร์...", diff --git a/locales/tr/common.json b/locales/tr/common.json index 7110da678..d6476166f 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Adres", + "description_column": "Açıklama", + "website_column": "Web sitesi", + "state": "Durum", + "actions": "İşlemler", + "no_description": "Açıklama yok", + "edit": "Düzenle", + "close": "Kapat", + "pagination": "Maskeli adres sayfaları", + "page": "Sayfa {page}/{pages}", + "previous": "Önceki", + "next": "Sonraki", + "delete": "Sil", + "delete_title": "Maskeli adres kalıcı olarak silinsin mi?", + "delete_warning": "{address} silinsin mi? Bu işlem geri alınamaz. Bu adrese gönderilen postalar artık teslim edilmeyecek.", + "delete_confirm": "Kalıcı olarak sil", + "cancel": "İptal", + "deleted": "Adres kalıcı olarak silindi.", + "deleted_refresh_failed": "Silme işlemi onaylandı ancak liste yenilenemedi. Güncel listeyi yüklemek için yenileyin.", + "delete_forbidden": "Geçerli oturumunuzun maskeli adres silme izni yok. İzin verilen diğer işlemler kullanılabilir durumda.", + "delete_failed": "Adres silinmedi. Tekrar denemeden önce listeyi yenileyin.", + "delete_unconfirmed": "Silme işlemi onaylanamadı. Yeniden denenmeyecek. Başka bir silme işleminden önce listeyi yenileyip adresi kontrol edin.", + "title": "Maskeli adresler", + "intro": "Ana adresinizi paylaşmadan posta alın. Devre dışı bırakılan bir adres, kendisine gelen yeni postaları reddeder. Bu adreslerle posta gönderilemez.", + "loading": "Maskeli adresler yükleniyor…", + "count": "{count, plural, one {# adres} other {# adres}}", + "empty": "Henüz maskeli adres yok.", + "website": "Web sitesi (isteğe bağlı)", + "description": "Açıklama (isteğe bağlı)", + "no_website": "Belirtilmedi", + "enabled": "Etkin", + "disabled": "Devre dışı", + "enable": "Etkinleştir", + "disable": "Devre dışı bırak", + "copy": "Kopyala", + "copied": "Adres kopyalandı.", + "copy_failed": "Kopyalanamadı. Yukarıdaki adresi seçip kopyalayın.", + "save": "Açıklamayı kaydet", + "create": "Maskeli adres oluştur", + "refresh": "Yenile", + "saved": "Değişiklik kaydedildi.", + "forbidden": "Geçerli oturumunuzun maskeli adresleri listeleme izni yok.", + "unavailable": "Maskeli adresler bu hesap veya sunucu için kullanılamıyor.", + "load_failed": "Adres listesinin tamamı yüklenemedi. Tekrar denemek için yenileyin.", + "save_failed": "Değişiklik onaylanamadı. Tekrar denemeden önce listeyi yenileyin.", + "create_forbidden": "Sunucu bu oturumla maskeli adres oluşturulmasına izin vermedi.", + "update_forbidden": "Sunucu bu oturumla maskeli adreslerin düzenlenmesine izin vermedi. Adresleri kopyalamaya devam edebilirsiniz.", + "over_quota": "Sunucunun maskeli adres kotasına ulaşıldı. Devre dışı adresler sınıra dahil edilmeye devam edebilir.", + "rate_limit": "Sunucunun oluşturma hızı sınırına ulaşıldı. Daha sonra tekrar deneyin." + }, "title": "Hesap Güvenliği", "description": "Parolanızı, iki faktörlü kimlik doğrulamayı ve güvenlik ayarlarını yönetin", "detecting": "Sunucu özellikleri algılanıyor...", diff --git a/locales/uk/common.json b/locales/uk/common.json index 7e9164679..bc5a28bea 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "Адреса", + "description_column": "Опис", + "website_column": "Сайт", + "state": "Стан", + "actions": "Дії", + "no_description": "Без опису", + "edit": "Редагувати", + "close": "Закрити", + "pagination": "Сторінки замаскованих адрес", + "page": "Сторінка {page} з {pages}", + "previous": "Попередня", + "next": "Наступна", + "delete": "Видалити", + "delete_title": "Остаточно видалити замасковану адресу?", + "delete_warning": "Видалити {address}? Цю дію неможливо скасувати. Листи на цю адресу більше не доставлятимуться.", + "delete_confirm": "Видалити остаточно", + "cancel": "Скасувати", + "deleted": "Адресу остаточно видалено.", + "deleted_refresh_failed": "Видалення підтверджено, але список не вдалося оновити. Оновіть його, щоб завантажити актуальний список.", + "delete_forbidden": "Поточному сеансу не дозволено видаляти замасковані адреси. Інші дозволені дії залишаються доступними.", + "delete_failed": "Адресу не видалено. Оновіть список, перш ніж спробувати знову.", + "delete_unconfirmed": "Не вдалося підтвердити видалення. Повторної спроби не буде. Оновіть список і перевірте адресу, перш ніж видаляти щось іще.", + "title": "Замасковані адреси", + "intro": "Отримуйте пошту, не розкриваючи свою основну адресу. Вимкнена адреса відхиляє нові листи. З цих адрес не можна надсилати пошту.", + "loading": "Завантаження замаскованих адрес…", + "count": "{count, plural, one {# адреса} few {# адреси} many {# адрес} other {# адреси}}", + "empty": "Замаскованих адрес ще немає.", + "website": "Сайт (необов'язково)", + "description": "Опис (необов'язково)", + "no_website": "Не вказано", + "enabled": "Увімкнено", + "disabled": "Вимкнено", + "enable": "Увімкнути", + "disable": "Вимкнути", + "copy": "Копіювати", + "copied": "Адресу скопійовано.", + "copy_failed": "Не вдалося скопіювати. Виділіть і скопіюйте адресу вище.", + "save": "Зберегти опис", + "create": "Створити замасковану адресу", + "refresh": "Оновити", + "saved": "Зміну збережено.", + "forbidden": "Поточному сеансу не дозволено переглядати замасковані адреси.", + "unavailable": "Замасковані адреси недоступні для цього облікового запису або сервера.", + "load_failed": "Не вдалося завантажити повний список адрес. Оновіть, щоб спробувати знову.", + "save_failed": "Не вдалося підтвердити зміну. Оновіть список, перш ніж спробувати знову.", + "create_forbidden": "Сервер не дозволив створити замасковану адресу в цьому сеансі.", + "update_forbidden": "Сервер не дозволив редагувати замасковані адреси в цьому сеансі. Копіювати адреси, як і раніше, можна.", + "over_quota": "Досягнуто квоти сервера на замасковані адреси. Вимкнені адреси теж можуть враховуватися в ліміті.", + "rate_limit": "Досягнуто ліміту сервера на створення адрес. Спробуйте пізніше." + }, "title": "Безпека облікового запису", "description": "Керуйте своїм паролем, двофакторною автентифікацією та налаштуваннями безпеки", "detecting": "Виявлення можливостей сервера...", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1d61146b2..ddb508042 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2142,6 +2142,57 @@ "managing": "正在管理:{name}" }, "security": { + "masked_addresses": { + "address": "地址", + "description_column": "說明", + "website_column": "網站", + "state": "狀態", + "actions": "動作", + "no_description": "無說明", + "edit": "編輯", + "close": "關閉", + "pagination": "隱藏地址分頁", + "page": "第 {page} 頁,共 {pages} 頁", + "previous": "上一頁", + "next": "下一頁", + "delete": "刪除", + "delete_title": "要永久刪除隱藏地址嗎?", + "delete_warning": "要刪除 {address} 嗎?此動作無法復原。寄到此地址的郵件將不再傳送。", + "delete_confirm": "永久刪除", + "cancel": "取消", + "deleted": "地址已永久刪除。", + "deleted_refresh_failed": "已確認刪除,但無法重新整理清單。請重新整理以載入目前的清單。", + "delete_forbidden": "你目前的工作階段無權刪除隱藏地址。其他允許的動作仍可使用。", + "delete_failed": "地址未刪除。請先重新整理清單再試一次。", + "delete_unconfirmed": "無法確認刪除,也不會重試。在進行其他刪除之前,請重新整理清單並檢查該地址。", + "title": "隱藏地址", + "intro": "不必提供主要地址也能收信。停用地址後,寄到該地址的新郵件會被拒收。這些地址無法用來寄信。", + "loading": "正在載入隱藏地址…", + "count": "{count, plural, other {# 個地址}}", + "empty": "尚無隱藏地址。", + "website": "網站(選填)", + "description": "說明(選填)", + "no_website": "未指定", + "enabled": "已啟用", + "disabled": "已停用", + "enable": "啟用", + "disable": "停用", + "copy": "複製", + "copied": "地址已複製。", + "copy_failed": "無法複製。請選取並複製上方的地址。", + "save": "儲存說明", + "create": "建立隱藏地址", + "refresh": "重新整理", + "saved": "變更已儲存。", + "forbidden": "你目前的工作階段無權列出隱藏地址。", + "unavailable": "此帳號或伺服器不支援隱藏地址。", + "load_failed": "無法載入完整的地址清單。請重新整理後再試一次。", + "save_failed": "無法確認變更。請先重新整理清單再試一次。", + "create_forbidden": "伺服器不允許以此工作階段建立隱藏地址。", + "update_forbidden": "伺服器不允許以此工作階段編輯隱藏地址。你仍可複製地址。", + "over_quota": "已達伺服器的隱藏地址配額。已停用的地址可能仍會計入上限。", + "rate_limit": "已達伺服器的建立頻率限制。請稍後再試。" + }, "title": "帳號安全", "description": "管理您的密碼、雙因素驗證與安全設定", "detecting": "正在偵測伺服器功能…", diff --git a/locales/zh/common.json b/locales/zh/common.json index da51c1acf..3e1bfc3d2 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2135,6 +2135,57 @@ } }, "security": { + "masked_addresses": { + "address": "地址", + "description_column": "描述", + "website_column": "网站", + "state": "状态", + "actions": "操作", + "no_description": "无描述", + "edit": "编辑", + "close": "关闭", + "pagination": "隐藏地址分页", + "page": "第 {page} 页,共 {pages} 页", + "previous": "上一页", + "next": "下一页", + "delete": "删除", + "delete_title": "永久删除隐藏地址?", + "delete_warning": "删除 {address}?此操作无法撤销。发送到此地址的邮件将不再投递。", + "delete_confirm": "永久删除", + "cancel": "取消", + "deleted": "地址已永久删除。", + "deleted_refresh_failed": "删除已确认,但无法刷新列表。请刷新以加载最新列表。", + "delete_forbidden": "你当前的会话无权删除隐藏地址。其他允许的操作仍可使用。", + "delete_failed": "地址未删除。请先刷新列表再重试。", + "delete_unconfirmed": "无法确认删除,且不会重试。在进行任何其他删除之前,请刷新列表并检查该地址。", + "title": "隐藏地址", + "intro": "无需透露主地址即可接收邮件。停用地址后,发往该地址的新邮件将被拒收。这些地址不能用于发送邮件。", + "loading": "正在加载隐藏地址…", + "count": "{count, plural, other {# 个地址}}", + "empty": "还没有隐藏地址。", + "website": "网站(可选)", + "description": "描述(可选)", + "no_website": "未指定", + "enabled": "已启用", + "disabled": "已停用", + "enable": "启用", + "disable": "停用", + "copy": "复制", + "copied": "地址已复制。", + "copy_failed": "无法复制。请选中并复制上方的地址。", + "save": "保存描述", + "create": "创建隐藏地址", + "refresh": "刷新", + "saved": "更改已保存。", + "forbidden": "你当前的会话无权列出隐藏地址。", + "unavailable": "此账户或服务器不支持隐藏地址。", + "load_failed": "无法加载完整的地址列表。请刷新后重试。", + "save_failed": "无法确认更改。请先刷新列表再重试。", + "create_forbidden": "服务器不允许使用此会话创建隐藏地址。", + "update_forbidden": "服务器不允许使用此会话编辑隐藏地址。你仍可以复制地址。", + "over_quota": "已达到服务器的隐藏地址配额。已停用的地址可能仍计入限额。", + "rate_limit": "已达到服务器的创建频率限制。请稍后再试。" + }, "title": "账户安全", "description": "管理您的密码、双因素身份验证和安全设置", "detecting": "正在检测服务器功能...", diff --git a/src/api/__tests__/masked-email.test.ts b/src/api/__tests__/masked-email.test.ts new file mode 100644 index 000000000..4127bf974 --- /dev/null +++ b/src/api/__tests__/masked-email.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ stalwart: true, moved: false })); + +vi.mock('../jmap-client', () => { + class AuthenticationError extends Error {} + return { + AuthenticationError, + jmapClient: { + connectedAccountId: 'personal' as string | null, + isConnected: true, + username: 'alice@example.test', + serverUrl: 'https://mail.example.test', + request: vi.fn(), + hasAccountCapability: (urn: string, accountId: string) => state.stalwart && urn === 'urn:stalwart:jmap' && accountId === 'personal', + getMaxObjectsInGet: () => 500, + }, + }; +}); +vi.mock('../../lib/compose-login', () => ({ loginMovedFrom: () => state.moved })); + +import { AuthenticationError, jmapClient } from '../jmap-client'; +import { maskedEmailManagerForActiveLogin } from '../masked-email'; + +const client = jmapClient as unknown as { connectedAccountId: string | null; isConnected: boolean }; +const mockRequest = jmapClient.request as ReturnType; +const answer = (name: string, result: unknown) => ({ methodResponses: [[name, result, 'masked']] }); + +beforeEach(() => { + state.stalwart = true; + state.moved = false; + client.connectedAccountId = 'personal'; + client.isConnected = true; + mockRequest.mockReset(); +}); + +describe('maskedEmailManagerForActiveLogin', () => { + it('is null unless the account lists Stalwart\'s extension and a login is served', () => { + state.stalwart = false; + expect(maskedEmailManagerForActiveLogin()).toBeNull(); + state.stalwart = true; + client.connectedAccountId = null; + expect(maskedEmailManagerForActiveLogin()).toBeNull(); + client.connectedAccountId = 'personal'; + client.isConnected = false; + expect(maskedEmailManagerForActiveLogin()).toBeNull(); + }); + + it('asks Stalwart itself, one call a request, naming its capability', async () => { + mockRequest + .mockResolvedValueOnce(answer('x:MaskedEmail/query', { accountId: 'personal', ids: [], total: 0 })) + .mockResolvedValueOnce(answer('x:MaskedEmail/get', { accountId: 'personal', list: [] })); + expect(await maskedEmailManagerForActiveLogin()!.list()).toEqual([]); + expect(mockRequest.mock.calls.map(([calls, using]) => [calls.length, calls[0][0], calls[0][1].accountId, using])).toEqual([ + [1, 'x:MaskedEmail/query', 'personal', ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']], + [1, 'x:MaskedEmail/get', 'personal', ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']], + ]); + }); + + it('throws a method error by its type, with the server\'s detail', async () => { + mockRequest.mockResolvedValue(answer('error', { type: 'forbidden', description: 'Missing permission' })); + await expect(maskedEmailManagerForActiveLogin()!.list()).rejects.toMatchObject({ type: 'forbidden', message: 'Missing permission' }); + mockRequest.mockResolvedValue(answer('error', {})); + await expect(maskedEmailManagerForActiveLogin()!.list()).rejects.toMatchObject({ type: 'serverFail' }); + }); + + it('rejects an answer to another method', async () => { + mockRequest.mockResolvedValue(answer('x:MaskedEmail/get', { accountId: 'personal', list: [] })); + await expect(maskedEmailManagerForActiveLogin()!.list()).rejects.toMatchObject({ type: 'invalidResponse' }); + mockRequest.mockResolvedValue({ methodResponses: [] }); + await expect(maskedEmailManagerForActiveLogin()!.list()).rejects.toMatchObject({ type: 'invalidResponse' }); + }); + + it('tells an expired sign-in from a request that got no answer', async () => { + mockRequest.mockRejectedValueOnce(new AuthenticationError('Session expired')); + await expect(maskedEmailManagerForActiveLogin()!.list()).rejects.toMatchObject({ type: 'notAuthenticated' }); + mockRequest.mockRejectedValueOnce(new Error('Network request failed')); + await expect(maskedEmailManagerForActiveLogin()!.list()).rejects.toThrow('Network request failed'); + }); + + it('stops once another login or account is active', async () => { + const manager = maskedEmailManagerForActiveLogin()!; + expect(manager.isCurrent()).toBe(true); + state.moved = true; + expect(manager.isCurrent()).toBe(false); + state.moved = false; + client.connectedAccountId = 'other'; + expect(manager.isCurrent()).toBe(false); + await expect(manager.list()).rejects.toMatchObject({ type: 'staleContext' }); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/masked-email.ts b/src/api/masked-email.ts new file mode 100644 index 000000000..be1c7216d --- /dev/null +++ b/src/api/masked-email.ts @@ -0,0 +1,46 @@ +// Masked addresses over the JMAP client (lib/masked-email has the calls and +// their checks). The webmail sends these through its server-side +// passthrough; the app holds its own credentials and asks Stalwart itself, +// as api/account-security does. + +import { AuthenticationError, jmapClient } from './jmap-client'; +import { CAPABILITIES } from './types'; +import { loginMovedFrom } from '../lib/compose-login'; +import { servedLoginOf } from '../lib/draft-login'; +import { MaskedEmailError, createMaskedEmailManager, type MaskedEmailManager } from '../lib/masked-email'; + +const STALWART_CAPABILITY = 'urn:stalwart:jmap'; +const USING = [CAPABILITIES.CORE, STALWART_CAPABILITY]; + +/** + * A manager for the login and account active now, or null when the account + * does not list Stalwart's extension. It stops sending once another login + * or account is active; ask for a new one then. + */ +export function maskedEmailManagerForActiveLogin(): MaskedEmailManager | null { + const accountId = jmapClient.connectedAccountId; + if (!accountId || !jmapClient.hasAccountCapability(STALWART_CAPABILITY, accountId)) return null; + const owner = servedLoginOf(jmapClient); + if (!owner) return null; + return createMaskedEmailManager({ + accountId, + maxObjectsInGet: jmapClient.getMaxObjectsInGet(), + isCurrent: () => !loginMovedFrom(owner) && jmapClient.connectedAccountId === accountId, + call: async (method, args) => { + let response; + try { + response = await jmapClient.request([[method, args, 'masked']], USING); + } catch (error) { + if (error instanceof AuthenticationError) throw new MaskedEmailError('notAuthenticated', error.message); + throw error; + } + const [name, result] = response.methodResponses?.[0] ?? []; + if (name === 'error') { + const { type, description } = (result ?? {}) as { type?: string; description?: string }; + throw new MaskedEmailError(type || 'serverFail', description); + } + if (name !== method) throw new MaskedEmailError('invalidResponse'); + return result as Record; + }, + }); +} diff --git a/src/components/settings/AccountSecuritySettings.tsx b/src/components/settings/AccountSecuritySettings.tsx index 37c9b908f..9c831d4ba 100644 --- a/src/components/settings/AccountSecuritySettings.tsx +++ b/src/components/settings/AccountSecuritySettings.tsx @@ -50,6 +50,7 @@ import { type PublicKeyInfo, } from '../../api/account-security'; import { generateTotpEnrolment, type TotpEnrolment } from '../../lib/totp'; +import { MaskedAddressesSection } from './MaskedAddressesSection'; // ── TLS client certificate (mobile-only) ────────────────── // Picks an installed Android cert (iOS: imports a .p12 file) for mTLS handshakes. Independent of the @@ -1075,6 +1076,8 @@ export function AccountSecuritySettings() { )} + + {supported && !restricted && } ); } diff --git a/src/components/settings/MaskedAddressesSection.tsx b/src/components/settings/MaskedAddressesSection.tsx new file mode 100644 index 000000000..865eab33a --- /dev/null +++ b/src/components/settings/MaskedAddressesSection.tsx @@ -0,0 +1,494 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, StyleSheet, Text, View } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { RefreshCw } from '../icons'; +import Button from '../Button'; +import Input from '../Input'; +import { SettingsKeyboardContext } from './keyboard-reveal'; +import { SettingsSection } from './settings-section'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { jmapClient } from '../../api/jmap-client'; +import { useAuthStore } from '../../stores/auth-store'; +import { useLocaleStore, type TranslateFn } from '../../stores/locale-store'; +import { toast } from '../../stores/toast-store'; +import { maskedEmailManagerForActiveLogin } from '../../api/masked-email'; +import type { MaskedAddress, MaskedEmailManager } from '../../lib/masked-email'; +import { + MASKED_PAGE_SIZE, + maskedFailureNotice, + maskedLoadFailure, + maskedPage, + type MaskedChange, + type MaskedLoadFailure, + type MaskedNotice, +} from './masked-addresses'; + +// Ported from the webmail's components/settings/masked-addresses-section.tsx +// (#1534): the account's masked addresses on Stalwart, ten to a page. One +// can be copied, described, turned off (new mail to it is then rejected) or +// deleted for good, and a new one created. The section stays hidden until +// the server has answered the first read with anything but "not offered". + +type Listing = + | { status: 'loading' } + | { status: MaskedLoadFailure } + | { status: 'ready'; addresses: MaskedAddress[] }; + +// What the server refused this sign-in stays on screen next to the buttons it turned off. +const REFUSALS: MaskedNotice[] = ['create_forbidden', 'update_forbidden', 'delete_forbidden']; + +function noticeText(t: TranslateFn, notice: MaskedNotice): string { + switch (notice) { + case 'saved': return t('settings.security.masked_addresses.saved', 'Change saved.'); + case 'deleted': return t('settings.security.masked_addresses.deleted', 'Address permanently deleted.'); + case 'deleted_refresh_failed': return t('settings.security.masked_addresses.deleted_refresh_failed', 'Deletion confirmed, but the list could not be refreshed. Refresh to load the current list.'); + case 'create_forbidden': return t('settings.security.masked_addresses.create_forbidden', 'The server did not allow creating a masked address with this session.'); + case 'update_forbidden': return t('settings.security.masked_addresses.update_forbidden', 'The server did not allow editing masked addresses with this session. You can still copy addresses.'); + case 'delete_forbidden': return t('settings.security.masked_addresses.delete_forbidden', 'Your current session is not allowed to delete masked addresses. Other permitted actions remain available.'); + case 'over_quota': return t('settings.security.masked_addresses.over_quota', "The server's masked address quota has been reached. Disabled addresses may still count towards the limit."); + case 'rate_limit': return t('settings.security.masked_addresses.rate_limit', "The server's creation rate limit has been reached. Try again later."); + case 'save_failed': return t('settings.security.masked_addresses.save_failed', 'The change could not be confirmed. Refresh the list before trying again.'); + case 'delete_unconfirmed': return t('settings.security.masked_addresses.delete_unconfirmed', 'Deletion could not be confirmed. It will not be retried. Refresh the list to check the address before any further deletion.'); + case 'delete_failed': return t('settings.security.masked_addresses.delete_failed', 'The address was not deleted. Refresh the list before trying again.'); + } +} + +function loadFailureText(t: TranslateFn, failure: MaskedLoadFailure): string { + switch (failure) { + case 'forbidden': return t('settings.security.masked_addresses.forbidden', 'Your current session is not allowed to list masked addresses.'); + case 'unavailable': return t('settings.security.masked_addresses.unavailable', "Masked addresses aren't available for this account or server."); + case 'error': return t('settings.security.masked_addresses.load_failed', 'Could not load the complete address list. Refresh to try again.'); + } +} + +interface RowProps { + address: MaskedAddress; + expanded: boolean; + busy: boolean; + canUpdate: boolean; + canDelete: boolean; + onExpand: () => void; + onUpdate: (id: string, changes: { description?: string; enabled?: boolean }) => void; + onDelete: (address: MaskedAddress) => void; +} + +function MaskedAddressRow({ address, expanded, busy, canUpdate, canDelete, onExpand, onUpdate, onDelete }: RowProps) { + const c = useColors(); + const styles = useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const revealOnKeyboard = React.useContext(SettingsKeyboardContext); + const detailsRef = useRef(null); + const [description, setDescription] = useState(address.description ?? ''); + useEffect(() => { + setDescription(address.description ?? ''); + }, [address.description, expanded]); + const unchanged = description.trim() === (address.description ?? ''); + const save = () => { + if (!busy && canUpdate && !unchanged) onUpdate(address.id, { description: description.trim() }); + }; + + const copy = () => { + Clipboard.setStringAsync(address.email).then( + () => toast.success(t('settings.security.masked_addresses.copied', 'Address copied.')), + () => toast.error(t('settings.security.masked_addresses.copy_failed', 'Could not copy. Select and copy the address above.')), + ); + }; + const copyLabel = t('settings.security.masked_addresses.copy', 'Copy'); + const expandLabel = expanded + ? t('settings.security.masked_addresses.close', 'Close') + : t('settings.security.masked_addresses.edit', 'Edit'); + const switchLabel = address.enabled + ? t('settings.security.masked_addresses.disable', 'Disable') + : t('settings.security.masked_addresses.enable', 'Enable'); + const deleteLabel = t('settings.security.masked_addresses.delete', 'Delete'); + const descriptionLabel = t('settings.security.masked_addresses.description', 'Description (optional)'); + + return ( + + + {address.email} + + {address.description || t('settings.security.masked_addresses.no_description', 'No description')} + {' · '} + {/* The website is shown as text only, never opened. */} + {address.forDomain || t('settings.security.masked_addresses.no_website', 'Not specified')} + + + + {address.enabled + ? t('settings.security.masked_addresses.enabled', 'Enabled') + : t('settings.security.masked_addresses.disabled', 'Disabled')} + + + + + + + + {expanded && ( + + revealOnKeyboard?.(detailsRef.current)} + /> + + + + + + + + + )} + + ); +} + +export function MaskedAddressesSection() { + const c = useColors(); + const styles = useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const session = useAuthStore((s) => s.session); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + // connectedAccountId, not accountId: the client is between sessions while + // an account switch reconnects, and accountId throws then. + const accountId = session ? jmapClient.connectedAccountId : null; + const revealOnKeyboard = React.useContext(SettingsKeyboardContext); + const formRef = useRef(null); + // The manager is bound to the login and account active when it is made; + // another login or account gets a new one. + // eslint-disable-next-line react-hooks/exhaustive-deps + const manager = useMemo(() => maskedEmailManagerForActiveLogin(), [activeAccountId, accountId]); + // The manager the section shows now: answers to an earlier one are dropped. + const latest = useRef(manager); + + const [view, setView] = useState<{ manager: MaskedEmailManager | null } & Listing>({ manager: null, status: 'loading' }); + // The manager whose server answered the first read with anything but "not offered". + const [shownFor, setShownFor] = useState(null); + const [busy, setBusy] = useState<'refresh' | MaskedChange | null>(null); + const operation = useRef(null); + const confirming = useRef(null); + const [canCreate, setCanCreate] = useState(true); + const [canUpdate, setCanUpdate] = useState(true); + const [canDelete, setCanDelete] = useState(true); + const [deleteNeedsRefresh, setDeleteNeedsRefresh] = useState(false); + const [expandedId, setExpandedId] = useState(null); + const [page, setPage] = useState(0); + const [notice, setNotice] = useState(null); + const [description, setDescription] = useState(''); + const [website, setWebsite] = useState(''); + + const load = useCallback(async (): Promise => { + if (!manager) return null; + try { + const addresses = await manager.list(); + if (latest.current !== manager || !manager.isCurrent()) return null; + setView({ manager, status: 'ready', addresses }); + setShownFor(manager); + setPage((previous) => maskedPage(addresses, previous).page); + setExpandedId((previous) => (addresses.some((address) => address.id === previous) ? previous : null)); + setDeleteNeedsRefresh(false); + return addresses; + } catch (error) { + if (latest.current === manager && manager.isCurrent()) { + const failure = maskedLoadFailure(error); + setView({ manager, status: failure }); + if (failure !== 'unavailable') setShownFor(manager); + } + return null; + } + }, [manager]); + + // Another account: nothing of the previous one stays, typed text included. + useEffect(() => { + latest.current = manager; + operation.current = null; + confirming.current = null; + setView({ manager, status: 'loading' }); + setBusy(null); + setCanCreate(true); + setCanUpdate(true); + setCanDelete(true); + setDeleteNeedsRefresh(false); + setExpandedId(null); + setPage(0); + setNotice(null); + setDescription(''); + setWebsite(''); + void load(); + }, [manager, load]); + + if (!manager || shownFor !== manager) return null; + // Still the manager on screen, for the login it was made for. + const live = () => latest.current === manager && manager.isCurrent(); + const current: Listing = view.manager === manager ? view : { status: 'loading' }; + const addresses = current.status === 'ready' ? current.addresses : []; + const shown = maskedPage(addresses, page); + + const begin = (kind: 'refresh' | MaskedChange) => { + if (operation.current === manager || !live()) return false; + operation.current = manager; + setBusy(kind); + setNotice(null); + return true; + }; + const end = () => { + if (operation.current === manager) operation.current = null; + if (live()) setBusy(null); + }; + const say = (next: MaskedNotice, failed: boolean) => { + setNotice(next); + if (failed) toast.error(noticeText(t, next)); + else toast.success(noticeText(t, next)); + }; + + const refresh = async () => { + if (!begin('refresh')) return; + try { + await load(); + } finally { + end(); + } + }; + + const change = async (kind: MaskedChange, action: () => Promise) => { + if (!begin(kind)) return; + let created: unknown; + try { + created = await action(); + } catch (error) { + if (live()) { + const failure = maskedFailureNotice(kind, error); + if (failure === 'create_forbidden') setCanCreate(false); + if (failure === 'update_forbidden') setCanUpdate(false); + if (failure === 'delete_forbidden') setCanDelete(false); + if (maskedLoadFailure(error) === 'unavailable') setView({ manager, status: 'unavailable' }); + // A delete that failed any other way is asked again only after a fresh read. + if (kind === 'destroy') setDeleteNeedsRefresh(failure !== 'delete_forbidden'); + say(failure, true); + } + end(); + return; + } + if (!live()) { + end(); + return; + } + if (kind === 'create') { + setDescription(''); + setWebsite(''); + } + if (kind === 'destroy') setExpandedId(null); + // Only a change the server confirmed is followed by a read. + try { + const loaded = await load(); + if (!live()) return; + // Show the page the new address is on, wherever the server lists it. + const index = kind === 'create' && loaded ? loaded.findIndex((address) => address.id === created) : -1; + if (index >= 0) setPage(Math.floor(index / MASKED_PAGE_SIZE)); + if (kind !== 'destroy') say('saved', false); + else if (loaded) say('deleted', false); + else say('deleted_refresh_failed', true); + } finally { + end(); + } + }; + + const destroy = (address: MaskedAddress) => { + if (!canDelete || deleteNeedsRefresh || operation.current === manager || confirming.current || !live()) return; + confirming.current = manager; + const answer = (accepted: boolean) => { + // An answer to a dialog another account's section left open does nothing. + if (confirming.current !== manager) return; + confirming.current = null; + if (accepted && live()) void change('destroy', () => manager.destroy(address.id)); + }; + Alert.alert( + t('settings.security.masked_addresses.delete_title', 'Permanently delete masked address?'), + t('settings.security.masked_addresses.delete_warning', 'Delete {address}? This cannot be undone. Mail sent to this address will no longer be delivered.', { address: address.email }), + [ + { text: t('settings.security.masked_addresses.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(false) }, + { text: t('settings.security.masked_addresses.delete_confirm', 'Delete permanently'), style: 'destructive', onPress: () => answer(true) }, + ], + { cancelable: true, onDismiss: () => answer(false) }, + ); + }; + + const changePage = (next: number) => { + setPage(next); + setExpandedId(null); + }; + const websiteLabel = t('settings.security.masked_addresses.website', 'Website (optional)'); + const descriptionLabel = t('settings.security.masked_addresses.description', 'Description (optional)'); + const createLabel = t('settings.security.masked_addresses.create', 'Create masked address'); + const create = () => { + if (!canCreate) return; + void change('create', () => manager.create({ description: description.trim(), forDomain: website.trim() })); + }; + + return ( + + + {current.status !== 'loading' && current.status !== 'ready' && ( + {loadFailureText(t, current.status)} + )} + + {current.status === 'ready' && ( + <> + + {t('settings.security.masked_addresses.count', '{count, plural, one {# address} other {# addresses}}', { count: addresses.length })} + + {addresses.length === 0 ? ( + {t('settings.security.masked_addresses.empty', 'No masked addresses yet.')} + ) : ( + + {shown.rows.map((address, index) => ( + 0 && styles.rowBorder}> + setExpandedId((previous) => (previous === address.id ? null : address.id))} + onUpdate={(id, changes) => { void change('update', () => manager.update(id, changes)); }} + onDelete={destroy} + /> + + ))} + + )} + {!canUpdate && {noticeText(t, 'update_forbidden')}} + {!canDelete && {noticeText(t, 'delete_forbidden')}} + {shown.pageCount > 1 && ( + + + + {t('settings.security.masked_addresses.page', 'Page {page} of {pages}', { page: shown.page + 1, pages: shown.pageCount })} + + + + )} + + + {createLabel} + revealOnKeyboard?.(formRef.current)} + /> + revealOnKeyboard?.(formRef.current)} + /> + + + + {!canCreate && {noticeText(t, 'create_forbidden')}} + + + )} + + {/* The toast reads the notice out; this keeps it on screen. */} + {notice && !REFUSALS.includes(notice) && {noticeText(t, notice)}} + {current.status !== 'loading' && current.status !== 'unavailable' && ( + + + + )} + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + body: { gap: spacing.md }, + muted: { ...typography.body, color: c.textMuted }, + small: { ...typography.caption, color: c.textMuted }, + list: { borderWidth: 1, borderColor: c.border, borderRadius: radius.md, overflow: 'hidden' }, + rowBorder: { borderTopWidth: 1, borderTopColor: c.border }, + row: { gap: 2, paddingHorizontal: spacing.md, paddingVertical: spacing.md }, + email: { ...typography.bodyMedium, color: c.text }, + meta: { ...typography.caption, color: c.textMuted }, + rowFoot: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.xs }, + state: { ...typography.captionMedium, color: c.text }, + stateOff: { color: c.textMuted }, + buttons: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm }, + details: { gap: spacing.md, padding: spacing.md, borderTopWidth: 1, borderTopColor: c.border, backgroundColor: c.muted }, + detailsFoot: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: spacing.sm }, + pager: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: spacing.sm }, + form: { gap: spacing.md, paddingTop: spacing.md, borderTopWidth: 1, borderTopColor: c.border }, + formTitle: { ...typography.bodyMedium, color: c.text }, + }); +} diff --git a/src/components/settings/__tests__/masked-addresses.test.ts b/src/components/settings/__tests__/masked-addresses.test.ts new file mode 100644 index 000000000..8b3e68efa --- /dev/null +++ b/src/components/settings/__tests__/masked-addresses.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { MaskedEmailError, type MaskedAddress } from '../../../lib/masked-email'; +import { maskedFailureNotice, maskedLoadFailure, maskedPage } from '../masked-addresses'; + +const ENTERPRISE = 'This feature is only available in the Enterprise edition. Obtain your trial license at https://license.stalw.art/trial.'; +const addresses = (count: number): MaskedAddress[] => Array.from({ length: count }, (_, i) => ({ + id: `m${i}`, email: `m${i}@example.test`, description: null, forDomain: null, enabled: true, +})); + +describe('maskedLoadFailure', () => { + it('hides the section where the server has no masked addresses', () => { + expect(maskedLoadFailure(new MaskedEmailError('unknownMethod'))).toBe('unavailable'); + expect(maskedLoadFailure(new MaskedEmailError('forbidden', ENTERPRISE))).toBe('unavailable'); + }); + + it('tells a refused read from a failed one', () => { + expect(maskedLoadFailure(new MaskedEmailError('forbidden', 'Missing permission'))).toBe('forbidden'); + expect(maskedLoadFailure(new MaskedEmailError('listChanged'))).toBe('error'); + expect(maskedLoadFailure(new Error('Network request failed'))).toBe('error'); + }); +}); + +describe('maskedFailureNotice', () => { + it('names what the server refused', () => { + expect(maskedFailureNotice('create', new MaskedEmailError('forbidden'))).toBe('create_forbidden'); + expect(maskedFailureNotice('update', new MaskedEmailError('forbidden'))).toBe('update_forbidden'); + expect(maskedFailureNotice('destroy', new MaskedEmailError('forbidden'))).toBe('delete_forbidden'); + expect(maskedFailureNotice('create', new MaskedEmailError('overQuota'))).toBe('over_quota'); + expect(maskedFailureNotice('create', new MaskedEmailError('rateLimit'))).toBe('rate_limit'); + }); + + it('does not call an unanswered delete failed', () => { + expect(maskedFailureNotice('destroy', new MaskedEmailError('deleteUnconfirmed'))).toBe('delete_unconfirmed'); + expect(maskedFailureNotice('destroy', new MaskedEmailError('notFound'))).toBe('delete_failed'); + }); + + it('asks for a refresh after any other failed save', () => { + expect(maskedFailureNotice('update', new Error('Network request failed'))).toBe('save_failed'); + expect(maskedFailureNotice('create', new MaskedEmailError('invalidProperties'))).toBe('save_failed'); + }); +}); + +describe('maskedPage', () => { + it('shows ten rows a page', () => { + expect(maskedPage(addresses(23), 0)).toMatchObject({ page: 0, pageCount: 3 }); + expect(maskedPage(addresses(23), 1).rows.map((row) => row.id)).toEqual(['m10', 'm11', 'm12', 'm13', 'm14', 'm15', 'm16', 'm17', 'm18', 'm19']); + expect(maskedPage(addresses(23), 2).rows).toHaveLength(3); + }); + + it('falls back to the last page once the one shown is gone', () => { + expect(maskedPage(addresses(20), 2)).toMatchObject({ page: 1, pageCount: 2 }); + expect(maskedPage([], 4)).toEqual({ rows: [], page: 0, pageCount: 1 }); + }); +}); diff --git a/src/components/settings/masked-addresses.ts b/src/components/settings/masked-addresses.ts new file mode 100644 index 000000000..2415e9211 --- /dev/null +++ b/src/components/settings/masked-addresses.ts @@ -0,0 +1,41 @@ +// What the Masked addresses section shows for an answer of the server +// (webmail components/settings/masked-addresses-section.tsx). + +import { maskedEmailErrorType, maskedEmailUnavailable, type MaskedAddress } from '../../lib/masked-email'; + +export const MASKED_PAGE_SIZE = 10; + +export type MaskedLoadFailure = 'unavailable' | 'forbidden' | 'error'; + +/** Why the list could not be read: not offered here, not permitted, or failed. */ +export function maskedLoadFailure(error: unknown): MaskedLoadFailure { + if (maskedEmailUnavailable(error)) return 'unavailable'; + return maskedEmailErrorType(error) === 'forbidden' ? 'forbidden' : 'error'; +} + +export type MaskedChange = 'create' | 'update' | 'destroy'; + +export type MaskedNotice = + | 'saved' | 'deleted' | 'deleted_refresh_failed' + | 'create_forbidden' | 'update_forbidden' | 'delete_forbidden' + | 'over_quota' | 'rate_limit' | 'save_failed' + | 'delete_unconfirmed' | 'delete_failed'; + +/** The line shown after a change the server did not make, or did not confirm. */ +export function maskedFailureNotice(change: MaskedChange, error: unknown): MaskedNotice { + const type = maskedEmailErrorType(error); + if (change === 'destroy') { + if (type === 'forbidden') return 'delete_forbidden'; + return type === 'deleteUnconfirmed' ? 'delete_unconfirmed' : 'delete_failed'; + } + if (type === 'forbidden') return change === 'create' ? 'create_forbidden' : 'update_forbidden'; + if (type === 'overQuota') return 'over_quota'; + return type === 'rateLimit' ? 'rate_limit' : 'save_failed'; +} + +/** The rows of `page`, clamped to the last page there is. */ +export function maskedPage(addresses: MaskedAddress[], page: number): { rows: MaskedAddress[]; page: number; pageCount: number } { + const pageCount = Math.max(1, Math.ceil(addresses.length / MASKED_PAGE_SIZE)); + const shown = Math.min(Math.max(0, page), pageCount - 1); + return { rows: addresses.slice(shown * MASKED_PAGE_SIZE, (shown + 1) * MASKED_PAGE_SIZE), page: shown, pageCount }; +} diff --git a/src/lib/__tests__/masked-email.test.ts b/src/lib/__tests__/masked-email.test.ts new file mode 100644 index 000000000..39ea54afb --- /dev/null +++ b/src/lib/__tests__/masked-email.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, it } from 'vitest'; +import { + MaskedEmailError, + createMaskedEmailManager, + maskedEmailErrorType, + maskedEmailUnavailable, +} from '../masked-email'; + +// The webmail's lib/__tests__/masked-email.test.ts and +// masked-email-destroy.test.ts, against a scripted server. + +type Args = Record; +type Answer = Record | Error; +type Script = (method: string, args: Args) => Answer | Promise; + +const ACCOUNT = 'personal'; +const PROPERTIES = ['id', 'accountId', 'email', 'forDomain', 'description', 'enable']; +const ENTERPRISE = 'This feature is only available in the Enterprise edition. Obtain your trial license at https://license.stalw.art/trial.'; + +const wire = (id = 'mask') => ({ id, email: `${id}@example.test`, forDomain: 'shop.example.test', description: null, enable: true }); +const refusal = (type: string, description?: string) => new MaskedEmailError(type, description); + +function setup(script: Script, options: { maxObjectsInGet?: number } = {}) { + const calls: [string, Args][] = []; + const state = { current: true }; + const manager = createMaskedEmailManager({ + accountId: ACCOUNT, + maxObjectsInGet: options.maxObjectsInGet ?? 2, + isCurrent: () => state.current, + call: async (method, args) => { + calls.push([method, args]); + const answer = await script(method, args); + if (answer instanceof Error) throw answer; + return answer; + }, + }); + return { manager, calls, state, methods: () => calls.map(([method]) => method) }; +} + +/** A server holding `ids`, answering query in pages of `pageSize` and every get in full. */ +function serverWith(ids: string[], options: { pageSize?: number; total?: boolean; set?: Script } = {}): Script { + const pageSize = options.pageSize ?? 50; + return (method, args) => { + if (method === 'x:MaskedEmail/query') { + return { + accountId: ACCOUNT, + ids: ids.slice(args.position, args.position + Math.min(pageSize, args.limit)), + ...(options.total === false ? {} : { total: ids.length }), + }; + } + if (method === 'x:MaskedEmail/get') return { accountId: ACCOUNT, list: (args.ids as string[]).map((id) => wire(id)) }; + return options.set ? options.set(method, args) : new Error(`unexpected ${method}`); + }; +} + +describe('listing', () => { + it('asks for the account alone and returns its addresses', async () => { + const { manager, calls } = setup(serverWith(['mask'])); + expect(await manager.list()).toEqual([ + { id: 'mask', email: 'mask@example.test', forDomain: 'shop.example.test', description: null, enabled: true }, + ]); + expect(calls).toEqual([ + ['x:MaskedEmail/query', { accountId: ACCOUNT, filter: { accountId: ACCOUNT }, position: 0, limit: 50, calculateTotal: true }], + ['x:MaskedEmail/get', { accountId: ACCOUNT, ids: ['mask'], properties: PROPERTIES }], + ]); + }); + + it('reads every page the server cuts short and stops at its total', async () => { + const { manager, calls } = setup(serverWith(['a', 'b', 'c', 'd', 'e'], { pageSize: 2 })); + expect((await manager.list()).map((address) => address.id)).toEqual(['a', 'b', 'c', 'd', 'e']); + expect(calls.filter(([method]) => method === 'x:MaskedEmail/query').map(([, args]) => args.position)).toEqual([0, 2, 4]); + }); + + it('keeps to maxObjectsInGet', async () => { + const { manager, calls } = setup(serverWith(['a', 'b', 'c']), { maxObjectsInGet: 2 }); + await manager.list(); + expect(calls.filter(([method]) => method === 'x:MaskedEmail/get').map(([, args]) => args.ids)).toEqual([['a', 'b'], ['c']]); + }); + + it('pages until an empty page when the server gives no total', async () => { + const { manager, calls } = setup(serverWith(['a', 'b', 'c'], { pageSize: 2, total: false })); + expect((await manager.list()).map((address) => address.id)).toEqual(['a', 'b', 'c']); + expect(calls.filter(([method]) => method === 'x:MaskedEmail/query').map(([, args]) => args.position)).toEqual([0, 2, 3]); + }); + + it('accepts an empty list only once an empty get succeeded too', async () => { + const { manager, calls } = setup(serverWith([])); + expect(await manager.list()).toEqual([]); + expect(calls[1]).toEqual(['x:MaskedEmail/get', { accountId: ACCOUNT, ids: [], properties: PROPERTIES }]); + }); + + it.each(['forbidden', 'unknownMethod'])('fails an empty query whose get answers %s', async (type) => { + const { manager, methods } = setup((method) => (method === 'x:MaskedEmail/query' ? { ids: [], total: 0 } : refusal(type))); + expect(maskedEmailErrorType(await manager.list().catch((e) => e))).toBe(type); + expect(methods()).toEqual(['x:MaskedEmail/query', 'x:MaskedEmail/get']); + }); + + it.each([ + ['a repeated id', { ids: ['a', 'a'], total: 2 }], + ['an id that is no string', { ids: [7], total: 1 }], + ['a total below what was returned', { ids: ['a'], total: 0 }], + ['a total that is no number', { ids: ['a'], total: '1' }], + ['no ids', {}], + ])('rejects a query with %s', async (_name, page) => { + const { manager } = setup((method, args) => (method === 'x:MaskedEmail/query' ? page : { accountId: ACCOUNT, list: args.ids.map(wire) })); + await expect(manager.list()).rejects.toMatchObject({ type: 'invalidResponse' }); + }); + + it('rejects a list that changed between pages', async () => { + const totals = [3, 4]; + const { manager } = setup((method, args) => (method === 'x:MaskedEmail/query' + ? { ids: args.position === 0 ? ['a', 'b'] : ['c'], total: totals.shift() } + : { accountId: ACCOUNT, list: args.ids.map(wire) })); + await expect(manager.list()).rejects.toMatchObject({ type: 'listChanged' }); + }); + + it('rejects a query state that changed between pages', async () => { + const { manager } = setup((method, args) => (method === 'x:MaskedEmail/query' + ? { ids: args.position === 0 ? ['a', 'b'] : ['c'], total: 3, queryState: args.position === 0 ? 's1' : 's2' } + : { accountId: ACCOUNT, list: args.ids.map(wire) })); + await expect(manager.list()).rejects.toMatchObject({ type: 'listChanged' }); + }); + + it.each([ + ['records nobody asked for', { accountId: ACCOUNT, list: [wire('a')] }, 'invalidResponse'], + ['no list', { accountId: ACCOUNT }, 'invalidResponse'], + ['no owner', { list: [] }, 'ownershipMismatch'], + ['another account', { accountId: 'foreign', list: [] }, 'ownershipMismatch'], + ])('fails an empty query whose get answers with %s', async (_name, answer, type) => { + const { manager } = setup((method) => (method === 'x:MaskedEmail/query' ? { ids: [], total: 0 } : answer)); + await expect(manager.list()).rejects.toMatchObject({ type }); + }); + + it('rejects a page that ends before the total', async () => { + const { manager } = setup((method, args) => (method === 'x:MaskedEmail/query' + ? { ids: args.position === 0 ? ['a'] : [], total: 2 } + : { accountId: ACCOUNT, list: args.ids.map(wire) })); + await expect(manager.list()).rejects.toMatchObject({ type: 'invalidResponse' }); + }); + + it('does not take records the get left out for a complete list', async () => { + const { manager } = setup((method) => (method === 'x:MaskedEmail/query' + ? { ids: ['a', 'b'], total: 2 } + : { accountId: ACCOUNT, list: [wire('a')] })); + await expect(manager.list()).rejects.toMatchObject({ type: 'listChanged' }); + }); + + it.each([ + ['an id that was not asked for', [wire('other')]], + ['the same record twice', [wire('a'), wire('a')]], + ['a switch that is no boolean', [{ ...wire('a'), enable: 'yes' }]], + ['the other servers\' field name', [{ id: 'a', email: 'a@example.test', enabled: true }]], + ['no address', [{ id: 'a', enable: true }]], + ['a description that is no string', [{ ...wire('a'), description: 5 }]], + ['null for a record', [null]], + ])('rejects a get with %s', async (_name, list) => { + const { manager } = setup((method) => (method === 'x:MaskedEmail/query' ? { ids: ['a'], total: 1 } : { accountId: ACCOUNT, list })); + await expect(manager.list()).rejects.toMatchObject({ type: 'invalidResponse' }); + }); + + it.each([ + ['a get without an owner', { list: [{ ...wire('a'), accountId: ACCOUNT }] }], + ['a get for another account', { accountId: 'foreign', list: [wire('a')] }], + ['a record of another account', { accountId: ACCOUNT, list: [{ ...wire('a'), accountId: 'foreign' }] }], + ])('rejects %s', async (_name, answer) => { + const { manager } = setup((method) => (method === 'x:MaskedEmail/query' ? { ids: ['a'], total: 1 } : answer)); + await expect(manager.list()).rejects.toMatchObject({ type: 'ownershipMismatch' }); + }); + + it('rejects a query answered for another account', async () => { + const { manager, methods } = setup(() => ({ accountId: 'foreign', ids: ['a'], total: 1 })); + await expect(manager.list()).rejects.toMatchObject({ type: 'ownershipMismatch' }); + expect(methods()).toEqual(['x:MaskedEmail/query']); + }); +}); + +describe('a server without masked addresses', () => { + it.each(['unknownMethod', 'unknownCapability', 'notImplemented'])('takes %s for not offered', async (type) => { + const { manager, calls } = setup(() => refusal(type)); + const error = await manager.list().catch((e) => e); + expect(maskedEmailUnavailable(error)).toBe(true); + expect(calls).toHaveLength(1); + }); + + it.each(['query', 'get'])('takes the Enterprise-only refusal of %s for not offered, not for a permission', async (step) => { + const { manager } = setup((method) => (step === 'get' && method === 'x:MaskedEmail/query' ? { ids: [] } : refusal('forbidden', ENTERPRISE))); + const error = await manager.list().catch((e) => e); + expect(maskedEmailErrorType(error)).toBe('forbidden'); + expect(maskedEmailUnavailable(error)).toBe(true); + }); + + it('keeps any other forbidden a permission', async () => { + const { manager } = setup(() => refusal('forbidden', 'Missing permission sysMaskedEmailGet')); + const error = await manager.list().catch((e) => e); + expect(maskedEmailErrorType(error)).toBe('forbidden'); + expect(maskedEmailUnavailable(error)).toBe(false); + }); + + it('does not take a failed request for not offered', async () => { + const { manager } = setup(() => new Error('Network request failed')); + expect(maskedEmailUnavailable(await manager.list().catch((e) => e))).toBe(false); + }); +}); + +describe('creating and changing', () => { + it('creates an enabled address and returns its id', async () => { + const { manager, calls } = setup(() => ({ accountId: ACCOUNT, created: { new: { id: 'created', email: 'created@example.test' } } })); + expect(await manager.create({ description: 'Shop', forDomain: 'shop.example.test' })).toBe('created'); + expect(calls).toEqual([['x:MaskedEmail/set', { + accountId: ACCOUNT, create: { new: { description: 'Shop', forDomain: 'shop.example.test', enable: true } }, + }]]); + }); + + it('sends an empty description or website as null', async () => { + const { manager, calls } = setup(serverWith(['mask'], { + set: (_method, args) => (args.create ? { created: { new: { id: 'created' } } } : { updated: { mask: null } }), + })); + await manager.create({ description: '', forDomain: '' }); + expect(calls[0][1].create.new).toEqual({ description: null, forDomain: null, enable: true }); + await manager.list(); + await manager.update('mask', { description: '' }); + expect(calls[calls.length - 1][1].update).toEqual({ mask: { description: null } }); + }); + + it.each(['forbidden', 'overQuota', 'rateLimit'])('passes on a create the server refused (%s), once', async (type) => { + const { manager, calls } = setup(() => ({ notCreated: { new: { type } } })); + await expect(manager.create({ description: '', forDomain: '' })).rejects.toMatchObject({ type }); + expect(calls).toHaveLength(1); + }); + + it('rejects a create answered without an id', async () => { + const { manager } = setup(() => ({ created: { new: {} } })); + await expect(manager.create({ description: '', forDomain: '' })).rejects.toMatchObject({ type: 'invalidResponse' }); + }); + + it('changes only the description and the switch, under the server\'s field name', async () => { + const { manager, calls } = setup(serverWith(['mask'], { set: () => ({ updated: { mask: null } }) })); + await manager.list(); + await manager.update('mask', { enabled: false }); + await manager.update('mask', { description: 'Shop' }); + expect(calls.slice(2).map(([, args]) => args.update)).toEqual([{ mask: { enable: false } }, { mask: { description: 'Shop' } }]); + }); + + it('refuses an id it has not listed, and an empty change, without a request', async () => { + const { manager, calls } = setup(serverWith(['mask'])); + await expect(manager.update('mask', { enabled: false })).rejects.toMatchObject({ type: 'ownershipMismatch' }); + expect(calls).toHaveLength(0); + await manager.list(); + await expect(manager.update('mask', {})).rejects.toMatchObject({ type: 'invalidProperties' }); + await expect(manager.update('foreign', { enabled: false })).rejects.toMatchObject({ type: 'ownershipMismatch' }); + expect(calls).toHaveLength(2); + }); + + it('passes on an update the server refused, and rejects one it did not confirm', async () => { + const answers: Answer[] = [{ notUpdated: { mask: { type: 'forbidden' } } }, { updated: {} }]; + const { manager } = setup(serverWith(['mask'], { set: () => answers.shift()! })); + await manager.list(); + await expect(manager.update('mask', { enabled: false })).rejects.toMatchObject({ type: 'forbidden' }); + await expect(manager.update('mask', { enabled: false })).rejects.toMatchObject({ type: 'invalidResponse' }); + }); +}); + +describe('deleting', () => { + const deleted = { accountId: ACCOUNT, destroyed: ['mask'] }; + + it('sends the one listed id and forgets it once the server confirmed', async () => { + const { manager, calls } = setup(serverWith(['mask'], { set: () => deleted })); + await manager.list(); + await manager.destroy('mask'); + expect(calls[2]).toEqual(['x:MaskedEmail/set', { accountId: ACCOUNT, destroy: ['mask'] }]); + await expect(manager.destroy('mask')).rejects.toMatchObject({ type: 'ownershipMismatch' }); + await expect(manager.update('mask', { enabled: true })).rejects.toMatchObject({ type: 'ownershipMismatch' }); + expect(calls).toHaveLength(3); + }); + + it('refuses an id it has not listed without a request', async () => { + const { manager, calls } = setup(serverWith(['mask'])); + await expect(manager.destroy('foreign')).rejects.toMatchObject({ type: 'ownershipMismatch' }); + expect(calls).toHaveLength(0); + }); + + it.each(['forbidden', 'notFound'])('passes on a delete the server refused (%s) and lets it be asked again', async (type) => { + const answers: Answer[] = [{ accountId: ACCOUNT, notDestroyed: { mask: { type } } }, deleted]; + const { manager } = setup(serverWith(['mask'], { set: () => answers.shift()! })); + await manager.list(); + await expect(manager.destroy('mask')).rejects.toMatchObject({ type }); + await manager.destroy('mask'); + }); + + it.each([ + {}, { accountId: 'foreign', destroyed: ['mask'] }, { destroyed: ['mask'] }, + { accountId: ACCOUNT, destroyed: ['other'] }, { accountId: ACCOUNT, destroyed: ['mask', 'mask'] }, + { accountId: ACCOUNT, destroyed: ['mask'], notDestroyed: { mask: { type: 'forbidden' } } }, + { accountId: ACCOUNT, notDestroyed: { mask: null } }, + ])('does not repeat a delete answered with %j', async (answer) => { + const { manager, calls } = setup(serverWith(['mask'], { set: () => answer })); + await manager.list(); + await expect(manager.destroy('mask')).rejects.toMatchObject({ type: 'deleteUnconfirmed' }); + await expect(manager.destroy('mask')).rejects.toMatchObject({ type: 'deleteUnconfirmed' }); + expect(calls).toHaveLength(3); + }); + + it('does not repeat a delete that got no answer until a fresh list shows the address', async () => { + const answers: Answer[] = [new Error('Network request failed'), deleted]; + const { manager, calls } = setup(serverWith(['mask'], { set: () => answers.shift()! })); + await manager.list(); + await expect(manager.destroy('mask')).rejects.toMatchObject({ type: 'deleteUnconfirmed' }); + await expect(manager.destroy('mask')).rejects.toMatchObject({ type: 'deleteUnconfirmed' }); + expect(calls).toHaveLength(3); + await manager.list(); + await manager.destroy('mask'); + }); + + it.each(['forbidden', 'notAuthenticated', 'unknownMethod'])('lets a delete the server turned down whole (%s) be asked again', async (type) => { + const answers: Answer[] = [refusal(type), deleted]; + const { manager } = setup(serverWith(['mask'], { set: () => answers.shift()! })); + await manager.list(); + await expect(manager.destroy('mask')).rejects.toMatchObject({ type }); + await manager.destroy('mask'); + }); + + it('sends a second delete of the same address only after the first was answered', async () => { + let answer: (value: Answer) => void = () => {}; + const { manager, calls } = setup(serverWith(['mask'], { set: () => new Promise((resolve) => { answer = resolve; }) })); + await manager.list(); + const first = manager.destroy('mask'); + await expect(manager.destroy('mask')).rejects.toMatchObject({ type: 'deleteUnconfirmed' }); + answer(deleted); + await first; + expect(calls).toHaveLength(3); + }); +}); + +describe('another login becoming active', () => { + it('sends nothing more', async () => { + const { manager, calls, state } = setup(serverWith(['mask'])); + await manager.list(); + state.current = false; + await expect(manager.list()).rejects.toMatchObject({ type: 'staleContext' }); + await expect(manager.create({ description: '', forDomain: '' })).rejects.toMatchObject({ type: 'staleContext' }); + await expect(manager.update('mask', { enabled: false })).rejects.toMatchObject({ type: 'staleContext' }); + await expect(manager.destroy('mask')).rejects.toMatchObject({ type: 'staleContext' }); + expect(calls).toHaveLength(2); + }); + + it('leaves a delete answered afterwards unconfirmed', async () => { + const { manager, calls, state } = setup(serverWith(['mask'], { + set: () => { + state.current = false; + return { accountId: ACCOUNT, destroyed: ['mask'] }; + }, + })); + await manager.list(); + await expect(manager.destroy('mask')).rejects.toMatchObject({ type: 'deleteUnconfirmed' }); + state.current = true; + await expect(manager.destroy('mask')).rejects.toMatchObject({ type: 'deleteUnconfirmed' }); + expect(calls).toHaveLength(3); + }); + + it('drops an answer that arrives afterwards', async () => { + const { manager, state } = setup((method) => { + state.current = false; + return method === 'x:MaskedEmail/query' ? { ids: ['mask'], total: 1 } : new Error('unexpected'); + }); + await expect(manager.list()).rejects.toMatchObject({ type: 'staleContext' }); + }); +}); diff --git a/src/lib/masked-email.ts b/src/lib/masked-email.ts new file mode 100644 index 000000000..ec460cbc7 --- /dev/null +++ b/src/lib/masked-email.ts @@ -0,0 +1,239 @@ +// Masked addresses (webmail lib/stalwart/masked-email.ts, #1534): Stalwart's +// x:MaskedEmail objects, addresses that deliver to the account's mailbox +// without giving its real address away. Listing, creating, describing, +// turning off and deleting, with the webmail's checks on every answer. +// +// The calls go through `MaskedEmailIo` (api/masked-email binds it to the +// JMAP client), which names the account and says whether the login the +// manager was made for is still the active one. Nothing is sent, and no +// answer is used, once it is not. + +export interface MaskedAddress { + id: string; + email: string; + forDomain: string | null; + description: string | null; + enabled: boolean; +} + +// Stalwart calls the switch `enable`. +type MaskedEmailRecord = Partial> & { accountId?: string; enable?: boolean }; + +type SetErrors = Record; + +export class MaskedEmailError extends Error { + constructor(public readonly type: string, description?: string) { + super(description || type); + this.name = 'MaskedEmailError'; + } +} + +export function maskedEmailErrorType(error: unknown): string | undefined { + return error instanceof MaskedEmailError ? error.type : undefined; +} + +/** + * Stalwart Community before 1.1.1 answers every call on an Enterprise-only + * registry object (MaskedEmail among them) with `forbidden` and this detail + * (`assert_enterprise_object`, crates/jmap/src/registry/mod.rs). Any other + * `forbidden` is a permission the sign-in lacks. + */ +function enterpriseOnly(error: unknown): boolean { + return error instanceof MaskedEmailError && error.type === 'forbidden' && + /only available in the enterprise edition/i.test(error.message); +} + +/** The server has no masked addresses for this account, as opposed to refusing or failing. */ +export function maskedEmailUnavailable(error: unknown): boolean { + return ['unknownMethod', 'unknownCapability', 'notImplemented', 'unsupported'].includes(maskedEmailErrorType(error) || '') || + enterpriseOnly(error); +} + +export interface MaskedEmailIo { + /** The JMAP account every call names. */ + accountId: string; + /** The server's maxObjectsInGet. */ + maxObjectsInGet: number; + /** Whether the login and account the manager was made for are still the active ones. */ + isCurrent(): boolean; + /** + * One method call, answered with its arguments. A method error is thrown + * as a `MaskedEmailError` of its type, an expired sign-in as + * `notAuthenticated`; a request that got no answer throws anything else. + */ + call(method: string, args: Record): Promise>; +} + +const has = (object: object, key: string) => Object.prototype.hasOwnProperty.call(object, key); + +const PAGE = 50; +const PROPERTIES = ['id', 'accountId', 'email', 'forDomain', 'description', 'enable']; + +export function createMaskedEmailManager(io: MaskedEmailIo) { + const { accountId } = io; + const ownedIds = new Set(); + const destroyAttempts = new Map(); + const maxGet = io.maxObjectsInGet; + const batchSize = Number.isInteger(maxGet) && maxGet > 0 ? Math.min(maxGet, PAGE) : 1; + const isCurrent = () => io.isCurrent(); + const assertCurrent = () => { + if (!isCurrent()) throw new MaskedEmailError('staleContext'); + }; + async function request(method: string, args: Record): Promise { + assertCurrent(); + const result = await io.call(method, { ...args, accountId }); + assertCurrent(); + if (!result || typeof result !== 'object') throw new MaskedEmailError('invalidResponse'); + if (result.accountId !== undefined && result.accountId !== accountId) throw new MaskedEmailError('ownershipMismatch'); + return result as T & { accountId?: string }; + } + function verifyRecord(raw: MaskedEmailRecord): MaskedAddress { + // An account-filtered get leaves each record's owner out for ordinary + // users. The answer's own accountId was checked; a record that does + // name an owner must still agree with it. + if (has(raw, 'accountId') && raw.accountId !== accountId) throw new MaskedEmailError('ownershipMismatch'); + if (typeof raw.id !== 'string' || typeof raw.email !== 'string' || typeof raw.enable !== 'boolean' || + !(raw.description == null || typeof raw.description === 'string') || + !(raw.forDomain == null || typeof raw.forDomain === 'string')) throw new MaskedEmailError('invalidResponse'); + return { id: raw.id, email: raw.email, enabled: raw.enable, + description: raw.description ?? null, forDomain: raw.forDomain ?? null }; + } + return { + isCurrent, + /** Every masked address of the account, or a throw: never part of the list. */ + async list(): Promise { + const addresses: MaskedAddress[] = []; + const seen = new Set(); + let position = 0; + let queryState: string | undefined; + let expectedTotal: number | undefined; + // The accountId filter keeps an admin's answer to this account. + for (;;) { + const page = await request<{ ids: string[]; queryState?: string; total?: number }>('x:MaskedEmail/query', { + filter: { accountId }, position, limit: PAGE, calculateTotal: true, + }); + if (!Array.isArray(page.ids) || page.ids.some((id) => typeof id !== 'string' || seen.has(id))) { + throw new MaskedEmailError('invalidResponse'); + } + if (page.total !== undefined) { + if (!Number.isSafeInteger(page.total) || page.total < position + page.ids.length) throw new MaskedEmailError('invalidResponse'); + if (expectedTotal !== undefined && page.total !== expectedTotal) throw new MaskedEmailError('listChanged'); + expectedTotal = page.total; + } + if (queryState !== undefined && page.queryState !== undefined && queryState !== page.queryState) { + throw new MaskedEmailError('listChanged'); + } + queryState = page.queryState; + if (page.ids.length === 0) { + if (expectedTotal !== undefined && position < expectedTotal) throw new MaskedEmailError('invalidResponse'); + if (position === 0) { + // The registry's query can succeed where the object's get is + // not offered (Stalwart Community) or not permitted. An empty + // get tells, without creating an address. + const empty = await request<{ list: MaskedEmailRecord[] }>('x:MaskedEmail/get', { ids: [], properties: PROPERTIES }); + if (empty.accountId !== accountId) throw new MaskedEmailError('ownershipMismatch'); + if (!Array.isArray(empty.list) || empty.list.length !== 0) throw new MaskedEmailError('invalidResponse'); + } + break; + } + page.ids.forEach((id) => seen.add(id)); + for (let offset = 0; offset < page.ids.length; offset += batchSize) { + const ids = page.ids.slice(offset, offset + batchSize); + const result = await request<{ list: MaskedEmailRecord[] }>('x:MaskedEmail/get', { ids, properties: PROPERTIES }); + if (result.accountId !== accountId) throw new MaskedEmailError('ownershipMismatch'); + if (!Array.isArray(result.list)) throw new MaskedEmailError('invalidResponse'); + for (const raw of result.list) { + if (!raw || typeof raw.id !== 'string' || !ids.includes(raw.id) || addresses.some((address) => address.id === raw.id)) { + throw new MaskedEmailError('invalidResponse'); + } + addresses.push(verifyRecord(raw)); + } + } + position += page.ids.length; + // Stop at a verified complete result, not merely at a short page. + if (position === expectedTotal) break; + } + if (expectedTotal !== undefined && addresses.length !== expectedTotal) throw new MaskedEmailError('listChanged'); + ownedIds.clear(); + addresses.forEach((address) => ownedIds.add(address.id)); + // A complete fresh read settles an uncertain deletion. Pending and + // confirmed ones stay guarded against being sent twice. + for (const [id, state] of destroyAttempts) { + if (state === 'uncertain') destroyAttempts.delete(id); + } + return addresses; + }, + async create(input: { description: string; forDomain: string }): Promise { + const result = await request<{ created?: Record; notCreated?: SetErrors }>('x:MaskedEmail/set', { + // Stalwart refuses a present but empty string; none is null. + create: { new: { description: input.description || null, forDomain: input.forDomain || null, enable: true } }, + }); + const error = result.notCreated?.new; + if (error) throw new MaskedEmailError(error.type, error.description); + const id = result.created?.new?.id; + if (typeof id !== 'string') throw new MaskedEmailError('invalidResponse'); + return id; + }, + async update(id: string, changes: { description?: string; enabled?: boolean }): Promise { + assertCurrent(); + if (!ownedIds.has(id)) throw new MaskedEmailError('ownershipMismatch'); + const patch: Record = {}; + // A cleared description is removed (null), as Stalwart refuses an empty one. + if (typeof changes.description === 'string') patch.description = changes.description || null; + if (typeof changes.enabled === 'boolean') patch.enable = changes.enabled; + if (!Object.keys(patch).length) throw new MaskedEmailError('invalidProperties'); + const result = await request<{ updated?: Record; notUpdated?: SetErrors }>('x:MaskedEmail/set', { + update: { [id]: patch }, + }); + const error = result.notUpdated?.[id]; + if (error) throw new MaskedEmailError(error.type, error.description); + if (!result.updated || !has(result.updated, id)) throw new MaskedEmailError('invalidResponse'); + }, + /** + * Deletes one address for good. A delete whose answer never came is not + * sent again (`deleteUnconfirmed`) until a fresh `list()` has shown + * whether the address is still there. + */ + async destroy(id: string): Promise { + assertCurrent(); + if (!ownedIds.has(id)) throw new MaskedEmailError('ownershipMismatch'); + if (destroyAttempts.has(id)) throw new MaskedEmailError('deleteUnconfirmed'); + destroyAttempts.set(id, 'pending'); + try { + const result = await request<{ destroyed?: unknown; notDestroyed?: SetErrors }>('x:MaskedEmail/set', { destroy: [id] }); + if (result.accountId !== accountId) throw new MaskedEmailError('ownershipMismatch'); + if (result.destroyed !== undefined && (!Array.isArray(result.destroyed) || + result.destroyed.some((value) => value !== id) || result.destroyed.length > 1)) throw new MaskedEmailError('invalidResponse'); + if (result.notDestroyed !== undefined && (!result.notDestroyed || typeof result.notDestroyed !== 'object' || + Array.isArray(result.notDestroyed) || Object.keys(result.notDestroyed).some((key) => key !== id))) { + throw new MaskedEmailError('invalidResponse'); + } + if (result.notDestroyed && has(result.notDestroyed, id)) { + const error = result.notDestroyed[id]; + if (!error || typeof error.type !== 'string' || (Array.isArray(result.destroyed) && result.destroyed.length)) { + throw new MaskedEmailError('invalidResponse'); + } + destroyAttempts.delete(id); + throw new MaskedEmailError(error.type, error.description); + } + if (!Array.isArray(result.destroyed) || result.destroyed.length !== 1) throw new MaskedEmailError('invalidResponse'); + destroyAttempts.set(id, 'confirmed'); + ownedIds.delete(id); + } catch (error) { + if (destroyAttempts.get(id) === 'pending') { + const type = maskedEmailErrorType(error); + // The server answered that it did nothing: the delete can be asked again. + if (type === 'forbidden' || type === 'notAuthenticated' || maskedEmailUnavailable(error)) { + destroyAttempts.delete(id); + } else { + destroyAttempts.set(id, 'uncertain'); + throw new MaskedEmailError('deleteUnconfirmed'); + } + } + throw error; + } + }, + }; +} + +export type MaskedEmailManager = ReturnType;