diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 5611f1c7..28c4edad 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -272,6 +272,9 @@ "recipient_copy": "نسخ العنوان", "recipient_move_to": "نقل إلى {field}", "recipient_remove": "إزالة", + "recipients_partly_refused": "رفض الخادم {recipients}. سيتلقى باقي المستلمين الرسالة.", + "recipients_partly_refused_title": "لم تُرسل إلى جميع المستلمين", + "recipients_refused": "لم تُرسل الرسالة: رفض الخادم {recipients}.", "remove_attachment": "إزالة المرفق", "save_draft": "حفظ المسودة", "save_failed_no_drafts": "لا يوجد مجلد مسودات لحفظ هذه المسودة فيه.", @@ -402,6 +405,8 @@ "show_less": "عرض أقل", "smime_unsupported": "موقّعة/مشفّرة باستخدام S/MIME - غير مدعوم على الجوال", "source_too_large": "الرسالة كبيرة جدًا بحيث لا يمكن عرضها كاملة. شارك المصدر الكامل بدلًا من ذلك.", + "still_scheduled": "لا تزال الرسالة مجدولة.", + "still_scheduled_at": "لا تزال الرسالة مجدولة للإرسال في {date}.", "thread_loading": "جارٍ تحميل المحادثة...", "undo_send_failed": "تعذّر التراجع عن الإرسال", "unsubscribe_banner": { diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 7a9de366..17c0ea7f 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -272,6 +272,9 @@ "recipient_copy": "Copia l'adreça", "recipient_move_to": "Mou a {field}", "recipient_remove": "Elimina", + "recipients_partly_refused": "El servidor ha rebutjat {recipients}. Els altres destinataris reben el missatge.", + "recipients_partly_refused_title": "No s'ha enviat a tothom", + "recipients_refused": "No s'ha enviat: el servidor ha rebutjat {recipients}.", "remove_attachment": "Elimina el fitxer adjunt", "save_draft": "Desa l'esborrany", "save_failed_no_drafts": "No hi ha cap carpeta Esborranys on desar aquest esborrany.", @@ -402,6 +405,8 @@ "show_less": "Mostra'n menys", "smime_unsupported": "Signat/xifrat amb S/MIME: no és compatible amb el mòbil", "source_too_large": "El missatge és massa gran per mostrar-lo sencer. Compartiu-ne el codi font complet.", + "still_scheduled": "El missatge encara està programat.", + "still_scheduled_at": "El missatge encara està programat per al {date}.", "thread_loading": "Carregant la conversa...", "undo_send_failed": "No s'ha pogut desfer l'enviament", "unsubscribe_banner": { diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 9a4d4f59..fabb1101 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -272,6 +272,9 @@ "recipient_copy": "Kopírovat adresu", "recipient_move_to": "Přesunout do pole {field}", "recipient_remove": "Odstranit", + "recipients_partly_refused": "Server odmítl {recipients}. Ostatní příjemci zprávu dostanou.", + "recipients_partly_refused_title": "Neodesláno všem", + "recipients_refused": "Neodesláno: server odmítl {recipients}.", "remove_attachment": "Odebrat přílohu", "save_draft": "Uložit koncept", "save_failed_no_drafts": "Chybí složka Koncepty, do které by šlo koncept uložit.", @@ -402,6 +405,8 @@ "show_less": "Zobrazit méně", "smime_unsupported": "Podepsáno/zašifrováno pomocí S/MIME – v mobilní aplikaci není podporováno", "source_too_large": "Zpráva je příliš velká na zobrazení v celé délce. Místo toho sdílejte celý zdroj.", + "still_scheduled": "Zpráva je stále naplánována.", + "still_scheduled_at": "Zpráva je stále naplánována na {date}.", "thread_loading": "Načítání konverzace...", "undo_send_failed": "Odeslání se nepodařilo vrátit", "unsubscribe_banner": { diff --git a/locales/rn/da.json b/locales/rn/da.json index ee9f7726..bd9dae7c 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -272,6 +272,9 @@ "recipient_copy": "Kopiér adresse", "recipient_move_to": "Flyt til {field}", "recipient_remove": "Fjern", + "recipients_partly_refused": "Serveren afviste {recipients}. De øvrige modtagere får beskeden.", + "recipients_partly_refused_title": "Ikke sendt til alle", + "recipients_refused": "Ikke sendt: serveren afviste {recipients}.", "remove_attachment": "Fjern vedhæftning", "save_draft": "Gem kladde", "save_failed_no_drafts": "Der er ingen Kladder-mappe at gemme denne kladde i.", @@ -402,6 +405,8 @@ "show_less": "Vis færre", "smime_unsupported": "Signeret/krypteret med S/MIME - understøttes ikke på mobil", "source_too_large": "Beskeden er for stor til at blive vist i sin helhed. Del i stedet hele kilden.", + "still_scheduled": "Beskeden er stadig planlagt.", + "still_scheduled_at": "Beskeden er stadig planlagt til {date}.", "thread_loading": "Indlæser samtale...", "undo_send_failed": "Afsendelsen kunne ikke fortrydes", "unsubscribe_banner": { diff --git a/locales/rn/de.json b/locales/rn/de.json index 46c8f7fc..3b0b76ac 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -272,6 +272,9 @@ "recipient_copy": "Adresse kopieren", "recipient_move_to": "Verschieben nach {field}", "recipient_remove": "Entfernen", + "recipients_partly_refused": "Der Server hat {recipients} abgelehnt. Die anderen Empfänger erhalten die Nachricht.", + "recipients_partly_refused_title": "Nicht an alle gesendet", + "recipients_refused": "Nicht gesendet: Der Server hat {recipients} abgelehnt.", "remove_attachment": "Anhang entfernen", "save_draft": "Entwurf speichern", "save_failed_no_drafts": "Es gibt keinen Entwürfe-Ordner, in dem dieser Entwurf gespeichert werden kann.", @@ -402,6 +405,8 @@ "show_less": "Weniger anzeigen", "smime_unsupported": "Mit S/MIME signiert/verschlüsselt - auf Mobilgeräten nicht unterstützt", "source_too_large": "Die Nachricht ist zu groß, um vollständig angezeigt zu werden. Teilen Sie stattdessen den vollständigen Quelltext.", + "still_scheduled": "Die Nachricht ist weiterhin geplant.", + "still_scheduled_at": "Die Nachricht ist weiterhin für {date} geplant.", "thread_loading": "Unterhaltung wird geladen...", "undo_send_failed": "Senden konnte nicht rückgängig gemacht werden", "unsubscribe_banner": { diff --git a/locales/rn/en.json b/locales/rn/en.json index f5d0a8b6..f300ba16 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -275,6 +275,9 @@ "recipient_copy": "Copy address", "recipient_move_to": "Move to {field}", "recipient_remove": "Remove", + "recipients_partly_refused": "The server refused {recipients}. The other recipients get the message.", + "recipients_partly_refused_title": "Not sent to everyone", + "recipients_refused": "Not sent: the server refused {recipients}.", "remove_attachment": "Remove attachment", "save_draft": "Save Draft", "save_failed_no_drafts": "There is no Drafts folder to save this draft to.", @@ -409,6 +412,8 @@ "show_less": "Show less", "smime_unsupported": "Signed/encrypted with S/MIME - not supported on mobile", "source_too_large": "The message is too large to display in full. Share the full source instead.", + "still_scheduled": "The message is still scheduled.", + "still_scheduled_at": "The message is still scheduled for {date}.", "thread_loading": "Loading conversation...", "undo_send_failed": "Undo send failed", "unsubscribe_banner": { diff --git a/locales/rn/es.json b/locales/rn/es.json index e4dcea80..d9322700 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -272,6 +272,9 @@ "recipient_copy": "Copiar dirección", "recipient_move_to": "Mover a {field}", "recipient_remove": "Eliminar", + "recipients_partly_refused": "El servidor rechazó {recipients}. Los demás destinatarios reciben el mensaje.", + "recipients_partly_refused_title": "No enviado a todos", + "recipients_refused": "No enviado: el servidor rechazó {recipients}.", "remove_attachment": "Quitar adjunto", "save_draft": "Guardar borrador", "save_failed_no_drafts": "No hay ninguna carpeta Borradores donde guardar este borrador.", @@ -402,6 +405,8 @@ "show_less": "Mostrar menos", "smime_unsupported": "Firmado/cifrado con S/MIME: no compatible en el móvil", "source_too_large": "El mensaje es demasiado grande para mostrarlo completo. Comparte el código fuente completo en su lugar.", + "still_scheduled": "El mensaje sigue programado.", + "still_scheduled_at": "El mensaje sigue programado para el {date}.", "thread_loading": "Cargando conversación...", "undo_send_failed": "No se pudo deshacer el envío", "unsubscribe_banner": { diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 3b41eeb2..6d2daac1 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -272,6 +272,9 @@ "recipient_copy": "کپی نشانی", "recipient_move_to": "انتقال به {field}", "recipient_remove": "حذف", + "recipients_partly_refused": "سرور {recipients} را رد کرد. سایر گیرندگان پیام را دریافت می‌کنند.", + "recipients_partly_refused_title": "برای همه ارسال نشد", + "recipients_refused": "ارسال نشد: سرور {recipients} را رد کرد.", "remove_attachment": "حذف پیوست", "save_draft": "ذخیره پیش‌نویس", "save_failed_no_drafts": "پوشه پیش‌نویس‌ها برای ذخیره این پیش‌نویس وجود ندارد.", @@ -402,6 +405,8 @@ "show_less": "نمایش کمتر", "smime_unsupported": "امضا/رمزگذاری‌شده با S/MIME - در موبایل پشتیبانی نمی‌شود", "source_too_large": "پیام برای نمایش کامل بیش از حد بزرگ است. به‌جای آن، منبع کامل را به اشتراک بگذارید.", + "still_scheduled": "پیام همچنان زمان‌بندی شده است.", + "still_scheduled_at": "پیام همچنان برای {date} زمان‌بندی شده است.", "thread_loading": "در حال بارگذاری مکالمه...", "undo_send_failed": "بازگردانی ارسال ممکن نشد", "unsubscribe_banner": { diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 6fce6919..92219edc 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -272,6 +272,9 @@ "recipient_copy": "Copier l'adresse", "recipient_move_to": "Déplacer vers {field}", "recipient_remove": "Retirer", + "recipients_partly_refused": "Le serveur a refusé {recipients}. Les autres destinataires reçoivent le message.", + "recipients_partly_refused_title": "Non envoyé à tous", + "recipients_refused": "Non envoyé : le serveur a refusé {recipients}.", "remove_attachment": "Retirer la pièce jointe", "save_draft": "Enregistrer le brouillon", "save_failed_no_drafts": "Il n'y a aucun dossier Brouillons où enregistrer ce brouillon.", @@ -402,6 +405,8 @@ "show_less": "Afficher moins", "smime_unsupported": "Signé/chiffré avec S/MIME - non pris en charge sur mobile", "source_too_large": "Le message est trop volumineux pour être affiché en entier. Partagez plutôt la source complète.", + "still_scheduled": "Le message reste planifié.", + "still_scheduled_at": "Le message reste planifié pour le {date}.", "thread_loading": "Chargement de la conversation...", "undo_send_failed": "Impossible d’annuler l’envoi", "unsubscribe_banner": { diff --git a/locales/rn/he.json b/locales/rn/he.json index fa71fbc6..ffaea5ae 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -272,6 +272,9 @@ "recipient_copy": "העתק כתובת", "recipient_move_to": "העבר אל {field}", "recipient_remove": "הסר", + "recipients_partly_refused": "השרת דחה את {recipients}. שאר הנמענים יקבלו את ההודעה.", + "recipients_partly_refused_title": "לא נשלח לכולם", + "recipients_refused": "לא נשלח: השרת דחה את {recipients}.", "remove_attachment": "הסר קובץ מצורף", "save_draft": "שמור טיוטה", "save_failed_no_drafts": "אין תיקיית טיוטות לשמירת הטיוטה הזו.", @@ -402,6 +405,8 @@ "show_less": "הצג פחות", "smime_unsupported": "חתום/מוצפן ב-S/MIME - לא נתמך בנייד", "source_too_large": "ההודעה גדולה מכדי להציג אותה במלואה. במקום זאת, שתף את קוד המקור המלא.", + "still_scheduled": "ההודעה עדיין מתוזמנת.", + "still_scheduled_at": "ההודעה עדיין מתוזמנת ל־{date}.", "thread_loading": "טוען שיחה...", "undo_send_failed": "לא ניתן היה לבטל את השליחה", "unsubscribe_banner": { diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 7179ab30..eb6c5424 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -272,6 +272,9 @@ "recipient_copy": "Cím másolása", "recipient_move_to": "Áthelyezés ide: {field}", "recipient_remove": "Eltávolítás", + "recipients_partly_refused": "A kiszolgáló elutasította a következőt: {recipients}. A többi címzett megkapja az üzenetet.", + "recipients_partly_refused_title": "Nincs elküldve mindenkinek", + "recipients_refused": "Nincs elküldve: a kiszolgáló elutasította a következőt: {recipients}.", "remove_attachment": "Csatolmány eltávolítása", "save_draft": "Piszkozat mentése", "save_failed_no_drafts": "Nincs Piszkozatok mappa, ahová ezt a piszkozatot menteni lehetne.", @@ -402,6 +405,8 @@ "show_less": "Kevesebb megjelenítése", "smime_unsupported": "S/MIME-mal aláírva/titkosítva – mobilon nem támogatott", "source_too_large": "Az üzenet túl nagy ahhoz, hogy teljes egészében megjelenjen. Oszd meg inkább a teljes forrást.", + "still_scheduled": "Az üzenet továbbra is ütemezve van.", + "still_scheduled_at": "Az üzenet továbbra is ütemezve van: {date}.", "thread_loading": "Beszélgetés betöltése...", "undo_send_failed": "A küldést nem sikerült visszavonni", "unsubscribe_banner": { diff --git a/locales/rn/it.json b/locales/rn/it.json index d2bb9d52..ea71283a 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -272,6 +272,9 @@ "recipient_copy": "Copia indirizzo", "recipient_move_to": "Sposta in {field}", "recipient_remove": "Rimuovi", + "recipients_partly_refused": "Il server ha rifiutato {recipients}. Gli altri destinatari ricevono il messaggio.", + "recipients_partly_refused_title": "Non inviato a tutti", + "recipients_refused": "Non inviato: il server ha rifiutato {recipients}.", "remove_attachment": "Rimuovi allegato", "save_draft": "Salva bozza", "save_failed_no_drafts": "Non c'è una cartella Bozze in cui salvare questa bozza.", @@ -402,6 +405,8 @@ "show_less": "Mostra meno", "smime_unsupported": "Firmato/cifrato con S/MIME - non supportato su mobile", "source_too_large": "Il messaggio è troppo grande per essere mostrato per intero. Condividi invece il sorgente completo.", + "still_scheduled": "Il messaggio è ancora programmato.", + "still_scheduled_at": "Il messaggio è ancora programmato per il {date}.", "thread_loading": "Caricamento conversazione...", "undo_send_failed": "Impossibile annullare l'invio", "unsubscribe_banner": { diff --git a/locales/rn/ja.json b/locales/rn/ja.json index d44ea051..4417adb8 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -272,6 +272,9 @@ "recipient_copy": "アドレスをコピー", "recipient_move_to": "{field}に移動", "recipient_remove": "削除", + "recipients_partly_refused": "サーバーが {recipients} を拒否しました。他の宛先にはメッセージが届きます。", + "recipients_partly_refused_title": "一部の宛先に送信されませんでした", + "recipients_refused": "送信されませんでした: サーバーが {recipients} を拒否しました。", "remove_attachment": "添付ファイルを削除", "save_draft": "下書きを保存", "save_failed_no_drafts": "この下書きを保存する下書きフォルダーがありません。", @@ -402,6 +405,8 @@ "show_less": "表示を減らす", "smime_unsupported": "S/MIME で署名/暗号化されています(モバイルでは非対応)", "source_too_large": "メッセージが大きすぎるため、すべてを表示できません。代わりに完全なソースを共有してください。", + "still_scheduled": "メッセージは引き続き送信予定です。", + "still_scheduled_at": "メッセージは引き続き {date} に送信予定です。", "thread_loading": "会話を読み込み中...", "undo_send_failed": "送信を取り消せませんでした", "unsubscribe_banner": { diff --git a/locales/rn/ko.json b/locales/rn/ko.json index 8939b34a..395ceb3e 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -272,6 +272,9 @@ "recipient_copy": "주소 복사", "recipient_move_to": "{field}(으)로 이동", "recipient_remove": "제거", + "recipients_partly_refused": "서버가 {recipients}을(를) 거부했습니다. 다른 받는 사람에게는 메시지가 전달됩니다.", + "recipients_partly_refused_title": "일부 받는 사람에게 보내지 못했습니다", + "recipients_refused": "보내지 못했습니다: 서버가 {recipients}을(를) 거부했습니다.", "remove_attachment": "첨부 파일 제거", "save_draft": "임시 저장", "save_failed_no_drafts": "이 메일을 저장할 임시보관함이 없어요.", @@ -402,6 +405,8 @@ "show_less": "간략히 보기", "smime_unsupported": "S/MIME로 서명/암호화됨 - 모바일에서는 지원되지 않아요", "source_too_large": "메시지가 너무 커서 전체를 표시할 수 없어요. 대신 전체 원본을 공유해 주세요.", + "still_scheduled": "메시지는 여전히 예약되어 있습니다.", + "still_scheduled_at": "메시지는 여전히 {date}에 보내도록 예약되어 있습니다.", "thread_loading": "대화를 불러오는 중...", "undo_send_failed": "보내기를 취소하지 못했습니다", "unsubscribe_banner": { diff --git a/locales/rn/lv.json b/locales/rn/lv.json index ce6419bc..5500acbb 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -272,6 +272,9 @@ "recipient_copy": "Kopēt adresi", "recipient_move_to": "Pārvietot uz lauku {field}", "recipient_remove": "Noņemt", + "recipients_partly_refused": "Serveris noraidīja {recipients}. Pārējie adresāti saņem ziņojumu.", + "recipients_partly_refused_title": "Nav nosūtīts visiem", + "recipients_refused": "Nav nosūtīts: serveris noraidīja {recipients}.", "remove_attachment": "Noņemt pielikumu", "save_draft": "Saglabāt melnrakstu", "save_failed_no_drafts": "Nav mapes Melnraksti, kurā saglabāt šo melnrakstu.", @@ -402,6 +405,8 @@ "show_less": "Rādīt mazāk", "smime_unsupported": "Parakstīts/šifrēts ar S/MIME – mobilajā lietotnē netiek atbalstīts", "source_too_large": "Ziņojums ir pārāk liels, lai to parādītu pilnībā. Tā vietā kopīgojiet pilno avotu.", + "still_scheduled": "Ziņojums joprojām ir ieplānots.", + "still_scheduled_at": "Ziņojums joprojām ir ieplānots uz {date}.", "thread_loading": "Ielādē sarunu...", "undo_send_failed": "Sūtīšanu neizdevās atsaukt", "unsubscribe_banner": { diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 45f4a360..fbb0a1be 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -272,6 +272,9 @@ "recipient_copy": "Хаягийг хуулах", "recipient_move_to": "\"{field}\" талбар руу зөөх", "recipient_remove": "Хасах", + "recipients_partly_refused": "Сервер дараах хүлээн авагчдыг татгалзлаа: {recipients}. Бусад хүлээн авагчид мессежийг хүлээн авна.", + "recipients_partly_refused_title": "Бүх хүлээн авагчид илгээгдсэнгүй", + "recipients_refused": "Илгээгдсэнгүй: сервер дараах хүлээн авагчдыг татгалзлаа: {recipients}.", "remove_attachment": "Хавсралтыг хасах", "save_draft": "Ноорог хадгалах", "save_failed_no_drafts": "Энэ ноорогийг хадгалах Ноорог хавтас алга.", @@ -402,6 +405,8 @@ "show_less": "Хураах", "smime_unsupported": "S/MIME-ээр гарын үсэг зурсан/шифрлэсэн - гар утсанд дэмжигдэхгүй", "source_too_large": "Энэ имэйл хэт том тул бүрэн харуулах боломжгүй. Оронд нь бүрэн эх кодыг хуваалцана уу.", + "still_scheduled": "Мессеж товлогдсон хэвээр байна.", + "still_scheduled_at": "Мессеж {date}-нд илгээгдэхээр товлогдсон хэвээр байна.", "thread_loading": "Харилцан яриаг ачаалж байна...", "undo_send_failed": "Илгээхийг буцааж чадсангүй", "unsubscribe_banner": { diff --git a/locales/rn/nb.json b/locales/rn/nb.json index c8204e77..b15c2314 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -272,6 +272,9 @@ "recipient_copy": "Kopier adressen", "recipient_move_to": "Flytt til {field}", "recipient_remove": "Fjern", + "recipients_partly_refused": "Serveren avviste {recipients}. De andre mottakerne får meldingen.", + "recipients_partly_refused_title": "Ikke sendt til alle", + "recipients_refused": "Ikke sendt: serveren avviste {recipients}.", "remove_attachment": "Fjern vedlegget", "save_draft": "Lagre utkast", "save_failed_no_drafts": "Det finnes ingen Utkast-mappe å lagre dette utkastet i.", @@ -402,6 +405,8 @@ "show_less": "Vis færre", "smime_unsupported": "Signert/kryptert med S/MIME – støttes ikke på mobil", "source_too_large": "Meldingen er for stor til å vises i sin helhet. Del hele kilden i stedet.", + "still_scheduled": "Meldingen er fortsatt planlagt.", + "still_scheduled_at": "Meldingen er fortsatt planlagt til {date}.", "thread_loading": "Laster inn samtale...", "undo_send_failed": "Sendingen kunne ikke angres", "unsubscribe_banner": { diff --git a/locales/rn/nl.json b/locales/rn/nl.json index c413da03..1d1bf74c 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -272,6 +272,9 @@ "recipient_copy": "Adres kopiëren", "recipient_move_to": "Verplaatsen naar {field}", "recipient_remove": "Verwijderen", + "recipients_partly_refused": "De server heeft {recipients} geweigerd. De andere ontvangers krijgen het bericht.", + "recipients_partly_refused_title": "Niet naar iedereen verzonden", + "recipients_refused": "Niet verzonden: de server heeft {recipients} geweigerd.", "remove_attachment": "Bijlage verwijderen", "save_draft": "Concept opslaan", "save_failed_no_drafts": "Er is geen map Concepten om dit concept in op te slaan.", @@ -402,6 +405,8 @@ "show_less": "Minder tonen", "smime_unsupported": "Ondertekend/versleuteld met S/MIME - niet ondersteund op mobiel", "source_too_large": "Het bericht is te groot om volledig weer te geven. Deel in plaats daarvan de volledige bron.", + "still_scheduled": "Het bericht staat nog steeds gepland.", + "still_scheduled_at": "Het bericht staat nog steeds gepland voor {date}.", "thread_loading": "Gesprek laden...", "undo_send_failed": "Verzenden kon niet ongedaan worden gemaakt", "unsubscribe_banner": { diff --git a/locales/rn/pl.json b/locales/rn/pl.json index ce62c7d4..e7ee4eb6 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -272,6 +272,9 @@ "recipient_copy": "Kopiuj adres", "recipient_move_to": "Przenieś do pola {field}", "recipient_remove": "Usuń", + "recipients_partly_refused": "Serwer odrzucił {recipients}. Pozostali odbiorcy otrzymają wiadomość.", + "recipients_partly_refused_title": "Nie wysłano do wszystkich", + "recipients_refused": "Nie wysłano: serwer odrzucił {recipients}.", "remove_attachment": "Usuń załącznik", "save_draft": "Zapisz szkic", "save_failed_no_drafts": "Brak folderu Szkice, w którym można zapisać ten szkic.", @@ -402,6 +405,8 @@ "show_less": "Pokaż mniej", "smime_unsupported": "Podpisano/zaszyfrowano za pomocą S/MIME – nieobsługiwane w aplikacji mobilnej", "source_too_large": "Wiadomość jest zbyt duża, aby wyświetlić ją w całości. Zamiast tego udostępnij pełne źródło.", + "still_scheduled": "Wiadomość jest nadal zaplanowana.", + "still_scheduled_at": "Wiadomość jest nadal zaplanowana na {date}.", "thread_loading": "Ładowanie konwersacji...", "undo_send_failed": "Nie udało się cofnąć wysyłki", "unsubscribe_banner": { diff --git a/locales/rn/pt.json b/locales/rn/pt.json index 7b616c42..31461840 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -272,6 +272,9 @@ "recipient_copy": "Copiar endereço", "recipient_move_to": "Mover para {field}", "recipient_remove": "Remover", + "recipients_partly_refused": "O servidor recusou {recipients}. Os demais destinatários recebem a mensagem.", + "recipients_partly_refused_title": "Não enviado a todos", + "recipients_refused": "Não enviado: o servidor recusou {recipients}.", "remove_attachment": "Remover anexo", "save_draft": "Salvar rascunho", "save_failed_no_drafts": "Não há uma pasta Rascunhos para salvar este rascunho.", @@ -402,6 +405,8 @@ "show_less": "Mostrar menos", "smime_unsupported": "Assinado/criptografado com S/MIME - não suportado no celular", "source_too_large": "A mensagem é grande demais para ser exibida por completo. Em vez disso, compartilhe o código-fonte completo.", + "still_scheduled": "A mensagem continua agendada.", + "still_scheduled_at": "A mensagem continua agendada para {date}.", "thread_loading": "Carregando conversa...", "undo_send_failed": "Não foi possível desfazer o envio", "unsubscribe_banner": { diff --git a/locales/rn/ro.json b/locales/rn/ro.json index e5c1d162..0442289c 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -272,6 +272,9 @@ "recipient_copy": "Copiați adresa", "recipient_move_to": "Mutați la {field}", "recipient_remove": "Eliminați", + "recipients_partly_refused": "Serverul a respins {recipients}. Ceilalți destinatari primesc mesajul.", + "recipients_partly_refused_title": "Netrimis tuturor", + "recipients_refused": "Netrimis: serverul a respins {recipients}.", "remove_attachment": "Eliminați atașamentul", "save_draft": "Salvați ciorna", "save_failed_no_drafts": "Nu există un folder Ciorne în care să fie salvată această ciornă.", @@ -402,6 +405,8 @@ "show_less": "Afișați mai puțin", "smime_unsupported": "Semnat/criptat cu S/MIME - nu este acceptat pe mobil", "source_too_large": "Mesajul este prea mare pentru a fi afișat integral. Partajați în schimb sursa completă.", + "still_scheduled": "Mesajul este încă programat.", + "still_scheduled_at": "Mesajul este încă programat pentru {date}.", "thread_loading": "Se încarcă conversația...", "undo_send_failed": "Trimiterea nu a putut fi anulată", "unsubscribe_banner": { diff --git a/locales/rn/ru.json b/locales/rn/ru.json index 1eacb58f..d2e4a783 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -272,6 +272,9 @@ "recipient_copy": "Копировать адрес", "recipient_move_to": "Переместить в «{field}»", "recipient_remove": "Удалить", + "recipients_partly_refused": "Сервер отклонил {recipients}. Остальные получатели получат письмо.", + "recipients_partly_refused_title": "Отправлено не всем", + "recipients_refused": "Не отправлено: сервер отклонил {recipients}.", "remove_attachment": "Удалить вложение", "save_draft": "Сохранить черновик", "save_failed_no_drafts": "Нет папки «Черновики», в которую можно сохранить этот черновик.", @@ -402,6 +405,8 @@ "show_less": "Свернуть", "smime_unsupported": "Подписано или зашифровано с помощью S/MIME — на мобильных устройствах не поддерживается", "source_too_large": "Письмо слишком большое для полного отображения. Вместо этого поделитесь полным исходным кодом.", + "still_scheduled": "Письмо по-прежнему запланировано.", + "still_scheduled_at": "Письмо по-прежнему запланировано на {date}.", "thread_loading": "Загрузка беседы...", "undo_send_failed": "Не удалось отменить отправку", "unsubscribe_banner": { diff --git a/locales/rn/sk.json b/locales/rn/sk.json index 2d8ce0a1..f2cf7c93 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -272,6 +272,9 @@ "recipient_copy": "Kopírovať adresu", "recipient_move_to": "Presunúť do poľa {field}", "recipient_remove": "Odstrániť", + "recipients_partly_refused": "Server odmietol {recipients}. Ostatní príjemcovia správu dostanú.", + "recipients_partly_refused_title": "Neodoslané všetkým", + "recipients_refused": "Neodoslané: server odmietol {recipients}.", "remove_attachment": "Odstrániť prílohu", "save_draft": "Uložiť koncept", "save_failed_no_drafts": "Chýba priečinok Koncepty, do ktorého by sa dal koncept uložiť.", @@ -402,6 +405,8 @@ "show_less": "Zobraziť menej", "smime_unsupported": "Podpísané/zašifrované pomocou S/MIME – v mobilnej aplikácii nie je podporované", "source_too_large": "Správa je príliš veľká na zobrazenie v celom rozsahu. Namiesto toho zdieľajte celý zdroj.", + "still_scheduled": "Správa je stále naplánovaná.", + "still_scheduled_at": "Správa je stále naplánovaná na {date}.", "thread_loading": "Načítavanie konverzácie...", "undo_send_failed": "Odoslanie sa nepodarilo vrátiť", "unsubscribe_banner": { diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 2c663902..dd9e86b4 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -272,6 +272,9 @@ "recipient_copy": "Adresi kopyala", "recipient_move_to": "{field} alanına taşı", "recipient_remove": "Kaldır", + "recipients_partly_refused": "Sunucu şu alıcıları reddetti: {recipients}. Diğer alıcılar iletiyi alır.", + "recipients_partly_refused_title": "Herkese gönderilmedi", + "recipients_refused": "Gönderilmedi: sunucu şu alıcıları reddetti: {recipients}.", "remove_attachment": "Eki kaldır", "save_draft": "Taslağı kaydet", "save_failed_no_drafts": "Bu taslağın kaydedileceği bir Taslaklar klasörü yok.", @@ -402,6 +405,8 @@ "show_less": "Daha az göster", "smime_unsupported": "S/MIME ile imzalanmış/şifrelenmiş - mobilde desteklenmiyor", "source_too_large": "İleti, tamamı gösterilemeyecek kadar büyük. Bunun yerine tam kaynağı paylaşın.", + "still_scheduled": "İleti hâlâ zamanlanmış durumda.", + "still_scheduled_at": "İleti hâlâ {date} için zamanlanmış durumda.", "thread_loading": "Konuşma yükleniyor...", "undo_send_failed": "Gönderme geri alınamadı", "unsubscribe_banner": { diff --git a/locales/rn/uk.json b/locales/rn/uk.json index ba5bc8f4..5e57c30b 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -272,6 +272,9 @@ "recipient_copy": "Копіювати адресу", "recipient_move_to": "Перемістити в «{field}»", "recipient_remove": "Видалити", + "recipients_partly_refused": "Сервер відхилив {recipients}. Інші одержувачі отримають лист.", + "recipients_partly_refused_title": "Надіслано не всім", + "recipients_refused": "Не надіслано: сервер відхилив {recipients}.", "remove_attachment": "Видалити вкладення", "save_draft": "Зберегти чернетку", "save_failed_no_drafts": "Немає папки «Чернетки», щоб зберегти цю чернетку.", @@ -402,6 +405,8 @@ "show_less": "Згорнути", "smime_unsupported": "Підписано або зашифровано за допомогою S/MIME — на мобільних пристроях не підтримується", "source_too_large": "Лист завеликий для повного відображення. Натомість поділіться повним вихідним кодом.", + "still_scheduled": "Лист і далі заплановано.", + "still_scheduled_at": "Лист і далі заплановано на {date}.", "thread_loading": "Завантаження розмови...", "undo_send_failed": "Не вдалося скасувати надсилання", "unsubscribe_banner": { diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index caed742a..b925d494 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -272,6 +272,9 @@ "recipient_copy": "複製地址", "recipient_move_to": "移至{field}", "recipient_remove": "移除", + "recipients_partly_refused": "伺服器拒絕了 {recipients}。其他收件者會收到此郵件。", + "recipients_partly_refused_title": "未傳送給所有收件者", + "recipients_refused": "未傳送:伺服器拒絕了 {recipients}。", "remove_attachment": "移除附件", "save_draft": "儲存草稿", "save_failed_no_drafts": "沒有可儲存這份草稿的草稿信件匣。", @@ -402,6 +405,8 @@ "show_less": "顯示較少", "smime_unsupported": "已使用 S/MIME 簽署/加密,行動裝置不支援", "source_too_large": "郵件太大,無法完整顯示。請改為分享完整原始碼。", + "still_scheduled": "郵件仍在排定傳送中。", + "still_scheduled_at": "郵件仍排定於 {date} 傳送。", "thread_loading": "正在載入對話串…", "undo_send_failed": "無法取消傳送", "unsubscribe_banner": { diff --git a/locales/rn/zh.json b/locales/rn/zh.json index d2eaa357..1956245e 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -272,6 +272,9 @@ "recipient_copy": "复制地址", "recipient_move_to": "移至{field}", "recipient_remove": "移除", + "recipients_partly_refused": "服务器拒绝了 {recipients}。其他收件人会收到此邮件。", + "recipients_partly_refused_title": "未发送给所有收件人", + "recipients_refused": "未发送:服务器拒绝了 {recipients}。", "remove_attachment": "移除附件", "save_draft": "保存草稿", "save_failed_no_drafts": "没有可用于保存此草稿的“草稿”文件夹。", @@ -402,6 +405,8 @@ "show_less": "收起", "smime_unsupported": "已使用 S/MIME 签名/加密——移动端不支持", "source_too_large": "邮件过大,无法完整显示。请改为分享完整源码。", + "still_scheduled": "邮件仍处于定时发送状态。", + "still_scheduled_at": "邮件仍计划于 {date} 发送。", "thread_loading": "正在加载会话…", "undo_send_failed": "无法撤销发送", "unsubscribe_banner": { diff --git a/src/api/__tests__/email-submission-cleanup.test.ts b/src/api/__tests__/email-submission-cleanup.test.ts index 83602419..ce533119 100644 --- a/src/api/__tests__/email-submission-cleanup.test.ts +++ b/src/api/__tests__/email-submission-cleanup.test.ts @@ -57,6 +57,8 @@ const OUTGOING = { textBody: 'Hi', }; const VIA_DRAFTS = { draftsMailboxId: 'drafts' }; +/** A send's request: the message, its submission and the submission's delivery status. */ +const SEND = ['Email/set', 'EmailSubmission/set', 'EmailSubmission/get']; const RECEIPT = { to: 'sender@remote.example', fromEmail: 'me@example.com', @@ -118,7 +120,7 @@ describe('an immediate send removes its own submission record', () => { const result = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS); - expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set'], ['EmailSubmission/set']]); + expect(server.methods()).toEqual([SEND, ['EmailSubmission/set']]); expect(argsOf(server, 'EmailSubmission/set')[1]).toEqual({ accountId: 'acc-1', destroy: [result.emailSubmissionId] }); expect(server.requests[1].using).toEqual(['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']); expect(server.submissions()).toEqual([]); @@ -134,7 +136,7 @@ describe('an immediate send removes its own submission record', () => { const result = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { ...VIA_DRAFTS, draftId: 'old-draft' }); expect(result.filingWarning).toBeUndefined(); - expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set'], ['Email/set', 'EmailSubmission/set']]); + expect(server.methods()).toEqual([SEND, ['Email/set', 'EmailSubmission/set']]); expect(server.requests[1].calls).toEqual([ ['Email/set', { accountId: 'acc-1', destroy: ['old-draft'] }, '0'], ['EmailSubmission/set', { accountId: 'acc-1', destroy: [result.emailSubmissionId] }, '1'], @@ -153,7 +155,7 @@ describe('an immediate send removes its own submission record', () => { const result = await sendEmail(OUTGOING, 'id-1', 'sent', 10, VIA_DRAFTS); expect(result.scheduled).toBe(true); - expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set']]); + expect(server.methods()).toEqual([SEND]); expect(server.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' }]); }, ); @@ -164,7 +166,7 @@ describe('an immediate send removes its own submission record', () => { await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS); - expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set']]); + expect(server.methods()).toEqual([SEND]); expect(server.submissions()).toHaveLength(1); }); @@ -215,11 +217,12 @@ describe('a full submission quota on a send (Stalwart 1.0)', () => { notCreated: { 'sub-1': { type: 'overQuota', description: OVER_QUOTA_DESCRIPTION } }, }), '1']); // ...then the prune made room and the copy already in Drafts went out. + // The retry asks for the delivery status again. expect(server.methods()).toEqual([ - ['Email/set', 'EmailSubmission/set'], + SEND, ['EmailSubmission/query', 'EmailSubmission/get'], ['EmailSubmission/set'], - ['EmailSubmission/set'], + ['EmailSubmission/set', 'EmailSubmission/get'], ['EmailSubmission/set'], ]); const retry = argsOf(server, 'EmailSubmission/set')[2]; @@ -240,7 +243,8 @@ describe('a full submission quota on a send (Stalwart 1.0)', () => { // The query can't see them; the log is read from "n" through each newState. expect(argsOf(server, 'EmailSubmission/query')).toHaveLength(1); expect(argsOf(server, 'EmailSubmission/changes').map((a) => a.sinceState)).toEqual(['n', 'r2', 'r4']); - expect(argsOf(server, 'EmailSubmission/get').at(-1)).toMatchObject({ ids: ['s1', 's2', 's3', 's4', 's5'] }); + const gets = argsOf(server, 'EmailSubmission/get').filter((a) => !String(a.ids?.[0]).startsWith('#')); + expect(gets.at(-1)).toMatchObject({ ids: ['s1', 's2', 's3', 's4', 's5'] }); expect(server.submissions()).toEqual([]); }); @@ -294,7 +298,7 @@ describe('a full submission quota on a send (Stalwart 1.0)', () => { expect(err).not.toBeInstanceOf(SubmissionQuotaError); expect((err as Error).message).toContain('Account quota exceeded.'); - expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set']]); + expect(server.methods()).toEqual([SEND]); }); it('Stalwart 0.16.23 does not enforce the limit: no retry is ever needed', async () => { @@ -303,7 +307,7 @@ describe('a full submission quota on a send (Stalwart 1.0)', () => { await sendEmail(OUTGOING, 'id-1', 'sent', 10, VIA_DRAFTS); - expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set']]); + expect(server.methods()).toEqual([SEND]); }); }); diff --git a/src/api/__tests__/email-submission-refusals.test.ts b/src/api/__tests__/email-submission-refusals.test.ts new file mode 100644 index 00000000..eded7c37 --- /dev/null +++ b/src/api/__tests__/email-submission-refusals.test.ts @@ -0,0 +1,388 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { FakeSubmissionServer } from './fake-submission-server'; + +// Stalwart runs the SMTP RCPT stage while it creates a submission, held or +// not, and records each refused recipient as `delivered: "no"` with its +// reply: a relay it doesn't allow ("550 5.1.2 Relay not allowed.") or a +// throttle that is used up ("452 4.4.5 Rate limit exceeded, try again +// later."). The create still succeeds - undoStatus final when it took nobody - +// and onSuccessUpdateEmail files the message in Sent. Seen live on 0.16.23 and +// 1.0; 0.16.6 records it the same way (submission/set.rs). These tests run the +// send paths against a fake Stalwart (./fake-submission-server) that models +// the RCPT stage and `#creationId` in EmailSubmission/get. + +const fake = vi.hoisted(() => ({ + server: undefined as unknown as FakeSubmissionServer, + stalwart: true, + accounts: ['acc-1'], + username: 'me@example.com', +})); + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + serverUrl: 'https://mail.example.com', + get username() { return fake.username; }, + request: vi.fn((calls: never, using?: string[]) => fake.server.request(calls, using)), + hasCapability: vi.fn(() => true), + hasAccountCapability: vi.fn((urn: string) => urn !== 'urn:stalwart:jmap' || fake.stalwart), + getSubmissionAccountIds: vi.fn(() => fake.accounts), + hasDelayedSend: vi.fn(() => true), + getMaxObjectsInGet: vi.fn(() => 500), + getMaxObjectsInSet: vi.fn(() => 500), + getMaxCallsInRequest: vi.fn(() => 16), + learnHoldLimit: vi.fn(), + }, +})); + +import { jmapClient } from '../jmap-client'; +import { listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email'; +import { RecipientsRefusedError, jmapErrorType } from '../jmap-result'; +import { pruneEmailSubmissions, resetSubmissionPrune } from '../submissions'; +import { + createFakeSubmissionServer, RATE_LIMITED, RELAY_DENIED, type StalwartVersion, +} from './fake-submission-server'; + +const HOUR = 3_600_000; + +const OUTGOING = { + from: [{ email: 'me@example.com' }], + to: [{ email: 'you@example.com' }], + subject: 'Hello', + textBody: 'Hi', +}; +const VIA_DRAFTS = { draftsMailboxId: 'drafts' }; +/** Stalwart's relay rule on the test servers: anything outside refused. */ +const relayOff = (email: string) => (email.endsWith('@example.net') ? RELAY_DENIED : undefined); + +function setup(version: StalwartVersion, options: Omit[0], 'version'> = {}) { + fake.server = createFakeSubmissionServer({ version, ...options }); + return fake.server; +} + +function addDraft(server: FakeSubmissionServer, id: string) { + server.emails.set(id, { id, mailboxIds: { drafts: true }, keywords: { $draft: true, $seen: true } }); +} + +function to(...addresses: string[]) { + return { ...OUTGOING, to: addresses.map((email) => ({ email })) }; +} + +/** The arguments of every call to `method`, in order. */ +function argsOf(server: FakeSubmissionServer, method: string): Array> { + return server.requests.flatMap(({ calls }) => calls.filter(([name]) => name === method).map(([, args]) => args)); +} + +beforeEach(() => { + vi.clearAllMocks(); + resetSubmissionPrune(); + Object.assign(fake, { stalwart: true, accounts: ['acc-1'], username: 'me@example.com' }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + // A record get reports as pending, with its time ahead, is never destroyed. + expect(fake.server?.violations ?? []).toEqual([]); +}); + +describe('a send whose recipients the server refuses', () => { + it.each(['1.0', '0.16.23', '0.16.10', '0.16.6'] as StalwartVersion[])( + 'Stalwart %s: fails when every recipient was refused, and keeps the previous draft', + async (version) => { + const server = setup(version, { refuseRecipient: relayOff }); + addDraft(server, 'old-draft'); + + const err = await sendEmail(to('nobody@example.net'), 'id-1', 'sent', undefined, { ...VIA_DRAFTS, draftId: 'old-draft' }) + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(RecipientsRefusedError); + expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]); + // The copy onSuccessUpdateEmail filed in Sent is gone, the previous + // draft is not ... + expect([...server.emails.keys()]).toEqual(['old-draft']); + // ... and neither is the record, which can neither go out nor be cancelled. + expect(server.submissions()).toEqual([]); + }, + ); + + it.each(['1.0', '0.16.23'] as StalwartVersion[])( + 'Stalwart %s: a held send (undo send, scheduled) is refused when it is created', + async (version) => { + const server = setup(version, { refuseRecipient: relayOff }); + + const err = await sendEmail(to('nobody@example.net'), 'id-1', 'sent', 30, VIA_DRAFTS).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(RecipientsRefusedError); + // The create answered final: nothing was queued to hold. + expect(server.requests[0].responses[1][1].created['sub-1']).toMatchObject({ undoStatus: 'final' }); + expect(server.emails.size).toBe(0); + expect(server.submissions()).toEqual([]); + }, + ); + + it('Stalwart 1.0: takes a used-up throttle for a refusal too', async () => { + setup('1.0', { refuseRecipient: () => RATE_LIMITED }); + + const err = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS).catch((e: unknown) => e); + + expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'you@example.com', smtpReply: RATE_LIMITED }]); + }); + + it.each(['1.0', '0.16.23', '0.16.6'] as StalwartVersion[])( + 'Stalwart %s: names the refused recipients and sends to the others', + async (version) => { + const server = setup(version, { refuseRecipient: relayOff }); + addDraft(server, 'old-draft'); + + const result = await sendEmail( + to('you@example.com', 'nobody@example.net'), 'id-1', 'sent', undefined, { ...VIA_DRAFTS, draftId: 'old-draft' }, + ); + + expect(result.refusedRecipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]); + expect(result.filingWarning).toBeUndefined(); + // Sent as usual: the previous draft goes, the message stays in Sent. + expect([...server.emails.keys()]).toEqual([result.emailId]); + expect(server.emails.get(result.emailId!)?.mailboxIds).toEqual({ sent: true }); + }, + ); + + it('Stalwart 1.0: reads the delivery status in the send request, before the record is destroyed', async () => { + const server = setup('1.0', { refuseRecipient: relayOff }); + + const result = await sendEmail(to('you@example.com', 'nobody@example.net'), 'id-1', 'sent', undefined, VIA_DRAFTS); + + expect(server.requests[0].calls[2]).toEqual( + ['EmailSubmission/get', { accountId: 'acc-1', ids: ['#sub-1'], properties: ['id', 'deliveryStatus'] }, '2'], + ); + expect(server.requests[0].using).toEqual([ + 'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission', + ]); + expect(server.requests.at(-1)?.calls).toEqual([ + ['EmailSubmission/set', { accountId: 'acc-1', destroy: [result.emailSubmissionId] }, '0'], + ]); + }); + + it('Stalwart 1.0: asks again with the retry after a full submission quota', async () => { + const server = setup('1.0', { limit: 1, refuseRecipient: relayOff }); + server.seed('acc-1', [{ sendAt: Date.now() - HOUR, status: 'final' }]); + + const err = await sendEmail(to('nobody@example.net'), 'id-1', 'sent', undefined, VIA_DRAFTS).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(RecipientsRefusedError); + const retry = server.requests.find(({ calls }) => calls[0][0] === 'EmailSubmission/set' && calls[0][1].create); + expect(retry?.calls.map(([name]) => name)).toEqual(['EmailSubmission/set', 'EmailSubmission/get']); + // Only the seeded record's message is left. + expect([...server.emails.keys()].filter((id) => !id.startsWith('seed'))).toEqual([]); + expect(server.submissions()).toEqual([]); + }); + + it('leaves the record to the prune on a server without urn:stalwart:jmap', async () => { + fake.stalwart = false; + const server = setup('1.0', { refuseRecipient: relayOff }); + + await expect(sendEmail(to('nobody@example.net'), 'id-1', 'sent', undefined, VIA_DRAFTS)) + .rejects.toBeInstanceOf(RecipientsRefusedError); + + expect(server.emails.size).toBe(0); + // Final: nothing was queued, so the prune may take it. + expect(server.submissions()).toEqual([{ id: expect.any(String), sendAt: expect.any(Number), undoStatus: 'final' }]); + }); + + it('sends as before when the server tells nothing about the recipients', async () => { + const server = setup('1.0', { refuseRecipient: relayOff }); + vi.mocked(jmapClient.request).mockImplementationOnce(async (calls, using) => { + const res = await server.request(calls, using); + return { + methodResponses: res.methodResponses.map((r) => (r[0] === 'EmailSubmission/get' + ? ['error', { type: 'unknownMethod' }, r[2]] as [string, Record, string] + : r)), + }; + }); + + const result = await sendEmail(to('nobody@example.net'), 'id-1', 'sent', undefined, VIA_DRAFTS); + + expect(result.emailSubmissionId).toBeDefined(); + expect(result.refusedRecipients).toBeUndefined(); + // The get's own failure is neither a failed send nor a filing problem. + expect(result.filingWarning).toBeUndefined(); + }); + + it('takes a server that keeps no delivery status for one that took everyone', async () => { + setup('1.0'); + vi.mocked(jmapClient.request).mockResolvedValueOnce({ + methodResponses: [ + ['Email/set', { created: { draft: { id: 'e-new' } } }, '0'], + ['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'], + ['EmailSubmission/get', { list: [{ id: 's-1', deliveryStatus: null }], notFound: [] }, '2'], + ], + }); + fake.stalwart = false; + + const result = await sendEmail(OUTGOING, 'id-1', 'sent'); + + expect(result).toMatchObject({ emailId: 'e-new', emailSubmissionId: 's-1' }); + expect(result.refusedRecipients).toBeUndefined(); + }); + + it("reports a refused create with the create's own reason, not the get's (1.0 and 0.16.23 answer the same)", async () => { + setup('1.0'); + vi.mocked(jmapClient.request) + .mockResolvedValueOnce({ + methodResponses: [ + ['Email/set', { created: { draft: { id: 'e-new' } } }, '0'], + ['EmailSubmission/set', { notCreated: { 'sub-1': { type: 'invalidProperties', description: 'Identity not found.', properties: ['identityId'] } } }, '1'], + ['error', { type: 'invalidResultReference', description: 'Id reference "sub-1" does not exist or is invalid.' }, '2'], + ], + }) + .mockResolvedValueOnce({ methodResponses: [['Email/set', { destroyed: ['e-new'] }, '0']] }); + + const err = await sendEmail(OUTGOING, 'id-x', 'sent', undefined, VIA_DRAFTS).catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(RecipientsRefusedError); + expect((err as Error).message).toBe('Identity not found.'); + }); +}); + +describe('"Send now" and "Reschedule" when the server refuses the recipients again', () => { + /** A scheduled message to `recipients`, held for an hour. */ + function scheduledTo(server: FakeSubmissionServer, ...recipients: string[]) { + server.emails.set('mail-1', { + id: 'mail-1', mailboxIds: { sent: true }, keywords: {}, to: recipients.map((email) => ({ email })), + }); + const [original] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending', emailId: 'mail-1' }]); + return { emailSubmissionId: original, emailId: 'mail-1', identityId: 'id-1', from: [{ email: 'me@example.com' }] }; + } + + it.each([ + ['1.0', 0], + ['1.0', 7200], + ['0.16.23', 0], + ['0.16.23', 7200], + ] as Array<[StalwartVersion, number]>)( + 'Stalwart %s: keeps the original when every recipient of the replacement is refused (hold %is)', + async (version, holdFor) => { + // A throttle used up since the message was scheduled. + const server = setup(version, { refuseRecipient: () => RATE_LIMITED }); + const scheduled = scheduledTo(server, 'bob@example.org'); + + const err = await rescheduleScheduledSend(scheduled, holdFor).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(RecipientsRefusedError); + expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]); + // Still goes at its old time; no cancel went out, and the dead + // replacement's record is gone. + expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false); + expect(server.submissions()).toEqual([ + { id: scheduled.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' }, + ]); + }, + ); + + it('Stalwart 1.0: reads the replacement\'s delivery status with its create', async () => { + const server = setup('1.0'); + const scheduled = scheduledTo(server, 'bob@example.org'); + + await rescheduleScheduledSend(scheduled, 0); + + const create = server.requests.find(({ calls }) => calls[0][1].create); + expect(create?.calls[1]).toEqual( + ['EmailSubmission/get', { accountId: 'acc-1', ids: ['#replacement'], properties: ['id', 'deliveryStatus'] }, '1'], + ); + expect(create?.using).toEqual(['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']); + }); + + it.each(['1.0', '0.16.23'] as StalwartVersion[])( + 'Stalwart %s: names the recipients refused for the replacement and replaces the original', + async (version) => { + const server = setup(version, { refuseRecipient: relayOff }); + const scheduled = scheduledTo(server, 'bob@example.org', 'nobody@example.net'); + + const result = await rescheduleScheduledSend(scheduled, 7200); + + expect(result.refusedRecipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]); + expect(server.submissions()).toEqual([ + { id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' }, + ]); + }, + ); + + it('leaves the refused replacement to the prune on a server without urn:stalwart:jmap', async () => { + fake.stalwart = false; + const server = setup('1.0', { refuseRecipient: () => RATE_LIMITED }); + const scheduled = scheduledTo(server, 'bob@example.org'); + + await expect(rescheduleScheduledSend(scheduled, 0)).rejects.toBeInstanceOf(RecipientsRefusedError); + + expect(server.submissions().map((s) => s.undoStatus)).toEqual(['pending', 'final']); + }); +}); + +describe('shared accounts that refuse the submission prune', () => { + const queriedAccounts = (server: FakeSubmissionServer) => argsOf(server, 'EmailSubmission/query').map((q) => q.accountId); + + // 0.16.23 lists an account that shared only an address book or a calendar + // with the submission capability, then refuses its records: "You are not an + // owner of account e" (live, userc). + it('Stalwart 0.16.23: leaves such an account alone while the app runs, without a warning', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const server = setup('0.16.23', { forbiddenAccounts: ['e'] }); + fake.accounts = ['acc-1', 'e']; + + await pruneEmailSubmissions({ force: true }); + await pruneEmailSubmissions({ force: true }); + await listScheduledEmails(); + + expect(queriedAccounts(server)).toEqual(['acc-1', 'e', 'acc-1', 'acc-1']); + expect(warn).not.toHaveBeenCalled(); + }); + + it('Stalwart 0.16.23: asks again for another login on the same server', async () => { + const server = setup('0.16.23', { forbiddenAccounts: ['e'] }); + fake.accounts = ['acc-1', 'e']; + + await pruneEmailSubmissions({ force: true }); + fake.username = 'usera@example.com'; + await pruneEmailSubmissions({ force: true }); + + expect(queriedAccounts(server)).toEqual(['acc-1', 'e', 'acc-1', 'e']); + }); + + it('Stalwart 1.0: keeps pruning a group account the user belongs to', async () => { + const server = setup('1.0'); + fake.accounts = ['acc-1', 'team']; + + await pruneEmailSubmissions({ force: true }); + await pruneEmailSubmissions({ force: true }); + + expect(queriedAccounts(server)).toEqual(['acc-1', 'team', 'acc-1', 'team']); + }); + + it('never leaves out the primary account, and warns about it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const server = setup('1.0', { forbiddenAccounts: ['acc-1'] }); + + await pruneEmailSubmissions({ force: true }); + await pruneEmailSubmissions({ force: true }); + + expect(queriedAccounts(server)).toEqual(['acc-1', 'acc-1']); + expect(warn).toHaveBeenCalledTimes(2); + expect(jmapErrorType(warn.mock.calls[0][1])).toBe('forbidden'); + }); + + it('keeps asking an account that failed for another reason', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const server = setup('1.0'); + fake.accounts = ['acc-1', 'team']; + server.beforeEachRequest((calls) => { + if ((calls[0][1] as { accountId?: string }).accountId === 'team') throw new Error('Network request failed'); + }); + + await pruneEmailSubmissions({ force: true }); + await pruneEmailSubmissions({ force: true }); + + expect(warn).toHaveBeenCalledTimes(2); + server.beforeEachRequest(() => undefined); + await pruneEmailSubmissions({ force: true }); + expect(queriedAccounts(server)).toEqual(['acc-1', 'acc-1', 'acc-1', 'team']); + }); +}); diff --git a/src/api/__tests__/email.test.ts b/src/api/__tests__/email.test.ts index 2ee07647..be14a65f 100644 --- a/src/api/__tests__/email.test.ts +++ b/src/api/__tests__/email.test.ts @@ -553,10 +553,12 @@ describe('email operations', () => { expect(mockRequest).toHaveBeenCalledTimes(1); const calls = mockRequest.mock.calls[0][0]; - expect(calls).toHaveLength(2); + expect(calls).toHaveLength(3); expect(calls[0][0]).toBe('Email/set'); expect(calls[1][0]).toBe('EmailSubmission/set'); expect(calls[1][1].create['sub-1'].emailId).toBe('#draft'); + // The new submission's delivery status, named by its creation id. + expect(calls[2]).toEqual(['EmailSubmission/get', { accountId: 'acc-1', ids: ['#sub-1'], properties: ['id', 'deliveryStatus'] }, '2']); }); it('should use htmlBody when provided', async () => { diff --git a/src/api/__tests__/fake-submission-server.ts b/src/api/__tests__/fake-submission-server.ts index 54cde059..77b8128d 100644 --- a/src/api/__tests__/fake-submission-server.ts +++ b/src/api/__tests__/fake-submission-server.ts @@ -13,6 +13,16 @@ // notFound for a record that is gone; // - EmailSubmission/changes from "n": the whole log in pages of maxChanges, // a record created and destroyed within it left out; +// - the SMTP RCPT stage, which runs while a submission is created, held or +// not: a refused recipient is recorded in deliveryStatus as +// `delivered: "no"` with the server's reply, the others as `queued`; with +// nobody taken nothing is queued, the create answers `final` and sendAt is +// the time of the create (seen live on 0.16.23 and 1.0); +// - `#creationId` in EmailSubmission/get `ids`, naming a submission created +// earlier in the same request, and Stalwart's invalidResultReference when +// that create failed; +// - a shared account that refuses this login's submission calls with +// `forbidden` (0.16.23 lists one that only shared an address book); // - Email/set, Email/import and Email/get, just enough for the send paths. // It records the one thing the client must never do - destroy a submission // that get reports as pending with sendAt in the future - in `violations`. @@ -24,8 +34,18 @@ export type StalwartVersion = '0.16.6' | '0.16.10' | '0.16.23' | '1.0'; export const OVER_QUOTA_DESCRIPTION = 'There are too many email submissions, please delete some before adding a new one.'; +// Stalwart's replies to a recipient it refuses at RCPT (live, 0.16.23 and 1.0). +export const RELAY_DENIED = '550 5.1.2 Relay not allowed.'; +export const RATE_LIMITED = '452 4.4.5 Rate limit exceeded, try again later.'; + const DAY_MS = 86_400_000; +interface DeliveryStatus { + delivered: string; + smtpReply: string; + displayed: string; +} + interface Submission { id: string; seq: number; @@ -35,6 +55,9 @@ interface Submission { canceled: boolean; /** Still in the queue with sendAt passed (a delivery being retried). */ retrying: boolean; + /** False when the RCPT stage refused every recipient: nothing was queued. */ + taken: boolean; + deliveryStatus?: Record; envelope?: unknown; } @@ -71,6 +94,10 @@ export function createFakeSubmissionServer(options: { maxChanges?: number; /** Leave `total` out of query answers, as a server may. */ omitTotal?: boolean; + /** The SMTP reply the RCPT stage refuses a recipient with; undefined takes it. */ + refuseRecipient?: (email: string) => string | undefined; + /** Accounts whose EmailSubmission calls this login is refused (`forbidden`). */ + forbiddenAccounts?: string[]; }) { const { version } = options; const limit = options.limit ?? 500; @@ -107,13 +134,14 @@ export function createFakeSubmissionServer(options: { function status(s: Submission): 'pending' | 'final' | 'canceled' { if (s.canceled) return 'canceled'; + if (!s.taken) return 'final'; if (s.retrying) return 'pending'; return s.sendAt > Date.now() ? 'pending' : 'final'; } - function addSubmission(accountId: string, fields: Omit): Submission { + function addSubmission(accountId: string, fields: Omit & { taken?: boolean }): Submission { seq += 1; - const submission: Submission = { id: `s${seq}`, seq, ...fields }; + const submission: Submission = { id: `s${seq}`, seq, ...fields, taken: fields.taken ?? true }; recordsOf(accountId).set(submission.id, submission); logOf(accountId).push({ id: submission.id, kind: 'created' }); return submission; @@ -128,6 +156,7 @@ export function createFakeSubmissionServer(options: { sendAt: utcDate(s.sendAt), undoStatus: status(s), envelope: s.envelope ?? null, + deliveryStatus: s.deliveryStatus ?? null, }; if (!properties) return full; return Object.fromEntries(['id', ...properties].map((p) => [p, full[p]])); @@ -156,7 +185,7 @@ export function createFakeSubmissionServer(options: { function createdAnswer(s: Submission): Record { if (version === '0.16.6') return { id: s.id }; if (version === '0.16.10') return { id: s.id, sendAt: utcDate(s.sendAt), undoStatus: 'final' }; - return { id: s.id, sendAt: utcDate(s.sendAt), undoStatus: 'pending' }; + return { id: s.id, sendAt: utcDate(s.sendAt), undoStatus: s.taken ? 'pending' : 'final' }; } function holdSeconds(create: Record): number { @@ -164,7 +193,26 @@ export function createFakeSubmissionServer(options: { return hold ? Number(hold) : 0; } - function submissionSet(args: Record, callId: string, createdEmails: Map): Response[] { + /** The RCPT stage: each recipient of the envelope, or else of the message, taken or refused. */ + function rcptStage(create: Record, email: StoredEmail): Record { + const envelope = (create.envelope?.rcptTo as Array<{ email: string }> | undefined)?.map((r) => r.email); + const headers = [email.to, email.cc, email.bcc] + .flatMap((list) => (Array.isArray(list) ? list as Array<{ email: string }> : [])) + .map((r) => r.email); + return Object.fromEntries((envelope ?? headers).map((address) => { + const reply = options.refuseRecipient?.(address); + return [address, reply + ? { delivered: 'no', smtpReply: reply, displayed: 'unknown' } + : { delivered: 'queued', smtpReply: '250 2.1.5 Queued', displayed: 'unknown' }]; + })); + } + + function submissionSet( + args: Record, + callId: string, + createdEmails: Map, + createdSubmissions: Map, + ): Response[] { const accountId = args.accountId as string; const records = recordsOf(accountId); const body: Record = { accountId, newState: `s${seq}` }; @@ -185,14 +233,22 @@ export function createFakeSubmissionServer(options: { continue; } accepted += 1; + // Held or not, the RCPT stage runs now. With nobody taken, nothing + // is queued, and the record's sendAt is the time of the create. + const deliveryStatus = rcptStage(create, emails.get(emailId)!); + const statuses = Object.values(deliveryStatus); + const taken = statuses.length === 0 || statuses.some((d) => d.delivered !== 'no'); const submission = addSubmission(accountId, { emailId, identityId: String(create.identityId), - sendAt: Date.now() + holdSeconds(create) * 1000, + sendAt: Date.now() + (taken ? holdSeconds(create) * 1000 : 0), canceled: false, retrying: false, + taken, + deliveryStatus, envelope: create.envelope, }); + createdSubmissions.set(creationId, submission.id); (body.created ??= {})[creationId] = createdAnswer(submission); const update = args.onSuccessUpdateEmail?.[`#${creationId}`]; if (update) { @@ -276,10 +332,13 @@ export function createFakeSubmissionServer(options: { const responses: Response[] = []; requests.push({ calls, using, responses }); const createdEmails = new Map(); + const createdSubmissions = new Map(); for (const [name, rawArgs, callId] of calls) { const args = rawArgs as Record; const accountId = args.accountId as string; - if (name === 'Email/set') { + if (name.startsWith('EmailSubmission/') && options.forbiddenAccounts?.includes(accountId)) { + responses.push(['error', { type: 'forbidden', description: `You are not an owner of account ${accountId}` }, callId]); + } else if (name === 'Email/set') { const body: Record = { accountId }; for (const [creationId, create] of Object.entries((args.create ?? {}) as Record>)) { if (emailCreateFailure) { @@ -341,9 +400,28 @@ export function createFakeSubmissionServer(options: { responses.push(['EmailSubmission/query', body, callId]); } else if (name === 'EmailSubmission/get') { const ref = args['#ids'] as { resultOf: string } | undefined; - const ids = ref - ? (responses.find(([, , id]) => id === ref.resultOf)?.[1].ids as string[]) - : (args.ids as string[]); + let ids: string[] = []; + let unresolved: string | undefined; + if (ref) { + const [sourceName, source] = responses.find(([, , id]) => id === ref.resultOf) ?? []; + if (sourceName && sourceName !== 'error') ids = source!.ids as string[]; + else unresolved = `Result reference to ${ref.resultOf} not found.`; + } else { + // "#" names a submission created earlier in this + // request; a create that failed leaves the whole get refused. + for (const id of args.ids as string[]) { + const created = id.startsWith('#') ? createdSubmissions.get(id.slice(1)) : id; + if (!created) { + unresolved = `Id reference "${id.slice(1)}" does not exist or is invalid.`; + break; + } + ids.push(created); + } + } + if (unresolved) { + responses.push(['error', { type: 'invalidResultReference', description: unresolved }, callId]); + continue; + } const records = recordsOf(accountId); responses.push(['EmailSubmission/get', { accountId, @@ -355,7 +433,7 @@ export function createFakeSubmissionServer(options: { notFound: ids.filter((id) => !records.has(id)), }, callId]); } else if (name === 'EmailSubmission/set') { - responses.push(...submissionSet(args, callId, createdEmails)); + responses.push(...submissionSet(args, callId, createdEmails, createdSubmissions)); } else if (name === 'EmailSubmission/changes') { responses.push(submissionChanges(args, callId)); } else { diff --git a/src/api/email.ts b/src/api/email.ts index 728f3a6c..0b7593f1 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1,13 +1,13 @@ import { jmapClient } from './jmap-client'; import { - assertSetResult, batched, failedIdsOf, JMAPSetError, NoLongerScheduledError, parseHoldLimit, requireMethodResult, - ScheduleTooLateError, + assertSetResult, batched, failedIdsOf, JMAPSetError, NoLongerScheduledError, parseHoldLimit, RecipientsRefusedError, + requireMethodResult, ScheduleTooLateError, type RefusedRecipient, } from './jmap-result'; import { keywordPointer, mailboxPointer } from './patch-pointer'; import { - destroyEmailSubmissions, isStalwartAccount, makeRoomForSubmission, readSubmissionDestroy, - submissionCreateError, submissionDestroyCall, submissionQuotaError, SUBMISSION_ONLY_USING, - SUBMISSION_SCAN_MAX_PAGES, + deliveryStatusCall, destroyEmailSubmissions, isStalwartAccount, makeRoomForSubmission, readSubmissionDestroy, + refusedRecipients, submissionAccountIds, 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'; @@ -1517,6 +1517,12 @@ export interface SendEmailResult { emailSubmissionId?: string; /** Post-send filing/cleanup problem (message did go out). */ filingWarning?: string; + /** + * Recipients the server refused while it took the submission; the others + * get the message. When it refused every one, the send throws + * RecipientsRefusedError instead. + */ + refusedRecipients?: RefusedRecipient[]; } function toMessageIdList(value: string[] | string | undefined): string[] | undefined { @@ -1693,6 +1699,9 @@ export async function sendEmail( [ ['Email/set', { accountId, create: { draft: emailCreate } }, '0'], ['EmailSubmission/set', submissionArgs, '1'], + // Which recipients the server took. A refused one is lost without a + // bounce while the create succeeds and files the message in Sent. + deliveryStatusCall(accountId, 'sub-1', '2'), ], SUBMISSION_USING, ); @@ -1705,15 +1714,23 @@ export async function sendEmail( if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') { await makeRoomForSubmission(accountId); const retry = await jmapClient.request( - [['EmailSubmission/set', { - ...submissionArgs, - create: { 'sub-1': { ...submissionCreate, emailId: copyId } }, - }, '1']], + [ + ['EmailSubmission/set', { + ...submissionArgs, + create: { 'sub-1': { ...submissionCreate, emailId: copyId } }, + }, '1'], + deliveryStatusCall(accountId, 'sub-1', '2'), + ], SUBMISSION_USING, ); res = { ...retry, methodResponses: [copyCreated, ...(retry.methodResponses ?? [])] }; } + // The get goes on its own: it fails whenever the create did, and must not + // count as a failed send or a filing problem either way. + const deliveryAnswer = res.methodResponses.find(([, , callId]) => callId === '2'); + res = { ...res, methodResponses: res.methodResponses.filter(([, , callId]) => callId !== '2') }; + let emailId: string | undefined; let emailSubmissionId: string | undefined; let sendAt: string | undefined; @@ -1781,6 +1798,25 @@ export async function sendEmail( throw failure; } + // Every recipient refused: nothing was queued, so nothing goes out, held or + // not, and no bounce will tell. That is a failed send. The copy made for it + // goes, as after any failed send - onSuccessUpdateEmail filed it in Sent, + // where it passes for a sent message - while the previous draft version + // stays and the composer keeps the text, so the address can be fixed and + // the message sent again. The record can neither go out nor be cancelled. + const delivery = emailSubmissionId ? refusedRecipients(deliveryAnswer) : { recipients: [], all: false }; + if (delivery.all) { + if (emailId) { + try { + await destroyEmails([emailId], accountId); + } catch (err) { + console.warn('[email] failed to remove the unsent copy:', err); + } + } + if (isStalwartAccount(accountId)) await destroyEmailSubmissions([emailSubmissionId!], accountId); + throw new RecipientsRefusedError(delivery.recipients); + } + // An immediate send's submission record has done its job once the request // that created it returned: the message is queued and filed, and with no // hold there is nothing to cancel. Stalwart 1.0 stops accepting submissions @@ -1825,6 +1861,9 @@ export async function sendEmail( emailId, emailSubmissionId, filingWarning, + // Some recipients refused: the others get the message, the caller says + // who doesn't. + ...(delivery.recipients.length > 0 ? { refusedRecipients: delivery.recipients } : {}), }; } @@ -1885,7 +1924,7 @@ export interface ScheduledEmail { // primary-only lookup never found it, so it could not be cancelled (webmail // #874). Empty when no account supports FUTURERELEASE. export async function listScheduledEmails(): Promise { - const accountIds = jmapClient.getSubmissionAccountIds().filter((id) => jmapClient.hasDelayedSend(id)); + const accountIds = submissionAccountIds().filter((id) => jmapClient.hasDelayedSend(id)); const out: ScheduledEmail[] = []; for (const accountId of accountIds) { try { @@ -2029,7 +2068,8 @@ export async function cancelScheduledSend(emailSubmissionId: string, accountIdOv * went out, and a refused cancel (the original had just gone out) still let * the replacement go, so it was sent twice. Now a refused replacement leaves * the original alone, and a replacement whose original can't be cancelled is - * withdrawn again. Returns the new submission id and send time. + * withdrawn again. Returns the new submission id and send time, and the + * recipients the server refused for the replacement when it took the others. */ export async function rescheduleScheduledSend( scheduled: { @@ -2043,7 +2083,7 @@ export async function rescheduleScheduledSend( }, holdForSeconds: number, recipients?: EmailAddress[], -): Promise<{ emailSubmissionId?: string; sendAt?: string }> { +): Promise<{ emailSubmissionId?: string; sendAt?: string; refusedRecipients?: RefusedRecipient[] }> { const accountId = scheduled.accountId ?? jmapClient.accountId; // "Send now" is a 1-second hold, so the replacement can still be withdrawn. const holdFor = Math.max(1, Math.ceil(holdForSeconds)); @@ -2080,15 +2120,18 @@ export async function rescheduleScheduledSend( // 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) } } }, + [ + ['EmailSubmission/set', { + accountId, + create: { + replacement: { + ...create, + envelope: { ...create.envelope, mailFrom: { ...create.envelope.mailFrom, parameters: { HOLDFOR: String(seconds) } } }, + }, }, - }, - }, '0']], + }, '0'], + deliveryStatusCall(accountId, 'replacement', '1'), + ], [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], ); let res = await createReplacement(holdFor); @@ -2112,6 +2155,16 @@ export async function rescheduleScheduledSend( 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'); + // The recipients are checked again for the replacement, and a throttle + // used up since the original was taken refuses them all: nothing was + // queued, and cancelling the original would lose the message. It goes at + // its old time instead; the replacement's record is dead weight. + const delivery = refusedRecipients(res.methodResponses.find(([, , callId]) => callId === '1')); + if (delivery.all) { + if (isStalwartAccount(accountId)) await destroyEmailSubmissions([created.id], accountId); + throw new RecipientsRefusedError(delivery.recipients); + } + // Either cancel below also removes the record it canceled, so a reschedule // leaves only the replacement behind. try { @@ -2130,7 +2183,11 @@ export async function rescheduleScheduledSend( throw new Error(`Failed to cancel the previous schedule: ${reason}`); } } - return { emailSubmissionId: created.id, sendAt: created.sendAt }; + return { + emailSubmissionId: created.id, + sendAt: created.sendAt, + ...(delivery.recipients.length > 0 ? { refusedRecipients: delivery.recipients } : {}), + }; } /** diff --git a/src/api/jmap-result.ts b/src/api/jmap-result.ts index 8d7af43c..eda79f3d 100644 --- a/src/api/jmap-result.ts +++ b/src/api/jmap-result.ts @@ -165,6 +165,32 @@ export class NoLongerScheduledError extends Error { } } +/** A recipient the server refused to take a message for (EmailSubmission deliveryStatus `delivered: "no"`). */ +export interface RefusedRecipient { + email: string; + /** The server's SMTP reply, e.g. "550 5.1.2 Relay not allowed." */ + smtpReply?: string; +} + +/** "nobody@example.net (550 5.1.2 Relay not allowed.)", one per refused recipient. */ +export function formatRefusedRecipients(recipients: RefusedRecipient[]): string { + return recipients.map((r) => (r.smtpReply ? `${r.email} (${r.smtpReply})` : r.email)).join(', '); +} + +/** + * The server took a submission but refused every one of its recipients, so + * nothing was queued: nothing goes out, and no bounce will say so. Stalwart + * answers this way to a relay it doesn't allow ("550 5.1.2 Relay not + * allowed.") and to a throttle that is used up ("452 4.4.5 Rate limit + * exceeded, try again later."), on 0.16 and 1.0 alike. + */ +export class RecipientsRefusedError extends Error { + constructor(readonly recipients: RefusedRecipient[]) { + super(`The server refused ${formatRefusedRecipients(recipients)}`); + this.name = 'RecipientsRefusedError'; + } +} + /** 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 eec9c8de..03bd2d00 100644 --- a/src/api/submissions.ts +++ b/src/api/submissions.ts @@ -8,9 +8,12 @@ // any more are ever destroyed. Port of the webmail's client (48382170). import { jmapClient } from './jmap-client'; -import { SubmissionQuotaError, batched, requireMethodResult } from './jmap-result'; +import { + SubmissionQuotaError, batched, jmapErrorType, requireMethodResult, type RefusedRecipient, +} from './jmap-result'; import { CAPABILITIES } from './types'; import type { JMAPMethodCall, JMAPResponseBody } from './types'; +import { debug } from '../lib/debug'; /** `using` for EmailSubmission calls that neither read nor write an Email. */ export const SUBMISSION_ONLY_USING = [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION]; @@ -29,15 +32,36 @@ export const SUBMISSION_SCAN_MAX_PAGES = 40; const prunedAt = new Map(); /** Accounts where making room found nothing to free, until when (epoch ms). */ const roomRecheckAt = new Map(); +/** + * Shared accounts that advertise EmailSubmission but refused this login's + * calls with `forbidden` (see pruneSubmissionAccount), keyed by server, login + * and account. Left out of submissionAccountIds while the app runs. + */ +const refusedAccounts = new Set(); function pruneKey(accountId: string): string { return `${jmapClient.serverUrl ?? ''}|${accountId}`; } +/** Access is the login's: another login on the same server may be a member of the account. */ +function refusalKey(accountId: string): string { + return `${pruneKey(accountId)}|${jmapClient.username ?? ''}`; +} + /** Test hook. */ export function resetSubmissionPrune(): void { prunedAt.clear(); roomRecheckAt.clear(); + refusedAccounts.clear(); +} + +/** + * The accounts whose submission records this login reaches: the primary + * submission account and every shared or group account advertising + * submission, except those that refused them. + */ +export function submissionAccountIds(): string[] { + return jmapClient.getSubmissionAccountIds().filter((id) => !refusedAccounts.has(refusalKey(id))); } /** @@ -98,6 +122,39 @@ export function submissionCreateError(res: JMAPResponseBody, creationId: string) ?.find(([name]) => name === 'EmailSubmission/set')?.[1]?.notCreated?.[creationId]; } +/** + * EmailSubmission/get of the submission a /set creates in the same request, + * named by its creation id; a result reference into the /set answer does not + * resolve on 1.0. The server runs the SMTP RCPT stage while it creates the + * submission - for a held send as well, only the delivery waits - and + * records a refused recipient right away as `delivered: "no"` with its + * reply. The create's own answer carries no deliveryStatus (0.16 and 1.0). + */ +export function deliveryStatusCall(accountId: string, creationId: string, callId: string): JMAPMethodCall { + return ['EmailSubmission/get', { accountId, ids: [`#${creationId}`], properties: ['id', 'deliveryStatus'] }, callId]; +} + +/** + * The recipients the server refused for a new submission, from the answer to + * deliveryStatusCall, and whether it refused every one. Nothing is known when + * that get failed - it does whenever the create did - or the server keeps no + * delivery status. + */ +export function refusedRecipients( + answer: [string, Record, string] | undefined, +): { recipients: RefusedRecipient[]; all: boolean } { + const [name, body] = answer ?? []; + const status = name === 'EmailSubmission/get' + ? (body?.list?.[0] as { deliveryStatus?: Record | null } | undefined) + ?.deliveryStatus + : undefined; + const entries = Object.entries(status ?? {}); + const recipients = entries + .filter(([, delivery]) => delivery?.delivered === 'no') + .map(([email, delivery]) => ({ email, ...(delivery?.smtpReply ? { smtpReply: delivery.smtpReply } : {}) })); + return { recipients, all: recipients.length > 0 && recipients.length === entries.length }; +} + export function submissionDestroyCall(ids: string[], accountId: string, callId: string): JMAPMethodCall { return ['EmailSubmission/set', { accountId, destroy: ids }, callId]; } @@ -143,9 +200,9 @@ export async function destroyEmailSubmissions(ids: string[], accountId: string): } /** - * Destroy, in every submission account, the records that can no longer be - * cancelled (see prunableSubmissionIds). That includes other devices' and - * other group members' records: the limit is per account. + * Destroy, in every submission account (submissionAccountIds), the records + * that can no longer be cancelled (see prunableSubmissionIds). That includes + * other devices' and other group members' records: the limit is per account. * EmailSubmission/query only sees records whose sendAt is at most three days * back, so older ones are left to makeRoomForSubmission. * @@ -160,8 +217,9 @@ export async function pruneEmailSubmissions(options: { force?: boolean } = {}): // Without submission there are no records, and a request naming the // capability would be refused whole. if (!jmapClient.hasCapability(CAPABILITIES.SUBMISSION)) return 0; - for (const accountId of jmapClient.getSubmissionAccountIds()) { - destroyed += await pruneSubmissionAccount(accountId, options.force ?? false); + const [primary] = jmapClient.getSubmissionAccountIds(); + for (const accountId of submissionAccountIds()) { + destroyed += await pruneSubmissionAccount(accountId, options.force ?? false, accountId === primary); } } catch (err) { console.warn('[submissions] prune failed:', err); @@ -169,7 +227,7 @@ export async function pruneEmailSubmissions(options: { force?: boolean } = {}): return destroyed; } -async function pruneSubmissionAccount(accountId: string, force: boolean): Promise { +async function pruneSubmissionAccount(accountId: string, force: boolean, primary: boolean): Promise { const now = Date.now(); const key = pruneKey(accountId); const lastRun = prunedAt.get(key); @@ -180,6 +238,15 @@ async function pruneSubmissionAccount(accountId: string, force: boolean): Promis if (prunable.length === 0) return 0; return (await destroyEmailSubmissions(prunable, accountId)).length; } catch (err) { + // Stalwart 0.16 lists an account that shared only an address book or a + // calendar with the submission capability, then refuses its records + // ("You are not an owner of account ..."). Nothing of this login's is + // there, so the account is left alone while the app runs. + if (jmapErrorType(err) === 'forbidden' && !primary) { + refusedAccounts.add(refusalKey(accountId)); + debug.log('email', `[submissions] ${accountId} refuses this login's submissions; not asking again`); + return 0; + } console.warn(`[submissions] pruning ${accountId} failed:`, err); return 0; } diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index 17dfe7bc..ac2e46de 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -13,7 +13,7 @@ import { sendEmail, patchKeywordsForEmails } from '../../api/email'; import { jmapClient } from '../../api/jmap-client'; import { pruneEmailSubmissions } from '../../api/submissions'; import { buildReplyRecipients } from '../../lib/reply-recipients'; -import { sendFailureMessage } from '../../lib/send-failure-message'; +import { partlyRefusedWarning, sendFailureMessage } from '../../lib/send-failure-message'; import { buildReplySubject } from '../../lib/subject-prefix'; import { computeReplyThreadingHeaders } from '../../lib/email-threading'; import { resolveReplyIdentity } from '../../lib/reply-identity'; @@ -136,8 +136,14 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P setText(''); // The keyboard would cover the undo bar or the toast. Keyboard.dismiss(); - // A reply held for the undo-send delay has not gone out yet (webmail b03a0c1d). - if (!result.scheduled) toast.success(t('notifications.email_sent', 'Email sent successfully')); + if (result.refusedRecipients?.length) { + // As in the composer: nothing else would say who never gets it. + const { title, message } = partlyRefusedWarning(result.refusedRecipients, t); + toast.warning(title, { message, duration: 10_000 }); + } else if (!result.scheduled) { + // A reply held for the undo-send delay has not gone out yet (webmail b03a0c1d). + toast.success(t('notifications.email_sent', 'Email sent successfully')); + } } catch (err) { Alert.alert(t('email_composer.send_failed', 'Failed to send'), sendFailureMessage(err, t)); } finally { diff --git a/src/lib/__tests__/send-failure-message.test.ts b/src/lib/__tests__/send-failure-message.test.ts index 8cced714..e554c59f 100644 --- a/src/lib/__tests__/send-failure-message.test.ts +++ b/src/lib/__tests__/send-failure-message.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from 'vitest'; import { RequestTimeoutError } from '../../api/jmap-client'; import { - JMAPMethodError, NoLongerScheduledError, ScheduleTooLateError, SubmissionQuotaError, + JMAPMethodError, NoLongerScheduledError, RecipientsRefusedError, ScheduleTooLateError, SubmissionQuotaError, + formatRefusedRecipients, } from '../../api/jmap-result'; -import { sendFailureMessage } from '../send-failure-message'; +import { partlyRefusedWarning, rescheduleFailureMessage, sendFailureMessage } from '../send-failure-message'; // The English fallback stands in for the catalog. const t = (key: string, fallback?: string, params?: Record) => @@ -48,3 +49,45 @@ describe('sendFailureMessage beyond Stalwart and around a reschedule', () => { expect(sendFailureMessage(new NoLongerScheduledError(false), t)).toBe('This message is no longer scheduled.'); }); }); + +// Stalwart 0.16 and 1.0 take the submission and refuse recipients at RCPT, +// each with its own reply. +describe('recipients the server refused', () => { + const refused = new RecipientsRefusedError([ + { email: 'nobody@example.net', smtpReply: '550 5.1.2 Relay not allowed.' }, + { email: 'busy@example.org', smtpReply: '452 4.4.5 Rate limit exceeded, try again later.' }, + ]); + + it('says the message was not sent and names every refused recipient with its reply', () => { + expect(sendFailureMessage(refused, t)).toBe( + 'Not sent: the server refused nobody@example.net (550 5.1.2 Relay not allowed.), busy@example.org (452 4.4.5 Rate limit exceeded, try again later.).', + ); + }); + + it('lists an address without its reply when the server gave none', () => { + expect(formatRefusedRecipients([{ email: 'a@example.net' }, { email: 'b@example.net', smtpReply: '550 5.1.1 No such user' }])) + .toBe('a@example.net, b@example.net (550 5.1.1 No such user)'); + }); + + it('says the message is still scheduled when the server refused every recipient of a reschedule', () => { + const error = new RecipientsRefusedError([{ email: 'bob@example.org', smtpReply: '452 4.4.5 Rate limit exceeded, try again later.' }]); + expect(rescheduleFailureMessage(error, t, 'Sep 26, 10:00')).toBe( + 'Not sent: the server refused bob@example.org (452 4.4.5 Rate limit exceeded, try again later.). The message is still scheduled for Sep 26, 10:00.', + ); + expect(rescheduleFailureMessage(error, t)).toBe( + 'Not sent: the server refused bob@example.org (452 4.4.5 Rate limit exceeded, try again later.). The message is still scheduled.', + ); + }); + + it('leaves other reschedule failures as the send failure message', () => { + expect(rescheduleFailureMessage(new NoLongerScheduledError(true), t, 'Sep 26, 10:00')).toBe('This message was already sent.'); + expect(rescheduleFailureMessage(new Error('Quota exceeded'), t)).toBe('Quota exceeded'); + }); + + it('warns about the recipients refused while the others get the message', () => { + expect(partlyRefusedWarning([{ email: 'nobody@example.net', smtpReply: '550 5.1.2 Relay not allowed.' }], t)).toEqual({ + title: 'Not sent to everyone', + message: 'The server refused nobody@example.net (550 5.1.2 Relay not allowed.). The other recipients get the message.', + }); + }); +}); diff --git a/src/lib/send-failure-message.ts b/src/lib/send-failure-message.ts index 5a119bab..60fcf059 100644 --- a/src/lib/send-failure-message.ts +++ b/src/lib/send-failure-message.ts @@ -1,5 +1,8 @@ import { RequestTimeoutError } from '../api/jmap-client'; -import { NoLongerScheduledError, ScheduleTooLateError, SubmissionQuotaError } from '../api/jmap-result'; +import { + NoLongerScheduledError, RecipientsRefusedError, ScheduleTooLateError, SubmissionQuotaError, formatRefusedRecipients, + type RefusedRecipient, +} from '../api/jmap-result'; import type { MessageParams } from '../i18n/format'; type Translate = (key: string, fallback?: string, params?: MessageParams) => string; @@ -17,7 +20,10 @@ type Translate = (key: string, fallback?: string, params?: MessageParams) => str * (#702); * - 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. + * scheduled, while it made room for the replacement; + * - every recipient refused: the server took the submission, so nothing + * else would say that the message went nowhere. Its reply per address + * goes with it. */ export function sendFailureMessage(error: unknown, t: Translate): string { if (error instanceof SubmissionQuotaError) { @@ -46,7 +52,44 @@ export function sendFailureMessage(error: unknown, t: Translate): string { if (error instanceof ScheduleTooLateError) { return t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.'); } + if (error instanceof RecipientsRefusedError) { + return t('email_composer.recipients_refused', 'Not sent: the server refused {recipients}.', { + recipients: formatRefusedRecipients(error.recipients), + }); + } return error instanceof Error && error.message ? error.message : t('notifications.error_sending', 'Failed to send email'); } + +/** + * What to tell the user about a "Send now" or reschedule that failed. When + * the server refused every recipient of the replacement, the original was + * kept: it still goes out at its old time, `scheduledAt` (formatted) when + * known (webmail rescheduleFailureToast). + */ +export function rescheduleFailureMessage(error: unknown, t: Translate, scheduledAt?: string): string { + const message = sendFailureMessage(error, t); + if (!(error instanceof RecipientsRefusedError)) return message; + const stillScheduled = scheduledAt + ? t('email_viewer.still_scheduled_at', 'The message is still scheduled for {date}.', { date: scheduledAt }) + : t('email_viewer.still_scheduled', 'The message is still scheduled.'); + return `${message} ${stillScheduled}`; +} + +/** + * The warning after a send that the server took for some recipients only: + * nothing else, not even a bounce, would tell the user who never gets it. + * The addresses and replies go in the message, which a toast shows on more + * lines than its title. + */ +export function partlyRefusedWarning(recipients: RefusedRecipient[], t: Translate): { title: string; message: string } { + return { + title: t('email_composer.recipients_partly_refused_title', 'Not sent to everyone'), + message: t( + 'email_composer.recipients_partly_refused', + 'The server refused {recipients}. The other recipients get the message.', + { recipients: formatRefusedRecipients(recipients) }, + ), + }; +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index fc3e4145..9bdc58da 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -43,7 +43,7 @@ import { import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client'; import { pruneEmailSubmissions } from '../api/submissions'; import { uploadBlob, uploadBytes } from '../api/blob'; -import { sendFailureMessage } from '../lib/send-failure-message'; +import { partlyRefusedWarning, sendFailureMessage } from '../lib/send-failure-message'; import { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients'; import { buildReplySubject, buildForwardSubject } from '../lib/subject-prefix'; import { computeReplyThreadingHeaders, generateMessageId, stripMessageIdBrackets } from '../lib/email-threading'; @@ -2122,6 +2122,12 @@ export default function ComposeScreen({ route, navigation }: Props) { if (result.filingWarning) { console.warn('[compose] post-send filing warning:', result.filingWarning); } + // The server refused some recipients outright while it took the others. + const refused = result.refusedRecipients ?? []; + if (refused.length > 0) { + const { title, message } = partlyRefusedWarning(refused, t); + toast.warning(title, { message, duration: 10_000 }); + } // Flag the original so the list shows the reply/forward arrow; best // effort - the message already left. if (replyTo?.originalEmailId) { @@ -2157,8 +2163,9 @@ export default function ComposeScreen({ route, navigation }: Props) { from: outgoing.from, to: [...outgoing.to, ...(outgoing.cc ?? []), ...(outgoing.bcc ?? [])], }); - } else { - // Held sends get the undo bar instead (webmail b03a0c1d). + } else if (refused.length === 0) { + // Held sends get the undo bar instead (webmail b03a0c1d), and refused + // recipients the warning above. toast.success(t('notifications.email_sent', 'Email sent successfully')); } allowLeaveRef.current = true; diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index 085c3618..ca3f0bdc 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -16,12 +16,13 @@ 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 { NoLongerScheduledError, RecipientsRefusedError, type RefusedRecipient } from '../api/jmap-result'; +import { partlyRefusedWarning, rescheduleFailureMessage, sendFailureMessage } from '../lib/send-failure-message'; import { useEmailStore } from '../stores/email-store'; import { useLocaleStore } from '../stores/locale-store'; import { useSettingsStore } from '../stores/settings-store'; import { useSendUndoStore } from '../stores/send-undo-store'; +import { toast } from '../stores/toast-store'; import { ownMailboxes } from '../lib/mailbox-tree'; import { draftContextFromEmail } from '../lib/draft-context'; import { formatQuoteDate } from '../lib/quote-header'; @@ -100,12 +101,25 @@ export default function ScheduledScreen({ navigation }: Props) { void load(); return; } + // The server refused every recipient of the replacement (a throttle + // used up since the message was scheduled): it goes at its old time. + if (e instanceof RecipientsRefusedError) { + Alert.alert(failTitle, rescheduleFailureMessage(e, t, formatWhen(item.sendAt))); + return; + } Alert.alert(failTitle, e instanceof Error ? e.message : String(e)); } finally { setBusyId(null); } }; + /** Recipients the server refused for a replacement it took for the others. */ + const warnRefused = (refused: RefusedRecipient[] | undefined) => { + if (!refused?.length) return; + const { title, message } = partlyRefusedWarning(refused, t); + toast.warning(title, { message, duration: 10_000 }); + }; + const removeItem = (id: string) => setItems((prev) => prev.filter((i) => i.emailSubmissionId !== id)); const onCancel = (item: ScheduledEmail) => { @@ -133,11 +147,12 @@ export default function ScheduledScreen({ navigation }: Props) { const onSendNow = (item: ScheduledEmail) => { void runAction(item, async () => { - await rescheduleScheduledSend(item, 0); + const result = await rescheduleScheduledSend(item, 0); if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { useSendUndoStore.getState().clear(); } removeItem(item.emailSubmissionId); + warnRefused(result.refusedRecipients); }, t('scheduled.send_now_failed', 'Could not send now')); }; @@ -167,6 +182,7 @@ export default function ScheduledScreen({ navigation }: Props) { ? { ...i, emailSubmissionId: result.emailSubmissionId ?? i.emailSubmissionId, sendAt: result.sendAt ?? date.toISOString() } : i)) .sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime())); + warnRefused(result.refusedRecipients); }, t('scheduled.reschedule_failed', 'Reschedule failed')); }; diff --git a/src/stores/__tests__/send-undo-store.test.ts b/src/stores/__tests__/send-undo-store.test.ts index 8f8039e4..53a1dcc8 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 { NoLongerScheduledError, SubmissionQuotaError } from '../../api/jmap-result'; +import { NoLongerScheduledError, RecipientsRefusedError, 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'; @@ -183,6 +183,44 @@ describe('send-undo-store', () => { expect(useSendUndoStore.getState()).toMatchObject({ pending: null, busy: false }); }); + // A throttle used up since the send was taken (Stalwart 0.16 and 1.0 check + // the recipients again for the release): the original was kept. + it('keeps the held message when the server refused every recipient of the release', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + api.rescheduleScheduledSend.mockRejectedValueOnce(new RecipientsRefusedError([ + { email: 'you@example.com', smtpReply: '452 4.4.5 Rate limit exceeded, try again later.' }, + ])); + const pending = entry(); + useSendUndoStore.getState().setPending(pending); + + await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(false); + + expect(useToastStore.getState().toasts).toMatchObject([{ + type: 'error', + title: 'Could not send now', + message: 'Not sent: the server refused you@example.com (452 4.4.5 Rate limit exceeded, try again later.). The message is still scheduled.', + }]); + // It still goes out when the delay ends, and can still be undone. + expect(useSendUndoStore.getState()).toMatchObject({ pending, busy: false }); + }); + + it('names the recipients the server refused for the release while the others get it', async () => { + api.rescheduleScheduledSend.mockResolvedValueOnce({ + emailSubmissionId: 'sub-2', + refusedRecipients: [{ email: 'nobody@example.net', smtpReply: '550 5.1.2 Relay not allowed.' }], + }); + useSendUndoStore.getState().setPending(entry()); + + await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(true); + + expect(useToastStore.getState().toasts).toMatchObject([{ + type: 'warning', + title: 'Not sent to everyone', + message: 'The server refused nobody@example.net (550 5.1.2 Relay not allowed.). The other recipients get the message.', + }]); + expect(useSendUndoStore.getState().pending).toBeNull(); + }); + 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/send-undo-store.ts b/src/stores/send-undo-store.ts index f8f8570b..4f028d68 100644 --- a/src/stores/send-undo-store.ts +++ b/src/stores/send-undo-store.ts @@ -15,7 +15,7 @@ import { pruneEmailSubmissions } from '../api/submissions'; import type { EmailAddress, Mailbox } from '../api/types'; import { draftContextFromEmail } from '../lib/draft-context'; import { mailboxesOfAccount } from '../lib/mailbox-tree'; -import { sendFailureMessage } from '../lib/send-failure-message'; +import { partlyRefusedWarning, rescheduleFailureMessage } from '../lib/send-failure-message'; import type { ComposeDraftContext } from '../navigation/types'; import { t } from './locale-store'; import { toast } from './toast-store'; @@ -107,7 +107,7 @@ export const useSendUndoStore = create((set, get) => ({ if (!entry || get().busy) return false; set({ busy: true }); try { - await rescheduleScheduledSend( + const result = await rescheduleScheduledSend( { emailSubmissionId: entry.emailSubmissionId, emailId: entry.emailId, @@ -120,12 +120,19 @@ export const useSendUndoStore = create((set, get) => ({ ); set({ pending: null, busy: false }); void pruneEmailSubmissions(); + // The recipients are checked again for the release; the ones the + // server refused this time never get the message. + if (result.refusedRecipients?.length) { + const { title, message } = partlyRefusedWarning(result.refusedRecipients, t); + toast.warning(title, { message, duration: 10_000 }); + } return true; } catch (err) { console.warn('[send-undo] send now failed', err); // 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)); + // did not go now: a full submission quota, say, or every recipient + // refused for the release. + toast.error(t('scheduled.send_now_failed', 'Could not send now'), rescheduleFailureMessage(err, t)); // 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 });