mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: keep push notifications working when a mailbox is shared with you
Stalwart refuses a push subscription's whole emailPush map as a `forbidden` SetError when it names a mailbox shared by ACL (0.16.16 and later, and 1.0), and 1.0 stops listing emailpush on such accounts. The app narrowed the map on any `forbidden`, including a refused method call, and put every account with folders in the first attempt, so 1.0 cost one refused call per install. Worse, a failed listing counted as "the server has no subscription" and dropped the key of a working registration, and any failed refresh - a network error, a timeout, rate limiting - started a replacement. - `jmap-result` gains `JMAPSetError` (a `JMAPMethodError` with the same name, carrying `properties`) and `isJMAPSetError`. `assertSetResult` throws it, so every refused object can be told apart from a refused call; existing `instanceof` checks and the outbox's name-based error classification are unchanged. - The emailPush map holds the primary account and the accounts whose `accountCapabilities` list `urn:ietf:params:jmap:emailpush`, which leaves ACL shares out up front on 1.0. On 0.16.16-0.16.23 the refused map still narrows to the accounts that look owned, then to the primary account, but only on a `forbidden` SetError. - A subscription ends only with a verified replacement, a user disable or the server's verdict: a failed listing aborts the run, and only a SetError on the refresh starts a replacement. Other failures leave the subscription and its key alone and the next run tries again. - `forbidden` and `overQuota` read as plain text in settings and the onboarding prompt, and settings says that mailboxes others share don't send background notifications. New strings in all 27 languages.
This commit is contained in:
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "أعاد Firebase رمز جهاز فارغًا.",
|
||||
"err_fcm_token": "تعذّر على Firebase إصدار رمز الجهاز ({detail}). يتطلب الإشعار عبر FCM خدمات Google Play على هذا الجهاز.",
|
||||
"err_fcm_token_up": "تعذّر على Firebase إصدار رمز الجهاز ({detail}). يتطلب الإشعار عبر FCM خدمات Google Play على هذا الجهاز. يوجد موزّع UnifiedPush مثبت على هذا الجهاز - غيّر طريقة التسليم إلى UnifiedPush بدلًا من ذلك.",
|
||||
"err_forbidden": "لا يسمح خادم البريد بالإشعارات في الخلفية لهذا الحساب.",
|
||||
"err_jmap_list": "تعذّرت قراءة تسجيلات الإشعارات الفورية من خادم البريد: {detail}",
|
||||
"err_jmap_refused": "رفض خادم البريد اشتراك الإشعارات: {detail}",
|
||||
"err_no_account": "لم يتم تحميل أي حساب - لا يمكن إعداد الإشعارات.",
|
||||
"err_over_quota": "لدى هذا الحساب عدد كبير جدًا من تسجيلات الإشعارات الفورية على خادم البريد (الحد عادةً 15). ألغِ تسجيل الأجهزة التي لم تعد تستخدمها من إعدادات الإشعارات، ثم حاول مرة أخرى.",
|
||||
"err_permission": "لم يُمنح إذن الإشعارات.",
|
||||
"err_relay_https": "يجب أن يستخدم رابط خادم الترحيل https://.",
|
||||
"err_relay_rejected": "رفض خادم ترحيل الإشعارات التسجيل: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "الخادم الذي يوصل إشعاراتك. الافتراضي هو خادم ترحيل Bulwark المستضاف؛ غيّره فقط إذا كنت تستضيف خادمك بنفسك. يجب أن يستخدم https.",
|
||||
"relay_invalid": "أدخل رابط https:// صالحًا",
|
||||
"setup_failed": "فشل الإعداد",
|
||||
"shared_mailboxes_no_push": "صناديق البريد التي يشاركها الآخرون معك لا ترسل إشعارات في الخلفية.",
|
||||
"status_android_only": "الإشعارات في الخلفية متاحة على Android فقط.",
|
||||
"transport_desc": "كيف تصل الإشعارات إلى هذا الجهاز: عبر Firebase Cloud Messaging من Google، أو عبر تطبيق موزّع لـ UnifiedPush (مثل ntfy) للتسليم دون Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "El Firebase ha retornat un testimoni de dispositiu buit.",
|
||||
"err_fcm_token": "El Firebase no ha pogut emetre un testimoni de dispositiu ({detail}). Les notificacions push per FCM necessiten els serveis de Google Play en aquest dispositiu.",
|
||||
"err_fcm_token_up": "El Firebase no ha pogut emetre un testimoni de dispositiu ({detail}). Les notificacions push per FCM necessiten els serveis de Google Play en aquest dispositiu. Aquest dispositiu té instal·lat un distribuïdor d'UnifiedPush: canvieu el mètode de lliurament a UnifiedPush.",
|
||||
"err_forbidden": "El servidor de correu no permet notificacions en segon pla per a aquest compte.",
|
||||
"err_jmap_list": "No s'han pogut llegir els registres push del servidor de correu: {detail}",
|
||||
"err_jmap_refused": "El servidor de correu ha rebutjat la subscripció push: {detail}",
|
||||
"err_no_account": "No hi ha cap compte carregat: no es poden configurar les notificacions push.",
|
||||
"err_over_quota": "Aquest compte té massa registres push al servidor de correu (el límit sol ser 15). Revoqueu els dispositius que ja no feu servir a la configuració de notificacions i torneu-ho a provar.",
|
||||
"err_permission": "No s'ha concedit el permís de notificacions.",
|
||||
"err_relay_https": "L'URL del repetidor ha d'utilitzar https://.",
|
||||
"err_relay_rejected": "El repetidor push ha rebutjat el registre: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "El servidor que entrega les notificacions. Per defecte utilitza el repetidor allotjat del Bulwark; canvieu-ho només si allotgeu el vostre propi servidor. Ha d'utilitzar https.",
|
||||
"relay_invalid": "Introduïu un URL https:// vàlid",
|
||||
"setup_failed": "Ha fallat la configuració",
|
||||
"shared_mailboxes_no_push": "Les bústies que altres usuaris comparteixen amb vós no envien notificacions en segon pla.",
|
||||
"status_android_only": "Les notificacions en segon pla només estan disponibles a Android.",
|
||||
"transport_desc": "Com arriben les notificacions a aquest dispositiu: el Firebase Cloud Messaging de Google o una aplicació distribuïdora d'UnifiedPush (com ara ntfy) per a un lliurament sense Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase vrátil prázdný token zařízení.",
|
||||
"err_fcm_token": "Firebase nemohl vydat token zařízení ({detail}). Push přes FCM vyžaduje v tomto zařízení služby Google Play.",
|
||||
"err_fcm_token_up": "Firebase nemohl vydat token zařízení ({detail}). Push přes FCM vyžaduje v tomto zařízení služby Google Play. V tomto zařízení je nainstalován distributor UnifiedPush – přepněte způsob doručení na UnifiedPush.",
|
||||
"err_forbidden": "Poštovní server pro tento účet nepovoluje oznámení na pozadí.",
|
||||
"err_jmap_list": "Registrace push se z poštovního serveru nepodařilo načíst: {detail}",
|
||||
"err_jmap_refused": "Poštovní server odmítl odběr push: {detail}",
|
||||
"err_no_account": "Není načten žádný účet – push nelze nastavit.",
|
||||
"err_over_quota": "Tento účet má na poštovním serveru příliš mnoho registrací push (limit je obvykle 15). V nastavení oznámení odvolejte zařízení, která už nepoužíváte, a zkuste to znovu.",
|
||||
"err_permission": "Oprávnění k oznámením nebylo uděleno.",
|
||||
"err_relay_https": "URL relay musí používat https://.",
|
||||
"err_relay_rejected": "Push relay odmítl registraci: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Server, který doručuje vaše oznámení. Výchozí je hostovaný relay Bulwark; měňte jen v případě, že provozujete vlastní. Musí používat https.",
|
||||
"relay_invalid": "Zadejte platnou adresu URL https://",
|
||||
"setup_failed": "Nastavení selhalo",
|
||||
"shared_mailboxes_no_push": "Poštovní schránky, které s vámi sdílejí ostatní, neodesílají oznámení na pozadí.",
|
||||
"status_android_only": "Oznámení na pozadí jsou k dispozici pouze v Androidu.",
|
||||
"transport_desc": "Jak se oznámení dostanou do tohoto zařízení: přes Firebase Cloud Messaging od Googlu, nebo přes aplikaci distributora UnifiedPush (například ntfy) bez Googlu.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase returnerede et tomt enhedstoken.",
|
||||
"err_fcm_token": "Firebase kunne ikke udstede et enhedstoken ({detail}). Push via FCM kræver Google Play-tjenester på denne enhed.",
|
||||
"err_fcm_token_up": "Firebase kunne ikke udstede et enhedstoken ({detail}). Push via FCM kræver Google Play-tjenester på denne enhed. Denne enhed har en UnifiedPush-distributør installeret - skift leveringsmetoden til UnifiedPush i stedet.",
|
||||
"err_forbidden": "Mailserveren tillader ikke baggrundsnotifikationer for denne konto.",
|
||||
"err_jmap_list": "Push-registreringerne kunne ikke læses fra mailserveren: {detail}",
|
||||
"err_jmap_refused": "Mailserveren afviste pushabonnementet: {detail}",
|
||||
"err_no_account": "Ingen konto indlæst - kan ikke konfigurere push.",
|
||||
"err_over_quota": "Denne konto har for mange push-registreringer på mailserveren (grænsen er normalt 15). Tilbagekald enheder, du ikke længere bruger, i notifikationsindstillingerne, og prøv igen.",
|
||||
"err_permission": "Tilladelse til notifikationer blev ikke givet.",
|
||||
"err_relay_https": "Relæets URL skal bruge https://.",
|
||||
"err_relay_rejected": "Push-relæet afviste registreringen: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Serveren, der leverer dine notifikationer. Som standard det hostede Bulwark-relæ; ændr det kun, hvis du selv hoster. Skal bruge https.",
|
||||
"relay_invalid": "Indtast en gyldig https://-URL",
|
||||
"setup_failed": "Opsætning mislykkedes",
|
||||
"shared_mailboxes_no_push": "Postkasser, som andre deler med dig, sender ikke baggrundsnotifikationer.",
|
||||
"status_android_only": "Baggrundsnotifikationer er kun tilgængelige på Android.",
|
||||
"transport_desc": "Hvordan notifikationer når frem til denne enhed: Googles Firebase Cloud Messaging eller en UnifiedPush-distributørapp (f.eks. ntfy) til levering uden Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase hat ein leeres Geräte-Token zurückgegeben.",
|
||||
"err_fcm_token": "Firebase konnte kein Geräte-Token ausstellen ({detail}). Push über FCM erfordert Google Play-Dienste auf diesem Gerät.",
|
||||
"err_fcm_token_up": "Firebase konnte kein Geräte-Token ausstellen ({detail}). Push über FCM erfordert Google Play-Dienste auf diesem Gerät. Auf diesem Gerät ist ein UnifiedPush-Distributor installiert - stellen Sie die Zustellmethode stattdessen auf UnifiedPush um.",
|
||||
"err_forbidden": "Der Mailserver erlaubt für dieses Konto keine Hintergrundbenachrichtigungen.",
|
||||
"err_jmap_list": "Die Push-Registrierungen konnten nicht vom Mailserver gelesen werden: {detail}",
|
||||
"err_jmap_refused": "Der Mailserver hat das Push-Abonnement abgelehnt: {detail}",
|
||||
"err_no_account": "Kein Konto geladen - Push kann nicht eingerichtet werden.",
|
||||
"err_over_quota": "Für dieses Konto gibt es auf dem Mailserver zu viele Push-Registrierungen (meist sind 15 erlaubt). Widerrufen Sie Geräte, die Sie nicht mehr nutzen, in den Benachrichtigungseinstellungen und versuchen Sie es erneut.",
|
||||
"err_permission": "Die Benachrichtigungsberechtigung wurde nicht erteilt.",
|
||||
"err_relay_https": "Die Relay-URL muss https:// verwenden.",
|
||||
"err_relay_rejected": "Das Push-Relay hat die Registrierung abgelehnt: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Der Server, der Ihre Benachrichtigungen zustellt. Standardmäßig das gehostete Bulwark-Relay; ändern Sie dies nur, wenn Sie selbst hosten. Muss https verwenden.",
|
||||
"relay_invalid": "Geben Sie eine gültige https://-URL ein",
|
||||
"setup_failed": "Einrichtung fehlgeschlagen",
|
||||
"shared_mailboxes_no_push": "Postfächer, die andere mit Ihnen teilen, senden keine Hintergrundbenachrichtigungen.",
|
||||
"status_android_only": "Hintergrundbenachrichtigungen sind nur unter Android verfügbar.",
|
||||
"transport_desc": "Wie Benachrichtigungen dieses Gerät erreichen: Googles Firebase Cloud Messaging oder eine UnifiedPush-Distributor-App (z. B. ntfy) für die Zustellung ohne Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -801,8 +801,11 @@
|
||||
"err_fcm_empty": "Firebase returned an empty device token.",
|
||||
"err_fcm_token": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device.",
|
||||
"err_fcm_token_up": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device. This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.",
|
||||
"err_forbidden": "The mail server doesn't allow background notifications for this account.",
|
||||
"err_jmap_list": "Could not read the push registrations from the mail server: {detail}",
|
||||
"err_jmap_refused": "The mail server refused the push subscription: {detail}",
|
||||
"err_no_account": "No account loaded - cannot set up push.",
|
||||
"err_over_quota": "This account has too many push registrations on the mail server (the limit is usually 15). Revoke devices you no longer use in the notification settings, then try again.",
|
||||
"err_permission": "Notification permission was not granted.",
|
||||
"err_relay_https": "The relay URL must use https://.",
|
||||
"err_relay_rejected": "The push relay rejected the registration: {detail}",
|
||||
@@ -822,6 +825,7 @@
|
||||
"relay_desc_mobile": "The server that delivers your notifications. Defaults to the hosted Bulwark relay; change only if you self-host. Must use https.",
|
||||
"relay_invalid": "Enter a valid https:// URL",
|
||||
"setup_failed": "Setup failed",
|
||||
"shared_mailboxes_no_push": "Mailboxes others share with you don't send background notifications.",
|
||||
"status_android_only": "Background notifications are available on Android only.",
|
||||
"transport_desc": "How notifications reach this device: Google’s Firebase Cloud Messaging, or a UnifiedPush distributor app (such as ntfy) for Google-free delivery.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase devolvió un token de dispositivo vacío.",
|
||||
"err_fcm_token": "Firebase no pudo emitir un token de dispositivo ({detail}). Las notificaciones push por FCM necesitan los servicios de Google Play en este dispositivo.",
|
||||
"err_fcm_token_up": "Firebase no pudo emitir un token de dispositivo ({detail}). Las notificaciones push por FCM necesitan los servicios de Google Play en este dispositivo. Este dispositivo tiene instalado un distribuidor UnifiedPush: cambia el método de entrega a UnifiedPush.",
|
||||
"err_forbidden": "El servidor de correo no permite notificaciones en segundo plano para esta cuenta.",
|
||||
"err_jmap_list": "No se pudieron leer los registros push del servidor de correo: {detail}",
|
||||
"err_jmap_refused": "El servidor de correo rechazó la suscripción push: {detail}",
|
||||
"err_no_account": "No hay ninguna cuenta cargada: no se pueden configurar las notificaciones push.",
|
||||
"err_over_quota": "Esta cuenta tiene demasiados registros push en el servidor de correo (el límite suele ser 15). Revoca los dispositivos que ya no uses en la configuración de notificaciones e inténtalo de nuevo.",
|
||||
"err_permission": "No se concedió el permiso de notificaciones.",
|
||||
"err_relay_https": "La URL del relay debe usar https://.",
|
||||
"err_relay_rejected": "El relay push rechazó el registro: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "El servidor que entrega tus notificaciones. Usa el relay alojado de Bulwark de forma predeterminada; cámbialo solo si te alojas tú mismo. Debe usar https.",
|
||||
"relay_invalid": "Introduce una URL https:// válida",
|
||||
"setup_failed": "Error en la configuración",
|
||||
"shared_mailboxes_no_push": "Los buzones que otras personas comparten contigo no envían notificaciones en segundo plano.",
|
||||
"status_android_only": "Las notificaciones en segundo plano solo están disponibles en Android.",
|
||||
"transport_desc": "Cómo llegan las notificaciones a este dispositivo: Firebase Cloud Messaging de Google o una app distribuidora de UnifiedPush (como ntfy) para una entrega sin Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase توکن دستگاه خالی برگرداند.",
|
||||
"err_fcm_token": "Firebase نتوانست توکن دستگاه صادر کند ({detail}). push از طریق FCM به سرویسهای Google Play روی این دستگاه نیاز دارد.",
|
||||
"err_fcm_token_up": "Firebase نتوانست توکن دستگاه صادر کند ({detail}). push از طریق FCM به سرویسهای Google Play روی این دستگاه نیاز دارد. روی این دستگاه یک توزیعکننده UnifiedPush نصب است - بهجای آن روش تحویل را به UnifiedPush تغییر دهید.",
|
||||
"err_forbidden": "سرور ایمیل اعلانهای پسزمینه را برای این حساب مجاز نمیداند.",
|
||||
"err_jmap_list": "خواندن ثبتهای پوش از سرور ایمیل ممکن نشد: {detail}",
|
||||
"err_jmap_refused": "سرور ایمیل اشتراک push را رد کرد: {detail}",
|
||||
"err_no_account": "هیچ حسابی بارگذاری نشده است - راهاندازی push ممکن نیست.",
|
||||
"err_over_quota": "این حساب روی سرور ایمیل ثبتهای پوش بیش از حد دارد (سقف معمولاً 15 است). دستگاههایی را که دیگر استفاده نمیکنید در تنظیمات اعلانها لغو کنید و دوباره امتحان کنید.",
|
||||
"err_permission": "مجوز اعلان داده نشد.",
|
||||
"err_relay_https": "نشانی رله باید از https:// استفاده کند.",
|
||||
"err_relay_rejected": "رله push ثبت را رد کرد: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "سروری که اعلانهای شما را تحویل میدهد. پیشفرض رله میزبانیشده Bulwark است؛ فقط اگر خودتان میزبانی میکنید آن را تغییر دهید. باید از https استفاده کند.",
|
||||
"relay_invalid": "یک نشانی https:// معتبر وارد کنید",
|
||||
"setup_failed": "راهاندازی ناموفق بود",
|
||||
"shared_mailboxes_no_push": "صندوقهای پستی که دیگران با شما به اشتراک میگذارند اعلان پسزمینه ارسال نمیکنند.",
|
||||
"status_android_only": "اعلانهای پسزمینه فقط در Android در دسترس است.",
|
||||
"transport_desc": "نحوه رسیدن اعلانها به این دستگاه: Firebase Cloud Messaging گوگل، یا یک برنامه توزیعکننده UnifiedPush (مانند ntfy) برای تحویل بدون Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase a renvoyé un jeton d'appareil vide.",
|
||||
"err_fcm_token": "Firebase n'a pas pu émettre de jeton d'appareil ({detail}). Le push via FCM nécessite les services Google Play sur cet appareil.",
|
||||
"err_fcm_token_up": "Firebase n'a pas pu émettre de jeton d'appareil ({detail}). Le push via FCM nécessite les services Google Play sur cet appareil. Un distributeur UnifiedPush est installé sur cet appareil - choisissez plutôt UnifiedPush comme mode de distribution.",
|
||||
"err_forbidden": "Le serveur de messagerie n'autorise pas les notifications en arrière-plan pour ce compte.",
|
||||
"err_jmap_list": "Impossible de lire les enregistrements push sur le serveur de messagerie : {detail}",
|
||||
"err_jmap_refused": "Le serveur de messagerie a refusé l'abonnement push : {detail}",
|
||||
"err_no_account": "Aucun compte chargé - impossible de configurer le push.",
|
||||
"err_over_quota": "Ce compte a trop d'enregistrements push sur le serveur de messagerie (la limite est généralement de 15). Révoquez les appareils que vous n'utilisez plus dans les paramètres de notification, puis réessayez.",
|
||||
"err_permission": "L'autorisation des notifications n'a pas été accordée.",
|
||||
"err_relay_https": "L'URL du relais doit utiliser https://.",
|
||||
"err_relay_rejected": "Le relais push a refusé l'enregistrement : {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Le serveur qui distribue vos notifications. Par défaut, le relais Bulwark hébergé. Ne le changez que si vous l'hébergez vous-même. Doit utiliser https.",
|
||||
"relay_invalid": "Saisissez une URL https:// valide",
|
||||
"setup_failed": "Échec de la configuration",
|
||||
"shared_mailboxes_no_push": "Les boîtes aux lettres que d'autres personnes partagent avec vous n'envoient pas de notifications en arrière-plan.",
|
||||
"status_android_only": "Les notifications en arrière-plan ne sont disponibles que sur Android.",
|
||||
"transport_desc": "Comment les notifications parviennent à cet appareil : Firebase Cloud Messaging de Google, ou une application de distribution UnifiedPush (comme ntfy) pour une distribution sans Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase החזיר אסימון מכשיר ריק.",
|
||||
"err_fcm_token": "Firebase לא הצליח להנפיק אסימון מכשיר ({detail}). Push דרך FCM דורש את שירותי Google Play במכשיר זה.",
|
||||
"err_fcm_token_up": "Firebase לא הצליח להנפיק אסימון מכשיר ({detail}). Push דרך FCM דורש את שירותי Google Play במכשיר זה. במכשיר זה מותקן מפיץ UnifiedPush - החלף את שיטת המסירה ל-UnifiedPush.",
|
||||
"err_forbidden": "שרת הדואר אינו מאפשר התראות ברקע עבור חשבון זה.",
|
||||
"err_jmap_list": "לא ניתן היה לקרוא את רישומי הפוש משרת הדואר: {detail}",
|
||||
"err_jmap_refused": "שרת הדואר דחה את מינוי ה-Push: {detail}",
|
||||
"err_no_account": "לא נטען חשבון - לא ניתן להגדיר Push.",
|
||||
"err_over_quota": "לחשבון זה יש יותר מדי רישומי פוש בשרת הדואר (המגבלה היא בדרך כלל 15). בטל בהגדרות ההתראות מכשירים שאינך משתמש בהם עוד, ונסה שוב.",
|
||||
"err_permission": "הרשאת ההתראות לא ניתנה.",
|
||||
"err_relay_https": "כתובת ה-URL של הממסר חייבת להשתמש ב-https://.",
|
||||
"err_relay_rejected": "ממסר הדחיפה דחה את הרישום: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "השרת שמוסר את ההתראות שלך. ברירת המחדל היא ממסר Bulwark המתארח; שנה רק אם אתה מארח בעצמך. חייב להשתמש ב-https.",
|
||||
"relay_invalid": "הזן כתובת https:// תקינה",
|
||||
"setup_failed": "ההגדרה נכשלה",
|
||||
"shared_mailboxes_no_push": "תיבות דואר שאחרים משתפים איתך אינן שולחות התראות ברקע.",
|
||||
"status_android_only": "התראות ברקע זמינות ב-Android בלבד.",
|
||||
"transport_desc": "איך ההתראות מגיעות למכשיר הזה: Firebase Cloud Messaging של Google, או אפליקציית מפיץ של UnifiedPush (כמו ntfy) למסירה ללא Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "A Firebase üres eszköztokent adott vissza.",
|
||||
"err_fcm_token": "A Firebase nem tudott eszköztokent kiadni ({detail}). Az FCM-en keresztüli pushhoz Google Play-szolgáltatások kellenek ezen az eszközön.",
|
||||
"err_fcm_token_up": "A Firebase nem tudott eszköztokent kiadni ({detail}). Az FCM-en keresztüli pushhoz Google Play-szolgáltatások kellenek ezen az eszközön. Ezen az eszközön telepítve van egy UnifiedPush-disztribútor – válts inkább UnifiedPush kézbesítési módra.",
|
||||
"err_forbidden": "A levelezőszerver ennél a fióknál nem engedélyezi a háttérértesítéseket.",
|
||||
"err_jmap_list": "Nem sikerült beolvasni a push-regisztrációkat a levelezőszerverről: {detail}",
|
||||
"err_jmap_refused": "A levelezőszerver elutasította a push-feliratkozást: {detail}",
|
||||
"err_no_account": "Nincs betöltött fiók – a push nem állítható be.",
|
||||
"err_over_quota": "Ennek a fióknak túl sok push-regisztrációja van a levelezőszerveren (a korlát általában 15). Az értesítési beállításokban vond vissza a már nem használt eszközöket, majd próbáld újra.",
|
||||
"err_permission": "Az értesítési engedélyt nem adtad meg.",
|
||||
"err_relay_https": "A relé URL-jének https://-t kell használnia.",
|
||||
"err_relay_rejected": "A push relé elutasította a regisztrációt: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Az értesítéseidet kézbesítő szerver. Alapértelmezés szerint a Bulwark által üzemeltetett relé; csak akkor változtasd meg, ha sajátot üzemeltetsz. https-t kell használnia.",
|
||||
"relay_invalid": "Adj meg érvényes https:// URL-t",
|
||||
"setup_failed": "A beállítás sikertelen",
|
||||
"shared_mailboxes_no_push": "A mások által veled megosztott postafiókok nem küldenek háttérértesítéseket.",
|
||||
"status_android_only": "A háttérértesítések csak Androidon érhetők el.",
|
||||
"transport_desc": "Hogyan jutnak el az értesítések erre az eszközre: a Google Firebase Cloud Messaging szolgáltatásán vagy egy UnifiedPush-disztribútor alkalmazáson (például ntfy) keresztül, Google nélkül.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase ha restituito un token del dispositivo vuoto.",
|
||||
"err_fcm_token": "Firebase non è riuscito a emettere un token del dispositivo ({detail}). Il push tramite FCM richiede i servizi Google Play su questo dispositivo.",
|
||||
"err_fcm_token_up": "Firebase non è riuscito a emettere un token del dispositivo ({detail}). Il push tramite FCM richiede i servizi Google Play su questo dispositivo. Su questo dispositivo è installato un distributore UnifiedPush - passa al metodo di consegna UnifiedPush.",
|
||||
"err_forbidden": "Il server di posta non consente le notifiche in background per questo account.",
|
||||
"err_jmap_list": "Impossibile leggere le registrazioni push dal server di posta: {detail}",
|
||||
"err_jmap_refused": "Il server di posta ha rifiutato la sottoscrizione push: {detail}",
|
||||
"err_no_account": "Nessun account caricato - impossibile configurare il push.",
|
||||
"err_over_quota": "Questo account ha troppe registrazioni push sul server di posta (il limite di solito è 15). Revoca i dispositivi che non usi più nelle impostazioni delle notifiche, poi riprova.",
|
||||
"err_permission": "L'autorizzazione alle notifiche non è stata concessa.",
|
||||
"err_relay_https": "L'URL del relay deve usare https://.",
|
||||
"err_relay_rejected": "Il relay push ha rifiutato la registrazione: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Il server che consegna le notifiche. Per impostazione predefinita usa il relay Bulwark ospitato; cambialo solo se ospiti in autonomia. Deve usare https.",
|
||||
"relay_invalid": "Inserisci un URL https:// valido",
|
||||
"setup_failed": "Configurazione non riuscita",
|
||||
"shared_mailboxes_no_push": "Le caselle di posta che altri condividono con te non inviano notifiche in background.",
|
||||
"status_android_only": "Le notifiche in background sono disponibili solo su Android.",
|
||||
"transport_desc": "Come arrivano le notifiche a questo dispositivo: Firebase Cloud Messaging di Google oppure un'app distributore UnifiedPush (come ntfy) per una consegna senza Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase が空のデバイストークンを返しました。",
|
||||
"err_fcm_token": "Firebase がデバイストークンを発行できませんでした({detail})。FCM によるプッシュには、このデバイスに Google Play 開発者サービスが必要です。",
|
||||
"err_fcm_token_up": "Firebase がデバイストークンを発行できませんでした({detail})。FCM によるプッシュには、このデバイスに Google Play 開発者サービスが必要です。このデバイスには UnifiedPush ディストリビューターがインストールされているため、代わりに配信方法を UnifiedPush に切り替えてください。",
|
||||
"err_forbidden": "メールサーバーは、このアカウントのバックグラウンド通知を許可していません。",
|
||||
"err_jmap_list": "メールサーバーからプッシュ登録を読み取れませんでした:{detail}",
|
||||
"err_jmap_refused": "メールサーバーがプッシュの購読を拒否しました:{detail}",
|
||||
"err_no_account": "アカウントが読み込まれていないため、プッシュを設定できません。",
|
||||
"err_over_quota": "このアカウントはメールサーバー上のプッシュ登録が多すぎます(上限は通常 15 件)。通知設定で使用していないデバイスを取り消してから、もう一度お試しください。",
|
||||
"err_permission": "通知が許可されませんでした。",
|
||||
"err_relay_https": "リレーの URL には https:// を使用する必要があります。",
|
||||
"err_relay_rejected": "プッシュリレーが登録を拒否しました:{detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "通知を配信するサーバーです。デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。https を使用する必要があります。",
|
||||
"relay_invalid": "有効な https:// URL を入力してください",
|
||||
"setup_failed": "セットアップに失敗しました",
|
||||
"shared_mailboxes_no_push": "他のユーザーから共有されたメールボックスでは、バックグラウンド通知は送信されません。",
|
||||
"status_android_only": "バックグラウンド通知は Android でのみ利用できます。",
|
||||
"transport_desc": "通知をこのデバイスに届ける方法です。Google の Firebase Cloud Messaging か、Google を使わずに配信する UnifiedPush ディストリビューターアプリ(ntfy など)を選択できます。",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase에서 빈 기기 토큰을 반환했어요.",
|
||||
"err_fcm_token": "Firebase에서 기기 토큰을 발급하지 못했어요({detail}). FCM으로 푸시를 받으려면 이 기기에 Google Play 서비스가 필요해요.",
|
||||
"err_fcm_token_up": "Firebase에서 기기 토큰을 발급하지 못했어요({detail}). FCM으로 푸시를 받으려면 이 기기에 Google Play 서비스가 필요해요. 이 기기에는 UnifiedPush 배포자가 설치되어 있으니, 대신 전달 방식을 UnifiedPush로 바꿔 주세요.",
|
||||
"err_forbidden": "메일 서버에서 이 계정의 백그라운드 알림을 허용하지 않아요.",
|
||||
"err_jmap_list": "메일 서버에서 푸시 등록을 읽을 수 없어요: {detail}",
|
||||
"err_jmap_refused": "메일 서버에서 푸시 구독을 거부했어요: {detail}",
|
||||
"err_no_account": "불러온 계정이 없어서 푸시를 설정할 수 없어요.",
|
||||
"err_over_quota": "이 계정은 메일 서버에 푸시 등록이 너무 많아요(한도는 보통 15개). 알림 설정에서 더 이상 사용하지 않는 기기를 해지한 후 다시 시도해 주세요.",
|
||||
"err_permission": "알림 권한이 허용되지 않았어요.",
|
||||
"err_relay_https": "릴레이 URL은 https://를 사용해야 해요.",
|
||||
"err_relay_rejected": "푸시 릴레이에서 등록을 거부했어요: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "알림을 전달하는 서버예요. 기본값은 호스팅된 Bulwark 릴레이이며, 셀프 호스팅하는 경우에만 변경하세요. https를 사용해야 해요.",
|
||||
"relay_invalid": "올바른 https:// URL을 입력해 주세요",
|
||||
"setup_failed": "설정에 실패했어요",
|
||||
"shared_mailboxes_no_push": "다른 사람이 공유한 메일함은 백그라운드 알림을 보내지 않아요.",
|
||||
"status_android_only": "백그라운드 알림은 Android에서만 사용할 수 있어요.",
|
||||
"transport_desc": "알림이 이 기기에 전달되는 방식이에요. Google의 Firebase Cloud Messaging 또는 Google 없이 전달하는 UnifiedPush 배포자 앱(예: ntfy) 중에서 선택할 수 있어요.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase atgrieza tukšu ierīces tokenu.",
|
||||
"err_fcm_token": "Firebase nevarēja izsniegt ierīces tokenu ({detail}). Lai izmantotu push ar FCM, šajā ierīcē nepieciešami Google Play pakalpojumi.",
|
||||
"err_fcm_token_up": "Firebase nevarēja izsniegt ierīces tokenu ({detail}). Lai izmantotu push ar FCM, šajā ierīcē nepieciešami Google Play pakalpojumi. Šajā ierīcē ir instalēts UnifiedPush izplatītājs – tā vietā pārslēdziet piegādes veidu uz UnifiedPush.",
|
||||
"err_forbidden": "Pasta serveris šim kontam neatļauj fona paziņojumus.",
|
||||
"err_jmap_list": "Neizdevās nolasīt push reģistrācijas no pasta servera: {detail}",
|
||||
"err_jmap_refused": "Pasta serveris noraidīja push abonementu: {detail}",
|
||||
"err_no_account": "Nav ielādēts neviens konts – push nevar iestatīt.",
|
||||
"err_over_quota": "Šim kontam pasta serverī ir pārāk daudz push reģistrāciju (ierobežojums parasti ir 15). Paziņojumu iestatījumos atsauciet ierīces, kuras vairs neizmantojat, un mēģiniet vēlreiz.",
|
||||
"err_permission": "Paziņojumu atļauja netika piešķirta.",
|
||||
"err_relay_https": "Releja URL jāizmanto https://.",
|
||||
"err_relay_rejected": "Push relejs noraidīja reģistrāciju: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Serveris, kas piegādā jūsu paziņojumus. Pēc noklusējuma tas ir Bulwark mitinātais relejs; mainiet tikai tad, ja izmantojat savu. Jāizmanto https.",
|
||||
"relay_invalid": "Ievadiet derīgu https:// URL",
|
||||
"setup_failed": "Iestatīšana neizdevās",
|
||||
"shared_mailboxes_no_push": "Pastkastes, ko ar jums kopīgo citi, nesūta fona paziņojumus.",
|
||||
"status_android_only": "Fona paziņojumi ir pieejami tikai Android ierīcēs.",
|
||||
"transport_desc": "Kā paziņojumi nonāk šajā ierīcē: ar Google Firebase Cloud Messaging vai ar UnifiedPush izplatītāja lietotni (piemēram, ntfy) bez Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase хоосон төхөөрөмжийн токен буцаалаа.",
|
||||
"err_fcm_token": "Firebase төхөөрөмжийн токен олгож чадсангүй ({detail}). FCM-ээр push хүлээн авахад энэ төхөөрөмжид Google Play үйлчилгээ шаардлагатай.",
|
||||
"err_fcm_token_up": "Firebase төхөөрөмжийн токен олгож чадсангүй ({detail}). FCM-ээр push хүлээн авахад энэ төхөөрөмжид Google Play үйлчилгээ шаардлагатай. Энэ төхөөрөмжид UnifiedPush түгээгч суулгасан байна - оронд нь хүргэх аргыг UnifiedPush болгож солино уу.",
|
||||
"err_forbidden": "Мэйл сервер энэ бүртгэлд арын мэдэгдэл зөвшөөрдөггүй.",
|
||||
"err_jmap_list": "Мэйл серверээс push бүртгэлүүдийг уншиж чадсангүй: {detail}",
|
||||
"err_jmap_refused": "Мэйл сервер push захиалгаас татгалзлаа: {detail}",
|
||||
"err_no_account": "Бүртгэл ачаалагдаагүй - push тохируулах боломжгүй.",
|
||||
"err_over_quota": "Энэ бүртгэл мэйл сервер дээр хэт олон push бүртгэлтэй байна (хязгаар нь ихэвчлэн 15). Мэдэгдлийн тохиргооноос ашиглахаа больсон төхөөрөмжүүдээ цуцлаад дахин оролдоно уу.",
|
||||
"err_permission": "Мэдэгдлийн зөвшөөрөл олгогдоогүй.",
|
||||
"err_relay_https": "Релейн URL нь https:// ашиглах ёстой.",
|
||||
"err_relay_rejected": "Түлхэх реле бүртгэлийг татгалзлаа: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Таны мэдэгдлийг хүргэдэг сервер. Өгөгдмөлөөр Bulwark-ийн байршуулсан реле; зөвхөн өөрөө сервер ажиллуулдаг бол өөрчилнө үү. https ашиглах ёстой.",
|
||||
"relay_invalid": "Хүчинтэй https:// URL оруулна уу",
|
||||
"setup_failed": "Тохируулж чадсангүй",
|
||||
"shared_mailboxes_no_push": "Бусдын тантай хуваалцсан шуудангийн хайрцгууд арын мэдэгдэл илгээдэггүй.",
|
||||
"status_android_only": "Арын мэдэгдэл зөвхөн Android дээр боломжтой.",
|
||||
"transport_desc": "Мэдэгдэл энэ төхөөрөмжид хэрхэн хүрэх: Google-ийн Firebase Cloud Messaging эсвэл Google-гүй хүргэлтэд зориулсан UnifiedPush түгээгч апп (жишээ нь ntfy).",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase returnerte et tomt enhetstoken.",
|
||||
"err_fcm_token": "Firebase kunne ikke utstede et enhetstoken ({detail}). Push via FCM krever Google Play-tjenester på denne enheten.",
|
||||
"err_fcm_token_up": "Firebase kunne ikke utstede et enhetstoken ({detail}). Push via FCM krever Google Play-tjenester på denne enheten. Denne enheten har en UnifiedPush-distributør installert – bytt leveringsmetode til UnifiedPush i stedet.",
|
||||
"err_forbidden": "E-postserveren tillater ikke bakgrunnsvarsler for denne kontoen.",
|
||||
"err_jmap_list": "Kunne ikke lese push-registreringene fra e-postserveren: {detail}",
|
||||
"err_jmap_refused": "E-postserveren avviste push-abonnementet: {detail}",
|
||||
"err_no_account": "Ingen konto er lastet inn – kan ikke sette opp push.",
|
||||
"err_over_quota": "Denne kontoen har for mange push-registreringer på e-postserveren (grensen er vanligvis 15). Tilbakekall enheter du ikke lenger bruker i varslingsinnstillingene, og prøv igjen.",
|
||||
"err_permission": "Tillatelse til varsler ble ikke gitt.",
|
||||
"err_relay_https": "Relé-URL-en må bruke https://.",
|
||||
"err_relay_rejected": "Push-reléet avviste registreringen: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Serveren som leverer varslene dine. Bulwarks relé brukes som standard; endre dette bare hvis du drifter et eget relé. Må bruke https.",
|
||||
"relay_invalid": "Skriv inn en gyldig https://-URL",
|
||||
"setup_failed": "Oppsettet mislyktes",
|
||||
"shared_mailboxes_no_push": "Postbokser som andre deler med deg, sender ikke bakgrunnsvarsler.",
|
||||
"status_android_only": "Bakgrunnsvarsler er bare tilgjengelige på Android.",
|
||||
"transport_desc": "Hvordan varsler når denne enheten: Googles Firebase Cloud Messaging eller en UnifiedPush-distributørapp (for eksempel ntfy) for levering uten Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase gaf een leeg apparaattoken terug.",
|
||||
"err_fcm_token": "Firebase kon geen apparaattoken uitgeven ({detail}). Push via FCM vereist Google Play-services op dit apparaat.",
|
||||
"err_fcm_token_up": "Firebase kon geen apparaattoken uitgeven ({detail}). Push via FCM vereist Google Play-services op dit apparaat. Op dit apparaat is een UnifiedPush-distributor geïnstalleerd - zet de bezorgmethode in plaats daarvan op UnifiedPush.",
|
||||
"err_forbidden": "De mailserver staat voor dit account geen achtergrondmeldingen toe.",
|
||||
"err_jmap_list": "Kon de pushregistraties niet van de mailserver lezen: {detail}",
|
||||
"err_jmap_refused": "De mailserver weigerde het push-abonnement: {detail}",
|
||||
"err_no_account": "Geen account geladen - kan push niet instellen.",
|
||||
"err_over_quota": "Dit account heeft te veel pushregistraties op de mailserver (de limiet is meestal 15). Trek in de meldingsinstellingen apparaten in die u niet meer gebruikt en probeer het opnieuw.",
|
||||
"err_permission": "Toestemming voor meldingen is niet verleend.",
|
||||
"err_relay_https": "De relay-URL moet https:// gebruiken.",
|
||||
"err_relay_rejected": "De push-relay weigerde de registratie: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "De server die uw meldingen bezorgt. Standaard de gehoste Bulwark-relay; wijzig dit alleen als u zelf host. Moet https gebruiken.",
|
||||
"relay_invalid": "Voer een geldige https://-URL in",
|
||||
"setup_failed": "Instellen mislukt",
|
||||
"shared_mailboxes_no_push": "Mailboxen die anderen met u delen, sturen geen achtergrondmeldingen.",
|
||||
"status_android_only": "Achtergrondmeldingen zijn alleen beschikbaar op Android.",
|
||||
"transport_desc": "Hoe meldingen dit apparaat bereiken: Firebase Cloud Messaging van Google, of een UnifiedPush-distributor-app (zoals ntfy) voor bezorging zonder Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase zwrócił pusty token urządzenia.",
|
||||
"err_fcm_token": "Firebase nie mógł wydać tokenu urządzenia ({detail}). Push przez FCM wymaga Usług Google Play na tym urządzeniu.",
|
||||
"err_fcm_token_up": "Firebase nie mógł wydać tokenu urządzenia ({detail}). Push przez FCM wymaga Usług Google Play na tym urządzeniu. Na tym urządzeniu jest zainstalowany dystrybutor UnifiedPush – zmień metodę dostarczania na UnifiedPush.",
|
||||
"err_forbidden": "Serwer poczty nie zezwala na powiadomienia w tle dla tego konta.",
|
||||
"err_jmap_list": "Nie udało się odczytać rejestracji push z serwera poczty: {detail}",
|
||||
"err_jmap_refused": "Serwer poczty odrzucił subskrypcję push: {detail}",
|
||||
"err_no_account": "Nie wczytano konta – nie można skonfigurować push.",
|
||||
"err_over_quota": "To konto ma zbyt wiele rejestracji push na serwerze poczty (limit wynosi zwykle 15). Unieważnij nieużywane urządzenia w ustawieniach powiadomień i spróbuj ponownie.",
|
||||
"err_permission": "Nie przyznano uprawnienia do powiadomień.",
|
||||
"err_relay_https": "Adres URL przekaźnika musi używać https://.",
|
||||
"err_relay_rejected": "Przekaźnik push odrzucił rejestrację: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Serwer, który dostarcza Twoje powiadomienia. Domyślnie jest to hostowany przekaźnik Bulwark; zmieniaj tylko, jeśli hostujesz własny. Musi używać https.",
|
||||
"relay_invalid": "Wpisz prawidłowy adres URL https://",
|
||||
"setup_failed": "Konfiguracja nie powiodła się",
|
||||
"shared_mailboxes_no_push": "Skrzynki pocztowe udostępnione ci przez innych nie wysyłają powiadomień w tle.",
|
||||
"status_android_only": "Powiadomienia w tle są dostępne tylko na Androidzie.",
|
||||
"transport_desc": "Sposób dostarczania powiadomień na to urządzenie: Firebase Cloud Messaging od Google lub aplikacja dystrybutora UnifiedPush (np. ntfy), która działa bez Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "O Firebase retornou um token de dispositivo vazio.",
|
||||
"err_fcm_token": "O Firebase não conseguiu emitir um token de dispositivo ({detail}). O push via FCM precisa do Google Play Services neste dispositivo.",
|
||||
"err_fcm_token_up": "O Firebase não conseguiu emitir um token de dispositivo ({detail}). O push via FCM precisa do Google Play Services neste dispositivo. Este dispositivo tem um distribuidor UnifiedPush instalado - mude o método de entrega para UnifiedPush.",
|
||||
"err_forbidden": "O servidor de e-mail não permite notificações em segundo plano para esta conta.",
|
||||
"err_jmap_list": "Não foi possível ler os registros push do servidor de e-mail: {detail}",
|
||||
"err_jmap_refused": "O servidor de e-mail recusou a inscrição push: {detail}",
|
||||
"err_no_account": "Nenhuma conta carregada - não é possível configurar o push.",
|
||||
"err_over_quota": "Esta conta tem registros push demais no servidor de e-mail (o limite costuma ser 15). Revogue os dispositivos que você não usa mais nas configurações de notificação e tente novamente.",
|
||||
"err_permission": "A permissão de notificação não foi concedida.",
|
||||
"err_relay_https": "A URL do relay deve usar https://.",
|
||||
"err_relay_rejected": "O relay push rejeitou o registro: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "O servidor que entrega suas notificações. Usa o relay Bulwark hospedado por padrão; altere apenas se você hospedar o seu próprio. Deve usar https.",
|
||||
"relay_invalid": "Digite uma URL https:// válida",
|
||||
"setup_failed": "Falha na configuração",
|
||||
"shared_mailboxes_no_push": "Caixas de correio que outras pessoas compartilham com você não enviam notificações em segundo plano.",
|
||||
"status_android_only": "As notificações em segundo plano estão disponíveis apenas no Android.",
|
||||
"transport_desc": "Como as notificações chegam a este dispositivo: o Firebase Cloud Messaging do Google ou um app distribuidor UnifiedPush (como o ntfy) para entrega sem Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase a returnat un token de dispozitiv gol.",
|
||||
"err_fcm_token": "Firebase nu a putut emite un token de dispozitiv ({detail}). Notificările push prin FCM necesită serviciile Google Play pe acest dispozitiv.",
|
||||
"err_fcm_token_up": "Firebase nu a putut emite un token de dispozitiv ({detail}). Notificările push prin FCM necesită serviciile Google Play pe acest dispozitiv. Pe acest dispozitiv este instalat un distribuitor UnifiedPush - schimbați metoda de livrare la UnifiedPush.",
|
||||
"err_forbidden": "Serverul de e-mail nu permite notificări în fundal pentru acest cont.",
|
||||
"err_jmap_list": "Înregistrările push nu au putut fi citite de pe serverul de e-mail: {detail}",
|
||||
"err_jmap_refused": "Serverul de e-mail a refuzat abonarea push: {detail}",
|
||||
"err_no_account": "Niciun cont încărcat - notificările push nu pot fi configurate.",
|
||||
"err_over_quota": "Acest cont are prea multe înregistrări push pe serverul de e-mail (limita este de obicei 15). Revocați din setările de notificări dispozitivele pe care nu le mai folosiți, apoi încercați din nou.",
|
||||
"err_permission": "Permisiunea pentru notificări nu a fost acordată.",
|
||||
"err_relay_https": "Adresa URL a relay-ului trebuie să folosească https://.",
|
||||
"err_relay_rejected": "Relay-ul push a respins înregistrarea: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Serverul care vă livrează notificările. Implicit este relay-ul găzduit Bulwark; modificați-l numai dacă folosiți un server propriu. Trebuie să folosească https.",
|
||||
"relay_invalid": "Introduceți un URL https:// valid",
|
||||
"setup_failed": "Configurarea a eșuat",
|
||||
"shared_mailboxes_no_push": "Căsuțele poștale pe care alții le partajează cu dvs. nu trimit notificări în fundal.",
|
||||
"status_android_only": "Notificările în fundal sunt disponibile doar pe Android.",
|
||||
"transport_desc": "Cum ajung notificările pe acest dispozitiv: Firebase Cloud Messaging de la Google sau o aplicație distribuitor UnifiedPush (cum ar fi ntfy) pentru livrare fără Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase вернул пустой токен устройства.",
|
||||
"err_fcm_token": "Firebase не смог выдать токен устройства ({detail}). Для push через FCM на этом устройстве нужны сервисы Google Play.",
|
||||
"err_fcm_token_up": "Firebase не смог выдать токен устройства ({detail}). Для push через FCM на этом устройстве нужны сервисы Google Play. На этом устройстве установлен дистрибьютор UnifiedPush — выберите способ доставки UnifiedPush.",
|
||||
"err_forbidden": "Почтовый сервер не разрешает фоновые уведомления для этой учётной записи.",
|
||||
"err_jmap_list": "Не удалось прочитать push-регистрации с почтового сервера: {detail}",
|
||||
"err_jmap_refused": "Почтовый сервер отклонил push-подписку: {detail}",
|
||||
"err_no_account": "Аккаунт не загружен — невозможно настроить push.",
|
||||
"err_over_quota": "У этой учётной записи слишком много push-регистраций на почтовом сервере (обычно разрешено 15). Отзовите неиспользуемые устройства в настройках уведомлений и повторите попытку.",
|
||||
"err_permission": "Разрешение на уведомления не предоставлено.",
|
||||
"err_relay_https": "URL релея должен использовать https://.",
|
||||
"err_relay_rejected": "Push-релей отклонил регистрацию: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Сервер, который доставляет ваши уведомления. По умолчанию — релей Bulwark; меняйте, только если размещаете свой. Должен использовать https.",
|
||||
"relay_invalid": "Введите действительный URL https://",
|
||||
"setup_failed": "Не удалось настроить",
|
||||
"shared_mailboxes_no_push": "Почтовые ящики, к которым вам открыли доступ другие пользователи, не присылают фоновые уведомления.",
|
||||
"status_android_only": "Фоновые уведомления доступны только на Android.",
|
||||
"transport_desc": "Как уведомления попадают на это устройство: через Firebase Cloud Messaging от Google или через приложение-дистрибьютор UnifiedPush (например, ntfy) без сервисов Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase vrátil prázdny token zariadenia.",
|
||||
"err_fcm_token": "Firebase nemohol vydať token zariadenia ({detail}). Push cez FCM vyžaduje v tomto zariadení služby Google Play.",
|
||||
"err_fcm_token_up": "Firebase nemohol vydať token zariadenia ({detail}). Push cez FCM vyžaduje v tomto zariadení služby Google Play. V tomto zariadení je nainštalovaný distribútor UnifiedPush – prepnite spôsob doručenia na UnifiedPush.",
|
||||
"err_forbidden": "Poštový server pre tento účet nepovoľuje oznámenia na pozadí.",
|
||||
"err_jmap_list": "Registrácie push sa z poštového servera nepodarilo načítať: {detail}",
|
||||
"err_jmap_refused": "Poštový server odmietol odber push: {detail}",
|
||||
"err_no_account": "Nie je načítaný žiadny účet – push nie je možné nastaviť.",
|
||||
"err_over_quota": "Tento účet má na poštovom serveri príliš veľa registrácií push (limit je zvyčajne 15). V nastaveniach oznámení odvolajte zariadenia, ktoré už nepoužívate, a skúste to znova.",
|
||||
"err_permission": "Povolenie oznámení nebolo udelené.",
|
||||
"err_relay_https": "URL relay musí používať https://.",
|
||||
"err_relay_rejected": "Push relay odmietol registráciu: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Server, ktorý doručuje vaše oznámenia. Predvolený je hostovaný relay Bulwark; meňte iba vtedy, ak prevádzkujete vlastný. Musí používať https.",
|
||||
"relay_invalid": "Zadajte platnú adresu URL https://",
|
||||
"setup_failed": "Nastavenie zlyhalo",
|
||||
"shared_mailboxes_no_push": "Poštové schránky, ktoré s vami zdieľajú iní, neposielajú oznámenia na pozadí.",
|
||||
"status_android_only": "Oznámenia na pozadí sú k dispozícii iba v Androide.",
|
||||
"transport_desc": "Ako sa oznámenia dostanú do tohto zariadenia: cez Firebase Cloud Messaging od Googlu alebo cez aplikáciu distribútora UnifiedPush (napríklad ntfy) bez Googlu.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase boş bir cihaz belirteci döndürdü.",
|
||||
"err_fcm_token": "Firebase cihaz belirteci veremedi ({detail}). FCM üzerinden push için bu cihazda Google Play hizmetleri gerekir.",
|
||||
"err_fcm_token_up": "Firebase cihaz belirteci veremedi ({detail}). FCM üzerinden push için bu cihazda Google Play hizmetleri gerekir. Bu cihazda bir UnifiedPush dağıtıcısı yüklü - bunun yerine teslimat yöntemini UnifiedPush olarak değiştirin.",
|
||||
"err_forbidden": "Posta sunucusu bu hesap için arka plan bildirimlerine izin vermiyor.",
|
||||
"err_jmap_list": "Push kayıtları posta sunucusundan okunamadı: {detail}",
|
||||
"err_jmap_refused": "Posta sunucusu push aboneliğini reddetti: {detail}",
|
||||
"err_no_account": "Hesap yüklenmedi - push ayarlanamıyor.",
|
||||
"err_over_quota": "Bu hesabın posta sunucusunda çok fazla push kaydı var (sınır genellikle 15'tir). Artık kullanmadığınız cihazları bildirim ayarlarından iptal edip yeniden deneyin.",
|
||||
"err_permission": "Bildirim izni verilmedi.",
|
||||
"err_relay_https": "Röle URL'si https:// kullanmalıdır.",
|
||||
"err_relay_rejected": "Push röle kaydı reddetti: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Bildirimlerinizi ileten sunucu. Varsayılan olarak barındırılan Bulwark rölesidir; yalnızca kendiniz barındırıyorsanız değiştirin. https kullanmalıdır.",
|
||||
"relay_invalid": "Geçerli bir https:// URL'si girin",
|
||||
"setup_failed": "Kurulum başarısız",
|
||||
"shared_mailboxes_no_push": "Başkalarının sizinle paylaştığı posta kutuları arka plan bildirimi göndermez.",
|
||||
"status_android_only": "Arka plan bildirimleri yalnızca Android'de kullanılabilir.",
|
||||
"transport_desc": "Bildirimlerin bu cihaza nasıl ulaşacağı: Google'ın Firebase Cloud Messaging hizmeti veya Google'sız teslimat için bir UnifiedPush dağıtıcı uygulaması (ör. ntfy).",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase повернув порожній токен пристрою.",
|
||||
"err_fcm_token": "Firebase не зміг видати токен пристрою ({detail}). Для push через FCM на цьому пристрої потрібні сервіси Google Play.",
|
||||
"err_fcm_token_up": "Firebase не зміг видати токен пристрою ({detail}). Для push через FCM на цьому пристрої потрібні сервіси Google Play. На цьому пристрої встановлено дистриб'ютор UnifiedPush — виберіть спосіб доставки UnifiedPush.",
|
||||
"err_forbidden": "Поштовий сервер не дозволяє фонові сповіщення для цього облікового запису.",
|
||||
"err_jmap_list": "Не вдалося прочитати push-реєстрації з поштового сервера: {detail}",
|
||||
"err_jmap_refused": "Поштовий сервер відхилив push-підписку: {detail}",
|
||||
"err_no_account": "Обліковий запис не завантажено — неможливо налаштувати push.",
|
||||
"err_over_quota": "Цей обліковий запис має забагато push-реєстрацій на поштовому сервері (зазвичай дозволено 15). Відкличте в налаштуваннях сповіщень пристрої, якими ви більше не користуєтеся, і спробуйте ще раз.",
|
||||
"err_permission": "Дозвіл на сповіщення не надано.",
|
||||
"err_relay_https": "URL реле має використовувати https://.",
|
||||
"err_relay_rejected": "Push-реле відхилило реєстрацію: {detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "Сервер, який доставляє ваші сповіщення. За замовчуванням — реле Bulwark; змінюйте, лише якщо розміщуєте власне. Має використовувати https.",
|
||||
"relay_invalid": "Введіть дійсну URL-адресу https://",
|
||||
"setup_failed": "Не вдалося налаштувати",
|
||||
"shared_mailboxes_no_push": "Поштові скриньки, до яких вам надали доступ інші користувачі, не надсилають фонових сповіщень.",
|
||||
"status_android_only": "Фонові сповіщення доступні лише на Android.",
|
||||
"transport_desc": "Як сповіщення надходять на цей пристрій: через Firebase Cloud Messaging від Google або через застосунок-дистриб'ютор UnifiedPush (наприклад, ntfy) без сервісів Google.",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase 傳回了空白的裝置權杖。",
|
||||
"err_fcm_token": "Firebase 無法核發裝置權杖({detail})。透過 FCM 推送需要此裝置具備 Google Play 服務。",
|
||||
"err_fcm_token_up": "Firebase 無法核發裝置權杖({detail})。透過 FCM 推送需要此裝置具備 Google Play 服務。此裝置已安裝 UnifiedPush 分發器,請改將傳送方式切換為 UnifiedPush。",
|
||||
"err_forbidden": "郵件伺服器不允許此帳號使用背景通知。",
|
||||
"err_jmap_list": "無法從郵件伺服器讀取推送註冊:{detail}",
|
||||
"err_jmap_refused": "郵件伺服器拒絕了推送訂閱:{detail}",
|
||||
"err_no_account": "尚未載入任何帳號,無法設定推送。",
|
||||
"err_over_quota": "此帳號在郵件伺服器上的推送註冊過多(上限通常為 15 個)。請在通知設定中撤銷不再使用的裝置,然後再試一次。",
|
||||
"err_permission": "未授予通知權限。",
|
||||
"err_relay_https": "中繼 URL 必須使用 https://。",
|
||||
"err_relay_rejected": "推送中繼拒絕了註冊:{detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "用來傳送通知的伺服器。預設為代管的 Bulwark 中繼;只有自行架設時才需要變更。必須使用 https。",
|
||||
"relay_invalid": "請輸入有效的 https:// URL",
|
||||
"setup_failed": "設定失敗",
|
||||
"shared_mailboxes_no_push": "其他人與您共用的信箱不會傳送背景通知。",
|
||||
"status_android_only": "背景通知僅適用於 Android。",
|
||||
"transport_desc": "通知傳送到此裝置的方式:Google 的 Firebase Cloud Messaging,或不經 Google 傳送的 UnifiedPush 分發器應用程式(例如 ntfy)。",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -788,8 +788,11 @@
|
||||
"err_fcm_empty": "Firebase 返回了空的设备令牌。",
|
||||
"err_fcm_token": "Firebase 无法签发设备令牌({detail})。通过 FCM 推送需要此设备安装 Google Play 服务。",
|
||||
"err_fcm_token_up": "Firebase 无法签发设备令牌({detail})。通过 FCM 推送需要此设备安装 Google Play 服务。此设备已安装 UnifiedPush 分发器,请改为将推送方式切换为 UnifiedPush。",
|
||||
"err_forbidden": "邮件服务器不允许此账户使用后台通知。",
|
||||
"err_jmap_list": "无法从邮件服务器读取推送注册:{detail}",
|
||||
"err_jmap_refused": "邮件服务器拒绝了推送订阅:{detail}",
|
||||
"err_no_account": "未加载任何账户,无法设置推送。",
|
||||
"err_over_quota": "此账户在邮件服务器上的推送注册过多(上限通常为 15 个)。请在通知设置中撤销不再使用的设备,然后重试。",
|
||||
"err_permission": "未授予通知权限。",
|
||||
"err_relay_https": "中继 URL 必须使用 https://。",
|
||||
"err_relay_rejected": "推送中继拒绝了注册:{detail}",
|
||||
@@ -809,6 +812,7 @@
|
||||
"relay_desc_mobile": "用于传送通知的服务器。默认使用托管的 Bulwark 中继;仅当您自托管时才更改。必须使用 https。",
|
||||
"relay_invalid": "请输入有效的 https:// URL",
|
||||
"setup_failed": "设置失败",
|
||||
"shared_mailboxes_no_push": "他人与您共享的邮箱不会发送后台通知。",
|
||||
"status_android_only": "后台通知仅在 Android 上可用。",
|
||||
"transport_desc": "通知送达此设备的方式:Google 的 Firebase Cloud Messaging,或无需 Google 的 UnifiedPush 分发器应用(如 ntfy)。",
|
||||
"transport_fcm": "Google (FCM)",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
assertSetResult,
|
||||
isJMAPSetError,
|
||||
JMAPMethodError,
|
||||
JMAPSetError,
|
||||
requireMethodResult,
|
||||
} from '../jmap-result';
|
||||
import { isTransientNetworkError } from '../../lib/network-error';
|
||||
|
||||
// A SetError refuses one object of a /set call; a method error refuses the
|
||||
// whole call. `forbidden` comes both ways, and only the SetError says
|
||||
// something about the object. Shapes as Stalwart 0.16.16-0.16.23 and 1.0
|
||||
// answer them (jmap-proto method/set.rs, error/set.rs).
|
||||
const EMAIL_PUSH_REFUSED = {
|
||||
type: 'forbidden',
|
||||
description: 'No access to one of the accounts in the emailPush map.',
|
||||
};
|
||||
|
||||
describe('JMAPSetError', () => {
|
||||
it.each([
|
||||
['notCreated', 'new', 'create'],
|
||||
['notUpdated', 's1', 'update'],
|
||||
['notDestroyed', 's1', 'destroy'],
|
||||
])('is what assertSetResult throws for %s (Stalwart 0.16.16+ and 1.0)', (bucket, id, verb) => {
|
||||
const body = { [bucket]: { [id]: EMAIL_PUSH_REFUSED } };
|
||||
let thrown: unknown;
|
||||
try {
|
||||
assertSetResult(body, [id], 'push subscription');
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(isJMAPSetError(thrown)).toBe(true);
|
||||
// Still a JMAPMethodError under that name, so older checks keep working.
|
||||
expect(thrown).toBeInstanceOf(JMAPMethodError);
|
||||
expect(thrown).toMatchObject({ name: 'JMAPMethodError', type: 'forbidden' });
|
||||
expect((thrown as Error).message).toBe(
|
||||
`Failed to ${verb} push subscription ${id}: forbidden – No access to one of the accounts in the emailPush map.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the properties the server named (Stalwart 1.0 keyword cap)', () => {
|
||||
const body = {
|
||||
notUpdated: {
|
||||
m1: { type: 'invalidProperties', description: 'Keywords cannot be longer than 128 bytes.', properties: ['keywords'] },
|
||||
},
|
||||
};
|
||||
expect(() => assertSetResult(body, ['m1'], 'email')).toThrow(JMAPSetError);
|
||||
try {
|
||||
assertSetResult(body, ['m1'], 'email');
|
||||
} catch (err) {
|
||||
expect((err as JMAPSetError).properties).toEqual(['keywords']);
|
||||
expect((err as JMAPSetError).type).toBe('invalidProperties');
|
||||
}
|
||||
});
|
||||
|
||||
it('is not what a refused method call throws, even with the same type', () => {
|
||||
const res = {
|
||||
methodResponses: [['error', { type: 'forbidden', description: 'Not allowed' }, '0']] as Array<
|
||||
[string, Record<string, unknown>, string]
|
||||
>,
|
||||
};
|
||||
let thrown: unknown;
|
||||
try {
|
||||
requireMethodResult(res, '0', 'PushSubscription/set');
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(JMAPMethodError);
|
||||
expect(isJMAPSetError(thrown)).toBe(false);
|
||||
expect((thrown as JMAPMethodError).type).toBe('forbidden');
|
||||
});
|
||||
|
||||
it('counts as a server refusal for the outbox, not a connectivity blip', () => {
|
||||
// The outbox classifies by error name; a SetError must stay terminal.
|
||||
expect(isTransientNetworkError(new JMAPSetError('forbidden', 'request timed out'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reads nothing else as a SetError', () => {
|
||||
expect(isJMAPSetError(new JMAPMethodError('forbidden'))).toBe(false);
|
||||
expect(isJMAPSetError(new Error('forbidden'))).toBe(false);
|
||||
expect(isJMAPSetError({ type: 'forbidden' })).toBe(false);
|
||||
expect(isJMAPSetError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
updatePushSubscription,
|
||||
verifyPushSubscription,
|
||||
} from '../push';
|
||||
import { isJMAPSetError } from '../jmap-result';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
|
||||
@@ -191,6 +192,51 @@ describe('push operations', () => {
|
||||
});
|
||||
await expect(updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// The push setup narrows the emailPush map, or replaces a subscription,
|
||||
// only on the server's verdict about the subscription itself.
|
||||
const rejection = (call: () => Promise<unknown>) => call().then(() => null, (err: unknown) => err);
|
||||
|
||||
it('throws a refused create as a SetError (Stalwart 0.16.16+ and 1.0)', async () => {
|
||||
// As Stalwart answers a map naming a mailbox shared by ACL: no
|
||||
// accountId, state or `created` key (jmap-proto method/set.rs).
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['PushSubscription/set', { notCreated: { new: forbidden } }, '0']],
|
||||
});
|
||||
const err = await rejection(() =>
|
||||
createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] }));
|
||||
expect(isJMAPSetError(err)).toBe(true);
|
||||
expect(err).toMatchObject({ type: 'forbidden' });
|
||||
});
|
||||
|
||||
it('throws an update of a vanished subscription as a notFound SetError', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['PushSubscription/set', { notUpdated: { s1: { type: 'notFound' } } }, '0']],
|
||||
});
|
||||
const err = await rejection(() => updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }));
|
||||
expect(isJMAPSetError(err)).toBe(true);
|
||||
expect(err).toMatchObject({ type: 'notFound' });
|
||||
});
|
||||
|
||||
it('throws a refused method call as a plain method error, even when forbidden', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['error', { type: 'forbidden', description: 'Permission denied' }, '0']],
|
||||
});
|
||||
const create = await rejection(() =>
|
||||
createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] }));
|
||||
const update = await rejection(() => updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }));
|
||||
for (const err of [create, update]) {
|
||||
expect(err).toMatchObject({ name: 'JMAPMethodError', type: 'forbidden' });
|
||||
expect(isJMAPSetError(err)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not read an update that is neither applied nor refused as a SetError', async () => {
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['PushSubscription/set', {}, '0']] });
|
||||
const err = await rejection(() => updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }));
|
||||
expect(err).toMatchObject({ name: 'JMAPMethodError' });
|
||||
expect(isJMAPSetError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startPolling', () => {
|
||||
|
||||
+27
-20
@@ -60,8 +60,31 @@ export function requireMethodResult<T = Record<string, any>>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw when a /set response reports the given ids (or any id when `ids` is
|
||||
* omitted) under `notCreated` / `notUpdated` / `notDestroyed`.
|
||||
* A SetError (RFC 8620 §5.3): the server looked at one object of a /set call
|
||||
* and refused it (`notCreated` / `notUpdated` / `notDestroyed`), as opposed
|
||||
* to refusing the whole method call. The same type can come either way
|
||||
* (`forbidden` is both), and only a SetError says something about that
|
||||
* object. It is a `JMAPMethodError` with that name, so `instanceof` checks
|
||||
* and the name-based classification in lib/network-error treat it as before.
|
||||
*/
|
||||
export class JMAPSetError extends JMAPMethodError {
|
||||
/** The properties the server named (`invalidProperties`), if any. */
|
||||
properties?: string[];
|
||||
constructor(type: string, description?: string, properties?: string[]) {
|
||||
super(type, description);
|
||||
if (properties?.length) this.properties = properties;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a thrown value is a SetError rather than a refused method call. */
|
||||
export function isJMAPSetError(err: unknown): err is JMAPSetError {
|
||||
return err instanceof JMAPSetError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a `JMAPSetError` when a /set response reports the given ids (or any
|
||||
* id when `ids` is omitted) under `notCreated` / `notUpdated` /
|
||||
* `notDestroyed`.
|
||||
*/
|
||||
export function assertSetResult(
|
||||
body: Record<string, any>,
|
||||
@@ -82,9 +105,10 @@ export function assertSetResult(
|
||||
if (err?.properties?.length) detail.push(`properties=[${err.properties.join(', ')}]`);
|
||||
if (err?.description) detail.push(err.description);
|
||||
const more = entries.length > 1 ? ` (+${entries.length - 1} more)` : '';
|
||||
throw new JMAPMethodError(
|
||||
throw new JMAPSetError(
|
||||
err?.type ?? 'setError',
|
||||
`Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`,
|
||||
err?.properties,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,23 +158,6 @@ export function batched<T>(items: T[], size: number): T[][] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A SetError (RFC 8620 §5.3): the server looked at one object of a /set call
|
||||
* and refused it (`notCreated` / `notUpdated` / `notDestroyed`), as opposed
|
||||
* to refusing the whole method call. The same type can come either way
|
||||
* (`forbidden` is both), and only a SetError says something about that
|
||||
* object. It is a `JMAPMethodError` with that name, so `instanceof` checks
|
||||
* and the name-based classification in lib/network-error treat it as before.
|
||||
*/
|
||||
export class JMAPSetError extends JMAPMethodError {
|
||||
/** The properties the server named (`invalidProperties`), if any. */
|
||||
properties?: string[];
|
||||
constructor(type: string, description?: string, properties?: string[]) {
|
||||
super(type, description);
|
||||
if (properties?.length) this.properties = properties;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties a thrown SetError names, so a caller can tell
|
||||
* `invalidProperties` on `media` from one on `name`; empty for anything else.
|
||||
|
||||
+13
-7
@@ -20,6 +20,11 @@ function pushUsing(withEmailPush: boolean): string[] {
|
||||
return withEmailPush ? [CAPABILITIES.CORE, CAPABILITIES.EMAIL_PUSH] : [CAPABILITIES.CORE];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every PushSubscription of the account. Throws when the server can't be
|
||||
* asked or refuses the call - never an empty list instead: to the push setup
|
||||
* an empty list means "the server no longer has our subscription".
|
||||
*/
|
||||
export async function listPushSubscriptions(): Promise<PushSubscription[]> {
|
||||
const withEmailPush = hasEmailPushCapability();
|
||||
// `emailPush` is not in the server's default property set, so ask for it
|
||||
@@ -41,9 +46,9 @@ export async function listPushSubscriptions(): Promise<PushSubscription[]> {
|
||||
* Create a PushSubscription pointing the JMAP server at the given relay URL.
|
||||
* Returns the server-assigned id (which the client also registers with the
|
||||
* relay so the relay can route incoming pushes to an Expo token) and the
|
||||
* expiry the server settled on. A refusal throws a JMAPMethodError carrying
|
||||
* the server's SetError type, so callers can tell a `forbidden` emailPush map
|
||||
* from other failures.
|
||||
* expiry the server settled on. A refused subscription throws a JMAPSetError
|
||||
* carrying the SetError type, so callers can tell a `forbidden` emailPush map
|
||||
* from a refused method call and other failures.
|
||||
*/
|
||||
export async function createPushSubscription(params: {
|
||||
deviceClientId: string;
|
||||
@@ -88,10 +93,11 @@ export async function createPushSubscription(params: {
|
||||
|
||||
/**
|
||||
* Push the subscription's expiry forward and re-sync its types or delivery
|
||||
* filter (RFC 8620 §7.2.1). Throws a JMAPMethodError carrying the server's
|
||||
* SetError type when it refuses the update - `notFound` once the
|
||||
* subscription is gone, `forbidden` for an emailPush map naming an account
|
||||
* the user may not subscribe to.
|
||||
* filter (RFC 8620 §7.2.1). Throws a JMAPSetError carrying the SetError type
|
||||
* when the server refuses the update - `notFound` once the subscription is
|
||||
* gone, `forbidden` for an emailPush map naming an account the user may not
|
||||
* subscribe to. An answer that neither updates nor refuses it is a plain
|
||||
* JMAPMethodError: it says nothing about the subscription.
|
||||
*/
|
||||
export async function updatePushSubscription(
|
||||
id: string,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
isPushSupported,
|
||||
isValidRelayUrl,
|
||||
listPushDevices,
|
||||
mailAccountsWithoutPush,
|
||||
PushSetupError,
|
||||
readPushAccountIds,
|
||||
revokePushDevice,
|
||||
@@ -143,6 +145,26 @@ export function NotificationSettings() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeAccountId, supported]);
|
||||
|
||||
// Stalwart pushes only the user's own mailboxes and their groups', so
|
||||
// mailboxes shared by other users stay silent in the background (they still
|
||||
// update while the app is open). Say so instead of leaving users to wonder.
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const [sharedWithoutPush, setSharedWithoutPush] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!supported || !client) {
|
||||
setSharedWithoutPush(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const accountIds = mailboxes.flatMap((m) => (m.isShared && m.accountId ? [m.accountId] : []));
|
||||
// Re-read after each enable run too: it may have added refused accounts.
|
||||
void mailAccountsWithoutPush(accountIds).then(
|
||||
(ids) => { if (!cancelled) setSharedWithoutPush(ids.length > 0); },
|
||||
() => undefined,
|
||||
);
|
||||
return () => { cancelled = true; };
|
||||
}, [supported, client, mailboxes, pushStatus.kind]);
|
||||
|
||||
const describeError = (error: unknown): string => {
|
||||
if (error instanceof PushSetupError) {
|
||||
const phase = PHASE_KEYS[error.phase];
|
||||
@@ -284,6 +306,15 @@ export function NotificationSettings() {
|
||||
</View>
|
||||
</SettingItem>
|
||||
|
||||
{sharedWithoutPush && (
|
||||
<Text style={styles.fieldDescription}>
|
||||
{t(
|
||||
'settings.notifications.push.shared_mailboxes_no_push',
|
||||
"Mailboxes others share with you don't send background notifications.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{upSupported && (
|
||||
<>
|
||||
<SettingItem
|
||||
|
||||
@@ -4,12 +4,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
// Mailbox/get (and the shared accounts') on every sign-in and start, next to
|
||||
// the mail store's identical load (PF7).
|
||||
|
||||
// A group the user belongs to lists emailpush on 0.16.16+ and 1.0; a mailbox
|
||||
// shared by ACL does on 0.16.16-0.16.23 only.
|
||||
const { EMAIL_PUSH } = vi.hoisted(() => ({
|
||||
EMAIL_PUSH: { 'urn:ietf:params:jmap:emailpush': {} },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
username: 'user@example.com',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
accountId: 'jmap-primary',
|
||||
currentSession: { capabilities: {} },
|
||||
currentSession: {
|
||||
capabilities: EMAIL_PUSH,
|
||||
accounts: {
|
||||
'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: EMAIL_PUSH },
|
||||
team: { name: 'team', isPersonal: false, isReadOnly: false, accountCapabilities: EMAIL_PUSH },
|
||||
'acl-b': { name: 'userb', isPersonal: false, isReadOnly: true, accountCapabilities: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -53,6 +66,18 @@ describe('push filter folders', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves out an account that does not list emailpush (a mailbox shared by ACL on Stalwart 1.0)', async () => {
|
||||
provideLoadedMailboxes(async () => [
|
||||
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
|
||||
{ id: 'team:j', originalId: 'j', role: 'junk', accountId: 'team', isShared: true },
|
||||
{ id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b', isShared: true },
|
||||
] as never);
|
||||
|
||||
const config = await buildEmailPushConfig();
|
||||
|
||||
expect(Object.keys(config).sort()).toEqual(['jmap-primary', 'team']);
|
||||
});
|
||||
|
||||
it('fetches them itself when the store has none for this account', async () => {
|
||||
provideLoadedMailboxes(async () => null);
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
deviceClientIdKey,
|
||||
disablePushForAccount,
|
||||
isValidRelayUrl,
|
||||
mailAccountsWithoutPush,
|
||||
notificationTapJmapAccountId,
|
||||
readPushAccountIds,
|
||||
readPushJmapAccountIds,
|
||||
@@ -89,7 +90,7 @@ import {
|
||||
verifyPushSubscription,
|
||||
} from '../../api/push';
|
||||
import { getSharedMailboxes } from '../../api/email';
|
||||
import { JMAPMethodError } from '../../api/jmap-result';
|
||||
import { JMAPMethodError, JMAPSetError } from '../../api/jmap-result';
|
||||
import type { EmailPushConfig } from '../../api/types';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { NativeModules } from 'react-native';
|
||||
@@ -354,11 +355,13 @@ describe('setupPushNotifications subscription shape', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPushNotifications with ACL-shared accounts (B18)', () => {
|
||||
describe('setupPushNotifications with ACL-shared accounts on Stalwart 0.16.16-0.16.23 (B18)', () => {
|
||||
const REFUSED_KEY = 'push:emailPushRefused:v1:' + ACCOUNT_ID;
|
||||
const sharedMock = getSharedMailboxes as ReturnType<typeof vi.fn>;
|
||||
const verifyMock = verifyPushSubscription as ReturnType<typeof vi.fn>;
|
||||
// 0.16.16 to 0.16.23 list emailpush on every account, ACL shares included.
|
||||
const calendars = (mayCreateCalendar: boolean) => ({
|
||||
'urn:ietf:params:jmap:emailpush': {},
|
||||
'urn:ietf:params:jmap:calendars': { mayCreateCalendar },
|
||||
});
|
||||
// What Stalwart puts in the session: a group the user belongs to and a
|
||||
@@ -371,8 +374,10 @@ describe('setupPushNotifications with ACL-shared accounts (B18)', () => {
|
||||
'acl-b': { name: 'userb', isPersonal: false, isReadOnly: false, accountCapabilities: calendars(false) },
|
||||
},
|
||||
};
|
||||
// The refusal is a SetError on the subscription (`notCreated` /
|
||||
// `notUpdated`), not a method error.
|
||||
const forbidden = () =>
|
||||
new JMAPMethodError('forbidden', 'No access to one of the accounts in the emailPush map.');
|
||||
new JMAPSetError('forbidden', 'No access to one of the accounts in the emailPush map.');
|
||||
// Stalwart refuses the whole map as soon as it names an account the user
|
||||
// doesn't own; `allowed` is what it accepts.
|
||||
const refuseUnless = (allowed: string[]) => (emailPush?: Record<string, EmailPushConfig>) => {
|
||||
@@ -514,6 +519,262 @@ describe('setupPushNotifications with ACL-shared accounts (B18)', () => {
|
||||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('existing');
|
||||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||||
});
|
||||
|
||||
it('does not narrow the map when the whole call is refused as forbidden', async () => {
|
||||
// A method-level `forbidden` (no permission to subscribe at all) says
|
||||
// nothing about the accounts in the map.
|
||||
createMock.mockImplementation(async () => {
|
||||
throw new JMAPMethodError('forbidden', 'You do not have enough permissions.');
|
||||
});
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(PushSetupError);
|
||||
expect(err.phase).toBe('jmap');
|
||||
expect(err.message).toBe("The mail server doesn't allow background notifications for this account.");
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(await AsyncStorage.getItem(REFUSED_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('explains a refused create when the server has too many subscriptions', async () => {
|
||||
createMock.mockImplementation(async () => {
|
||||
throw new JMAPSetError('overQuota', 'There are too many push subscriptions, please delete some.');
|
||||
});
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||
|
||||
expect(err.phase).toBe('jmap');
|
||||
expect(err.message).toContain('too many push registrations on the mail server');
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPushNotifications with ACL-shared accounts on Stalwart 1.0', () => {
|
||||
const REFUSED_KEY = 'push:emailPushRefused:v1:' + ACCOUNT_ID;
|
||||
const sharedMock = getSharedMailboxes as ReturnType<typeof vi.fn>;
|
||||
const OWNER = { accountIdForPrincipal: 'jmap-primary', principalId: 'jmap-primary' };
|
||||
// 1.0 lists emailpush on the user's own account and their groups only; an
|
||||
// ACL share lacks it (and Submission, Sieve, VacationResponse, Quota), and
|
||||
// every account carries principals:owner (jmap/src/api/session.rs).
|
||||
const SESSION_1_0 = {
|
||||
capabilities: { 'urn:ietf:params:jmap:emailpush': {}, 'urn:ietf:params:jmap:metadata': {} },
|
||||
accounts: {
|
||||
'jmap-primary': {
|
||||
name: 'user',
|
||||
isPersonal: true,
|
||||
isReadOnly: false,
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:emailpush': {},
|
||||
'urn:ietf:params:jmap:principals:owner': OWNER,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
name: 'team',
|
||||
isPersonal: false,
|
||||
isReadOnly: false,
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:emailpush': {},
|
||||
'urn:ietf:params:jmap:principals:owner': { ...OWNER, principalId: 'team' },
|
||||
},
|
||||
},
|
||||
'acl-b': {
|
||||
name: 'userb',
|
||||
isPersonal: false,
|
||||
isReadOnly: true,
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:principals:owner': { ...OWNER, principalId: 'acl-b' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mapKeys = (emailPush: Record<string, EmailPushConfig> | undefined) =>
|
||||
Object.keys(emailPush ?? {}).sort();
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||||
(jmapClient as { currentSession: unknown }).currentSession = SESSION_1_0;
|
||||
sharedMock.mockResolvedValue([
|
||||
{ id: 'team:inbox', originalId: 'inbox', role: 'inbox', accountId: 'team' },
|
||||
{ id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b' },
|
||||
]);
|
||||
listMock.mockResolvedValue([]);
|
||||
installFetch({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
};
|
||||
sharedMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('leaves the ACL share out of the map up front, without a refused call', async () => {
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(mapKeys(createMock.mock.calls[0][0].emailPush)).toEqual(['jmap-primary', 'team']);
|
||||
expect(await AsyncStorage.getItem(REFUSED_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('adds the group to an existing map in one update, leaving the ACL share out', async () => {
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
const filter = { filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }] }, properties: ['id', 'threadId'], urgency: 'high' };
|
||||
listMock.mockResolvedValue([{
|
||||
id: 'existing',
|
||||
deviceClientId: OUR_DCID,
|
||||
expires: new Date(Date.now() + 80 * 86400000).toISOString(),
|
||||
types: ['EmailDelivery'],
|
||||
emailPush: { 'jmap-primary': filter },
|
||||
}]);
|
||||
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.subscriptionId).toBe('existing');
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
expect(mapKeys(updateMock.mock.calls[0][1].emailPush)).toEqual(['jmap-primary', 'team']);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPushNotifications when the server cannot be asked', () => {
|
||||
const inDays = (days: number) => new Date(Date.now() + days * 86400000).toISOString();
|
||||
const dueForRefresh = () => ({ id: 'existing', deviceClientId: OUR_DCID, expires: inDays(3), types: ['EmailDelivery'] });
|
||||
const named = (name: string, message: string) => Object.assign(new Error(message), { name });
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
};
|
||||
installFetch({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
listMock.mockImplementation(async () => []);
|
||||
updateMock.mockImplementation(async () => undefined);
|
||||
});
|
||||
|
||||
it.each([false, true])('keeps the subscription and its key when listing fails (forceRecreate: %s)', async (forceRecreate) => {
|
||||
listMock.mockRejectedValue(named('NetworkError', 'Network request failed'));
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(PushSetupError);
|
||||
expect(err.phase).toBe('jmap');
|
||||
expect(err.message).toContain('Network request failed');
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(destroyMock).not.toHaveBeenCalled();
|
||||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a network error', named('NetworkError', 'Network request failed')],
|
||||
['a timeout', named('RequestTimeoutError', 'Request timed out after 30s')],
|
||||
['rate limiting', named('RateLimitError', 'Rate limited by server')],
|
||||
['a refused method call', new JMAPMethodError('serverFail', 'Store unavailable')],
|
||||
])('keeps a working subscription when its refresh fails with %s', async (_what, error) => {
|
||||
listMock.mockResolvedValue([dueForRefresh()]);
|
||||
updateMock.mockRejectedValue(error);
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(PushSetupError);
|
||||
expect(err.phase).toBe('jmap');
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(destroyMock).not.toHaveBeenCalled();
|
||||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||||
});
|
||||
|
||||
it('replaces the subscription when the server says it is gone, destroying it after verification', async () => {
|
||||
listMock.mockResolvedValue([dueForRefresh()]);
|
||||
updateMock.mockRejectedValue(new JMAPSetError('notFound'));
|
||||
const verifyMock = verifyPushSubscription as ReturnType<typeof vi.fn>;
|
||||
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.subscriptionId).toBe('new-server-id');
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
const destroyOrder = destroyMock.mock.invocationCallOrder[
|
||||
destroyMock.mock.calls.findIndex((c) => c[0] === 'existing')
|
||||
];
|
||||
expect(destroyOrder).toBeGreaterThan(verifyMock.mock.invocationCallOrder[0]);
|
||||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailAccountsWithoutPush', () => {
|
||||
const REFUSED_KEY = 'push:emailPushRefused:v1:' + ACCOUNT_ID;
|
||||
const account = (isPersonal: boolean, caps: Record<string, unknown>, isReadOnly = false) => ({
|
||||
name: 'x', isPersonal, isReadOnly, accountCapabilities: caps,
|
||||
});
|
||||
const owned = { 'urn:ietf:params:jmap:calendars': { mayCreateCalendar: true } };
|
||||
const aclShared = { 'urn:ietf:params:jmap:calendars': { mayCreateCalendar: false } };
|
||||
const emailPush = { 'urn:ietf:params:jmap:emailpush': {} };
|
||||
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
};
|
||||
});
|
||||
|
||||
it('names the accounts without emailpush on Stalwart 1.0', async () => {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: emailPush,
|
||||
accounts: {
|
||||
'jmap-primary': account(true, { ...owned, ...emailPush }),
|
||||
team: account(false, { ...owned, ...emailPush }),
|
||||
'acl-b': account(false, aclShared, true),
|
||||
},
|
||||
};
|
||||
expect(await mailAccountsWithoutPush(['team', 'acl-b', 'acl-b', 'jmap-primary'])).toEqual(['acl-b']);
|
||||
});
|
||||
|
||||
it('falls back to refused and not-owned accounts on Stalwart 0.16.16-0.16.23', async () => {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: emailPush,
|
||||
accounts: {
|
||||
'jmap-primary': account(true, { ...owned, ...emailPush }),
|
||||
team: account(false, { ...owned, ...emailPush }),
|
||||
'acl-b': account(false, { ...aclShared, ...emailPush }),
|
||||
'acl-c': account(false, { ...owned, ...emailPush }),
|
||||
},
|
||||
};
|
||||
expect(await mailAccountsWithoutPush(['team', 'acl-b'])).toEqual(['acl-b']);
|
||||
// An account the server refused in the map, whatever it looks like.
|
||||
await AsyncStorage.setItem(REFUSED_KEY, JSON.stringify(['acl-c']));
|
||||
expect(await mailAccountsWithoutPush(['team', 'acl-b', 'acl-c'])).toEqual(['acl-b', 'acl-c']);
|
||||
});
|
||||
|
||||
it('judges by ownership alone on Stalwart 0.16.6-0.16.15, which has no emailpush', async () => {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: {},
|
||||
accounts: {
|
||||
'jmap-primary': account(true, owned),
|
||||
team: account(false, owned),
|
||||
'acl-b': account(false, aclShared),
|
||||
},
|
||||
};
|
||||
expect(await mailAccountsWithoutPush(['team', 'acl-b'])).toEqual(['acl-b']);
|
||||
});
|
||||
|
||||
it('names nothing for the user\'s own mailboxes', async () => {
|
||||
expect(await mailAccountsWithoutPush(['jmap-primary'])).toEqual([]);
|
||||
expect(await mailAccountsWithoutPush([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPushNotifications over UnifiedPush', () => {
|
||||
@@ -574,18 +835,22 @@ describe('setupPushNotifications over UnifiedPush', () => {
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('narrows a refused emailPush map over UnifiedPush as well', async () => {
|
||||
it('narrows a refused emailPush map over UnifiedPush as well (Stalwart 0.16.16+)', async () => {
|
||||
installUpFetch();
|
||||
const emailPush = { 'urn:ietf:params:jmap:emailpush': {} };
|
||||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||||
capabilities: { 'urn:ietf:params:jmap:emailpush': {} },
|
||||
accounts: { 'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false } },
|
||||
capabilities: emailPush,
|
||||
accounts: {
|
||||
'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: emailPush },
|
||||
'acl-b': { name: 'userb', isPersonal: false, isReadOnly: false, accountCapabilities: emailPush },
|
||||
},
|
||||
};
|
||||
(getSharedMailboxes as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b' },
|
||||
]);
|
||||
createMock.mockImplementation(async (params: { emailPush?: Record<string, EmailPushConfig> }) => {
|
||||
if (params.emailPush && 'acl-b' in params.emailPush) {
|
||||
throw new JMAPMethodError('forbidden', 'No access to one of the accounts in the emailPush map.');
|
||||
throw new JMAPSetError('forbidden', 'No access to one of the accounts in the emailPush map.');
|
||||
}
|
||||
return CREATED;
|
||||
});
|
||||
|
||||
+125
-20
@@ -10,9 +10,9 @@ import {
|
||||
import { getMailboxes, getSharedMailboxes } from '../api/email';
|
||||
import { loadedMailboxes } from './mailbox-source';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { JMAPMethodError } from '../api/jmap-result';
|
||||
import { isJMAPSetError, JMAPMethodError } from '../api/jmap-result';
|
||||
import { CAPABILITIES } from '../api/types';
|
||||
import type { EmailPushConfig, JMAPAccountInfo, Mailbox } from '../api/types';
|
||||
import type { EmailPushConfig, JMAPAccountInfo, Mailbox, PushSubscription } from '../api/types';
|
||||
import { generateAccountId } from './account-utils';
|
||||
import { t } from '../stores/locale-store';
|
||||
import {
|
||||
@@ -251,12 +251,29 @@ export function serverSupportsEmailPush(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session lists emailpush among the account's own capabilities.
|
||||
* Not jmapClient.hasAccountCapability: that falls back to the session-level
|
||||
* capability, which Stalwart advertises whatever the account.
|
||||
*/
|
||||
function accountListsEmailPush(accountId: string): boolean {
|
||||
const caps = jmapClient.currentSession?.accounts?.[accountId]?.accountCapabilities;
|
||||
return !!caps && EMAIL_PUSH_CAPABILITY in caps;
|
||||
}
|
||||
|
||||
/**
|
||||
* The delivery filter we want on every account the subscription fans out to:
|
||||
* skip anything the spam filter tagged `$junk` and anything that lives only in
|
||||
* a Junk-role mailbox (Sieve `fileinto` doesn't set the keyword). The two are
|
||||
* ANDed so a stale mailbox id - the user deleted and recreated Junk - degrades
|
||||
* to keyword-only filtering rather than letting everything through.
|
||||
*
|
||||
* Only the primary account and the accounts that list emailpush in their
|
||||
* accountCapabilities go in the map. Stalwart fans a subscription out to the
|
||||
* user's own account and their groups only, and refuses the whole map as
|
||||
* `forbidden` when it names a mailbox shared by ACL. 1.0 lists emailpush on
|
||||
* exactly those accounts; 0.16.16 to 0.16.23 list it on every account, and
|
||||
* the setup narrows the map when the server refuses it.
|
||||
*/
|
||||
export async function buildEmailPushConfig(): Promise<Record<string, EmailPushConfig>> {
|
||||
const primary = jmapClient.accountId;
|
||||
@@ -272,6 +289,7 @@ export async function buildEmailPushConfig(): Promise<Record<string, EmailPushCo
|
||||
];
|
||||
for (const m of all) {
|
||||
const accountId = m.accountId || primary;
|
||||
if (accountId !== primary && !accountListsEmailPush(accountId)) continue;
|
||||
const junk = junkByAccount.get(accountId) ?? [];
|
||||
// Shared-account mailboxes carry a client-side "<account>:<id>" id;
|
||||
// the server only knows the original.
|
||||
@@ -363,8 +381,11 @@ export function emailPushFallbacks(
|
||||
|
||||
/**
|
||||
* Run a subscription write that carries `emailPush`, narrowing the map along
|
||||
* emailPushFallbacks while the server refuses it as `forbidden`. Resolves to
|
||||
* the write's result and the map the server took.
|
||||
* emailPushFallbacks while the server refuses the subscription with a
|
||||
* `forbidden` SetError. Anything else - a refused method call (which can be
|
||||
* `forbidden` too), overQuota, a failed request - would fail the same way
|
||||
* with a narrower map, so it is thrown as is. Resolves to the write's result
|
||||
* and the map the server took.
|
||||
*/
|
||||
async function writeWithEmailPush<T>(
|
||||
emailPush: Record<string, EmailPushConfig> | null,
|
||||
@@ -377,8 +398,8 @@ async function writeWithEmailPush<T>(
|
||||
try {
|
||||
return { result: await write(attempts[i]), emailPush: attempts[i] };
|
||||
} catch (err) {
|
||||
const forbidden = err instanceof JMAPMethodError && err.type === 'forbidden';
|
||||
if (!forbidden || i === attempts.length - 1) throw err;
|
||||
const mapRefused = isJMAPSetError(err) && err.type === 'forbidden';
|
||||
if (!mapRefused || i === attempts.length - 1) throw err;
|
||||
logPhase('jmap', 'emailPush map refused, retrying with fewer accounts');
|
||||
}
|
||||
}
|
||||
@@ -416,6 +437,40 @@ function withoutAccounts(
|
||||
return Object.fromEntries(Object.entries(config).filter(([id]) => !accountIds.includes(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of `accountIds` (accounts the user sees mailboxes in) background
|
||||
* notifications don't reach: mailboxes others share with the user by ACL.
|
||||
* Stalwart only fans a subscription out to the user's own account and their
|
||||
* groups, whatever the emailPush map says, on every version.
|
||||
*
|
||||
* 1.0 leaves emailpush out of exactly those accounts' capabilities, so an
|
||||
* account without it settles the question for every account. 0.16.16 to
|
||||
* 0.16.23 list emailpush everywhere (and 0.16.6 to 0.16.15 nowhere), so there
|
||||
* it is the accounts the server refused in the map, and the ones that don't
|
||||
* look owned.
|
||||
*/
|
||||
export async function mailAccountsWithoutPush(accountIds: Iterable<string>): Promise<string[]> {
|
||||
let primary: string;
|
||||
try {
|
||||
primary = jmapClient.accountId;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const others = Array.from(new Set(accountIds)).filter((id) => id && id !== primary);
|
||||
if (others.length === 0) return [];
|
||||
if (serverSupportsEmailPush()) {
|
||||
const withoutEmailPush = others.filter((id) => !accountListsEmailPush(id));
|
||||
if (withoutEmailPush.length > 0) return withoutEmailPush;
|
||||
}
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
const refused = username && serverUrl
|
||||
? await readRefusedEmailPushAccounts(generateAccountId(username, serverUrl))
|
||||
: [];
|
||||
const accounts = jmapClient.currentSession?.accounts;
|
||||
return others.filter((id) => refused.includes(id) || !looksOwned(accounts?.[id]));
|
||||
}
|
||||
|
||||
type BulwarkFcmNative = {
|
||||
getToken(): Promise<string>;
|
||||
deleteToken(): Promise<void>;
|
||||
@@ -488,6 +543,35 @@ export class PushSetupError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// A subscription write the mail server refused or could not take. The
|
||||
// refusals a user can do something about read as plain text; anything else
|
||||
// keeps the error's own message.
|
||||
function subscriptionError(err: unknown): PushSetupError {
|
||||
const type = err instanceof JMAPMethodError ? err.type : undefined;
|
||||
if (type === 'forbidden') {
|
||||
return new PushSetupError(
|
||||
'jmap',
|
||||
t('settings.notifications.push.err_forbidden', "The mail server doesn't allow background notifications for this account."),
|
||||
);
|
||||
}
|
||||
if (type === 'overQuota') {
|
||||
// Stalwart allows 15 subscriptions per account by default, and a
|
||||
// replacement is created before the one it replaces is destroyed.
|
||||
return new PushSetupError(
|
||||
'jmap',
|
||||
t(
|
||||
'settings.notifications.push.err_over_quota',
|
||||
'This account has too many push registrations on the mail server (the limit is usually 15). Revoke devices you no longer use in the notification settings, then try again.',
|
||||
),
|
||||
);
|
||||
}
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
return new PushSetupError(
|
||||
'jmap',
|
||||
t('settings.notifications.push.err_jmap_refused', 'The mail server refused the push subscription: {detail}', { detail }),
|
||||
);
|
||||
}
|
||||
|
||||
function randomClientId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
@@ -916,8 +1000,20 @@ async function setupPushNotificationsInner(
|
||||
// With forceRecreate we skip the reuse and replace it instead (#841). Either
|
||||
// way the old record keeps delivering until its replacement is verified, so
|
||||
// a replacement the server refuses never leaves the device without push.
|
||||
//
|
||||
// A failed listing ends the run here: taken for "the server has no
|
||||
// subscription", it would drop the key of a working registration.
|
||||
logPhase('jmap');
|
||||
const existingSubs = await listPushSubscriptions().catch(() => []);
|
||||
let existingSubs: PushSubscription[];
|
||||
try {
|
||||
existingSubs = await listPushSubscriptions();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new PushSetupError(
|
||||
'jmap',
|
||||
t('settings.notifications.push.err_jmap_list', 'Could not read the push registrations from the mail server: {detail}', { detail }),
|
||||
);
|
||||
}
|
||||
// A forced re-registration re-learns which accounts the server accepts in
|
||||
// the emailPush map.
|
||||
if (params.forceRecreate) await AsyncStorage.removeItem(emailPushRefusedKey(accountId));
|
||||
@@ -939,7 +1035,15 @@ async function setupPushNotificationsInner(
|
||||
if (match) {
|
||||
if (match.expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), match.expires);
|
||||
if (!params.forceRecreate) {
|
||||
const refreshed = await refreshSubscriptionExpires(match, emailPush);
|
||||
let refreshed: Awaited<ReturnType<typeof refreshSubscriptionExpires>>;
|
||||
try {
|
||||
refreshed = await refreshSubscriptionExpires(match, emailPush);
|
||||
} catch (err) {
|
||||
// Nothing the server said about the subscription (a failed request,
|
||||
// rate limiting, a refused method call): it keeps delivering, and
|
||||
// the next run tries again.
|
||||
throw subscriptionError(err);
|
||||
}
|
||||
if (refreshed) {
|
||||
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
|
||||
await addPushAccountId(accountId);
|
||||
@@ -949,8 +1053,9 @@ async function setupPushNotificationsInner(
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
}
|
||||
}
|
||||
// Server rejected the refresh or the caller asked for a fresh record:
|
||||
// create a replacement below and destroy this one once it's verified.
|
||||
// The server refused the refresh with a SetError, or the caller asked
|
||||
// for a fresh record: create a replacement below and destroy this one
|
||||
// once it's verified.
|
||||
replacedServerId = storedServerId;
|
||||
} else {
|
||||
await AsyncStorage.removeItem(subKey);
|
||||
@@ -998,11 +1103,7 @@ async function setupPushNotificationsInner(
|
||||
serverExpires = created.result.expires;
|
||||
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new PushSetupError(
|
||||
'jmap',
|
||||
t('settings.notifications.push.err_jmap_refused', 'The mail server refused the push subscription: {detail}', { detail }),
|
||||
);
|
||||
throw subscriptionError(err);
|
||||
}
|
||||
|
||||
logPhase('verify');
|
||||
@@ -1041,9 +1142,12 @@ async function addPushAccountId(accountId: string): Promise<void> {
|
||||
// server's ceiling, and re-sync `types` / the delivery filter when they drift
|
||||
// from what this client wants (a subscription created by an older build still
|
||||
// listens to `Email`/`Mailbox`; a Junk mailbox id can change under us).
|
||||
// Returns false if the server rejects the update, which the caller treats as
|
||||
// "replace"; otherwise the emailPush map the update installed (null when it
|
||||
// left the filter alone).
|
||||
// Returns false when the server refuses the update with a SetError (the
|
||||
// subscription is gone, or no narrower map is accepted), which the caller
|
||||
// answers with a replacement; otherwise the emailPush map the update
|
||||
// installed (null when it left the filter alone). Every other failure - the
|
||||
// network, a timeout, rate limiting, a refused method call - says nothing
|
||||
// about the subscription and is thrown.
|
||||
async function refreshSubscriptionExpires(
|
||||
sub: {
|
||||
id: string;
|
||||
@@ -1075,8 +1179,9 @@ async function refreshSubscriptionExpires(
|
||||
(filter) => updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch),
|
||||
);
|
||||
return { emailPush };
|
||||
} catch {
|
||||
return false;
|
||||
} catch (err) {
|
||||
if (isJMAPSetError(err)) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user