fix: stop reporting mail as sent in the app when the server refused its recipients

Stalwart runs the SMTP RCPT stage while it creates a submission and
records a refused recipient as `delivered: "no"` with its reply: a relay
it doesn't allow ("550 5.1.2 Relay not allowed.") or a used-up throttle
("452 4.4.5 Rate limit exceeded, try again later."), on 0.16 and 1.0
alike. The create still succeeds and onSuccessUpdateEmail files the
message in Sent. The app read only the new record's id and sendAt, said
"Email sent", and on Stalwart destroyed the record right after, the only
trace of the refusal. The message was lost without a bounce. Port of
the webmail's e0b0dfe0.

- the send reads the new submission's delivery status in the same
  request (`EmailSubmission/get` of `#sub-1`, its creation id; the
  create's own answer has none), also with the retry after a full
  submission quota, before the record is destroyed
- every recipient refused: the send fails with the server's reply per
  address, the copy filed in Sent is removed and the previous draft
  kept, so the composer or the quick reply keeps the text and the
  address can be fixed; held sends (undo send, scheduled) are refused
  when they are created, so this covers them too
- some recipients refused: the others get the message, and a warning
  names the refused ones with their replies
- "Send now" and "Reschedule" check the replacement the same way and
  keep the original, which still goes out at its old time, when every
  recipient of the replacement is refused; a partial refusal there gets
  the warning as well
- the submission prune leaves a shared account that refuses its records
  with `forbidden` alone while the app runs, for that login, with a
  debug log instead of a warning, and the scheduled list skips it:
  0.16.23 lists an account that only shared an address book or a
  calendar with the submission capability

`RecipientsRefusedError` and `RefusedRecipient` are added to
`jmap-result.ts`. Five new strings in all 27 languages, four of them
with the webmail's translations.
This commit is contained in:
Linus Rath
2026-09-26 00:51:59 +02:00
parent 00619751d7
commit 7326fec611
41 changed files with 983 additions and 66 deletions
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
@@ -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]);
});
});
@@ -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<Parameters<typeof createFakeSubmissionServer>[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<Record<string, any>> {
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, any>, 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']);
});
});
+3 -1
View File
@@ -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 () => {
+88 -10
View File
@@ -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<string, DeliveryStatus>;
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, 'id' | 'seq'>): Submission {
function addSubmission(accountId: string, fields: Omit<Submission, 'id' | 'seq' | 'taken'> & { 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<string, unknown> {
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<string, any>): number {
@@ -164,7 +193,26 @@ export function createFakeSubmissionServer(options: {
return hold ? Number(hold) : 0;
}
function submissionSet(args: Record<string, any>, callId: string, createdEmails: Map<string, string>): Response[] {
/** The RCPT stage: each recipient of the envelope, or else of the message, taken or refused. */
function rcptStage(create: Record<string, any>, email: StoredEmail): Record<string, DeliveryStatus> {
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<string, any>,
callId: string,
createdEmails: Map<string, string>,
createdSubmissions: Map<string, string>,
): Response[] {
const accountId = args.accountId as string;
const records = recordsOf(accountId);
const body: Record<string, any> = { 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<string, string>();
const createdSubmissions = new Map<string, string>();
for (const [name, rawArgs, callId] of calls) {
const args = rawArgs as Record<string, any>;
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<string, any> = { accountId };
for (const [creationId, create] of Object.entries((args.create ?? {}) as Record<string, Record<string, any>>)) {
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 {
// "#<creation id>" 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 {
+78 -21
View File
@@ -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<ScheduledEmail[]> {
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 } : {}),
};
}
/**
+26
View File
@@ -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;
+74 -7
View File
@@ -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<string, number>();
/** Accounts where making room found nothing to free, until when (epoch ms). */
const roomRecheckAt = new Map<string, number>();
/**
* 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<string>();
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, any>, string] | undefined,
): { recipients: RefusedRecipient[]; all: boolean } {
const [name, body] = answer ?? [];
const status = name === 'EmailSubmission/get'
? (body?.list?.[0] as { deliveryStatus?: Record<string, { delivered?: string; smtpReply?: string } | null> | 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<number> {
async function pruneSubmissionAccount(accountId: string, force: boolean, primary: boolean): Promise<number> {
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;
}
+9 -3
View File
@@ -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 {
+45 -2
View File
@@ -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<string, unknown>) =>
@@ -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.',
});
});
});
+45 -2
View File
@@ -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) },
),
};
}
+10 -3
View File
@@ -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;
+19 -3
View File
@@ -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'));
};
+39 -1
View File
@@ -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);
+11 -4
View File
@@ -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<SendUndoState>((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<SendUndoState>((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 });