From 00619751d7348045d0e2567aba3879ea6ec2307a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:51:39 +0200 Subject: [PATCH] fix: never send a rescheduled message twice and handle partial refusals "Send now" and "Reschedule" at a full submission quota (Stalwart 1.0) made room and then created the replacement again. Making room can take a while - a prune, a walk through the change log - and the original could go out meanwhile, so the replacement sent it a second time, with a HOLDFOR that still counted from before the wait. The original is now read again first: nothing is resubmitted unless it is still pending ("This message was already sent" or "no longer scheduled" otherwise, and the undo bar lets go of it), a failed read changes nothing, and the hold is worked out again from the time the user picked. Port of the webmail's 65eb4b10. - the Stalwart explanation of a full quota (Bulwark removed what it could, an administrator has to raise the limit) needs `urn:stalwart:jmap` on the submission account; another server's `overQuota` gets a generic sentence with its own description - when making room finds nothing that can go, the account is not searched again for five minutes; each send, reschedule and automatic read receipt at a full quota used to run the prune, the change-log walk and the gets again - unsubscribing by mail at a full quota explains the quota instead of only saying "Unable to unsubscribe" - push on Stalwart 1.0 (`principals:owner` on the account) takes the emailpush capability as the answer to which accounts notify: a group with only mail no longer counts as unreachable, and a refusal remembered under 0.16 no longer keeps a group out of the map - a push re-registration the user asked for at the server's subscription limit removes the old record first and tries once more; the old record counted against the limit, so it could never be replaced - "Mark folder as read" on a folder and its subfolders goes on past a folder the server refuses and reports the refusal at the end; the folders and the list refresh either way - marking, starring or tagging a selection writes every account before reporting a refusal and shows only the messages the server updated; one refusing account or message used to leave the rest unwritten, and what was written looked unchanged - a new photo for a contact opened from the cache is not saved when the card's media can't be read from the server either; it would have replaced the card's other media (a logo, more photos) --- locales/rn/ar.json | 4 + locales/rn/ca.json | 4 + locales/rn/cs.json | 4 + locales/rn/da.json | 4 + locales/rn/de.json | 4 + locales/rn/en.json | 4 + locales/rn/es.json | 4 + locales/rn/fa.json | 4 + locales/rn/fr.json | 4 + locales/rn/he.json | 4 + locales/rn/hu.json | 4 + locales/rn/it.json | 4 + locales/rn/ja.json | 4 + locales/rn/ko.json | 4 + locales/rn/lv.json | 4 + locales/rn/mn.json | 4 + locales/rn/nb.json | 4 + locales/rn/nl.json | 4 + locales/rn/pl.json | 4 + locales/rn/pt.json | 4 + locales/rn/ro.json | 4 + locales/rn/ru.json | 4 + locales/rn/sk.json | 4 + locales/rn/tr.json | 4 + locales/rn/uk.json | 4 + locales/rn/zh-TW.json | 4 + locales/rn/zh.json | 4 + .../__tests__/email-keyword-refusals.test.ts | 69 ++++++- .../email-submission-cleanup.test.ts | 169 +++++++++++++++++- src/api/__tests__/fake-submission-server.ts | 27 +++ src/api/email.ts | 94 ++++++++-- src/api/jmap-result.ts | 41 ++++- src/api/submissions.ts | 78 ++++++-- src/components/SidebarDrawer.tsx | 15 +- src/components/email/UnsubscribeBanner.tsx | 6 + src/lib/__tests__/contact-media.test.ts | 13 ++ src/lib/__tests__/push-notifications.test.ts | 112 ++++++++++++ .../__tests__/send-failure-message.test.ts | 22 ++- src/lib/contact-media.ts | 24 ++- src/lib/push-notifications.ts | 64 +++++-- src/lib/send-failure-message.ts | 35 ++-- src/screens/ContactFormScreen.tsx | 8 +- src/screens/ScheduledScreen.tsx | 8 + .../__tests__/email-store-all-folders.test.ts | 31 ++++ src/stores/__tests__/fake-jmap-server.ts | 17 +- src/stores/__tests__/send-undo-store.test.ts | 19 +- src/stores/email-store.ts | 29 ++- src/stores/send-undo-store.ts | 5 +- 48 files changed, 906 insertions(+), 88 deletions(-) diff --git a/locales/rn/ar.json b/locales/rn/ar.json index b638bc58..5611f1c7 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -206,6 +206,7 @@ "new_organization": "مؤسسة جديدة", "org_name_required": "أضف اسم المؤسسة.", "photo_hint_mobile": "اضغط على الصورة لاختيار صورة", + "photo_media_unread": "تعذّرت قراءة صور جهة الاتصال من الخادم، لذا لم يُحفظ أي شيء. حاول مرة أخرى.", "photo_permission_message": "امنح إذن الوصول إلى مكتبة الصور لاختيار صورة جهة الاتصال.", "photo_permission_title": "يلزم الوصول إلى الصور", "pick_date": "اختر تاريخًا", @@ -291,6 +292,7 @@ "send_now": "إرسال الآن", "send_timeout_body": "ربما تم إرسال الرسالة بالفعل. تحقق من مجلد المرسل قبل إرسالها مرة أخرى.", "send_timeout_title": "لا يوجد رد من الخادم", + "submission_limit": "رفض الخادم إرسال الرسالة لبلوغ أحد الحدود: {reason}", "submission_quota": "يحتفظ الخادم بعدد كبير جدًا من سجلات الرسائل المرسلة لهذا الحساب. أزال Bulwark ما استطاع منها؛ وإذا تكرر ذلك، فاطلب من المسؤول رفع حد عمليات إرسال البريد.", "undo_send_label": "الإرسال خلال {seconds} ث…", "upload_failed_short": "فشل الرفع", @@ -563,10 +565,12 @@ "title": "تلقَّ إشعارات بالبريد الجديد" }, "scheduled": { + "already_sent": "أُرسلت هذه الرسالة بالفعل.", "cancel_body": "لن يتم تسليم الرسالة. ستبقى نسخة منها في مجلد المرسل.", "cancel_failed": "فشل الإلغاء", "edit_failed": "تعذّر فتح الرسالة لتعديلها", "keep_scheduled": "إبقاؤها مجدولة", + "no_longer_scheduled": "لم تعد هذه الرسالة مجدولة.", "reschedule_failed": "فشلت إعادة الجدولة", "send_now_failed": "تعذّر الإرسال الآن" }, diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 2089ec27..7a9de366 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -206,6 +206,7 @@ "new_organization": "Organització nova", "org_name_required": "Afegiu el nom de l'organització.", "photo_hint_mobile": "Toqueu la foto per triar una imatge", + "photo_media_unread": "No s'han pogut llegir les fotos del contacte des del servidor, així que no s'ha desat res. Torneu-ho a provar.", "photo_permission_message": "Concediu permís per a la galeria de fotos per triar una foto de contacte.", "photo_permission_title": "Cal accés a les fotos", "pick_date": "Tria una data", @@ -291,6 +292,7 @@ "send_now": "Envia ara", "send_timeout_body": "És possible que el missatge ja s'hagi enviat. Comproveu la carpeta Enviats abans de tornar-lo a enviar.", "send_timeout_title": "El servidor no respon", + "submission_limit": "El servidor s'ha negat a enviar el missatge perquè s'ha arribat a un límit: {reason}", "submission_quota": "El servidor té massa registres de missatges enviats per a aquest compte. Bulwark ha eliminat els que ha pogut; si torna a passar, demaneu a l'administrador que augmenti el límit d'enviaments de correu.", "undo_send_label": "S'envia d'aquí a {seconds} s…", "upload_failed_short": "No s'ha pogut pujar", @@ -563,10 +565,12 @@ "title": "Rebeu avisos de correu nou" }, "scheduled": { + "already_sent": "Aquest missatge ja s'ha enviat.", "cancel_body": "El missatge no s'entregarà. Se'n conserva una còpia a la carpeta Enviats.", "cancel_failed": "No s'ha pogut cancel·lar", "edit_failed": "No s'ha pogut obrir el missatge per editar-lo", "keep_scheduled": "Mantén-lo programat", + "no_longer_scheduled": "Aquest missatge ja no està programat.", "reschedule_failed": "No s'ha pogut reprogramar", "send_now_failed": "No s'ha pogut enviar ara" }, diff --git a/locales/rn/cs.json b/locales/rn/cs.json index f1303b24..9a4d4f59 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -206,6 +206,7 @@ "new_organization": "Nová organizace", "org_name_required": "Zadejte název organizace.", "photo_hint_mobile": "Klepnutím na fotku vyberete obrázek", + "photo_media_unread": "Fotografie kontaktu se nepodařilo načíst ze serveru, proto se nic neuložilo. Zkuste to znovu.", "photo_permission_message": "Pro výběr fotky kontaktu povolte přístup ke knihovně fotek.", "photo_permission_title": "Je potřeba přístup k fotkám", "pick_date": "Vybrat datum", @@ -291,6 +292,7 @@ "send_now": "Odeslat nyní", "send_timeout_body": "Zpráva už mohla odejít. Než ji odešlete znovu, zkontrolujte složku Odeslané.", "send_timeout_title": "Server neodpovídá", + "submission_limit": "Server odmítl zprávu odeslat, protože byl dosažen limit: {reason}", "submission_quota": "Server má pro tento účet příliš mnoho záznamů o odeslaných zprávách. Bulwark odstranil ty, které mohl; pokud se to bude opakovat, požádejte správce o zvýšení limitu odesílání e-mailů.", "undo_send_label": "Odeslání za {seconds} s…", "upload_failed_short": "Nahrávání selhalo", @@ -563,10 +565,12 @@ "title": "Dostávejte oznámení o nové poště" }, "scheduled": { + "already_sent": "Tato zpráva již byla odeslána.", "cancel_body": "Zpráva nebude doručena. Kopie zůstane ve složce Odeslané.", "cancel_failed": "Zrušení selhalo", "edit_failed": "Zprávu se nepodařilo otevřít k úpravám", "keep_scheduled": "Ponechat naplánované", + "no_longer_scheduled": "Tato zpráva už není naplánována.", "reschedule_failed": "Přeplánování selhalo", "send_now_failed": "Zprávu se nepodařilo odeslat nyní" }, diff --git a/locales/rn/da.json b/locales/rn/da.json index c93d7647..ee9f7726 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -206,6 +206,7 @@ "new_organization": "Ny organisation", "org_name_required": "Tilføj organisationens navn.", "photo_hint_mobile": "Tryk på billedet for at vælge et billede", + "photo_media_unread": "Kontaktens billeder kunne ikke læses fra serveren, så intet blev gemt. Prøv igen.", "photo_permission_message": "Giv adgang til fotobiblioteket for at vælge et kontaktbillede.", "photo_permission_title": "Adgang til fotos påkrævet", "pick_date": "Vælg en dato", @@ -291,6 +292,7 @@ "send_now": "Send nu", "send_timeout_body": "Beskeden er muligvis allerede sendt. Tjek din Sendt-mappe, før du sender den igen.", "send_timeout_title": "Intet svar fra serveren", + "submission_limit": "Serveren afviste at sende beskeden, fordi en grænse er nået: {reason}", "submission_quota": "Serveren har for mange registreringer af sendte beskeder for denne konto. Bulwark har fjernet dem, den kunne; hvis det bliver ved med at ske, så bed din administrator om at hæve grænsen for afsendelse af e-mails.", "undo_send_label": "Sender om {seconds} s…", "upload_failed_short": "Upload mislykkedes", @@ -563,10 +565,12 @@ "title": "Få besked om ny mail" }, "scheduled": { + "already_sent": "Denne besked er allerede sendt.", "cancel_body": "Beskeden bliver ikke leveret. En kopi bliver i din Sendt-mappe.", "cancel_failed": "Annullering mislykkedes", "edit_failed": "Kunne ikke åbne beskeden til redigering", "keep_scheduled": "Behold planlagt", + "no_longer_scheduled": "Denne besked er ikke længere planlagt.", "reschedule_failed": "Omplanlægning mislykkedes", "send_now_failed": "Kunne ikke sende nu" }, diff --git a/locales/rn/de.json b/locales/rn/de.json index 7e6e3247..46c8f7fc 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -206,6 +206,7 @@ "new_organization": "Neue Organisation", "org_name_required": "Geben Sie den Namen der Organisation an.", "photo_hint_mobile": "Tippen Sie auf das Foto, um ein Bild auszuwählen", + "photo_media_unread": "Die Fotos des Kontakts konnten nicht vom Server gelesen werden, daher wurde nichts gespeichert. Versuchen Sie es erneut.", "photo_permission_message": "Erlauben Sie den Zugriff auf die Fotomediathek, um ein Kontaktfoto auszuwählen.", "photo_permission_title": "Fotozugriff erforderlich", "pick_date": "Datum auswählen", @@ -291,6 +292,7 @@ "send_now": "Jetzt senden", "send_timeout_body": "Die Nachricht wurde möglicherweise bereits versendet. Prüfen Sie Ihren Gesendet-Ordner, bevor Sie sie erneut senden.", "send_timeout_title": "Keine Antwort vom Server", + "submission_limit": "Der Server hat das Senden abgelehnt, weil ein Limit erreicht ist: {reason}", "submission_quota": "Der Server hat für dieses Konto zu viele Einträge zu gesendeten Nachrichten. Bulwark hat entfernt, was möglich war; wenn das weiterhin vorkommt, bitten Sie Ihren Administrator, das Limit für E-Mail-Übermittlungen zu erhöhen.", "undo_send_label": "Senden in {seconds} s…", "upload_failed_short": "Upload fehlgeschlagen", @@ -563,10 +565,12 @@ "title": "Bei neuen E-Mails benachrichtigt werden" }, "scheduled": { + "already_sent": "Diese Nachricht wurde bereits gesendet.", "cancel_body": "Die Nachricht wird nicht zugestellt. Eine Kopie bleibt in Ihrem Gesendet-Ordner.", "cancel_failed": "Abbrechen fehlgeschlagen", "edit_failed": "Die Nachricht konnte nicht zum Bearbeiten geöffnet werden", "keep_scheduled": "Geplant lassen", + "no_longer_scheduled": "Diese Nachricht ist nicht mehr geplant.", "reschedule_failed": "Neu planen fehlgeschlagen", "send_now_failed": "Sofortiges Senden fehlgeschlagen" }, diff --git a/locales/rn/en.json b/locales/rn/en.json index c8764408..f5d0a8b6 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -206,6 +206,7 @@ "new_organization": "New organization", "org_name_required": "Add the organization name.", "photo_hint_mobile": "Tap photo to choose an image", + "photo_media_unread": "The contact's photos could not be read from the server, so nothing was saved. Try again.", "photo_permission_message": "Grant photo library permission to pick a contact photo.", "photo_permission_title": "Photo access needed", "pick_date": "Pick a date", @@ -294,6 +295,7 @@ "send_now": "Send now", "send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.", "send_timeout_title": "No answer from the server", + "submission_limit": "The server refused to send the message because a limit was reached: {reason}", "submission_quota": "The server has too many sent-message records for this account. Bulwark removed the ones it could; if this keeps happening, ask your administrator to raise the email submission limit.", "toolbar": { "background_color": "Background color", @@ -570,10 +572,12 @@ "title": "Get notified about new mail" }, "scheduled": { + "already_sent": "This message was already sent.", "cancel_body": "The message will not be delivered. A copy stays in your Sent folder.", "cancel_failed": "Cancel failed", "edit_failed": "Could not open the message for editing", "keep_scheduled": "Keep scheduled", + "no_longer_scheduled": "This message is no longer scheduled.", "reschedule_failed": "Reschedule failed", "send_now_failed": "Could not send now" }, diff --git a/locales/rn/es.json b/locales/rn/es.json index 100eba1d..e4dcea80 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -206,6 +206,7 @@ "new_organization": "Nueva organización", "org_name_required": "Añade el nombre de la organización.", "photo_hint_mobile": "Toca la foto para elegir una imagen", + "photo_media_unread": "No se pudieron leer las fotos del contacto desde el servidor, así que no se guardó nada. Inténtalo de nuevo.", "photo_permission_message": "Concede acceso a la galería de fotos para elegir una foto de contacto.", "photo_permission_title": "Se necesita acceso a las fotos", "pick_date": "Elegir una fecha", @@ -291,6 +292,7 @@ "send_now": "Enviar ahora", "send_timeout_body": "Puede que el mensaje ya se haya enviado. Revisa tu carpeta Enviados antes de volver a enviarlo.", "send_timeout_title": "El servidor no responde", + "submission_limit": "El servidor se negó a enviar el mensaje porque se alcanzó un límite: {reason}", "submission_quota": "El servidor tiene demasiados registros de mensajes enviados para esta cuenta. Bulwark eliminó los que pudo; si esto sigue ocurriendo, pida a su administrador que aumente el límite de envíos de correo.", "undo_send_label": "Enviando en {seconds} s…", "upload_failed_short": "Error al subir", @@ -563,10 +565,12 @@ "title": "Recibe avisos de correo nuevo" }, "scheduled": { + "already_sent": "Este mensaje ya se envió.", "cancel_body": "El mensaje no se entregará. Se conserva una copia en tu carpeta Enviados.", "cancel_failed": "No se pudo cancelar", "edit_failed": "No se pudo abrir el mensaje para editarlo", "keep_scheduled": "Mantener programado", + "no_longer_scheduled": "Este mensaje ya no está programado.", "reschedule_failed": "No se pudo reprogramar", "send_now_failed": "No se pudo enviar ahora" }, diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 444549f6..3b41eeb2 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -206,6 +206,7 @@ "new_organization": "سازمان جدید", "org_name_required": "نام سازمان را اضافه کنید.", "photo_hint_mobile": "برای انتخاب تصویر روی عکس ضربه بزنید", + "photo_media_unread": "عکس‌های مخاطب از سرور خوانده نشد، بنابراین چیزی ذخیره نشد. دوباره تلاش کنید.", "photo_permission_message": "برای انتخاب عکس مخاطب، اجازه دسترسی به گالری عکس را بدهید.", "photo_permission_title": "دسترسی به عکس‌ها لازم است", "pick_date": "انتخاب تاریخ", @@ -291,6 +292,7 @@ "send_now": "ارسال اکنون", "send_timeout_body": "ممکن است پیام قبلاً ارسال شده باشد. پیش از ارسال دوباره، پوشه ارسال شده را بررسی کنید.", "send_timeout_title": "پاسخی از سرور نیامد", + "submission_limit": "سرور از ارسال پیام خودداری کرد، زیرا به یک محدودیت رسیده است: {reason}", "submission_quota": "سرور برای این حساب سوابق بیش از حدی از پیام‌های ارسال‌شده دارد. Bulwark هر کدام را که می‌توانست حذف کرد؛ اگر این وضعیت ادامه یافت، از مدیر سیستم بخواهید سقف ارسال ایمیل را افزایش دهد.", "undo_send_label": "ارسال تا {seconds} ثانیه دیگر…", "upload_failed_short": "بارگذاری ناموفق بود", @@ -563,10 +565,12 @@ "title": "از ایمیل جدید باخبر شوید" }, "scheduled": { + "already_sent": "این پیام قبلاً ارسال شده است.", "cancel_body": "پیام تحویل داده نمی‌شود. یک نسخه در پوشه ارسال شده باقی می‌ماند.", "cancel_failed": "لغو ناموفق بود", "edit_failed": "باز کردن پیام برای ویرایش ممکن نشد", "keep_scheduled": "حفظ زمان‌بندی", + "no_longer_scheduled": "این پیام دیگر زمان‌بندی نشده است.", "reschedule_failed": "باززمان‌بندی ناموفق بود", "send_now_failed": "ارسال اکنون ممکن نشد" }, diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 84fb4118..6fce6919 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -206,6 +206,7 @@ "new_organization": "Nouvelle organisation", "org_name_required": "Ajoutez le nom de l'organisation.", "photo_hint_mobile": "Appuyez sur la photo pour choisir une image", + "photo_media_unread": "Les photos du contact n'ont pas pu être lues depuis le serveur ; rien n'a donc été enregistré. Réessayez.", "photo_permission_message": "Autorisez l'accès à la photothèque pour choisir une photo de contact.", "photo_permission_title": "Accès aux photos requis", "pick_date": "Choisir une date", @@ -291,6 +292,7 @@ "send_now": "Envoyer maintenant", "send_timeout_body": "Le message est peut-être déjà parti. Vérifiez votre dossier Envoyés avant de le renvoyer.", "send_timeout_title": "Aucune réponse du serveur", + "submission_limit": "Le serveur a refusé d’envoyer le message, car une limite est atteinte : {reason}", "submission_quota": "Le serveur conserve trop d’enregistrements de messages envoyés pour ce compte. Bulwark a supprimé ceux qu’il pouvait ; si cela se reproduit, demandez à votre administrateur de relever la limite d’envois de courriels.", "undo_send_label": "Envoi dans {seconds} s…", "upload_failed_short": "Échec du téléversement", @@ -563,10 +565,12 @@ "title": "Soyez averti des nouveaux e-mails" }, "scheduled": { + "already_sent": "Ce message a déjà été envoyé.", "cancel_body": "Le message ne sera pas distribué. Une copie reste dans votre dossier Envoyés.", "cancel_failed": "Échec de l'annulation", "edit_failed": "Impossible d'ouvrir le message pour le modifier", "keep_scheduled": "Garder planifié", + "no_longer_scheduled": "Ce message n’est plus planifié.", "reschedule_failed": "Échec de la replanification", "send_now_failed": "Impossible d'envoyer maintenant" }, diff --git a/locales/rn/he.json b/locales/rn/he.json index ece532f6..fa71fbc6 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -206,6 +206,7 @@ "new_organization": "ארגון חדש", "org_name_required": "הוסף את שם הארגון.", "photo_hint_mobile": "הקש על התמונה כדי לבחור תמונה", + "photo_media_unread": "לא ניתן היה לקרוא את התמונות של איש הקשר מהשרת, ולכן לא נשמר דבר. נסה שוב.", "photo_permission_message": "הענק הרשאה לספריית התמונות כדי לבחור תמונה לאיש הקשר.", "photo_permission_title": "נדרשת גישה לתמונות", "pick_date": "בחר תאריך", @@ -291,6 +292,7 @@ "send_now": "שלח עכשיו", "send_timeout_body": "ייתכן שההודעה כבר נשלחה. בדוק את תיקיית \"נשלח\" לפני שתשלח אותה שוב.", "send_timeout_title": "אין תשובה מהשרת", + "submission_limit": "השרת סירב לשלוח את ההודעה כי הושגה מגבלה: {reason}", "submission_quota": "בשרת יש יותר מדי רשומות של הודעות שנשלחו עבור החשבון הזה. Bulwark הסיר את אלה שיכול היה; אם זה ממשיך לקרות, בקשו ממנהל המערכת להגדיל את מגבלת שליחת הדואר.", "undo_send_label": "שולח בעוד {seconds} שנ׳…", "upload_failed_short": "ההעלאה נכשלה", @@ -563,10 +565,12 @@ "title": "קבל התראות על דואר חדש" }, "scheduled": { + "already_sent": "ההודעה הזו כבר נשלחה.", "cancel_body": "ההודעה לא תימסר. עותק יישאר בתיקיית \"נשלח\".", "cancel_failed": "הביטול נכשל", "edit_failed": "לא ניתן היה לפתוח את ההודעה לעריכה", "keep_scheduled": "השאר מתוזמן", + "no_longer_scheduled": "ההודעה הזו כבר אינה מתוזמנת.", "reschedule_failed": "התזמון מחדש נכשל", "send_now_failed": "לא ניתן היה לשלוח עכשיו" }, diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 5a3dda35..7179ab30 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -206,6 +206,7 @@ "new_organization": "Új szervezet", "org_name_required": "Add meg a szervezet nevét.", "photo_hint_mobile": "Kép kiválasztásához koppints a fotóra", + "photo_media_unread": "A névjegy fotóit nem sikerült beolvasni a szerverről, ezért semmi sem lett mentve. Próbáld újra.", "photo_permission_message": "A névjegyfotó kiválasztásához engedélyezd a hozzáférést a fotókhoz.", "photo_permission_title": "Hozzáférés szükséges a fotókhoz", "pick_date": "Dátum kiválasztása", @@ -291,6 +292,7 @@ "send_now": "Küldés most", "send_timeout_body": "Lehet, hogy az üzenet már elment. Mielőtt újra elküldenéd, nézd meg az Elküldött mappát.", "send_timeout_title": "Nem érkezett válasz a szervertől", + "submission_limit": "A kiszolgáló elutasította az üzenet küldését, mert elért egy korlátot: {reason}", "submission_quota": "A kiszolgáló túl sok elküldött üzenetről szóló bejegyzést tárol ehhez a fiókhoz. A Bulwark eltávolította, amit tudott; ha ez továbbra is előfordul, kérje meg a rendszergazdát, hogy emelje meg az e-mail-küldési korlátot.", "undo_send_label": "Küldés {seconds} mp múlva…", "upload_failed_short": "Sikertelen feltöltés", @@ -563,10 +565,12 @@ "title": "Értesítések új levelekről" }, "scheduled": { + "already_sent": "Ez az üzenet már el lett küldve.", "cancel_body": "Az üzenet nem lesz kézbesítve. Egy másolat az Elküldött mappában marad.", "cancel_failed": "A megszakítás sikertelen", "edit_failed": "Nem sikerült megnyitni az üzenetet szerkesztésre", "keep_scheduled": "Ütemezés megtartása", + "no_longer_scheduled": "Ez az üzenet már nincs ütemezve.", "reschedule_failed": "Az átütemezés sikertelen", "send_now_failed": "Nem sikerült most elküldeni" }, diff --git a/locales/rn/it.json b/locales/rn/it.json index 26831397..d2bb9d52 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -206,6 +206,7 @@ "new_organization": "Nuova organizzazione", "org_name_required": "Aggiungi il nome dell'organizzazione.", "photo_hint_mobile": "Tocca la foto per scegliere un'immagine", + "photo_media_unread": "Non è stato possibile leggere le foto del contatto dal server, quindi non è stato salvato nulla. Riprova.", "photo_permission_message": "Concedi l'autorizzazione alla galleria foto per scegliere una foto del contatto.", "photo_permission_title": "Serve l'accesso alle foto", "pick_date": "Scegli una data", @@ -291,6 +292,7 @@ "send_now": "Invia ora", "send_timeout_body": "Il messaggio potrebbe essere già stato inviato. Controlla la cartella Inviati prima di inviarlo di nuovo.", "send_timeout_title": "Nessuna risposta dal server", + "submission_limit": "Il server ha rifiutato di inviare il messaggio perché è stato raggiunto un limite: {reason}", "submission_quota": "Il server conserva troppi record di messaggi inviati per questo account. Bulwark ha rimosso quelli che poteva; se il problema si ripete, chieda all'amministratore di aumentare il limite di invii di posta.", "undo_send_label": "Invio tra {seconds} s…", "upload_failed_short": "Caricamento non riuscito", @@ -563,10 +565,12 @@ "title": "Ricevi notifiche per la nuova posta" }, "scheduled": { + "already_sent": "Questo messaggio è già stato inviato.", "cancel_body": "Il messaggio non verrà consegnato. Una copia resta nella cartella Inviati.", "cancel_failed": "Annullamento non riuscito", "edit_failed": "Impossibile aprire il messaggio per modificarlo", "keep_scheduled": "Mantieni programmato", + "no_longer_scheduled": "Questo messaggio non è più programmato.", "reschedule_failed": "Riprogrammazione non riuscita", "send_now_failed": "Impossibile inviare ora" }, diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 08b096cf..d44ea051 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -206,6 +206,7 @@ "new_organization": "新しい組織", "org_name_required": "組織名を入力してください。", "photo_hint_mobile": "写真をタップして画像を選択", + "photo_media_unread": "サーバーから連絡先の写真を読み込めなかったため、何も保存されませんでした。もう一度お試しください。", "photo_permission_message": "連絡先の写真を選択するには、写真ライブラリへのアクセスを許可してください。", "photo_permission_title": "写真へのアクセスが必要です", "pick_date": "日付を選択", @@ -291,6 +292,7 @@ "send_now": "今すぐ送信", "send_timeout_body": "メッセージはすでに送信されている可能性があります。再送信する前に送信済みフォルダーを確認してください。", "send_timeout_title": "サーバーから応答がありません", + "submission_limit": "上限に達したため、サーバーがメッセージの送信を拒否しました: {reason}", "submission_quota": "このアカウントの送信済みメッセージの記録がサーバー上に多すぎます。Bulwark は削除できる記録を削除しました。この状態が続く場合は、メール送信数の上限を引き上げるよう管理者に依頼してください。", "undo_send_label": "{seconds}秒後に送信…", "upload_failed_short": "アップロードに失敗しました", @@ -563,10 +565,12 @@ "title": "新着メールの通知を受け取る" }, "scheduled": { + "already_sent": "このメッセージはすでに送信されています。", "cancel_body": "メッセージは配信されません。コピーは送信済みフォルダーに残ります。", "cancel_failed": "キャンセルに失敗しました", "edit_failed": "メッセージを編集用に開けませんでした", "keep_scheduled": "予約を維持", + "no_longer_scheduled": "このメッセージはもう送信予定ではありません。", "reschedule_failed": "再予約に失敗しました", "send_now_failed": "今すぐ送信できませんでした" }, diff --git a/locales/rn/ko.json b/locales/rn/ko.json index 1d7eeabe..8939b34a 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -206,6 +206,7 @@ "new_organization": "새 조직", "org_name_required": "조직 이름을 추가해 주세요.", "photo_hint_mobile": "사진을 탭하여 이미지 선택", + "photo_media_unread": "서버에서 연락처 사진을 읽을 수 없어서 아무것도 저장되지 않았어요. 다시 시도해 주세요.", "photo_permission_message": "연락처 사진을 선택하려면 사진 보관함 권한을 허용해 주세요.", "photo_permission_title": "사진 접근 권한 필요", "pick_date": "날짜 선택", @@ -291,6 +292,7 @@ "send_now": "지금 보내기", "send_timeout_body": "메시지가 이미 발송되었을 수 있어요. 다시 보내기 전에 보낸편지함을 확인해 주세요.", "send_timeout_title": "서버에서 응답이 없어요", + "submission_limit": "한도에 도달하여 서버가 메시지 전송을 거부했습니다: {reason}", "submission_quota": "서버에 이 계정의 보낸 메시지 기록이 너무 많습니다. Bulwark가 삭제할 수 있는 기록은 삭제했습니다. 이 문제가 계속되면 관리자에게 메일 발송 한도를 늘려 달라고 요청하세요.", "undo_send_label": "{seconds}초 후 전송…", "upload_failed_short": "업로드 실패", @@ -563,10 +565,12 @@ "title": "새 메일 알림 받기" }, "scheduled": { + "already_sent": "이 메시지는 이미 보냈습니다.", "cancel_body": "메시지는 전송되지 않아요. 사본은 보낸편지함에 남아 있어요.", "cancel_failed": "취소하지 못했어요", "edit_failed": "메시지를 편집하기 위해 열 수 없어요", "keep_scheduled": "예약 유지", + "no_longer_scheduled": "이 메시지는 더 이상 예약되어 있지 않습니다.", "reschedule_failed": "다시 예약하지 못했어요", "send_now_failed": "지금 보낼 수 없어요" }, diff --git a/locales/rn/lv.json b/locales/rn/lv.json index fe1cbd18..ce6419bc 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -206,6 +206,7 @@ "new_organization": "Jauna organizācija", "org_name_required": "Pievienojiet organizācijas nosaukumu.", "photo_hint_mobile": "Pieskarieties fotoattēlam, lai izvēlētos attēlu", + "photo_media_unread": "Kontakta fotoattēlus neizdevās nolasīt no servera, tāpēc nekas netika saglabāts. Mēģiniet vēlreiz.", "photo_permission_message": "Lai izvēlētos kontakta fotoattēlu, piešķiriet fotoattēlu bibliotēkas atļauju.", "photo_permission_title": "Nepieciešama piekļuve fotoattēliem", "pick_date": "Izvēlēties datumu", @@ -291,6 +292,7 @@ "send_now": "Sūtīt tagad", "send_timeout_body": "Ziņojums, iespējams, jau ir nosūtīts. Pirms sūtāt to vēlreiz, pārbaudiet mapi Nosūtītie.", "send_timeout_title": "Serveris neatbild", + "submission_limit": "Serveris atteicās nosūtīt ziņojumu, jo ir sasniegts ierobežojums: {reason}", "submission_quota": "Serverī šim kontam ir pārāk daudz nosūtīto ziņojumu ierakstu. Bulwark noņēma tos, ko varēja; ja tas atkārtojas, lūdziet administratoram palielināt e-pasta sūtījumu ierobežojumu.", "undo_send_label": "Sūtīšana pēc {seconds} s…", "upload_failed_short": "Augšupielāde neizdevās", @@ -563,10 +565,12 @@ "title": "Saņemiet paziņojumus par jaunu pastu" }, "scheduled": { + "already_sent": "Šis ziņojums jau ir nosūtīts.", "cancel_body": "Ziņojums netiks piegādāts. Kopija paliek mapē Nosūtītie.", "cancel_failed": "Atcelšana neizdevās", "edit_failed": "Neizdevās atvērt ziņojumu rediģēšanai", "keep_scheduled": "Paturēt ieplānotu", + "no_longer_scheduled": "Šis ziņojums vairs nav ieplānots.", "reschedule_failed": "Pārplānošana neizdevās", "send_now_failed": "Neizdevās nosūtīt tagad" }, diff --git a/locales/rn/mn.json b/locales/rn/mn.json index f848aa9c..45f4a360 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -206,6 +206,7 @@ "new_organization": "Шинэ байгууллага", "org_name_required": "Байгууллагын нэрийг нэмнэ үү.", "photo_hint_mobile": "Зураг сонгохын тулд зураг дээр товшино уу", + "photo_media_unread": "Харилцагчийн зургийг серверээс уншиж чадсангүй, тиймээс юу ч хадгалагдсангүй. Дахин оролдоно уу.", "photo_permission_message": "Харилцагчийн зураг сонгохын тулд зургийн сангийн зөвшөөрөл олгоно уу.", "photo_permission_title": "Зургийн хандалт шаардлагатай", "pick_date": "Огноо сонгох", @@ -291,6 +292,7 @@ "send_now": "Одоо илгээх", "send_timeout_body": "Имэйл аль хэдийн илгээгдсэн байж магадгүй. Дахин илгээхээсээ өмнө Илгээсэн хавтсаа шалгана уу.", "send_timeout_title": "Серверээс хариу алга", + "submission_limit": "Хязгаарт хүрсэн тул сервер мессежийг илгээхээс татгалзлаа: {reason}", "submission_quota": "Сервер дээр энэ бүртгэлийн илгээсэн мессежийн бичлэг хэт олон байна. Bulwark боломжтойг нь устгасан; дахин давтагдвал админаасаа имэйл илгээх хязгаарыг нэмэгдүүлэхийг хүснэ үү.", "undo_send_label": "{seconds} секундын дараа илгээнэ…", "upload_failed_short": "Байршуулж чадсангүй", @@ -563,10 +565,12 @@ "title": "Шинэ имэйлийн мэдэгдэл авах" }, "scheduled": { + "already_sent": "Энэ мессеж аль хэдийн илгээгдсэн.", "cancel_body": "Имэйл хүргэгдэхгүй. Хуулбар нь Илгээсэн хавтсанд үлдэнэ.", "cancel_failed": "Цуцалж чадсангүй", "edit_failed": "Имэйлийг засахаар нээж чадсангүй", "keep_scheduled": "Товлосноор нь үлдээх", + "no_longer_scheduled": "Энэ мессеж товлогдоогүй болсон.", "reschedule_failed": "Хуваарийг өөрчилж чадсангүй", "send_now_failed": "Одоо илгээж чадсангүй" }, diff --git a/locales/rn/nb.json b/locales/rn/nb.json index 854adaf8..c8204e77 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -206,6 +206,7 @@ "new_organization": "Ny organisasjon", "org_name_required": "Legg til navnet på organisasjonen.", "photo_hint_mobile": "Trykk på bildet for å velge et bilde", + "photo_media_unread": "Kontaktens bilder kunne ikke leses fra serveren, så ingenting ble lagret. Prøv igjen.", "photo_permission_message": "Gi tilgang til bildebiblioteket for å velge et kontaktbilde.", "photo_permission_title": "Trenger tilgang til bilder", "pick_date": "Velg en dato", @@ -291,6 +292,7 @@ "send_now": "Send nå", "send_timeout_body": "Meldingen kan allerede ha blitt sendt. Sjekk Sendt-mappen før du sender den på nytt.", "send_timeout_title": "Ingen svar fra serveren", + "submission_limit": "Serveren nektet å sende meldingen fordi en grense er nådd: {reason}", "submission_quota": "Serveren har for mange registreringer av sendte meldinger for denne kontoen. Bulwark fjernet dem den kunne; hvis dette fortsetter, be administratoren din om å øke grensen for innsending av e-post.", "undo_send_label": "Sender om {seconds} s…", "upload_failed_short": "Opplastingen mislyktes", @@ -563,10 +565,12 @@ "title": "Få varsler om ny e-post" }, "scheduled": { + "already_sent": "Denne meldingen er allerede sendt.", "cancel_body": "Meldingen blir ikke levert. En kopi blir liggende i Sendt-mappen.", "cancel_failed": "Kunne ikke avbryte", "edit_failed": "Kunne ikke åpne meldingen for redigering", "keep_scheduled": "Behold planlagt", + "no_longer_scheduled": "Denne meldingen er ikke lenger planlagt.", "reschedule_failed": "Kunne ikke planlegge på nytt", "send_now_failed": "Kunne ikke sende nå" }, diff --git a/locales/rn/nl.json b/locales/rn/nl.json index bc0352f4..c413da03 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -206,6 +206,7 @@ "new_organization": "Nieuwe organisatie", "org_name_required": "Voeg de naam van de organisatie toe.", "photo_hint_mobile": "Tik op de foto om een afbeelding te kiezen", + "photo_media_unread": "De foto's van het contact konden niet van de server worden gelezen, dus er is niets opgeslagen. Probeer het opnieuw.", "photo_permission_message": "Geef toegang tot de fotobibliotheek om een contactfoto te kiezen.", "photo_permission_title": "Toegang tot foto's nodig", "pick_date": "Kies een datum", @@ -291,6 +292,7 @@ "send_now": "Nu verzenden", "send_timeout_body": "Het bericht is mogelijk al verzonden. Controleer uw map Verzonden voordat u het opnieuw verstuurt.", "send_timeout_title": "Geen antwoord van de server", + "submission_limit": "De server weigerde het bericht te verzenden omdat een limiet is bereikt: {reason}", "submission_quota": "De server bewaart te veel records van verzonden berichten voor dit account. Bulwark heeft verwijderd wat kon; blijft dit gebeuren, vraag uw beheerder dan de limiet voor e-mailverzendingen te verhogen.", "undo_send_label": "Verzenden over {seconds} s…", "upload_failed_short": "Uploaden mislukt", @@ -563,10 +565,12 @@ "title": "Meldingen ontvangen over nieuwe e-mail" }, "scheduled": { + "already_sent": "Dit bericht is al verzonden.", "cancel_body": "Het bericht wordt niet bezorgd. Er blijft een kopie in uw map Verzonden.", "cancel_failed": "Annuleren mislukt", "edit_failed": "Kon het bericht niet openen om te bewerken", "keep_scheduled": "Gepland laten", + "no_longer_scheduled": "Dit bericht staat niet meer gepland.", "reschedule_failed": "Opnieuw plannen mislukt", "send_now_failed": "Kon niet nu verzenden" }, diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 4c333592..ce62c7d4 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -206,6 +206,7 @@ "new_organization": "Nowa organizacja", "org_name_required": "Dodaj nazwę organizacji.", "photo_hint_mobile": "Stuknij zdjęcie, aby wybrać obraz", + "photo_media_unread": "Nie udało się odczytać zdjęć kontaktu z serwera, więc nic nie zostało zapisane. Spróbuj ponownie.", "photo_permission_message": "Zezwól na dostęp do galerii zdjęć, aby wybrać zdjęcie kontaktu.", "photo_permission_title": "Wymagany dostęp do zdjęć", "pick_date": "Wybierz datę", @@ -291,6 +292,7 @@ "send_now": "Wyślij teraz", "send_timeout_body": "Wiadomość mogła już zostać wysłana. Zanim wyślesz ją ponownie, sprawdź folder Wysłane.", "send_timeout_title": "Brak odpowiedzi serwera", + "submission_limit": "Serwer odmówił wysłania wiadomości, ponieważ osiągnięto limit: {reason}", "submission_quota": "Serwer przechowuje zbyt wiele rekordów wysłanych wiadomości dla tego konta. Bulwark usunął te, które mógł; jeśli to się powtarza, poproś administratora o podniesienie limitu wysyłek e-mail.", "undo_send_label": "Wysyłanie za {seconds} s…", "upload_failed_short": "Przesyłanie nie powiodło się", @@ -563,10 +565,12 @@ "title": "Otrzymuj powiadomienia o nowej poczcie" }, "scheduled": { + "already_sent": "Ta wiadomość została już wysłana.", "cancel_body": "Wiadomość nie zostanie dostarczona. Kopia pozostanie w folderze Wysłane.", "cancel_failed": "Anulowanie nie powiodło się", "edit_failed": "Nie udało się otworzyć wiadomości do edycji", "keep_scheduled": "Zostaw zaplanowaną", + "no_longer_scheduled": "Ta wiadomość nie jest już zaplanowana.", "reschedule_failed": "Nie udało się zmienić terminu", "send_now_failed": "Nie udało się wysłać teraz" }, diff --git a/locales/rn/pt.json b/locales/rn/pt.json index b0e44991..7b616c42 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -206,6 +206,7 @@ "new_organization": "Nova organização", "org_name_required": "Adicione o nome da organização.", "photo_hint_mobile": "Toque na foto para escolher uma imagem", + "photo_media_unread": "Não foi possível ler as fotos do contato no servidor, então nada foi salvo. Tente novamente.", "photo_permission_message": "Conceda permissão à galeria de fotos para escolher uma foto de contato.", "photo_permission_title": "Acesso às fotos necessário", "pick_date": "Escolher uma data", @@ -291,6 +292,7 @@ "send_now": "Enviar agora", "send_timeout_body": "A mensagem pode já ter sido enviada. Verifique sua pasta Enviados antes de enviá-la novamente.", "send_timeout_title": "Sem resposta do servidor", + "submission_limit": "O servidor se recusou a enviar a mensagem porque um limite foi atingido: {reason}", "submission_quota": "O servidor tem registros de mensagens enviadas demais para esta conta. O Bulwark removeu os que pôde; se isso continuar acontecendo, peça ao seu administrador para aumentar o limite de envios de e-mail.", "undo_send_label": "Enviando em {seconds}s…", "upload_failed_short": "Falha no envio", @@ -563,10 +565,12 @@ "title": "Seja notificado sobre novos e-mails" }, "scheduled": { + "already_sent": "Esta mensagem já foi enviada.", "cancel_body": "A mensagem não será entregue. Uma cópia permanece na sua pasta Enviados.", "cancel_failed": "Falha ao cancelar", "edit_failed": "Não foi possível abrir a mensagem para edição", "keep_scheduled": "Manter agendado", + "no_longer_scheduled": "Esta mensagem não está mais agendada.", "reschedule_failed": "Falha ao reagendar", "send_now_failed": "Não foi possível enviar agora" }, diff --git a/locales/rn/ro.json b/locales/rn/ro.json index b742cec3..e5c1d162 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -206,6 +206,7 @@ "new_organization": "Organizație nouă", "org_name_required": "Adăugați numele organizației.", "photo_hint_mobile": "Atingeți fotografia pentru a alege o imagine", + "photo_media_unread": "Fotografiile contactului nu au putut fi citite de pe server, așa că nu s-a salvat nimic. Încercați din nou.", "photo_permission_message": "Acordați permisiunea pentru galeria foto pentru a alege o fotografie de contact.", "photo_permission_title": "Este necesar accesul la fotografii", "pick_date": "Alegeți o dată", @@ -291,6 +292,7 @@ "send_now": "Trimite acum", "send_timeout_body": "Este posibil ca mesajul să fi fost deja trimis. Verificați folderul Trimise înainte de a-l trimite din nou.", "send_timeout_title": "Serverul nu răspunde", + "submission_limit": "Serverul a refuzat să trimită mesajul deoarece s-a atins o limită: {reason}", "submission_quota": "Serverul păstrează prea multe înregistrări de mesaje trimise pentru acest cont. Bulwark le-a eliminat pe cele pe care a putut; dacă problema persistă, cereți administratorului să mărească limita de trimiteri de e-mail.", "undo_send_label": "Se trimite în {seconds} s…", "upload_failed_short": "Încărcarea a eșuat", @@ -563,10 +565,12 @@ "title": "Primiți notificări pentru e-mailurile noi" }, "scheduled": { + "already_sent": "Acest mesaj a fost deja trimis.", "cancel_body": "Mesajul nu va fi livrat. O copie rămâne în folderul Trimise.", "cancel_failed": "Anularea a eșuat", "edit_failed": "Mesajul nu a putut fi deschis pentru editare", "keep_scheduled": "Păstrați programarea", + "no_longer_scheduled": "Acest mesaj nu mai este programat.", "reschedule_failed": "Reprogramarea a eșuat", "send_now_failed": "Nu s-a putut trimite acum" }, diff --git a/locales/rn/ru.json b/locales/rn/ru.json index 8ea0da68..1eacb58f 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -206,6 +206,7 @@ "new_organization": "Новая организация", "org_name_required": "Добавьте название организации.", "photo_hint_mobile": "Нажмите на фото, чтобы выбрать изображение", + "photo_media_unread": "Не удалось прочитать фотографии контакта с сервера, поэтому ничего не сохранено. Попробуйте ещё раз.", "photo_permission_message": "Разрешите доступ к фотогалерее, чтобы выбрать фото контакта.", "photo_permission_title": "Нужен доступ к фото", "pick_date": "Выберите дату", @@ -291,6 +292,7 @@ "send_now": "Отправить сейчас", "send_timeout_body": "Возможно, письмо уже отправлено. Проверьте папку «Отправленные», прежде чем отправлять его снова.", "send_timeout_title": "Сервер не отвечает", + "submission_limit": "Сервер отказался отправить письмо, так как достигнут лимит: {reason}", "submission_quota": "На сервере слишком много записей об отправленных письмах для этой учётной записи. Bulwark удалил те, что мог; если это повторяется, попросите администратора увеличить лимит отправки писем.", "undo_send_label": "Отправка через {seconds} с…", "upload_failed_short": "Ошибка загрузки", @@ -563,10 +565,12 @@ "title": "Уведомления о новой почте" }, "scheduled": { + "already_sent": "Это письмо уже отправлено.", "cancel_body": "Письмо не будет доставлено. Копия останется в папке «Отправленные».", "cancel_failed": "Не удалось отменить", "edit_failed": "Не удалось открыть письмо для редактирования", "keep_scheduled": "Оставить запланированным", + "no_longer_scheduled": "Это письмо больше не запланировано.", "reschedule_failed": "Не удалось перенести", "send_now_failed": "Не удалось отправить сейчас" }, diff --git a/locales/rn/sk.json b/locales/rn/sk.json index eaf40c2d..2d8ce0a1 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -206,6 +206,7 @@ "new_organization": "Nová organizácia", "org_name_required": "Zadajte názov organizácie.", "photo_hint_mobile": "Klepnutím na fotku vyberiete obrázok", + "photo_media_unread": "Fotografie kontaktu sa nepodarilo načítať zo servera, preto sa nič neuložilo. Skúste to znova.", "photo_permission_message": "Na výber fotky kontaktu povoľte prístup ku knižnici fotiek.", "photo_permission_title": "Vyžaduje sa prístup k fotkám", "pick_date": "Vybrať dátum", @@ -291,6 +292,7 @@ "send_now": "Odoslať teraz", "send_timeout_body": "Správa už možno odišla. Skôr ako ju odošlete znova, skontrolujte priečinok Odoslané.", "send_timeout_title": "Server neodpovedá", + "submission_limit": "Server odmietol správu odoslať, pretože sa dosiahol limit: {reason}", "submission_quota": "Server má pre tento účet príliš veľa záznamov o odoslaných správach. Bulwark odstránil tie, ktoré mohol; ak sa to bude opakovať, požiadajte správcu o zvýšenie limitu odosielania e-mailov.", "undo_send_label": "Odoslanie o {seconds} s…", "upload_failed_short": "Nahrávanie zlyhalo", @@ -563,10 +565,12 @@ "title": "Dostávajte oznámenia o novej pošte" }, "scheduled": { + "already_sent": "Táto správa už bola odoslaná.", "cancel_body": "Správa nebude doručená. Kópia zostane v priečinku Odoslané.", "cancel_failed": "Zrušenie zlyhalo", "edit_failed": "Správu sa nepodarilo otvoriť na úpravu", "keep_scheduled": "Ponechať naplánované", + "no_longer_scheduled": "Táto správa už nie je naplánovaná.", "reschedule_failed": "Preplánovanie zlyhalo", "send_now_failed": "Správu sa nepodarilo odoslať teraz" }, diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 52f889d7..2c663902 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -206,6 +206,7 @@ "new_organization": "Yeni kuruluş", "org_name_required": "Kuruluş adını ekleyin.", "photo_hint_mobile": "Görsel seçmek için fotoğrafa dokunun", + "photo_media_unread": "Kişinin fotoğrafları sunucudan okunamadı, bu yüzden hiçbir şey kaydedilmedi. Tekrar deneyin.", "photo_permission_message": "Kişi fotoğrafı seçmek için fotoğraf arşivi izni verin.", "photo_permission_title": "Fotoğraf erişimi gerekli", "pick_date": "Tarih seçin", @@ -291,6 +292,7 @@ "send_now": "Şimdi gönder", "send_timeout_body": "İleti zaten gönderilmiş olabilir. Yeniden göndermeden önce Gönderilenler klasörünü kontrol edin.", "send_timeout_title": "Sunucudan yanıt yok", + "submission_limit": "Bir sınıra ulaşıldığı için sunucu iletiyi göndermeyi reddetti: {reason}", "submission_quota": "Sunucuda bu hesap için çok fazla gönderilmiş ileti kaydı var. Bulwark kaldırabildiklerini kaldırdı; bu durum sürerse yöneticinizden e-posta gönderim sınırını yükseltmesini isteyin.", "undo_send_label": "{seconds} sn içinde gönderiliyor…", "upload_failed_short": "Yükleme başarısız", @@ -563,10 +565,12 @@ "title": "Yeni postalardan haberdar olun" }, "scheduled": { + "already_sent": "Bu ileti zaten gönderildi.", "cancel_body": "İleti teslim edilmeyecek. Bir kopyası Gönderilenler klasöründe kalır.", "cancel_failed": "İptal başarısız", "edit_failed": "İleti düzenleme için açılamadı", "keep_scheduled": "Zamanlanmış kalsın", + "no_longer_scheduled": "Bu ileti artık zamanlanmış değil.", "reschedule_failed": "Yeniden zamanlama başarısız", "send_now_failed": "Şimdi gönderilemedi" }, diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 1a45cb4f..ba5bc8f4 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -206,6 +206,7 @@ "new_organization": "Нова організація", "org_name_required": "Додайте назву організації.", "photo_hint_mobile": "Торкніться фото, щоб вибрати зображення", + "photo_media_unread": "Не вдалося прочитати фотографії контакту із сервера, тому нічого не збережено. Спробуйте ще раз.", "photo_permission_message": "Надайте доступ до фотогалереї, щоб вибрати фото контакту.", "photo_permission_title": "Потрібен доступ до фото", "pick_date": "Виберіть дату", @@ -291,6 +292,7 @@ "send_now": "Надіслати зараз", "send_timeout_body": "Можливо, лист уже надіслано. Перевірте папку «Надіслані», перш ніж надсилати його знову.", "send_timeout_title": "Сервер не відповідає", + "submission_limit": "Сервер відмовився надіслати лист, бо досягнуто ліміту: {reason}", "submission_quota": "На сервері забагато записів про надіслані листи для цього облікового запису. Bulwark видалив ті, що міг; якщо це повторюється, попросіть адміністратора збільшити ліміт надсилання листів.", "undo_send_label": "Надсилання через {seconds} с…", "upload_failed_short": "Помилка завантаження", @@ -563,10 +565,12 @@ "title": "Сповіщення про нову пошту" }, "scheduled": { + "already_sent": "Цей лист уже надіслано.", "cancel_body": "Лист не буде доставлено. Копія залишиться в папці «Надіслані».", "cancel_failed": "Не вдалося скасувати", "edit_failed": "Не вдалося відкрити лист для редагування", "keep_scheduled": "Залишити запланованим", + "no_longer_scheduled": "Цей лист більше не заплановано.", "reschedule_failed": "Не вдалося перенести", "send_now_failed": "Не вдалося надіслати зараз" }, diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 9e38869b..caed742a 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -206,6 +206,7 @@ "new_organization": "新組織", "org_name_required": "請新增組織名稱。", "photo_hint_mobile": "點選相片以選擇圖片", + "photo_media_unread": "無法從伺服器讀取聯絡人的相片,因此未儲存任何內容。請再試一次。", "photo_permission_message": "請授予相片圖庫權限,以選擇聯絡人相片。", "photo_permission_title": "需要相片存取權", "pick_date": "選擇日期", @@ -291,6 +292,7 @@ "send_now": "立即傳送", "send_timeout_body": "郵件可能已經寄出。再次傳送前,請先檢查「寄件備份」信件匣。", "send_timeout_title": "伺服器沒有回應", + "submission_limit": "由於達到上限,伺服器拒絕傳送此郵件:{reason}", "submission_quota": "伺服器上此帳戶的已寄出郵件記錄過多。Bulwark 已移除可以移除的記錄;如果問題持續發生,請要求管理員提高郵件提交數量上限。", "undo_send_label": "{seconds} 秒後傳送…", "upload_failed_short": "上傳失敗", @@ -563,10 +565,12 @@ "title": "接收新郵件通知" }, "scheduled": { + "already_sent": "此郵件已寄出。", "cancel_body": "這封郵件將不會寄出。副本會保留在「寄件備份」信件匣中。", "cancel_failed": "取消失敗", "edit_failed": "無法開啟郵件進行編輯", "keep_scheduled": "保留排程", + "no_longer_scheduled": "此郵件已不在排定傳送中。", "reschedule_failed": "重新排程失敗", "send_now_failed": "無法立即傳送" }, diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 1515a5b0..d2eaa357 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -206,6 +206,7 @@ "new_organization": "新组织", "org_name_required": "请添加组织名称。", "photo_hint_mobile": "点按照片以选择图片", + "photo_media_unread": "无法从服务器读取联系人的照片,因此未保存任何内容。请重试。", "photo_permission_message": "请授予相册权限以选择联系人照片。", "photo_permission_title": "需要相册访问权限", "pick_date": "选择日期", @@ -291,6 +292,7 @@ "send_now": "立即发送", "send_timeout_body": "邮件可能已经发出。再次发送前,请检查“已发送”文件夹。", "send_timeout_title": "服务器无响应", + "submission_limit": "由于达到上限,服务器拒绝发送此邮件:{reason}", "submission_quota": "服务器上此账户的已发送邮件记录过多。Bulwark 已删除了能删除的记录;如果问题仍然出现,请让管理员提高邮件提交数量上限。", "undo_send_label": "{seconds} 秒后发送…", "upload_failed_short": "上传失败", @@ -563,10 +565,12 @@ "title": "接收新邮件通知" }, "scheduled": { + "already_sent": "此邮件已发送。", "cancel_body": "该邮件将不会被投递。副本会保留在“已发送”文件夹中。", "cancel_failed": "取消失败", "edit_failed": "无法打开邮件进行编辑", "keep_scheduled": "保留计划", + "no_longer_scheduled": "此邮件已不在定时发送中。", "reschedule_failed": "重新计划失败", "send_now_failed": "无法立即发送" }, diff --git a/src/api/__tests__/email-keyword-refusals.test.ts b/src/api/__tests__/email-keyword-refusals.test.ts index 31f16342..2272ac03 100644 --- a/src/api/__tests__/email-keyword-refusals.test.ts +++ b/src/api/__tests__/email-keyword-refusals.test.ts @@ -15,8 +15,8 @@ vi.mock('../jmap-client', () => ({ })); import { jmapClient } from '../jmap-client'; -import { markMailboxAsRead, patchKeywordsForEmails } from '../email'; -import { isJMAPSetError, JMAPSetError } from '../jmap-result'; +import { markMailboxAsRead, markMailboxesAsRead, patchKeywordsForEmails } from '../email'; +import { failedIdsOf, isJMAPSetError, JMAPSetError } from '../jmap-result'; const mockRequest = jmapClient.request as ReturnType; const mockMaxInSet = jmapClient.getMaxObjectsInSet as ReturnType; @@ -91,6 +91,18 @@ describe('keyword writes report what the server refused', () => { expect(sent.map(([, args]) => Object.keys(args.update as Record))).toEqual([['m1'], ['m2'], ['m3']]); }); + + it('names every refused message across the batches, so callers show only what was written', async () => { + mockMaxInSet.mockReturnValue(2); + serve(refuse({ type: 'forbidden' }, (id) => id === 'm1' || id === 'm4')); + + const err = await patchKeywordsForEmails(['m1', 'm2', 'm3', 'm4'], { $seen: true }).catch((e) => e); + + expect(err).toBeInstanceOf(JMAPSetError); + expect(failedIdsOf(err)).toEqual(['m1', 'm4']); + // A refused request says nothing about which messages were written. + expect(failedIdsOf(new Error('Network request failed'))).toBeNull(); + }); }); describe('markMailboxAsRead', () => { @@ -129,3 +141,56 @@ describe('markMailboxAsRead', () => { expect(await markMailboxAsRead('a')).toBe(2); }); }); + +describe('markMailboxesAsRead (a folder and its subfolders)', () => { + /** Unread messages per folder; `readOnly` folders refuse every update. */ + function serveFolders(unread: Record, readOnly: string[]) { + const folderOf = (id: string) => Object.keys(unread).find((f) => unread[f].includes(id))!; + const sent: Call[] = []; + mockRequest.mockImplementation(async (calls: Call[]) => { + sent.push(...calls); + return { + methodResponses: calls.map(([name, args, id]) => { + if (name === 'Email/query') { + const [inMailbox] = (args.filter as { conditions: Array<{ inMailbox?: string }> }).conditions; + return [name, { accountId: args.accountId, ids: [...unread[inMailbox.inMailbox!]] }, id]; + } + const ids = Object.keys(args.update as Record); + const refused = ids.filter((m) => readOnly.includes(folderOf(m))); + for (const m of ids.filter((m) => !refused.includes(m))) unread[folderOf(m)].splice(unread[folderOf(m)].indexOf(m), 1); + return [name, { + accountId: args.accountId, + updated: Object.fromEntries(ids.filter((m) => !refused.includes(m)).map((m) => [m, null])), + ...(refused.length ? { notUpdated: Object.fromEntries(refused.map((m) => [m, { type: 'forbidden' }])) } : {}), + }, id]; + }), + }; + }); + return sent; + } + + it('marks the folders after one the server refuses, then reports the refusal', async () => { + const unread = { parent: ['p1'], shared: ['s1'], child: ['c1', 'c2'] }; + serveFolders(unread, ['shared']); + + const err = await markMailboxesAsRead([{ id: 'parent' }, { id: 'shared', accountId: 'b' }, { id: 'child' }]) + .catch((e) => e); + + expect(err).toMatchObject({ type: 'forbidden' }); + expect(unread).toEqual({ parent: [], shared: ['s1'], child: [] }); + }); + + it('stops at a lost connection instead of trying every folder', async () => { + mockRequest.mockRejectedValue(new TypeError('Network request failed')); + + await expect(markMailboxesAsRead([{ id: 'a' }, { id: 'b' }])).rejects.toThrow('Network request failed'); + + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('resolves with the count when every folder is marked', async () => { + serveFolders({ a: ['m1'], b: ['m2', 'm3'] }, []); + + await expect(markMailboxesAsRead([{ id: 'a' }, { id: 'b' }])).resolves.toBe(3); + }); +}); diff --git a/src/api/__tests__/email-submission-cleanup.test.ts b/src/api/__tests__/email-submission-cleanup.test.ts index e6b71982..83602419 100644 --- a/src/api/__tests__/email-submission-cleanup.test.ts +++ b/src/api/__tests__/email-submission-cleanup.test.ts @@ -42,7 +42,7 @@ import { jmapClient } from '../jmap-client'; import { cancelScheduledSend, listScheduledEmails, rescheduleScheduledSend, sendEmail, sendReadReceipt, } from '../email'; -import { SubmissionQuotaError } from '../jmap-result'; +import { NoLongerScheduledError, SubmissionQuotaError } from '../jmap-result'; import { prunableSubmissionIds, pruneEmailSubmissions, resetSubmissionPrune } from '../submissions'; import { createFakeSubmissionServer, OVER_QUOTA_DESCRIPTION, type StalwartVersion } from './fake-submission-server'; @@ -259,7 +259,8 @@ describe('a full submission quota on a send (Stalwart 1.0)', () => { .catch((e: unknown) => e); expect(err).toBeInstanceOf(SubmissionQuotaError); - expect(err).toMatchObject({ type: 'overQuota', message: OVER_QUOTA_DESCRIPTION }); + // Stalwart counts records: the explanation that an administrator can help. + expect(err).toMatchObject({ type: 'overQuota', message: OVER_QUOTA_DESCRIPTION, countsRecords: true }); // One retry only. expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(2); expect(server.submissions().map((s) => s.id)).toEqual(pending); @@ -279,6 +280,9 @@ describe('a full submission quota on a send (Stalwart 1.0)', () => { const err = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS).catch((e: unknown) => e); expect(err).toBeInstanceOf(SubmissionQuotaError); + // Another server's overQuota may be any sending limit: its own words, no + // Stalwart explanation. + expect(err).toMatchObject({ countsRecords: false, description: OVER_QUOTA_DESCRIPTION }); expect(server.methods().flat()).not.toContain('EmailSubmission/changes'); }); @@ -470,6 +474,101 @@ describe('rescheduling and "Send now"', () => { expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false); expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'pending' }]); }); + + /** A full account: the original, due soon, and two delivered records the prune can remove. */ + function fullWithOriginal(dueIn: number) { + const server = setup('1.0', { limit: 3 }); + const now = Date.now(); + const [original] = server.seed('acc-1', [{ sendAt: now + dueIn, status: 'pending', emailId: 'mail-1' }]); + server.seed('acc-1', [{ sendAt: now - HOUR, status: 'final' }, { sendAt: now - 2 * HOUR, status: 'final' }]); + return { server, original }; + } + const isQuery = (calls: Array<[string, unknown, string]>) => calls[0][0] === 'EmailSubmission/query'; + + it('Stalwart 1.0: an original that went out while room was made is not sent a second time', async () => { + const { server, original } = fullWithOriginal(30_000); + // Its HOLDFOR runs out while the prune looks for records to remove. + server.afterEachRequest((calls) => { + if (isQuery(calls)) server.deliver('acc-1', original); + }); + + const err = await rescheduleScheduledSend(scheduled(original), 0).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(NoLongerScheduledError); + expect(err).toMatchObject({ sent: true }); + // The original was read again after the prune, and only the refused create was made. + expect(server.requests.at(-1)?.calls).toEqual([ + ['EmailSubmission/get', { accountId: 'acc-1', ids: [original], properties: ['id', 'undoStatus'] }, '0'], + ]); + expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1); + expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false); + expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'final' }]); + }); + + it('Stalwart 1.0: an original cancelled on another device meanwhile is left as it is', async () => { + const { server, original } = fullWithOriginal(HOUR); + server.afterEachRequest((calls) => { + if (isQuery(calls)) server.cancel('acc-1', original); + }); + + const err = await rescheduleScheduledSend(scheduled(original), 2 * 3600).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(NoLongerScheduledError); + expect(err).toMatchObject({ sent: false }); + expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1); + expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'canceled' }]); + }); + + it('Stalwart 1.0: when the original cannot be read again, nothing is resubmitted', async () => { + const { server, original } = fullWithOriginal(HOUR); + server.beforeEachRequest((calls) => { + const [name, args] = calls[0]; + if (name === 'EmailSubmission/get' && (args as { properties?: string[] }).properties?.join() === 'id,undoStatus') { + throw new Error('Network request failed'); + } + }); + + await expect(rescheduleScheduledSend(scheduled(original), 2 * 3600)).rejects.toThrow('Network request failed'); + + expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1); + expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'pending' }]); + }); + + it('Stalwart 1.0: the retry takes the time spent making room off the hold', async () => { + let clock = Date.parse('2026-09-25T12:00:00Z'); + vi.spyOn(Date, 'now').mockImplementation(() => clock); + const { server, original } = fullWithOriginal(HOUR); + const target = clock + 2 * HOUR; + // The prune takes five minutes. + server.afterEachRequest((calls) => { + if (isQuery(calls)) clock += 5 * MINUTE; + }); + + const result = await rescheduleScheduledSend(scheduled(original), 2 * 3600); + + const holds = argsOf(server, 'EmailSubmission/set') + .filter((a) => a.create) + .map((a) => a.create.replacement.envelope.mailFrom.parameters.HOLDFOR); + expect(holds).toEqual(['7200', '6900']); + // Out at the time the user picked, not five minutes later. + expect(server.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: target, undoStatus: 'pending' }]); + }); + + it('Stalwart 1.0: "Send now" that waited for room still goes out at once', async () => { + let clock = Date.parse('2026-09-25T12:00:00Z'); + vi.spyOn(Date, 'now').mockImplementation(() => clock); + const { server, original } = fullWithOriginal(HOUR); + server.afterEachRequest((calls) => { + if (isQuery(calls)) clock += 5 * MINUTE; + }); + + await rescheduleScheduledSend(scheduled(original), 0); + + const holds = argsOf(server, 'EmailSubmission/set') + .filter((a) => a.create) + .map((a) => a.create.replacement.envelope.mailFrom.parameters.HOLDFOR); + expect(holds).toEqual(['1', '1']); + }); }); describe('read receipts', () => { @@ -536,3 +635,69 @@ describe('the scheduled list', () => { expect(argsOf(server, 'EmailSubmission/query').map((a) => a.position)).toEqual([0, 2]); }); }); + +describe('making room where nothing can go (Stalwart 1.0)', () => { + /** Requests that look for records to remove: the prune's query and the change-log walk. */ + const searches = (server: FakeSubmissionServer) => + server.methods().flat().filter((m) => m === 'EmailSubmission/query' || m === 'EmailSubmission/changes').length; + + it('does not search the account again for five minutes', async () => { + let clock = Date.parse('2026-09-25T12:00:00Z'); + vi.spyOn(Date, 'now').mockImplementation(() => clock); + const server = setup('1.0', { limit: 2 }); + const pending = server.seed('acc-1', [ + { sendAt: clock + DAY, status: 'pending' }, + { sendAt: clock + 2 * DAY, status: 'pending' }, + ]); + + await expect(sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS)).rejects.toBeInstanceOf(SubmissionQuotaError); + const first = searches(server); + // The query, and the change-log walk once it found nothing. + expect(first).toBe(2); + + // A send and an automatic read receipt right after: one retry each, no search. + clock += MINUTE; + await expect(sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS)).rejects.toBeInstanceOf(SubmissionQuotaError); + await expect(sendReadReceipt(RECEIPT)).rejects.toBeInstanceOf(SubmissionQuotaError); + expect(searches(server)).toBe(first); + expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(6); + + clock += 5 * MINUTE; + await expect(sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS)).rejects.toBeInstanceOf(SubmissionQuotaError); + expect(searches(server)).toBe(2 * first); + expect(server.submissions().map((s) => s.id)).toEqual(pending); + }); + + it('searches again at once when a search failed', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const server = setup('1.0', { limit: 1 }); + server.seed('acc-1', [{ sendAt: Date.now() + DAY, status: 'pending' }]); + let failQuery = true; + server.beforeEachRequest((calls) => { + if (failQuery && calls[0][0] === 'EmailSubmission/query') { + failQuery = false; + throw new Error('Network request failed'); + } + }); + + await expect(sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS)).rejects.toBeInstanceOf(SubmissionQuotaError); + expect(warn).toHaveBeenCalled(); + const first = argsOf(server, 'EmailSubmission/query').length; + + await expect(sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS)).rejects.toBeInstanceOf(SubmissionQuotaError); + // A failed look-up proved nothing, so the second attempt looked again. + expect(argsOf(server, 'EmailSubmission/query').length).toBe(first + 1); + }); + + it('keeps no pause once a search found something to remove', async () => { + const server = setup('1.0', { limit: 2 }); + const now = Date.now(); + server.seed('acc-1', [{ sendAt: now + DAY, status: 'pending' }, { sendAt: now - HOUR, status: 'final' }]); + + await sendEmail(OUTGOING, 'id-1', 'sent', 10, VIA_DRAFTS); + // Full again: a scheduled send holds the freed place. + await expect(sendEmail(OUTGOING, 'id-1', 'sent', 10, VIA_DRAFTS)).rejects.toBeInstanceOf(SubmissionQuotaError); + + expect(argsOf(server, 'EmailSubmission/query')).toHaveLength(2); + }); +}); diff --git a/src/api/__tests__/fake-submission-server.ts b/src/api/__tests__/fake-submission-server.ts index 8d32a27e..54cde059 100644 --- a/src/api/__tests__/fake-submission-server.ts +++ b/src/api/__tests__/fake-submission-server.ts @@ -84,6 +84,9 @@ export function createFakeSubmissionServer(options: { let emailSeq = 0; /** Makes the next Email/set create fail with this SetError. */ let emailCreateFailure: { type: string; description?: string } | null = null; + /** Test hooks around each request: the queue changing between two requests, a lost request. */ + let beforeRequest: ((calls: JMAPMethodCall[]) => void) | null = null; + let afterRequest: ((calls: JMAPMethodCall[]) => void) | null = null; const recordsOf = (accountId: string) => { let records = accounts.get(accountId); @@ -269,6 +272,7 @@ export function createFakeSubmissionServer(options: { } async function request(calls: JMAPMethodCall[], using?: string[]): Promise<{ methodResponses: Response[] }> { + beforeRequest?.(calls); const responses: Response[] = []; requests.push({ calls, using, responses }); const createdEmails = new Map(); @@ -358,6 +362,7 @@ export function createFakeSubmissionServer(options: { throw new Error(`fake server: unexpected ${name}`); } } + afterRequest?.(calls); return { methodResponses: responses }; } @@ -386,6 +391,28 @@ export function createFakeSubmissionServer(options: { failNextEmailCreate(failure: { type: string; description?: string }) { emailCreateFailure = failure; }, + /** Runs before every request from now on; throwing from it fails the request in transit. */ + beforeEachRequest(hook: (calls: JMAPMethodCall[]) => void) { + beforeRequest = hook; + }, + /** Runs after every request from now on, before the client sees the answer. */ + afterEachRequest(hook: (calls: JMAPMethodCall[]) => void) { + afterRequest = hook; + }, + /** A held send goes out now: final, as its HOLDFOR ran out. */ + deliver(accountId: string, id: string) { + const s = recordsOf(accountId).get(id); + if (!s) return; + s.sendAt = Math.min(s.sendAt, Date.now() - 1000); + s.retrying = false; + }, + /** Another device cancels a held send. */ + cancel(accountId: string, id: string) { + const s = recordsOf(accountId).get(id); + if (!s) return; + s.canceled = true; + logOf(accountId).push({ id, kind: 'updated' }); + }, /** The method names of every request, in order. */ methods(): string[][] { return requests.map(({ calls }) => calls.map(([name]) => name)); diff --git a/src/api/email.ts b/src/api/email.ts index 79a9bb34..728f3a6c 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1,11 +1,13 @@ import { jmapClient } from './jmap-client'; import { - assertSetResult, batched, parseHoldLimit, requireMethodResult, ScheduleTooLateError, SubmissionQuotaError, + assertSetResult, batched, failedIdsOf, JMAPSetError, NoLongerScheduledError, parseHoldLimit, requireMethodResult, + ScheduleTooLateError, } from './jmap-result'; import { keywordPointer, mailboxPointer } from './patch-pointer'; import { destroyEmailSubmissions, isStalwartAccount, makeRoomForSubmission, readSubmissionDestroy, - submissionCreateError, submissionDestroyCall, SUBMISSION_ONLY_USING, SUBMISSION_SCAN_MAX_PAGES, + submissionCreateError, submissionDestroyCall, submissionQuotaError, SUBMISSION_ONLY_USING, + SUBMISSION_SCAN_MAX_PAGES, } from './submissions'; import { CAPABILITIES } from './types'; import type { Attachment, Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types'; @@ -15,6 +17,7 @@ import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threadin import { buildMdnMessage, type MdnOptions } from '../lib/mdn'; import { hasTruncatedDisplayedBody } from '../lib/email-body'; import { stalwartStateWentBack } from './stalwart-state'; +import { isTransientNetworkError } from '../lib/network-error'; export const EMAIL_LIST_PROPERTIES = [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', @@ -117,11 +120,14 @@ async function emailSetBatched( const out = { updated: [] as string[], destroyed: [] as string[] }; const size = maxInSet(); let refusal: unknown = null; + // Every batch's refusals, named on the error thrown: the rest was written. + const failed: string[] = []; const check = (body: Record, ids: string[]) => { try { assertSetResult(body, ids, what); } catch (err) { refusal ??= err; + failed.push(...(failedIdsOf(err) ?? [])); } }; const updateEntries = Object.entries(args.update ?? {}); @@ -139,7 +145,10 @@ async function emailSetBatched( check(body, slice); out.destroyed.push(...((body.destroyed as string[] | undefined) ?? [])); } - if (refusal) throw refusal; + if (refusal) { + if (refusal instanceof JMAPSetError) refusal.failedIds = failed; + throw refusal; + } return out; } @@ -389,6 +398,28 @@ export async function markMailboxAsRead(mailboxId: string, accountIdOverride?: s return total; } +/** + * Mark every message in each folder read ("Mark folder as read" on a folder + * and its subfolders). A folder the server refuses - a read-only share, a + * message at the keyword limit - doesn't leave the folders after it unread: + * the first refusal is thrown once every folder was tried. A lost connection + * stops at once. Resolves with the number of messages marked. + */ +export async function markMailboxesAsRead(mailboxes: Array<{ id: string; accountId?: string }>): Promise { + let total = 0; + let refusal: unknown = null; + for (const { id, accountId } of mailboxes) { + try { + total += await markMailboxAsRead(id, accountId); + } catch (err) { + if (isTransientNetworkError(err)) throw err; + refusal ??= err; + } + } + if (refusal) throw refusal; + return total; +} + function buildMailboxQueryFilter( mailboxId: string | undefined, userFilter: Record | undefined, @@ -1584,8 +1615,8 @@ function buildEmailCreate(email: OutgoingEmail): Record { * `SubmissionQuotaError`, which the UI explains instead of showing the * server's "please delete some". */ -function submissionError(err: { description?: string; type?: string }, fallback: string): Error { - if (err.type === 'overQuota') return new SubmissionQuotaError(err.description); +function submissionError(err: { description?: string; type?: string }, fallback: string, accountId: string): Error { + if (err.type === 'overQuota') return submissionQuotaError(err.description, accountId); const limit = parseHoldLimit(err.description); if (limit === null) return new Error(err.description ?? err.type ?? fallback); jmapClient.learnHoldLimit(limit); @@ -1726,7 +1757,7 @@ export async function sendEmail( if (methodName === 'EmailSubmission/set') { const notCreated = (result as { notCreated?: Record }).notCreated?.['sub-1']; if (notCreated) { - failure = submissionError(notCreated, 'Failed to submit message'); + failure = submissionError(notCreated, 'Failed to submit message', accountId); break; } const created = (result as { created?: Record }).created?.['sub-1']; @@ -2046,20 +2077,38 @@ export async function rescheduleScheduledSend( }, }; - const createReplacement = () => jmapClient.request( - [['EmailSubmission/set', { accountId, create: { replacement: create } }, '0']], + // HOLDFOR counts from when the server takes the request. + const sendAt = Date.now() + holdFor * 1000; + const createReplacement = (seconds: number) => jmapClient.request( + [['EmailSubmission/set', { + accountId, + create: { + replacement: { + ...create, + envelope: { ...create.envelope, mailFrom: { ...create.envelope.mailFrom, parameters: { HOLDFOR: String(seconds) } } }, + }, + }, + }, '0']], [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], ); - let res = await createReplacement(); + let res = await createReplacement(holdFor); // A full submission quota (Stalwart 1.0): make room and try once more. The // original is still pending, so making room never touches it. if (submissionCreateError(res, 'replacement')?.type === 'overQuota') { await makeRoomForSubmission(accountId); - res = await createReplacement(); + // Making room can take a while - a prune, a walk through the change log - + // and meanwhile the original may have gone out: its time came, or it was + // "Send now" of a message due any moment. A replacement would then send + // it a second time. A read that fails throws, and nothing changes. + const original = await readSubmissionUndoStatus(scheduled.emailSubmissionId, accountId); + if (original !== 'pending') throw new NoLongerScheduledError(original === 'final'); + // The time spent is taken off the hold; a moment already past goes out + // at once. + res = await createReplacement(Math.max(1, Math.ceil((sendAt - Date.now()) / 1000))); } const body = requireMethodResult(res, '0', 'EmailSubmission/set'); const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined; - if (notCreated) throw submissionError(notCreated, 'Failed to reschedule'); + if (notCreated) throw submissionError(notCreated, 'Failed to reschedule', accountId); const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined; if (!created?.id) throw new Error('Failed to reschedule: the server returned no submission'); @@ -2084,15 +2133,24 @@ export async function rescheduleScheduledSend( return { emailSubmissionId: created.id, sendAt: created.sendAt }; } +/** + * A submission's `undoStatus`; undefined when the server no longer has the + * record. Throws when it can't be read. + */ +async function readSubmissionUndoStatus(emailSubmissionId: string, accountId: string): Promise { + const res = await jmapClient.request( + [['EmailSubmission/get', { accountId, ids: [emailSubmissionId], properties: ['id', 'undoStatus'] }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], + ); + const list = requireMethodResult(res, '0', 'EmailSubmission/get').list as + Array<{ id?: string; undoStatus?: string }> | undefined; + return list?.find((s) => s.id === emailSubmissionId)?.undoStatus; +} + /** A submission's `undoStatus`, or undefined when it can't be read. */ async function submissionUndoStatus(emailSubmissionId: string, accountId: string): Promise { try { - const res = await jmapClient.request( - [['EmailSubmission/get', { accountId, ids: [emailSubmissionId], properties: ['undoStatus'] }, '0']], - [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], - ); - const list = requireMethodResult(res, '0', 'EmailSubmission/get').list as Array<{ undoStatus?: string }> | undefined; - return list?.[0]?.undoStatus; + return await readSubmissionUndoStatus(emailSubmissionId, accountId); } catch { return undefined; } @@ -2173,7 +2231,7 @@ export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise>( export class JMAPSetError extends JMAPMethodError { /** The properties the server named (`invalidProperties`), if any. */ properties?: string[]; + /** + * The objects a /set of several left unchanged, when the thrower knows them: + * the server refuses objects one by one and applies the rest. + */ + failedIds?: string[]; constructor(type: string, description?: string, properties?: string[]) { super(type, description); if (properties?.length) this.properties = properties; } } +/** + * The objects a write of several left unchanged, when the error names them + * (JMAPSetError.failedIds); null when it doesn't say - a failed request, a + * refused method call - so any of them may be unchanged. + */ +export function failedIdsOf(error: unknown): string[] | null { + return error instanceof JMAPSetError && error.failedIds ? error.failedIds : null; +} + /** 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; @@ -105,11 +119,13 @@ 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 JMAPSetError( + const error = new JMAPSetError( err?.type ?? 'setError', `Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`, err?.properties, ); + error.failedIds = entries.map(([failed]) => failed); + throw error; } } @@ -122,12 +138,33 @@ export function assertSetResult( * the mailbox storage is full. */ export class SubmissionQuotaError extends JMAPMethodError { - constructor(description?: string) { + constructor( + /** The server's own description, if it gave one. */ + readonly description?: string, + /** + * The submission account is on Stalwart (`urn:stalwart:jmap`), whose + * limit is the count of submission records. Another server's overQuota + * may stand for any sending limit. + */ + readonly countsRecords = false, + ) { super('overQuota', description || 'There are too many email submissions'); this.name = 'SubmissionQuotaError'; } } +/** + * "Send now" or a reschedule found the original submission no longer pending + * after making room for its replacement, which can take a while: it went out + * (`sent`), or was cancelled or removed elsewhere. No replacement was made. + */ +export class NoLongerScheduledError extends Error { + constructor(readonly sent: boolean) { + super(sent ? 'The scheduled message was already sent' : 'The message is no longer scheduled'); + this.name = 'NoLongerScheduledError'; + } +} + /** A scheduled send later than the server's hold limit. */ export class ScheduleTooLateError extends Error { maxSeconds?: number; diff --git a/src/api/submissions.ts b/src/api/submissions.ts index ef5c62a7..eec9c8de 100644 --- a/src/api/submissions.ts +++ b/src/api/submissions.ts @@ -8,7 +8,7 @@ // any more are ever destroyed. Port of the webmail's client (48382170). import { jmapClient } from './jmap-client'; -import { batched, requireMethodResult } from './jmap-result'; +import { SubmissionQuotaError, batched, requireMethodResult } from './jmap-result'; import { CAPABILITIES } from './types'; import type { JMAPMethodCall, JMAPResponseBody } from './types'; @@ -17,11 +17,18 @@ export const SUBMISSION_ONLY_USING = [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION /** How often the background prune may sweep one account's records. */ const PRUNE_INTERVAL_MS = 10 * 60 * 1000; +/** + * How long an account where making room found nothing that could go is not + * searched again; see makeRoomForSubmission. + */ +const ROOM_RECHECK_MS = 5 * 60 * 1000; /** Upper bound on the pages one prune, change-log walk or scheduled list reads. */ export const SUBMISSION_SCAN_MAX_PAGES = 40; /** When each account's records were last pruned, keyed by server and account. */ const prunedAt = new Map(); +/** Accounts where making room found nothing to free, until when (epoch ms). */ +const roomRecheckAt = new Map(); function pruneKey(accountId: string): string { return `${jmapClient.serverUrl ?? ''}|${accountId}`; @@ -30,6 +37,7 @@ function pruneKey(accountId: string): string { /** Test hook. */ export function resetSubmissionPrune(): void { prunedAt.clear(); + roomRecheckAt.clear(); } /** @@ -41,6 +49,16 @@ export function isStalwartAccount(accountId: string): boolean { return jmapClient.hasAccountCapability('urn:stalwart:jmap', accountId); } +/** + * The error for a submission create the account refused with overQuota, + * noting whether the account is on Stalwart: its limit is the count of + * submission records, which only an administrator can raise once making room + * freed nothing. Another server's may stand for any sending limit. + */ +export function submissionQuotaError(description: string | undefined, accountId: string): SubmissionQuotaError { + return new SubmissionQuotaError(description, isStalwartAccount(accountId)); +} + export interface SubmissionStatus { id: string; sendAt?: string | null; @@ -167,6 +185,20 @@ async function pruneSubmissionAccount(accountId: string, force: boolean): Promis } } +/** The records in the account's change log that can go (see submissionIdsFromChangeLog). */ +async function findOlderPrunableSubmissions(accountId: string): Promise { + const ids = await submissionIdsFromChangeLog(accountId); + const now = Date.now(); + const prunable: string[] = []; + for (const slice of batched(ids, Math.min(500, jmapClient.getMaxObjectsInGet()))) { + const res = await jmapClient.request([ + ['EmailSubmission/get', { accountId, ids: slice, properties: ['id', 'sendAt', 'undoStatus'] }, '0'], + ], SUBMISSION_ONLY_USING); + prunable.push(...prunableSubmissionIds(submissionList(res, '0'), now)); + } + return prunable; +} + /** * Page through the records the query can see that might go - sendAt passed * or canceled, a superset either way - and let EmailSubmission/get decide. @@ -217,25 +249,39 @@ function submissionList(res: JMAPResponseBody, callId: string): SubmissionStatus * by default), and get and destroy by id work at any age, so they are found * there and pruned by the same rules. Never rejects: the caller retries the * create either way, since another device may have made room meanwhile. + * + * At a full quota every attempt ends up here - each send, reschedule and + * read receipt. When a search found nothing that can go, the account is left + * alone for five minutes: what is left is pending or out of reach, and + * looking again at once would cost the prune, the change-log walk and the + * gets once more for the same answer. */ export async function makeRoomForSubmission(accountId: string): Promise { - const freed = await pruneSubmissionAccount(accountId, true); - if (freed > 0) return; - try { - if (!isStalwartAccount(accountId)) return; - const ids = await submissionIdsFromChangeLog(accountId); - const now = Date.now(); - const prunable: string[] = []; - for (const slice of batched(ids, Math.min(500, jmapClient.getMaxObjectsInGet()))) { - const res = await jmapClient.request([ - ['EmailSubmission/get', { accountId, ids: slice, properties: ['id', 'sendAt', 'undoStatus'] }, '0'], - ], SUBMISSION_ONLY_USING); - prunable.push(...prunableSubmissionIds(submissionList(res, '0'), now)); + const key = pruneKey(accountId); + const recheckAt = roomRecheckAt.get(key); + if (recheckAt !== undefined && Date.now() < recheckAt) return; + const now = Date.now(); + prunedAt.set(key, now); + let found = 0; + let freed = 0; + let complete = true; + const prune = async (lookUp: () => Promise, what: string) => { + try { + const prunable = await lookUp(); + found += prunable.length; + if (prunable.length > 0) freed += (await destroyEmailSubmissions(prunable, accountId)).length; + } catch (err) { + complete = false; + console.warn(`[submissions] looking up ${what} of ${accountId} failed:`, err); } - if (prunable.length > 0) await destroyEmailSubmissions(prunable, accountId); - } catch (err) { - console.warn(`[submissions] looking up older submissions of ${accountId} failed:`, err); + }; + await prune(() => findPrunableSubmissions(accountId, now), 'recent submissions'); + if (freed === 0 && isStalwartAccount(accountId)) { + await prune(() => findOlderPrunableSubmissions(accountId), 'older submissions'); } + // A failed look-up proves nothing; only an answer of "nothing" counts. + if (complete && found === 0) roomRecheckAt.set(key, Date.now() + ROOM_RECHECK_MS); + else roomRecheckAt.delete(key); } /** diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index d207879f..41e70c9d 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -30,7 +30,7 @@ import { showUnifiedSection } from '../lib/unified-section'; import { generateAvatarColor, getAccountInitials } from '../lib/avatar-utils'; import { jmapClient } from '../api/jmap-client'; import { - markMailboxAsRead, emptyMailbox, createMailbox, updateMailbox, deleteMailbox, + markMailboxesAsRead, emptyMailbox, createMailbox, updateMailbox, deleteMailbox, } from '../api/email'; import { useTagCountsStore } from '../stores/tag-counts-store'; import { useNavigation } from '@react-navigation/native'; @@ -456,11 +456,12 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) setBusy(true); try { await work(); - await fetchMailboxes(); - if (affectsCurrent) await refreshEmails(); } catch (err) { Alert.alert(label, err instanceof Error ? err.message : String(err)); } finally { + // Also after a refusal: what did get written shows. + await fetchMailboxes().catch(() => undefined); + if (affectsCurrent) await refreshEmails().catch(() => undefined); setBusy(false); } }; @@ -471,12 +472,8 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) const affectsCurrent = !!currentMailboxId && ids.includes(currentMailboxId); void runFolderAction( t('mailbox_context_menu.toast_error_mark_read', 'Failed to mark as read'), - async () => { - for (const mb of targets) { - const ref = refFor(mb); - await markMailboxAsRead(ref.id, ref.accountId); - } - }, + // Past a folder the server refuses, reporting the refusal at the end. + () => markMailboxesAsRead(targets.map(refFor)), affectsCurrent, ); }; diff --git a/src/components/email/UnsubscribeBanner.tsx b/src/components/email/UnsubscribeBanner.tsx index a33c3e50..0184c5c6 100644 --- a/src/components/email/UnsubscribeBanner.tsx +++ b/src/components/email/UnsubscribeBanner.tsx @@ -11,6 +11,8 @@ import { useSettingsStore } from '../../stores/settings-store'; import { useEmailStore } from '../../stores/email-store'; import { sendEmail } from '../../api/email'; import { pruneEmailSubmissions } from '../../api/submissions'; +import { SubmissionQuotaError } from '../../api/jmap-result'; +import { sendFailureMessage } from '../../lib/send-failure-message'; import { isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe } from '../../lib/unsubscribe'; import type { ListHeaders } from '../../lib/email-headers'; import { findReceivingIdentity } from '../../lib/email-headers'; @@ -127,6 +129,10 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr } catch (err) { console.warn('[unsubscribe] failed', err); setState('error'); + // A full submission quota needs an administrator, not another try: say so. + if (err instanceof SubmissionQuotaError) { + Alert.alert(t('email_viewer.unsubscribe_banner.error', 'Unable to unsubscribe'), sendFailureMessage(err, t)); + } } }; diff --git a/src/lib/__tests__/contact-media.test.ts b/src/lib/__tests__/contact-media.test.ts index 1380a37a..3c36a291 100644 --- a/src/lib/__tests__/contact-media.test.ts +++ b/src/lib/__tests__/contact-media.test.ts @@ -53,6 +53,7 @@ import { inlineContactMediaForExport, isBlobMediaRejection, mediaForFormSave, + ContactMediaUnreadError, mediaForPhotoEdit, resetContactBlobMediaRefusals, supportsContactBlobMedia, @@ -175,6 +176,18 @@ describe('mediaForFormSave', () => { }); }); + it('refuses a new photo for a card whose media neither the form nor the server read gave (0.16 and 1.0)', () => { + // Opened from the contacts cache (stored without media) and the fresh read + // failed: a map of only the new photo would delete the logo and the rest. + expect(() => mediaForFormSave(undefined, null, { kind: 'new', uri: PNG_URI, mediaType: 'image/png' }, true)) + .toThrow(ContactMediaUnreadError); + // A new card has nothing on the server to lose. + expect(mediaForFormSave(undefined, null, { kind: 'new', uri: PNG_URI, mediaType: 'image/png' }, false)?.photoKey) + .toBe('photo'); + // Removing a photo the form never showed changes nothing either way. + expect(mediaForFormSave(undefined, null, { kind: 'removed' }, true)).toBeNull(); + }); + it('does nothing when a photo the form never showed is removed', () => { const current = { media: { photo: { blobId: 'b-photo', kind: 'photo' } } }; expect(mediaForFormSave(undefined, current, { kind: 'removed' })).toBeNull(); diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 5f0c6a36..ee4225f7 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -623,6 +623,15 @@ describe('setupPushNotifications with ACL-shared accounts on Stalwart 1.0', () = expect(await AsyncStorage.getItem(REFUSED_KEY)).toBeNull(); }); + it('ignores accounts remembered as refused while the server was on 0.16', async () => { + await AsyncStorage.setItem(REFUSED_KEY, JSON.stringify(['team'])); + + await setupPushNotifications({ relayBaseUrl: RELAY }); + + expect(createMock).toHaveBeenCalledTimes(1); + expect(mapKeys(createMock.mock.calls[0][0].emailPush)).toEqual(['jmap-primary', 'team']); + }); + 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' }; @@ -776,6 +785,109 @@ describe('mailAccountsWithoutPush', () => { expect(await mailAccountsWithoutPush(['jmap-primary'])).toEqual([]); expect(await mailAccountsWithoutPush([])).toEqual([]); }); + + it('takes the emailpush capability as the whole answer on Stalwart 1.0 (principals:owner)', async () => { + const owner = (principalId: string) => ({ + 'urn:ietf:params:jmap:principals:owner': { accountIdForPrincipal: 'jmap-primary', principalId }, + }); + (jmapClient as { currentSession: unknown }).currentSession = { + capabilities: emailPush, + accounts: { + 'jmap-primary': account(true, { ...owned, ...emailPush, ...owner('jmap-primary') }), + // A group with mail only: no calendars, contacts or files to tell + // that the user belongs to it. + 'mail-group': account(false, { ...emailPush, ...owner('mail-group') }), + }, + }; + expect(await mailAccountsWithoutPush(['mail-group'])).toEqual([]); + // Nor does a refusal remembered while the server was on 0.16 count. + await AsyncStorage.setItem(REFUSED_KEY, JSON.stringify(['mail-group'])); + expect(await mailAccountsWithoutPush(['mail-group'])).toEqual([]); + }); +}); + +describe('setupPushNotifications at the server\'s subscription limit', () => { + const overQuota = () => new JMAPSetError('overQuota', 'There are too many push subscriptions, please delete some.'); + const recorded = (expiresInDays: number) => ({ + id: 'existing', + deviceClientId: OUR_DCID, + expires: new Date(Date.now() + expiresInDays * 86400000).toISOString(), + types: ['EmailDelivery'], + }); + const verifyMock = verifyPushSubscription as ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + await AsyncStorage.clear(); + await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID); + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([recorded(80)]); + installFetch({}); + }); + + afterEach(() => { + listMock.mockImplementation(async () => []); + createMock.mockImplementation(async () => CREATED); + updateMock.mockImplementation(async () => undefined); + }); + + it('a forced re-register frees the old record\'s place and tries once more', async () => { + createMock.mockRejectedValueOnce(overQuota()); + + const result = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }); + + expect(result).toEqual({ subscriptionId: 'new-server-id', verified: true }); + expect(createMock).toHaveBeenCalledTimes(2); + const oldDestroys = destroyMock.mock.calls.flatMap((c, i) => (c[0] === 'existing' ? [destroyMock.mock.invocationCallOrder[i]] : [])); + // Once, between the refused create and the retry - not again after verification. + expect(oldDestroys).toHaveLength(1); + expect(oldDestroys[0]).toBeGreaterThan(createMock.mock.invocationCallOrder[0]); + expect(oldDestroys[0]).toBeLessThan(createMock.mock.invocationCallOrder[1]); + expect(verifyMock).toHaveBeenCalledWith('new-server-id', 'CODE'); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id'); + }); + + it('retries only once, and says why when the limit still refuses', async () => { + createMock.mockImplementation(async () => { + throw overQuota(); + }); + + const err = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }).catch((e) => e); + + expect(err).toBeInstanceOf(PushSetupError); + expect(err.phase).toBe('jmap'); + expect(err.message).toContain('too many push registrations on the mail server'); + expect(createMock).toHaveBeenCalledTimes(2); + // The old record is gone, and so is its key. + expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('keeps the old record when its removal fails', async () => { + createMock.mockRejectedValueOnce(overQuota()); + destroyMock.mockRejectedValueOnce(new Error('Network request failed')); + + const err = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }).catch((e) => e); + + expect(err).toBeInstanceOf(PushSetupError); + expect(createMock).toHaveBeenCalledTimes(1); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing'); + }); + + it('never gives up a working record for a replacement the user did not ask for', async () => { + // The refresh is refused, so the setup replaces the record by itself. + listMock.mockResolvedValue([recorded(1)]); + updateMock.mockRejectedValue(new JMAPSetError('invalidProperties')); + createMock.mockImplementation(async () => { + throw overQuota(); + }); + + const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e); + + expect(err.phase).toBe('jmap'); + expect(createMock).toHaveBeenCalledTimes(1); + expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('existing'); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing'); + }); }); describe('setupPushNotifications over UnifiedPush', () => { diff --git a/src/lib/__tests__/send-failure-message.test.ts b/src/lib/__tests__/send-failure-message.test.ts index 0c72152c..8cced714 100644 --- a/src/lib/__tests__/send-failure-message.test.ts +++ b/src/lib/__tests__/send-failure-message.test.ts @@ -1,15 +1,19 @@ import { describe, it, expect } from 'vitest'; import { RequestTimeoutError } from '../../api/jmap-client'; -import { JMAPMethodError, ScheduleTooLateError, SubmissionQuotaError } from '../../api/jmap-result'; +import { + JMAPMethodError, NoLongerScheduledError, ScheduleTooLateError, SubmissionQuotaError, +} from '../../api/jmap-result'; import { sendFailureMessage } from '../send-failure-message'; // The English fallback stands in for the catalog. -const t = (key: string, fallback?: string) => fallback ?? key; +const t = (key: string, fallback?: string, params?: Record) => + (fallback ?? key).replace(/\{(\w+)\}/g, (_, name: string) => String(params?.[name] ?? `{${name}}`)); describe('sendFailureMessage', () => { it('explains a full submission quota instead of showing Stalwart 1.0\'s "please delete some"', () => { const quota = new SubmissionQuotaError( 'There are too many email submissions, please delete some before adding a new one.', + true, ); expect(quota).toBeInstanceOf(JMAPMethodError); expect(quota.type).toBe('overQuota'); @@ -30,3 +34,17 @@ describe('sendFailureMessage', () => { expect(sendFailureMessage('boom', t)).toBe('Failed to send email'); }); }); + +describe('sendFailureMessage beyond Stalwart and around a reschedule', () => { + it('gives another server\'s overQuota a generic sentence with its own words (no urn:stalwart:jmap)', () => { + expect(sendFailureMessage(new SubmissionQuotaError('Daily sending limit reached', false), t)) + .toBe('The server refused to send the message because a limit was reached: Daily sending limit reached'); + expect(sendFailureMessage(new SubmissionQuotaError(undefined, false), t)) + .toBe('The server refused to send the message because a limit was reached: overQuota'); + }); + + it('says a rescheduled message already went out, or is no longer scheduled', () => { + expect(sendFailureMessage(new NoLongerScheduledError(true), t)).toBe('This message was already sent.'); + expect(sendFailureMessage(new NoLongerScheduledError(false), t)).toBe('This message is no longer scheduled.'); + }); +}); diff --git a/src/lib/contact-media.ts b/src/lib/contact-media.ts index 1341ab38..81ef4eb8 100644 --- a/src/lib/contact-media.ts +++ b/src/lib/contact-media.ts @@ -93,23 +93,39 @@ export function mediaForPhotoEdit( return { media, photoKey }; } +/** + * A new photo for a card on the server that neither the form nor a fresh read + * knows the media of: a `media` map built from nothing would delete the + * card's other entries - a logo, other photos - since a map replaces them all. + */ +export class ContactMediaUnreadError extends Error { + constructor() { + super("The contact's photos could not be read"); + this.name = 'ContactMediaUnreadError'; + } +} + /** * What the contact form saves in `media`, and the key of a newly picked * photo; null when `media` stays out of the save, which keeps every entry on * the server (an unchanged photo). * * `known` is the card's media as the form got it, `current` as the server has - * it now (null when it couldn't be read). The contacts cache is stored without - * media, so a card opened from it before a refresh knows none: a new photo - * then replaces the photo the server has, and removing one the form never - * showed changes nothing. + * it now (null when it couldn't be read, or for a new card). The contacts + * cache is stored without media, so a card opened from it before a refresh + * knows none: a new photo then replaces the photo the server has, and + * removing one the form never showed changes nothing. When the server's copy + * of such a card (`onServer`) couldn't be read either, a new photo throws + * ContactMediaUnreadError instead of replacing every entry. */ export function mediaForFormSave( known: Record | undefined, current: Pick | null, edit: PhotoEdit, + onServer = false, ): { media: Record | null; photoKey?: string } | null { if (edit.kind === 'unchanged') return null; + if (edit.kind === 'new' && onServer && known === undefined && current === null) throw new ContactMediaUnreadError(); const formCopy = edit.kind === 'new' ? known ?? current?.media ?? {} : known ?? {}; const shownKey = findContactPhotoEntry({ media: formCopy })?.[0]; if (edit.kind === 'removed' && !shownKey) return null; diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index ec3028ff..fec7f4c2 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -262,6 +262,25 @@ function accountListsEmailPush(accountId: string): boolean { return !!caps && EMAIL_PUSH_CAPABILITY in caps; } +// RFC 9670. Stalwart 1.0 lists it in every account's accountCapabilities, +// 0.16 in none. +const PRINCIPALS_OWNER_CAPABILITY = 'urn:ietf:params:jmap:principals:owner'; + +/** + * Whether the account capabilities name the accounts a subscription reaches: + * Stalwart 1.0 lists emailpush on the user's own account and their groups + * only. 0.16.16 to 0.16.23 list it on every account, ACL shares included, and + * 0.16.6 to 0.16.15 on none, so there the app has to work it out. + */ +function emailPushCapabilityIsExact(): boolean { + try { + const caps = jmapClient.currentSession?.accounts?.[jmapClient.accountId]?.accountCapabilities; + return !!caps && PRINCIPALS_OWNER_CAPABILITY in caps; + } catch { + return false; + } +} + /** * 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 @@ -444,8 +463,9 @@ function withoutAccounts( * 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 + * 1.0 leaves emailpush out of exactly those accounts' capabilities, and that + * settles it: a group without calendars, contacts or files looks unowned, and + * a refusal remembered under 0.16 could only keep a group out. 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. @@ -459,6 +479,7 @@ export async function mailAccountsWithoutPush(accountIds: Iterable): Pro } const others = Array.from(new Set(accountIds)).filter((id) => id && id !== primary); if (others.length === 0) return []; + if (emailPushCapabilityIsExact()) return others.filter((id) => !accountListsEmailPush(id)); if (serverSupportsEmailPush()) { const withoutEmailPush = others.filter((id) => !accountListsEmailPush(id)); if (withoutEmailPush.length > 0) return withoutEmailPush; @@ -1018,7 +1039,10 @@ async function setupPushNotificationsInner( // A forced re-registration re-learns which accounts the server accepts in // the emailPush map. if (params.forceRecreate) await AsyncStorage.removeItem(emailPushRefusedKey(accountId)); - const refusedBefore = await readRefusedEmailPushAccounts(accountId); + // On 1.0 the capabilities the map is built from already leave out every + // account the server refuses; a list remembered under 0.16 could only keep + // a group out. + const refusedBefore = emailPushCapabilityIsExact() ? [] : await readRefusedEmailPushAccounts(accountId); const emailPush = serverSupportsEmailPush() ? withoutAccounts(await buildEmailPushConfig(), refusedBefore) : null; @@ -1099,16 +1123,32 @@ async function setupPushNotificationsInner( let serverAssignedId: string; let serverExpires: string | null; + const create = () => writeWithEmailPush(emailPush, (filter) => + createPushSubscription({ + deviceClientId, + url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), + types: [...PUSH_TYPES], + expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), + ...(filter ? { emailPush: filter } : {}), + }), + ); try { - const created = await writeWithEmailPush(emailPush, (filter) => - createPushSubscription({ - deviceClientId, - url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), - types: [...PUSH_TYPES], - expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), - ...(filter ? { emailPush: filter } : {}), - }), - ); + let created: Awaited>; + try { + created = await create(); + } catch (err) { + // Stalwart caps an account's subscriptions (15 by default) and counts + // the one being replaced, so at the cap a forced re-register could never + // create its replacement. The user asked for a new record: free the old + // one's place and try once more. Should that fail too, push is off and + // the error says why. + if (!params.forceRecreate || !replacedServerId || !isJMAPSetError(err) || err.type !== 'overQuota') throw err; + logPhase('jmap', 'subscription limit reached, replacing the old record first'); + await destroyPushSubscription(replacedServerId); + await AsyncStorage.removeItem(subKey); + replacedServerId = null; + created = await create(); + } serverAssignedId = created.result.id; serverExpires = created.result.expires; await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); diff --git a/src/lib/send-failure-message.ts b/src/lib/send-failure-message.ts index 5a575c4f..5a119bab 100644 --- a/src/lib/send-failure-message.ts +++ b/src/lib/send-failure-message.ts @@ -1,26 +1,41 @@ import { RequestTimeoutError } from '../api/jmap-client'; -import { ScheduleTooLateError, SubmissionQuotaError } from '../api/jmap-result'; +import { NoLongerScheduledError, ScheduleTooLateError, SubmissionQuotaError } from '../api/jmap-result'; +import type { MessageParams } from '../i18n/format'; -type Translate = (key: string, fallback?: string) => string; +type Translate = (key: string, fallback?: string, params?: MessageParams) => string; /** * What to tell the user about a send (or reschedule) that failed (webmail * send-failure-message). Most errors carry the server's own description; * these are the ones it would misrepresent or leave unexplained: - * - a full submission quota: Stalwart asks the user to "delete some", but - * the app has already removed every record it may, so only an - * administrator can help; + * - a full submission quota on Stalwart: it asks the user to "delete some", + * but the app has already removed every record it may, so only an + * administrator can help. Another server's overQuota may stand for any + * sending limit, so its own description goes with a generic sentence; * - a timeout, which is not a clean failure: the message may have gone out * with only the answer lost, so point at Sent rather than invite a re-send * (#702); - * - a hold beyond the server's limit. + * - a hold beyond the server's limit; + * - "Send now" or a reschedule that found the message gone out, or no longer + * scheduled, while it made room for the replacement. */ export function sendFailureMessage(error: unknown, t: Translate): string { if (error instanceof SubmissionQuotaError) { - return t( - 'email_composer.submission_quota', - 'The server has too many sent-message records for this account. Bulwark removed the ones it could; if this keeps happening, ask your administrator to raise the email submission limit.', - ); + return error.countsRecords + ? t( + 'email_composer.submission_quota', + 'The server has too many sent-message records for this account. Bulwark removed the ones it could; if this keeps happening, ask your administrator to raise the email submission limit.', + ) + : t( + 'email_composer.submission_limit', + 'The server refused to send the message because a limit was reached: {reason}', + { reason: error.description || error.type }, + ); + } + if (error instanceof NoLongerScheduledError) { + return error.sent + ? t('scheduled.already_sent', 'This message was already sent.') + : t('scheduled.no_longer_scheduled', 'This message is no longer scheduled.'); } if (error instanceof RequestTimeoutError) { return t( diff --git a/src/screens/ContactFormScreen.tsx b/src/screens/ContactFormScreen.tsx index 54ce4b6e..a46c6e1a 100644 --- a/src/screens/ContactFormScreen.tsx +++ b/src/screens/ContactFormScreen.tsx @@ -21,7 +21,7 @@ import { stringToPartialDate, type ContactPhotoRef, } from '../lib/contact-utils'; import { blankForm, contactToForm, formToPatch, type FormState } from '../lib/contact-form'; -import { mediaForFormSave } from '../lib/contact-media'; +import { ContactMediaUnreadError, mediaForFormSave } from '../lib/contact-media'; import { splitMailbox } from '../lib/rfc5322-mailbox'; import Dialog from '../components/Dialog'; import ContactPhoto from '../components/contacts/ContactPhoto'; @@ -389,7 +389,7 @@ export default function ContactFormScreen() { let photoKey: string | undefined; if (form.photo.kind !== 'unchanged') { const current = existing ? await fetchContactMedia(existing.id) : null; - const edited = mediaForFormSave(existing?.media, current, form.photo); + const edited = mediaForFormSave(existing?.media, current, form.photo, !!existing); if (edited) { // `null` when no entry is left: it deletes them on the server. Object.assign(patch, { media: edited.media }); @@ -410,7 +410,9 @@ export default function ContactFormScreen() { asGroup ? t('contacts.groups.save_failed', 'Failed to save group') : t('contacts.form.save_failed', 'Failed to save contact'), - err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'), + err instanceof ContactMediaUnreadError + ? t('contacts.form.photo_media_unread', "The contact's photos could not be read from the server, so nothing was saved. Try again.") + : err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'), ); } finally { setSaving(false); diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index 09aff521..085c3618 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -16,6 +16,7 @@ import { type ScheduledEmail, } from '../api/email'; import { jmapClient, ScheduleTooLateError, SubmissionQuotaError } from '../api/jmap-client'; +import { NoLongerScheduledError } from '../api/jmap-result'; import { sendFailureMessage } from '../lib/send-failure-message'; import { useEmailStore } from '../stores/email-store'; import { useLocaleStore } from '../stores/locale-store'; @@ -92,6 +93,13 @@ export default function ScheduledScreen({ navigation }: Props) { Alert.alert(failTitle, sendFailureMessage(e, t)); return; } + // Making room for the replacement took a while, and the message went + // out, or was cancelled elsewhere, meanwhile: nothing was changed. + if (e instanceof NoLongerScheduledError) { + Alert.alert(failTitle, sendFailureMessage(e, t)); + void load(); + return; + } Alert.alert(failTitle, e instanceof Error ? e.message : String(e)); } finally { setBusyId(null); diff --git a/src/stores/__tests__/email-store-all-folders.test.ts b/src/stores/__tests__/email-store-all-folders.test.ts index f376861d..f3d4ad57 100644 --- a/src/stores/__tests__/email-store-all-folders.test.ts +++ b/src/stores/__tests__/email-store-all-folders.test.ts @@ -341,6 +341,37 @@ describe('opening and acting on "All folders" rows (#1082)', () => { expect(server.current!.accounts.team.find((e) => e.id === 'm1')!.keywords).toEqual({ $seen: true }); }); + it('reads the own rows of a mixed selection when the team account refuses, then reports it', async () => { + await search('zephyr'); + // Every write to the team account fails (a share that turned read-only). + server.current!.failing.set('team', 'forbidden'); + + const err = await useEmailStore.getState() + .setKeywordForEmails(['team:m1', 'c:o1', 'c:o3'], '$seen', true) + .catch((e: unknown) => e); + + expect(err).toMatchObject({ type: 'forbidden' }); + // The own account was still written after the team account refused. + expect(server.current!.callsOf('Email/set').map(([, a]) => a.accountId)).toEqual(['team', 'c']); + expect(server.current!.accounts.c.filter((e) => e.keywords.$seen).map((e) => e.id)).toEqual(['o1', 'o3']); + const seen = (key: string) => !!useEmailStore.getState().emails.find((e) => rowKeyOf(e) === key)!.keywords.$seen; + expect([seen('team:m1'), seen('c:o1'), seen('c:o3')]).toEqual([false, true, true]); + }); + + it('shows only the messages the server updated when it refuses some of them', async () => { + await search('zephyr'); + server.current!.refusing.set('team', new Set(['m2'])); + + const err = await useEmailStore.getState() + .setKeywordForEmails(['team:m1', 'team:m2', 'c:o1'], '$flagged', true) + .catch((e: unknown) => e); + + expect(err).toMatchObject({ type: 'forbidden', failedIds: ['m2'] }); + const flagged = (key: string) => !!useEmailStore.getState().emails.find((e) => rowKeyOf(e) === key)!.keywords.$flagged; + expect([flagged('team:m1'), flagged('team:m2'), flagged('c:o1')]).toEqual([true, false, true]); + expect(server.current!.accounts.team.find((e) => e.id === 'm2')!.keywords).toEqual({}); + }); + it('stars a team row in the team account', async () => { await search('zephyr'); await useEmailStore.getState().toggleStar('team:m3', true); diff --git a/src/stores/__tests__/fake-jmap-server.ts b/src/stores/__tests__/fake-jmap-server.ts index ce744e4e..58cee7eb 100644 --- a/src/stores/__tests__/fake-jmap-server.ts +++ b/src/stores/__tests__/fake-jmap-server.ts @@ -38,6 +38,8 @@ export function createFakeJmap( const requests: JMAPMethodCall[][] = []; /** JMAP account id → error type every call for it is answered with. */ const failing = new Map(); + /** JMAP account id → messages whose Email/set updates it refuses as `forbidden`. */ + const refusing = new Map>(); const request = async (calls: JMAPMethodCall[]) => { requests.push(calls); @@ -90,9 +92,14 @@ export function createFakeJmap( response = ['Thread/get', { accountId, list, state: 't', notFound: [] }, callId]; } else if (name === 'Email/set') { const updated: Record = {}; + const notUpdated: Record = {}; for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { const email = store.find((e) => e.id === id); if (!email) continue; + if (refusing.get(accountId)?.has(id)) { + notUpdated[id] = { type: 'forbidden' }; + continue; + } for (const [path, value] of Object.entries(patch)) { if (path === 'mailboxIds') email.mailboxIds = { ...(value as Record) }; else if (path === 'keywords') email.keywords = { ...(value as Record) }; @@ -107,7 +114,13 @@ export function createFakeJmap( } const destroyed = ((args.destroy ?? []) as string[]).filter((id) => store.some((e) => e.id === id)); accounts[accountId] = store.filter((e) => !destroyed.includes(e.id)); - response = ['Email/set', { accountId, updated, destroyed, newState: 'n' }, callId]; + response = ['Email/set', { + accountId, + updated, + ...(Object.keys(notUpdated).length > 0 ? { notUpdated } : {}), + destroyed, + newState: 'n', + }, callId]; } else { response = ['error', { type: 'unknownMethod' }, callId]; } @@ -123,5 +136,5 @@ export function createFakeJmap( /** The calls of `name` sent so far. */ const callsOf = (name: string) => calls().filter(([n]) => n === name); - return { request, requests, calls, callsOf, failing, accounts }; + return { request, requests, calls, callsOf, failing, refusing, accounts }; } diff --git a/src/stores/__tests__/send-undo-store.test.ts b/src/stores/__tests__/send-undo-store.test.ts index c7047daf..8f8039e4 100644 --- a/src/stores/__tests__/send-undo-store.test.ts +++ b/src/stores/__tests__/send-undo-store.test.ts @@ -19,7 +19,7 @@ vi.mock('../../api/submissions', () => ({ import * as emailApi from '../../api/email'; import * as submissions from '../../api/submissions'; -import { SubmissionQuotaError } from '../../api/jmap-result'; +import { NoLongerScheduledError, SubmissionQuotaError } from '../../api/jmap-result'; import type { Email, Mailbox } from '../../api/types'; import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../send-undo-store'; import { useToastStore } from '../toast-store'; @@ -152,6 +152,7 @@ describe('send-undo-store', () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined); api.rescheduleScheduledSend.mockRejectedValueOnce(new SubmissionQuotaError( 'There are too many email submissions, please delete some before adding a new one.', + true, )); useSendUndoStore.getState().setPending(entry()); @@ -166,6 +167,22 @@ describe('send-undo-store', () => { expect(useSendUndoStore.getState().pending).not.toBeNull(); }); + it('drops the entry when the message went out while send now made room (Stalwart 1.0)', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + api.rescheduleScheduledSend.mockRejectedValueOnce(new NoLongerScheduledError(true)); + useSendUndoStore.getState().setPending(entry()); + + await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(false); + + expect(useToastStore.getState().toasts).toMatchObject([{ + type: 'error', + title: 'Could not send now', + message: 'This message was already sent.', + }]); + // Nothing is left to undo. + expect(useSendUndoStore.getState()).toMatchObject({ pending: null, busy: false }); + }); + it('releases the held message from the account holding it', async () => { useSendUndoStore.getState().setPending(entry({ accountId: 'team' })); await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(true); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index ce0bf6cf..04d4cb76 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -37,7 +37,8 @@ import { import { applyOwnWritesToList, ownEmailWritesBetween, whenOwnWritesSettled } from '../api/own-writes'; import { provideLoadedMailboxes } from '../lib/mailbox-source'; import { useNetworkStore } from './network-store'; -import { JMAPMethodError } from '../api/jmap-result'; +import { JMAPMethodError, failedIdsOf } from '../api/jmap-result'; +import { isTransientNetworkError } from '../lib/network-error'; import { mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox, findArchiveMailbox, findTrashMailbox, ownMailboxes, } from '../lib/mailbox-tree'; @@ -1927,6 +1928,11 @@ export const useEmailStore = create()( // message goes to the account it names. const groups = viewed ? [{ accountId: viewed.accountId, emails: targets }] : groupByAccount(state, targets); const patch = { [token]: on ? true : null }; + // Messages the server refused, as `${account}:${id}`: they keep their + // keywords here, and the first refusal is thrown once the rest is shown. + const refused = new Set(); + const refusedKey = (accountId: string | undefined, id: string) => `${accountId ?? ''}:${id}`; + let refusal: unknown = null; await applyOrQueueBatch( groups.flatMap(({ accountId, emails }) => emails.map((e): OutboxOp => ({ kind: 'keywords', @@ -1935,12 +1941,26 @@ export const useEmailStore = create()( patch, }))), async () => { + // Every account the selection spans gets its write: one that refuses + // (a read-only share) leaves the others done. for (const { accountId, emails } of groups) { - await patchKeywordsForEmails(emails.map((e) => e.id), patch, accountId); + const ids = emails.map((e) => e.id); + try { + await patchKeywordsForEmails(ids, patch, accountId); + } catch (err) { + // A lost connection queues the whole batch instead. + if (isTransientNetworkError(err)) throw err; + refusal ??= err; + for (const id of failedIdsOf(err) ?? ids) refused.add(refusedKey(accountId, id)); + } } }, ); - const touched = new Set(listed ? targets.map(rowKeyOf) : []); + const written = groups.map(({ accountId, emails }) => ({ + accountId, + emails: emails.filter((e) => !refused.has(refusedKey(accountId, e.id))), + })); + const touched = new Set(listed ? written.flatMap(({ emails }) => emails.map(rowKeyOf)) : []); set({ emails: get().emails.map((e) => touched.has(rowKeyOf(e)) ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, @@ -1951,9 +1971,10 @@ export const useEmailStore = create()( ? { retainedIds: retain(get().retainedIds, [...touched]) } : {}), }); - for (const { accountId, emails } of groups) { + for (const { accountId, emails } of written) { for (const e of emails) patchCache(e.id, { keywords: patch }, accountId); } + if (refusal) throw refusal; }, undoLast: async () => { diff --git a/src/stores/send-undo-store.ts b/src/stores/send-undo-store.ts index fdac5985..f8f8570b 100644 --- a/src/stores/send-undo-store.ts +++ b/src/stores/send-undo-store.ts @@ -10,6 +10,7 @@ import { type SendEmailResult, } from '../api/email'; import { jmapClient } from '../api/jmap-client'; +import { NoLongerScheduledError } from '../api/jmap-result'; import { pruneEmailSubmissions } from '../api/submissions'; import type { EmailAddress, Mailbox } from '../api/types'; import { draftContextFromEmail } from '../lib/draft-context'; @@ -125,7 +126,9 @@ export const useSendUndoStore = create((set, get) => ({ // The message stays held and goes out when the delay ends; say why it // did not go now (a full submission quota, for one). toast.error(t('scheduled.send_now_failed', 'Could not send now'), sendFailureMessage(err, t)); - set({ busy: false }); + // Making room for the release took long enough for the message to go + // out, or be cancelled on another device: nothing is left to undo. + set(err instanceof NoLongerScheduledError ? { pending: null, busy: false } : { busy: false }); return false; } },