mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: keep sending working in the app past Stalwart 1.0's submission limit
Stalwart 1.0 counts every EmailSubmission record of an account against `maxSubmissions` (500 by default), whatever its status or age, and its own purge of old records never runs. The app never removed a record, so after about 500 sends from an account every send, quick reply, read receipt and "Send now" failed with Stalwart's raw "There are too many email submissions, please delete some" error. This ports the webmail fix (48382170). The app now removes the records that can no longer be cancelled. Destroying a record never stops its delivery, but a pending one could no longer be cancelled, so a record EmailSubmission/get reports as pending is never touched: - an immediate send (no undo delay, no schedule) and a read receipt destroy their own record right after the send, riding along with the old-draft cleanup when there is one; only where `urn:stalwart:jmap` is advertised, and never decided by the created undoStatus, which 0.16.10-0.16.16 report as final for held sends - a throttled background prune (`src/api/submissions.ts`) destroys canceled records and final records whose sendAt has passed, going by EmailSubmission/get, in every submission account; it runs at app start, on return to the foreground and after sends and undo - a confirmed cancel (undo, cancel, reschedule, "Send now") destroys the canceled record in a request of its own - an `overQuota` on a submission create prunes at once, on Stalwart walks `EmailSubmission/changes` from "n" for records older than the query's three-day window, retries once, and otherwise throws `SubmissionQuotaError`, shown as a translated explanation in the composer, quick reply, read receipts, the undo bar and the scheduled list; the copy the failed attempt made is removed as before 0.16 never enforces the limit, and destroy never cancels on any version, so the same cleanup runs there unchanged. The undo bar now also says when an undo or "Send now" failed instead of only logging it. The scheduled list reads every page of EmailSubmission/query with calculateTotal; it asked for 200 records, oldest first, so the mail scheduled furthest ahead dropped off once the three-day window held more than that. `SubmissionQuotaError` is added to `jmap-result.ts` as a subclass of `JMAPMethodError`.
This commit is contained in:
@@ -11,6 +11,7 @@ import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-nati
|
||||
|
||||
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
|
||||
import { jmapClient } from './src/api/jmap-client';
|
||||
import { pruneEmailSubmissions } from './src/api/submissions';
|
||||
import type { StateChange } from './src/api/types';
|
||||
import { dispatchStateChange } from './src/lib/state-change-bus';
|
||||
import {
|
||||
@@ -652,6 +653,7 @@ export default function App() {
|
||||
void email.fetchMailboxes();
|
||||
if (email.currentMailboxId) void email.refreshEmails();
|
||||
void useOutboxStore.getState().flush();
|
||||
void pruneEmailSubmissions();
|
||||
};
|
||||
|
||||
// Foreground liveness for the per-account connection dot: the open
|
||||
@@ -672,6 +674,11 @@ export default function App() {
|
||||
});
|
||||
|
||||
void start();
|
||||
// Stalwart 1.0 stops accepting sends once an account holds
|
||||
// `maxSubmissions` submission records, so remove the ones that can no
|
||||
// longer be cancelled (throttled per account; a resume or a send runs it
|
||||
// again).
|
||||
void pruneEmailSubmissions();
|
||||
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
const nowActive = state === 'active';
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "إرسال الآن",
|
||||
"send_timeout_body": "ربما تم إرسال الرسالة بالفعل. تحقق من مجلد المرسل قبل إرسالها مرة أخرى.",
|
||||
"send_timeout_title": "لا يوجد رد من الخادم",
|
||||
"submission_quota": "يحتفظ الخادم بعدد كبير جدًا من سجلات الرسائل المرسلة لهذا الحساب. أزال Bulwark ما استطاع منها؛ وإذا تكرر ذلك، فاطلب من المسؤول رفع حد عمليات إرسال البريد.",
|
||||
"undo_send_label": "الإرسال خلال {seconds} ث…",
|
||||
"upload_failed_short": "فشل الرفع",
|
||||
"uploading": "جارٍ الرفع...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"smime_unsupported": "موقّعة/مشفّرة باستخدام S/MIME - غير مدعوم على الجوال",
|
||||
"source_too_large": "الرسالة كبيرة جدًا بحيث لا يمكن عرضها كاملة. شارك المصدر الكامل بدلًا من ذلك.",
|
||||
"thread_loading": "جارٍ تحميل المحادثة...",
|
||||
"undo_send_failed": "تعذّر التراجع عن الإرسال",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "لا توجد هوية إرسال متاحة"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Envia ara",
|
||||
"send_timeout_body": "És possible que el missatge ja s'hagi enviat. Comproveu la carpeta Enviats abans de tornar-lo a enviar.",
|
||||
"send_timeout_title": "El servidor no respon",
|
||||
"submission_quota": "El servidor té massa registres de missatges enviats per a aquest compte. Bulwark ha eliminat els que ha pogut; si torna a passar, demaneu a l'administrador que augmenti el límit d'enviaments de correu.",
|
||||
"undo_send_label": "S'envia d'aquí a {seconds} s…",
|
||||
"upload_failed_short": "No s'ha pogut pujar",
|
||||
"uploading": "Pujant...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Carregant la conversa...",
|
||||
"undo_send_failed": "No s'ha pogut desfer l'enviament",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "No hi ha cap identitat d'enviament disponible"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Odeslat nyní",
|
||||
"send_timeout_body": "Zpráva už mohla odejít. Než ji odešlete znovu, zkontrolujte složku Odeslané.",
|
||||
"send_timeout_title": "Server neodpovídá",
|
||||
"submission_quota": "Server má pro tento účet příliš mnoho záznamů o odeslaných zprávách. Bulwark odstranil ty, které mohl; pokud se to bude opakovat, požádejte správce o zvýšení limitu odesílání e-mailů.",
|
||||
"undo_send_label": "Odeslání za {seconds} s…",
|
||||
"upload_failed_short": "Nahrávání selhalo",
|
||||
"uploading": "Nahrávání...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Načítání konverzace...",
|
||||
"undo_send_failed": "Odeslání se nepodařilo vrátit",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Není k dispozici žádná identita pro odesílání"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Send nu",
|
||||
"send_timeout_body": "Beskeden er muligvis allerede sendt. Tjek din Sendt-mappe, før du sender den igen.",
|
||||
"send_timeout_title": "Intet svar fra serveren",
|
||||
"submission_quota": "Serveren har for mange registreringer af sendte beskeder for denne konto. Bulwark har fjernet dem, den kunne; hvis det bliver ved med at ske, så bed din administrator om at hæve grænsen for afsendelse af e-mails.",
|
||||
"undo_send_label": "Sender om {seconds} s…",
|
||||
"upload_failed_short": "Upload mislykkedes",
|
||||
"uploading": "Uploader...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Indlæser samtale...",
|
||||
"undo_send_failed": "Afsendelsen kunne ikke fortrydes",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Ingen afsenderidentitet tilgængelig"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Jetzt senden",
|
||||
"send_timeout_body": "Die Nachricht wurde möglicherweise bereits versendet. Prüfen Sie Ihren Gesendet-Ordner, bevor Sie sie erneut senden.",
|
||||
"send_timeout_title": "Keine Antwort vom Server",
|
||||
"submission_quota": "Der Server hat für dieses Konto zu viele Einträge zu gesendeten Nachrichten. Bulwark hat entfernt, was möglich war; wenn das weiterhin vorkommt, bitten Sie Ihren Administrator, das Limit für E-Mail-Übermittlungen zu erhöhen.",
|
||||
"undo_send_label": "Senden in {seconds} s…",
|
||||
"upload_failed_short": "Upload fehlgeschlagen",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Unterhaltung wird geladen...",
|
||||
"undo_send_failed": "Senden konnte nicht rückgängig gemacht werden",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Keine Absenderidentität verfügbar"
|
||||
},
|
||||
|
||||
@@ -290,6 +290,7 @@
|
||||
"send_now": "Send now",
|
||||
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
|
||||
"send_timeout_title": "No answer from the server",
|
||||
"submission_quota": "The server has too many sent-message records for this account. Bulwark removed the ones it could; if this keeps happening, ask your administrator to raise the email submission limit.",
|
||||
"toolbar": {
|
||||
"background_color": "Background color",
|
||||
"remove_background_color": "Remove background color"
|
||||
@@ -403,6 +404,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Loading conversation...",
|
||||
"undo_send_failed": "Undo send failed",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "No sending identity available"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Enviar ahora",
|
||||
"send_timeout_body": "Puede que el mensaje ya se haya enviado. Revisa tu carpeta Enviados antes de volver a enviarlo.",
|
||||
"send_timeout_title": "El servidor no responde",
|
||||
"submission_quota": "El servidor tiene demasiados registros de mensajes enviados para esta cuenta. Bulwark eliminó los que pudo; si esto sigue ocurriendo, pida a su administrador que aumente el límite de envíos de correo.",
|
||||
"undo_send_label": "Enviando en {seconds} s…",
|
||||
"upload_failed_short": "Error al subir",
|
||||
"uploading": "Subiendo...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Cargando conversación...",
|
||||
"undo_send_failed": "No se pudo deshacer el envío",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "No hay ninguna identidad de envío disponible"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "ارسال اکنون",
|
||||
"send_timeout_body": "ممکن است پیام قبلاً ارسال شده باشد. پیش از ارسال دوباره، پوشه ارسال شده را بررسی کنید.",
|
||||
"send_timeout_title": "پاسخی از سرور نیامد",
|
||||
"submission_quota": "سرور برای این حساب سوابق بیش از حدی از پیامهای ارسالشده دارد. Bulwark هر کدام را که میتوانست حذف کرد؛ اگر این وضعیت ادامه یافت، از مدیر سیستم بخواهید سقف ارسال ایمیل را افزایش دهد.",
|
||||
"undo_send_label": "ارسال تا {seconds} ثانیه دیگر…",
|
||||
"upload_failed_short": "بارگذاری ناموفق بود",
|
||||
"uploading": "در حال بارگذاری...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"smime_unsupported": "امضا/رمزگذاریشده با S/MIME - در موبایل پشتیبانی نمیشود",
|
||||
"source_too_large": "پیام برای نمایش کامل بیش از حد بزرگ است. بهجای آن، منبع کامل را به اشتراک بگذارید.",
|
||||
"thread_loading": "در حال بارگذاری مکالمه...",
|
||||
"undo_send_failed": "بازگردانی ارسال ممکن نشد",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "هیچ هویت ارسالی در دسترس نیست"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Envoyer maintenant",
|
||||
"send_timeout_body": "Le message est peut-être déjà parti. Vérifiez votre dossier Envoyés avant de le renvoyer.",
|
||||
"send_timeout_title": "Aucune réponse du serveur",
|
||||
"submission_quota": "Le serveur conserve trop d’enregistrements de messages envoyés pour ce compte. Bulwark a supprimé ceux qu’il pouvait ; si cela se reproduit, demandez à votre administrateur de relever la limite d’envois de courriels.",
|
||||
"undo_send_label": "Envoi dans {seconds} s…",
|
||||
"upload_failed_short": "Échec du téléversement",
|
||||
"uploading": "Téléversement en cours...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Chargement de la conversation...",
|
||||
"undo_send_failed": "Impossible d’annuler l’envoi",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Aucune identité d'envoi disponible"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "שלח עכשיו",
|
||||
"send_timeout_body": "ייתכן שההודעה כבר נשלחה. בדוק את תיקיית \"נשלח\" לפני שתשלח אותה שוב.",
|
||||
"send_timeout_title": "אין תשובה מהשרת",
|
||||
"submission_quota": "בשרת יש יותר מדי רשומות של הודעות שנשלחו עבור החשבון הזה. Bulwark הסיר את אלה שיכול היה; אם זה ממשיך לקרות, בקשו ממנהל המערכת להגדיל את מגבלת שליחת הדואר.",
|
||||
"undo_send_label": "שולח בעוד {seconds} שנ׳…",
|
||||
"upload_failed_short": "ההעלאה נכשלה",
|
||||
"uploading": "מעלה...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"smime_unsupported": "חתום/מוצפן ב-S/MIME - לא נתמך בנייד",
|
||||
"source_too_large": "ההודעה גדולה מכדי להציג אותה במלואה. במקום זאת, שתף את קוד המקור המלא.",
|
||||
"thread_loading": "טוען שיחה...",
|
||||
"undo_send_failed": "לא ניתן היה לבטל את השליחה",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "אין זהות שליחה זמינה"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Küldés most",
|
||||
"send_timeout_body": "Lehet, hogy az üzenet már elment. Mielőtt újra elküldenéd, nézd meg az Elküldött mappát.",
|
||||
"send_timeout_title": "Nem érkezett válasz a szervertől",
|
||||
"submission_quota": "A kiszolgáló túl sok elküldött üzenetről szóló bejegyzést tárol ehhez a fiókhoz. A Bulwark eltávolította, amit tudott; ha ez továbbra is előfordul, kérje meg a rendszergazdát, hogy emelje meg az e-mail-küldési korlátot.",
|
||||
"undo_send_label": "Küldés {seconds} mp múlva…",
|
||||
"upload_failed_short": "Sikertelen feltöltés",
|
||||
"uploading": "Feltöltés...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Beszélgetés betöltése...",
|
||||
"undo_send_failed": "A küldést nem sikerült visszavonni",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Nincs elérhető küldő azonosság"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Invia ora",
|
||||
"send_timeout_body": "Il messaggio potrebbe essere già stato inviato. Controlla la cartella Inviati prima di inviarlo di nuovo.",
|
||||
"send_timeout_title": "Nessuna risposta dal server",
|
||||
"submission_quota": "Il server conserva troppi record di messaggi inviati per questo account. Bulwark ha rimosso quelli che poteva; se il problema si ripete, chieda all'amministratore di aumentare il limite di invii di posta.",
|
||||
"undo_send_label": "Invio tra {seconds} s…",
|
||||
"upload_failed_short": "Caricamento non riuscito",
|
||||
"uploading": "Caricamento in corso...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Caricamento conversazione...",
|
||||
"undo_send_failed": "Impossibile annullare l'invio",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Nessuna identità di invio disponibile"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "今すぐ送信",
|
||||
"send_timeout_body": "メッセージはすでに送信されている可能性があります。再送信する前に送信済みフォルダーを確認してください。",
|
||||
"send_timeout_title": "サーバーから応答がありません",
|
||||
"submission_quota": "このアカウントの送信済みメッセージの記録がサーバー上に多すぎます。Bulwark は削除できる記録を削除しました。この状態が続く場合は、メール送信数の上限を引き上げるよう管理者に依頼してください。",
|
||||
"undo_send_label": "{seconds}秒後に送信…",
|
||||
"upload_failed_short": "アップロードに失敗しました",
|
||||
"uploading": "アップロード中...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"smime_unsupported": "S/MIME で署名/暗号化されています(モバイルでは非対応)",
|
||||
"source_too_large": "メッセージが大きすぎるため、すべてを表示できません。代わりに完全なソースを共有してください。",
|
||||
"thread_loading": "会話を読み込み中...",
|
||||
"undo_send_failed": "送信を取り消せませんでした",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "利用できる送信者情報がありません"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "지금 보내기",
|
||||
"send_timeout_body": "메시지가 이미 발송되었을 수 있어요. 다시 보내기 전에 보낸편지함을 확인해 주세요.",
|
||||
"send_timeout_title": "서버에서 응답이 없어요",
|
||||
"submission_quota": "서버에 이 계정의 보낸 메시지 기록이 너무 많습니다. Bulwark가 삭제할 수 있는 기록은 삭제했습니다. 이 문제가 계속되면 관리자에게 메일 발송 한도를 늘려 달라고 요청하세요.",
|
||||
"undo_send_label": "{seconds}초 후 전송…",
|
||||
"upload_failed_short": "업로드 실패",
|
||||
"uploading": "업로드 중...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"smime_unsupported": "S/MIME로 서명/암호화됨 - 모바일에서는 지원되지 않아요",
|
||||
"source_too_large": "메시지가 너무 커서 전체를 표시할 수 없어요. 대신 전체 원본을 공유해 주세요.",
|
||||
"thread_loading": "대화를 불러오는 중...",
|
||||
"undo_send_failed": "보내기를 취소하지 못했습니다",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "사용할 수 있는 보내는 사람 주소가 없어요"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Sūtīt tagad",
|
||||
"send_timeout_body": "Ziņojums, iespējams, jau ir nosūtīts. Pirms sūtāt to vēlreiz, pārbaudiet mapi Nosūtītie.",
|
||||
"send_timeout_title": "Serveris neatbild",
|
||||
"submission_quota": "Serverī šim kontam ir pārāk daudz nosūtīto ziņojumu ierakstu. Bulwark noņēma tos, ko varēja; ja tas atkārtojas, lūdziet administratoram palielināt e-pasta sūtījumu ierobežojumu.",
|
||||
"undo_send_label": "Sūtīšana pēc {seconds} s…",
|
||||
"upload_failed_short": "Augšupielāde neizdevās",
|
||||
"uploading": "Augšupielādē...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Ielādē sarunu...",
|
||||
"undo_send_failed": "Sūtīšanu neizdevās atsaukt",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Nav pieejamas sūtīšanas identitātes"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Одоо илгээх",
|
||||
"send_timeout_body": "Имэйл аль хэдийн илгээгдсэн байж магадгүй. Дахин илгээхээсээ өмнө Илгээсэн хавтсаа шалгана уу.",
|
||||
"send_timeout_title": "Серверээс хариу алга",
|
||||
"submission_quota": "Сервер дээр энэ бүртгэлийн илгээсэн мессежийн бичлэг хэт олон байна. Bulwark боломжтойг нь устгасан; дахин давтагдвал админаасаа имэйл илгээх хязгаарыг нэмэгдүүлэхийг хүснэ үү.",
|
||||
"undo_send_label": "{seconds} секундын дараа илгээнэ…",
|
||||
"upload_failed_short": "Байршуулж чадсангүй",
|
||||
"uploading": "Байршуулж байна...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"smime_unsupported": "S/MIME-ээр гарын үсэг зурсан/шифрлэсэн - гар утсанд дэмжигдэхгүй",
|
||||
"source_too_large": "Энэ имэйл хэт том тул бүрэн харуулах боломжгүй. Оронд нь бүрэн эх кодыг хуваалцана уу.",
|
||||
"thread_loading": "Харилцан яриаг ачаалж байна...",
|
||||
"undo_send_failed": "Илгээхийг буцааж чадсангүй",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Илгээх таниулбар алга"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Send nå",
|
||||
"send_timeout_body": "Meldingen kan allerede ha blitt sendt. Sjekk Sendt-mappen før du sender den på nytt.",
|
||||
"send_timeout_title": "Ingen svar fra serveren",
|
||||
"submission_quota": "Serveren har for mange registreringer av sendte meldinger for denne kontoen. Bulwark fjernet dem den kunne; hvis dette fortsetter, be administratoren din om å øke grensen for innsending av e-post.",
|
||||
"undo_send_label": "Sender om {seconds} s…",
|
||||
"upload_failed_short": "Opplastingen mislyktes",
|
||||
"uploading": "Laster opp...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Laster inn samtale...",
|
||||
"undo_send_failed": "Sendingen kunne ikke angres",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Ingen avsenderidentitet er tilgjengelig"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Nu verzenden",
|
||||
"send_timeout_body": "Het bericht is mogelijk al verzonden. Controleer uw map Verzonden voordat u het opnieuw verstuurt.",
|
||||
"send_timeout_title": "Geen antwoord van de server",
|
||||
"submission_quota": "De server bewaart te veel records van verzonden berichten voor dit account. Bulwark heeft verwijderd wat kon; blijft dit gebeuren, vraag uw beheerder dan de limiet voor e-mailverzendingen te verhogen.",
|
||||
"undo_send_label": "Verzenden over {seconds} s…",
|
||||
"upload_failed_short": "Uploaden mislukt",
|
||||
"uploading": "Uploaden...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Gesprek laden...",
|
||||
"undo_send_failed": "Verzenden kon niet ongedaan worden gemaakt",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Geen verzendidentiteit beschikbaar"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Wyślij teraz",
|
||||
"send_timeout_body": "Wiadomość mogła już zostać wysłana. Zanim wyślesz ją ponownie, sprawdź folder Wysłane.",
|
||||
"send_timeout_title": "Brak odpowiedzi serwera",
|
||||
"submission_quota": "Serwer przechowuje zbyt wiele rekordów wysłanych wiadomości dla tego konta. Bulwark usunął te, które mógł; jeśli to się powtarza, poproś administratora o podniesienie limitu wysyłek e-mail.",
|
||||
"undo_send_label": "Wysyłanie za {seconds} s…",
|
||||
"upload_failed_short": "Przesyłanie nie powiodło się",
|
||||
"uploading": "Przesyłanie...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Ładowanie konwersacji...",
|
||||
"undo_send_failed": "Nie udało się cofnąć wysyłki",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Brak tożsamości do wysyłki"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Enviar agora",
|
||||
"send_timeout_body": "A mensagem pode já ter sido enviada. Verifique sua pasta Enviados antes de enviá-la novamente.",
|
||||
"send_timeout_title": "Sem resposta do servidor",
|
||||
"submission_quota": "O servidor tem registros de mensagens enviadas demais para esta conta. O Bulwark removeu os que pôde; se isso continuar acontecendo, peça ao seu administrador para aumentar o limite de envios de e-mail.",
|
||||
"undo_send_label": "Enviando em {seconds}s…",
|
||||
"upload_failed_short": "Falha no envio",
|
||||
"uploading": "Enviando...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Carregando conversa...",
|
||||
"undo_send_failed": "Não foi possível desfazer o envio",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Nenhuma identidade de envio disponível"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Trimite acum",
|
||||
"send_timeout_body": "Este posibil ca mesajul să fi fost deja trimis. Verificați folderul Trimise înainte de a-l trimite din nou.",
|
||||
"send_timeout_title": "Serverul nu răspunde",
|
||||
"submission_quota": "Serverul păstrează prea multe înregistrări de mesaje trimise pentru acest cont. Bulwark le-a eliminat pe cele pe care a putut; dacă problema persistă, cereți administratorului să mărească limita de trimiteri de e-mail.",
|
||||
"undo_send_label": "Se trimite în {seconds} s…",
|
||||
"upload_failed_short": "Încărcarea a eșuat",
|
||||
"uploading": "Se încarcă...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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ă.",
|
||||
"thread_loading": "Se încarcă conversația...",
|
||||
"undo_send_failed": "Trimiterea nu a putut fi anulată",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Nu există nicio identitate de trimitere disponibilă"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Отправить сейчас",
|
||||
"send_timeout_body": "Возможно, письмо уже отправлено. Проверьте папку «Отправленные», прежде чем отправлять его снова.",
|
||||
"send_timeout_title": "Сервер не отвечает",
|
||||
"submission_quota": "На сервере слишком много записей об отправленных письмах для этой учётной записи. Bulwark удалил те, что мог; если это повторяется, попросите администратора увеличить лимит отправки писем.",
|
||||
"undo_send_label": "Отправка через {seconds} с…",
|
||||
"upload_failed_short": "Ошибка загрузки",
|
||||
"uploading": "Загрузка...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"smime_unsupported": "Подписано или зашифровано с помощью S/MIME — на мобильных устройствах не поддерживается",
|
||||
"source_too_large": "Письмо слишком большое для полного отображения. Вместо этого поделитесь полным исходным кодом.",
|
||||
"thread_loading": "Загрузка беседы...",
|
||||
"undo_send_failed": "Не удалось отменить отправку",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Нет удостоверения для отправки"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Odoslať teraz",
|
||||
"send_timeout_body": "Správa už možno odišla. Skôr ako ju odošlete znova, skontrolujte priečinok Odoslané.",
|
||||
"send_timeout_title": "Server neodpovedá",
|
||||
"submission_quota": "Server má pre tento účet príliš veľa záznamov o odoslaných správach. Bulwark odstránil tie, ktoré mohol; ak sa to bude opakovať, požiadajte správcu o zvýšenie limitu odosielania e-mailov.",
|
||||
"undo_send_label": "Odoslanie o {seconds} s…",
|
||||
"upload_failed_short": "Nahrávanie zlyhalo",
|
||||
"uploading": "Nahrávanie...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Načítavanie konverzácie...",
|
||||
"undo_send_failed": "Odoslanie sa nepodarilo vrátiť",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Nie je k dispozícii žiadna identita na odosielanie"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Şimdi gönder",
|
||||
"send_timeout_body": "İleti zaten gönderilmiş olabilir. Yeniden göndermeden önce Gönderilenler klasörünü kontrol edin.",
|
||||
"send_timeout_title": "Sunucudan yanıt yok",
|
||||
"submission_quota": "Sunucuda bu hesap için çok fazla gönderilmiş ileti kaydı var. Bulwark kaldırabildiklerini kaldırdı; bu durum sürerse yöneticinizden e-posta gönderim sınırını yükseltmesini isteyin.",
|
||||
"undo_send_label": "{seconds} sn içinde gönderiliyor…",
|
||||
"upload_failed_short": "Yükleme başarısız",
|
||||
"uploading": "Yükleniyor...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"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.",
|
||||
"thread_loading": "Konuşma yükleniyor...",
|
||||
"undo_send_failed": "Gönderme geri alınamadı",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Kullanılabilir gönderim kimliği yok"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "Надіслати зараз",
|
||||
"send_timeout_body": "Можливо, лист уже надіслано. Перевірте папку «Надіслані», перш ніж надсилати його знову.",
|
||||
"send_timeout_title": "Сервер не відповідає",
|
||||
"submission_quota": "На сервері забагато записів про надіслані листи для цього облікового запису. Bulwark видалив ті, що міг; якщо це повторюється, попросіть адміністратора збільшити ліміт надсилання листів.",
|
||||
"undo_send_label": "Надсилання через {seconds} с…",
|
||||
"upload_failed_short": "Помилка завантаження",
|
||||
"uploading": "Завантаження...",
|
||||
@@ -396,6 +397,7 @@
|
||||
"smime_unsupported": "Підписано або зашифровано за допомогою S/MIME — на мобільних пристроях не підтримується",
|
||||
"source_too_large": "Лист завеликий для повного відображення. Натомість поділіться повним вихідним кодом.",
|
||||
"thread_loading": "Завантаження розмови...",
|
||||
"undo_send_failed": "Не вдалося скасувати надсилання",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "Немає ідентичності для надсилання"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "立即傳送",
|
||||
"send_timeout_body": "郵件可能已經寄出。再次傳送前,請先檢查「寄件備份」信件匣。",
|
||||
"send_timeout_title": "伺服器沒有回應",
|
||||
"submission_quota": "伺服器上此帳戶的已寄出郵件記錄過多。Bulwark 已移除可以移除的記錄;如果問題持續發生,請要求管理員提高郵件提交數量上限。",
|
||||
"undo_send_label": "{seconds} 秒後傳送…",
|
||||
"upload_failed_short": "上傳失敗",
|
||||
"uploading": "正在上傳…",
|
||||
@@ -396,6 +397,7 @@
|
||||
"smime_unsupported": "已使用 S/MIME 簽署/加密,行動裝置不支援",
|
||||
"source_too_large": "郵件太大,無法完整顯示。請改為分享完整原始碼。",
|
||||
"thread_loading": "正在載入對話串…",
|
||||
"undo_send_failed": "無法取消傳送",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "沒有可用的寄件身分"
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"send_now": "立即发送",
|
||||
"send_timeout_body": "邮件可能已经发出。再次发送前,请检查“已发送”文件夹。",
|
||||
"send_timeout_title": "服务器无响应",
|
||||
"submission_quota": "服务器上此账户的已发送邮件记录过多。Bulwark 已删除了能删除的记录;如果问题仍然出现,请让管理员提高邮件提交数量上限。",
|
||||
"undo_send_label": "{seconds} 秒后发送…",
|
||||
"upload_failed_short": "上传失败",
|
||||
"uploading": "正在上传…",
|
||||
@@ -396,6 +397,7 @@
|
||||
"smime_unsupported": "已使用 S/MIME 签名/加密——移动端不支持",
|
||||
"source_too_large": "邮件过大,无法完整显示。请改为分享完整源码。",
|
||||
"thread_loading": "正在加载会话…",
|
||||
"undo_send_failed": "无法撤销发送",
|
||||
"unsubscribe_banner": {
|
||||
"no_identity": "没有可用的发送身份"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { FakeSubmissionServer } from './fake-submission-server';
|
||||
|
||||
// Stalwart 1.0 refuses new submissions once an account holds `maxSubmissions`
|
||||
// EmailSubmission records (500 by default), counting every record whatever its
|
||||
// status or age. These tests run the send paths against a fake Stalwart
|
||||
// (./fake-submission-server) in the 0.16 and 1.0 shapes, and check after each
|
||||
// one that no submission get reports as pending with sendAt ahead was ever
|
||||
// destroyed: that record is the only way to cancel the mail.
|
||||
|
||||
const fake = vi.hoisted(() => ({
|
||||
server: undefined as unknown as FakeSubmissionServer,
|
||||
stalwart: true,
|
||||
submission: true,
|
||||
accounts: ['acc-1'],
|
||||
maxInGet: 500,
|
||||
maxInSet: 500,
|
||||
}));
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
request: vi.fn((calls: never, using?: string[]) => fake.server.request(calls, using)),
|
||||
hasCapability: vi.fn((urn: string) => urn !== 'urn:ietf:params:jmap:submission' || fake.submission),
|
||||
hasAccountCapability: vi.fn((urn: string) => urn !== 'urn:stalwart:jmap' || fake.stalwart),
|
||||
getSubmissionAccountIds: vi.fn(() => fake.accounts),
|
||||
hasDelayedSend: vi.fn(() => true),
|
||||
getMaxObjectsInGet: vi.fn(() => fake.maxInGet),
|
||||
getMaxObjectsInSet: vi.fn(() => fake.maxInSet),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
learnHoldLimit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// blob.ts pulls in expo-file-system; the receipt only needs the byte upload.
|
||||
vi.mock('../blob', () => ({
|
||||
uploadBytes: vi.fn(async () => ({ blobId: 'blob-1', size: 1, type: 'message/rfc822' })),
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import {
|
||||
cancelScheduledSend, listScheduledEmails, rescheduleScheduledSend, sendEmail, sendReadReceipt,
|
||||
} from '../email';
|
||||
import { SubmissionQuotaError } from '../jmap-result';
|
||||
import { prunableSubmissionIds, pruneEmailSubmissions, resetSubmissionPrune } from '../submissions';
|
||||
import { createFakeSubmissionServer, OVER_QUOTA_DESCRIPTION, type StalwartVersion } from './fake-submission-server';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
const OUTGOING = {
|
||||
from: [{ email: 'me@example.com' }],
|
||||
to: [{ email: 'you@example.com' }],
|
||||
subject: 'Hello',
|
||||
textBody: 'Hi',
|
||||
};
|
||||
const VIA_DRAFTS = { draftsMailboxId: 'drafts' };
|
||||
const RECEIPT = {
|
||||
to: 'sender@remote.example',
|
||||
fromEmail: 'me@example.com',
|
||||
identityId: 'id-1',
|
||||
sentMailboxId: 'sent',
|
||||
originalSubject: 'Hello',
|
||||
automatic: true,
|
||||
};
|
||||
|
||||
function setup(version: StalwartVersion, options: { limit?: number; maxChanges?: number; omitTotal?: boolean } = {}) {
|
||||
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 } });
|
||||
}
|
||||
|
||||
/** 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, submission: true, accounts: ['acc-1'], maxInGet: 500, maxInSet: 500 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// The rule that must always hold.
|
||||
expect(fake.server?.violations ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
describe('prunableSubmissionIds', () => {
|
||||
it('picks canceled records and final ones whose sendAt has passed, never a pending one', () => {
|
||||
const now = Date.parse('2026-09-25T12:00:00Z');
|
||||
expect(prunableSubmissionIds([
|
||||
{ id: 'held', sendAt: '2026-09-25T13:00:00Z', undoStatus: 'pending' },
|
||||
{ id: 'retrying', sendAt: '2026-09-25T11:00:00Z', undoStatus: 'pending' },
|
||||
{ id: 'delivered', sendAt: '2026-09-25T11:00:00Z', undoStatus: 'final' },
|
||||
{ id: 'delivered-now', sendAt: '2026-09-25T12:00:00Z', undoStatus: 'final' },
|
||||
{ id: 'final-ahead', sendAt: '2026-09-25T13:00:00Z', undoStatus: 'final' },
|
||||
{ id: 'canceled-ahead', sendAt: '2026-09-26T12:00:00Z', undoStatus: 'canceled' },
|
||||
{ id: 'canceled-old', sendAt: '2026-09-01T12:00:00Z', undoStatus: 'canceled' },
|
||||
{ id: 'no-send-at', undoStatus: 'final' },
|
||||
{ id: 'no-status', sendAt: '2026-09-25T11:00:00Z' },
|
||||
{ id: 'bad-date', sendAt: 'soon', undoStatus: 'final' },
|
||||
], now)).toEqual(['delivered', 'delivered-now', 'canceled-ahead', 'canceled-old']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an immediate send removes its own submission record', () => {
|
||||
it.each(['0.16.6', '0.16.10', '0.16.23', '1.0'] as StalwartVersion[])(
|
||||
'Stalwart %s: a send without a draft removes it in a request of its own',
|
||||
async (version) => {
|
||||
const server = setup(version);
|
||||
|
||||
const result = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS);
|
||||
|
||||
expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set'], ['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([]);
|
||||
// The message itself is untouched: filed in Sent.
|
||||
expect(server.emails.get(result.emailId!)?.mailboxIds).toEqual({ sent: true });
|
||||
},
|
||||
);
|
||||
|
||||
it('Stalwart 1.0: a send from a draft removes the record with the old draft, in one request', async () => {
|
||||
const server = setup('1.0');
|
||||
addDraft(server, 'old-draft');
|
||||
|
||||
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.requests[1].calls).toEqual([
|
||||
['Email/set', { accountId: 'acc-1', destroy: ['old-draft'] }, '0'],
|
||||
['EmailSubmission/set', { accountId: 'acc-1', destroy: [result.emailSubmissionId] }, '1'],
|
||||
]);
|
||||
expect(server.requests[1].using).toContain('urn:ietf:params:jmap:submission');
|
||||
expect(server.submissions()).toEqual([]);
|
||||
expect(server.emails.has('old-draft')).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['0.16.10', '0.16.23', '1.0'] as StalwartVersion[])(
|
||||
'Stalwart %s: a send held for the undo window keeps its record, whatever the create answered',
|
||||
async (version) => {
|
||||
// 0.16.10-0.16.16 answer undoStatus "final" even for this held send.
|
||||
const server = setup(version);
|
||||
|
||||
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.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' }]);
|
||||
},
|
||||
);
|
||||
|
||||
it('leaves the record to the prune on a server without urn:stalwart:jmap', async () => {
|
||||
fake.stalwart = false;
|
||||
const server = setup('1.0');
|
||||
|
||||
await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS);
|
||||
|
||||
expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set']]);
|
||||
expect(server.submissions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never turns a record that could not be removed into a filing warning (1.0)', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const server = setup('1.0');
|
||||
addDraft(server, 'old-draft');
|
||||
vi.mocked(jmapClient.request)
|
||||
.mockImplementationOnce((calls, using) => server.request(calls, using))
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [
|
||||
['Email/set', { accountId: 'acc-1', destroyed: ['old-draft'] }, '0'],
|
||||
['EmailSubmission/set', { accountId: 'acc-1', notDestroyed: { s1: { type: 'forbidden' } } }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const result = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { ...VIA_DRAFTS, draftId: 'old-draft' });
|
||||
|
||||
expect(result.filingWarning).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith('[submissions] not destroyed:', { s1: { type: 'forbidden' } });
|
||||
});
|
||||
|
||||
it('Stalwart 1.0: 600 sends in a row never reach the limit of 500', async () => {
|
||||
const server = setup('1.0', { limit: 500 });
|
||||
|
||||
for (let i = 0; i < 600; i++) await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS);
|
||||
|
||||
expect(server.submissions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a full submission quota on a send (Stalwart 1.0)', () => {
|
||||
it('removes what can go and submits the Drafts copy again', async () => {
|
||||
const server = setup('1.0', { limit: 5 });
|
||||
const now = Date.now();
|
||||
const [scheduled] = server.seed('acc-1', [{ sendAt: now + DAY, status: 'pending' }]);
|
||||
server.seed('acc-1', [
|
||||
{ sendAt: now - HOUR, status: 'final' },
|
||||
{ sendAt: now - 2 * HOUR, status: 'final' },
|
||||
{ sendAt: now - 2 * DAY, status: 'final' },
|
||||
{ sendAt: now + 2 * DAY, status: 'canceled' },
|
||||
]);
|
||||
|
||||
const result = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS);
|
||||
|
||||
// The first create was refused in Stalwart's words...
|
||||
expect(server.requests[0].responses[1]).toEqual(['EmailSubmission/set', expect.objectContaining({
|
||||
notCreated: { 'sub-1': { type: 'overQuota', description: OVER_QUOTA_DESCRIPTION } },
|
||||
}), '1']);
|
||||
// ...then the prune made room and the copy already in Drafts went out.
|
||||
expect(server.methods()).toEqual([
|
||||
['Email/set', 'EmailSubmission/set'],
|
||||
['EmailSubmission/query', 'EmailSubmission/get'],
|
||||
['EmailSubmission/set'],
|
||||
['EmailSubmission/set'],
|
||||
['EmailSubmission/set'],
|
||||
]);
|
||||
const retry = argsOf(server, 'EmailSubmission/set')[2];
|
||||
expect(retry.create['sub-1']).toEqual({ emailId: result.emailId, identityId: 'id-1' });
|
||||
expect(retry.onSuccessUpdateEmail).toEqual({ '#sub-1': { mailboxIds: { sent: true }, 'keywords/$draft': null } });
|
||||
expect(server.emails.get(result.emailId!)?.mailboxIds).toEqual({ sent: true });
|
||||
// Only the scheduled send is left; the new send's own record went too.
|
||||
expect(server.submissions().map((s) => s.id)).toEqual([scheduled]);
|
||||
});
|
||||
|
||||
it('finds records older than three days in the change log', async () => {
|
||||
const server = setup('1.0', { limit: 5, maxChanges: 2 });
|
||||
server.seed('acc-1', Array.from({ length: 5 }, (_, i) => ({ sendAt: Date.now() - (10 + i) * DAY, status: 'final' as const })));
|
||||
|
||||
const result = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS);
|
||||
|
||||
expect(result.emailSubmissionId).toBeDefined();
|
||||
// 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'] });
|
||||
expect(server.submissions()).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives up with SubmissionQuotaError when nothing can go, and leaves no copy in Drafts', async () => {
|
||||
const server = setup('1.0', { limit: 3 });
|
||||
const now = Date.now();
|
||||
const pending = server.seed('acc-1', [
|
||||
{ sendAt: now + DAY, status: 'pending' },
|
||||
{ sendAt: now + 2 * DAY, status: 'pending' },
|
||||
// Past its sendAt but still in the queue (retrying): pending too.
|
||||
{ sendAt: now - HOUR, status: 'pending' },
|
||||
]);
|
||||
addDraft(server, 'old-draft');
|
||||
|
||||
const err = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { ...VIA_DRAFTS, draftId: 'old-draft' })
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(SubmissionQuotaError);
|
||||
expect(err).toMatchObject({ type: 'overQuota', message: OVER_QUOTA_DESCRIPTION });
|
||||
// One retry only.
|
||||
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(2);
|
||||
expect(server.submissions().map((s) => s.id)).toEqual(pending);
|
||||
// The copy made for this attempt is gone; the user's own draft stays.
|
||||
const drafts = [...server.emails.values()].filter((e) => e.mailboxIds.drafts).map((e) => e.id);
|
||||
expect(drafts).toEqual(['old-draft']);
|
||||
});
|
||||
|
||||
it('does not walk the change log on a server without urn:stalwart:jmap', async () => {
|
||||
fake.stalwart = false;
|
||||
const server = setup('1.0', { limit: 2 });
|
||||
server.seed('acc-1', [
|
||||
{ sendAt: Date.now() - 10 * DAY, status: 'final' },
|
||||
{ sendAt: Date.now() - 11 * DAY, status: 'final' },
|
||||
]);
|
||||
|
||||
const err = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS).catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(SubmissionQuotaError);
|
||||
expect(server.methods().flat()).not.toContain('EmailSubmission/changes');
|
||||
});
|
||||
|
||||
it('keeps a full mailbox (Email/set overQuota) apart from the submission quota', async () => {
|
||||
const server = setup('1.0');
|
||||
server.failNextEmailCreate({ type: 'overQuota', description: 'Account quota exceeded.' });
|
||||
|
||||
const err = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS).catch((e: unknown) => e);
|
||||
|
||||
expect(err).not.toBeInstanceOf(SubmissionQuotaError);
|
||||
expect((err as Error).message).toContain('Account quota exceeded.');
|
||||
expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set']]);
|
||||
});
|
||||
|
||||
it('Stalwart 0.16.23 does not enforce the limit: no retry is ever needed', async () => {
|
||||
const server = setup('0.16.23', { limit: 1 });
|
||||
server.seed('acc-1', [{ sendAt: Date.now() + DAY, status: 'pending' }, { sendAt: Date.now() + DAY, status: 'pending' }]);
|
||||
|
||||
await sendEmail(OUTGOING, 'id-1', 'sent', 10, VIA_DRAFTS);
|
||||
|
||||
expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the background prune', () => {
|
||||
it.each(['0.16.23', '1.0'] as StalwartVersion[])(
|
||||
'Stalwart %s: removes canceled and delivered records in every submission account, never a pending one',
|
||||
async (version) => {
|
||||
const server = setup(version);
|
||||
fake.accounts = ['acc-1', 'grp-1'];
|
||||
const now = Date.now();
|
||||
const kept = server.seed('acc-1', [
|
||||
{ sendAt: now + DAY, status: 'pending' },
|
||||
{ sendAt: now - HOUR, status: 'pending' },
|
||||
]);
|
||||
server.seed('acc-1', [
|
||||
{ sendAt: now - HOUR, status: 'final' },
|
||||
{ sendAt: now + DAY, status: 'canceled' },
|
||||
{ sendAt: now - 2 * DAY, status: 'canceled' },
|
||||
]);
|
||||
const keptInGroup = server.seed('grp-1', [{ sendAt: now + 2 * HOUR, status: 'pending' }]);
|
||||
server.seed('grp-1', [{ sendAt: now - 2 * DAY, status: 'final' }]);
|
||||
|
||||
await expect(pruneEmailSubmissions()).resolves.toBe(4);
|
||||
|
||||
expect(server.submissions('acc-1').map((s) => s.id)).toEqual(kept);
|
||||
expect(server.submissions('grp-1').map((s) => s.id)).toEqual(keptInGroup);
|
||||
expect(argsOf(server, 'EmailSubmission/query')[0]).toMatchObject({
|
||||
filter: { operator: 'OR', conditions: [{ before: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/) }, { undoStatus: 'canceled' }] },
|
||||
calculateTotal: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('reads the query page by page and destroys in batches the server accepts', async () => {
|
||||
const server = setup('1.0');
|
||||
fake.maxInGet = 2;
|
||||
fake.maxInSet = 2;
|
||||
server.seed('acc-1', Array.from({ length: 5 }, (_, i) => ({ sendAt: Date.now() - (i + 1) * HOUR, status: 'final' as const })));
|
||||
|
||||
await expect(pruneEmailSubmissions()).resolves.toBe(5);
|
||||
|
||||
expect(argsOf(server, 'EmailSubmission/query').map((a) => [a.position, a.limit])).toEqual([[0, 2], [2, 2], [4, 2]]);
|
||||
expect(argsOf(server, 'EmailSubmission/set').map((a) => a.destroy)).toEqual([['s5', 's4'], ['s3', 's2'], ['s1']]);
|
||||
});
|
||||
|
||||
it('stops at a short page when the server leaves out total', async () => {
|
||||
const server = setup('1.0', { omitTotal: true });
|
||||
fake.maxInGet = 2;
|
||||
server.seed('acc-1', Array.from({ length: 3 }, () => ({ sendAt: Date.now() - HOUR, status: 'final' as const })));
|
||||
|
||||
await expect(pruneEmailSubmissions()).resolves.toBe(3);
|
||||
|
||||
expect(argsOf(server, 'EmailSubmission/query').map((a) => a.position)).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it('runs at most once per account in ten minutes unless forced', async () => {
|
||||
const server = setup('1.0');
|
||||
const start = Date.now();
|
||||
server.seed('acc-1', [{ sendAt: start - HOUR, status: 'final' }]);
|
||||
await expect(pruneEmailSubmissions()).resolves.toBe(1);
|
||||
|
||||
server.seed('acc-1', [{ sendAt: start - HOUR, status: 'final' }]);
|
||||
const requestsBefore = server.requests.length;
|
||||
await expect(pruneEmailSubmissions()).resolves.toBe(0);
|
||||
expect(server.requests).toHaveLength(requestsBefore);
|
||||
|
||||
vi.spyOn(Date, 'now').mockReturnValue(start + 11 * MINUTE);
|
||||
await expect(pruneEmailSubmissions()).resolves.toBe(1);
|
||||
|
||||
server.seed('acc-1', [{ sendAt: start - HOUR, status: 'final' }]);
|
||||
await expect(pruneEmailSubmissions({ force: true })).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('asks nothing of a server without submission', async () => {
|
||||
fake.submission = false;
|
||||
const server = setup('1.0');
|
||||
|
||||
await expect(pruneEmailSubmissions()).resolves.toBe(0);
|
||||
|
||||
expect(server.requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('never rejects', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
setup('1.0');
|
||||
vi.mocked(jmapClient.request).mockRejectedValueOnce(new Error('Network request failed'));
|
||||
|
||||
await expect(pruneEmailSubmissions()).resolves.toBe(0);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelling a scheduled send', () => {
|
||||
it.each(['0.16.23', '1.0'] as StalwartVersion[])(
|
||||
'Stalwart %s: removes the canceled record in a request of its own, once the cancel is confirmed',
|
||||
async (version) => {
|
||||
const server = setup(version);
|
||||
const [held] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending' }]);
|
||||
|
||||
await cancelScheduledSend(held);
|
||||
|
||||
expect(server.requests.map(({ calls }) => calls)).toEqual([
|
||||
[['EmailSubmission/set', { accountId: 'acc-1', update: { [held]: { undoStatus: 'canceled' } } }, '0']],
|
||||
[['EmailSubmission/set', { accountId: 'acc-1', destroy: [held] }, '0']],
|
||||
]);
|
||||
expect(server.submissions()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('removes nothing when the cancel is refused because the mail went out', async () => {
|
||||
const server = setup('1.0');
|
||||
const [sent] = server.seed('acc-1', [{ sendAt: Date.now() - MINUTE, status: 'final' }]);
|
||||
|
||||
await expect(cancelScheduledSend(sent)).rejects.toThrow('The message has already been sent.');
|
||||
|
||||
expect(server.requests).toHaveLength(1);
|
||||
expect(server.submissions().map((s) => s.id)).toEqual([sent]);
|
||||
});
|
||||
|
||||
it('removes nothing when the cancel failed in transit', async () => {
|
||||
const server = setup('1.0');
|
||||
const [held] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending' }]);
|
||||
vi.mocked(jmapClient.request).mockRejectedValueOnce(new Error('Network request failed'));
|
||||
|
||||
await expect(cancelScheduledSend(held)).rejects.toThrow('Network request failed');
|
||||
|
||||
expect(server.submissions().map((s) => s.id)).toEqual([held]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rescheduling and "Send now"', () => {
|
||||
const scheduled = (emailSubmissionId: string) => ({
|
||||
emailSubmissionId,
|
||||
emailId: 'mail-1',
|
||||
identityId: 'id-1',
|
||||
from: [{ email: 'me@example.com' }],
|
||||
});
|
||||
|
||||
it('Stalwart 0.16.23: "Send now" leaves only the replacement behind', async () => {
|
||||
const server = setup('0.16.23');
|
||||
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending', emailId: 'mail-1' }]);
|
||||
|
||||
const result = await rescheduleScheduledSend(scheduled(original), 0);
|
||||
|
||||
expect(server.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' }]);
|
||||
expect(argsOf(server, 'EmailSubmission/set').map((a) => a.destroy).filter(Boolean)).toEqual([[original]]);
|
||||
});
|
||||
|
||||
it('Stalwart 1.0: a full quota makes room without touching the pending original', async () => {
|
||||
const server = setup('1.0', { limit: 3 });
|
||||
const now = Date.now();
|
||||
const [original] = server.seed('acc-1', [{ sendAt: now + HOUR, status: 'pending', emailId: 'mail-1' }]);
|
||||
server.seed('acc-1', [{ sendAt: now - HOUR, status: 'final' }, { sendAt: now - 2 * HOUR, status: 'final' }]);
|
||||
|
||||
const result = await rescheduleScheduledSend(scheduled(original), 2 * 3600);
|
||||
|
||||
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(2);
|
||||
expect(server.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' }]);
|
||||
});
|
||||
|
||||
it('Stalwart 1.0: still full after making room, the original stays scheduled', async () => {
|
||||
const server = setup('1.0', { limit: 1 });
|
||||
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending', emailId: 'mail-1' }]);
|
||||
|
||||
const err = await rescheduleScheduledSend(scheduled(original), 0).catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(SubmissionQuotaError);
|
||||
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false);
|
||||
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'pending' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('read receipts', () => {
|
||||
it('Stalwart 1.0: a sent receipt removes its record at once', async () => {
|
||||
const server = setup('1.0');
|
||||
|
||||
const emailId = await sendReadReceipt(RECEIPT);
|
||||
|
||||
expect(server.methods()).toEqual([['Email/import'], ['EmailSubmission/set'], ['EmailSubmission/set']]);
|
||||
expect(server.submissions()).toEqual([]);
|
||||
expect(server.emails.get(emailId)?.mailboxIds).toEqual({ sent: true });
|
||||
});
|
||||
|
||||
it('Stalwart 1.0: a full quota makes room and retries once', async () => {
|
||||
const server = setup('1.0', { limit: 1 });
|
||||
server.seed('acc-1', [{ sendAt: Date.now() - HOUR, status: 'final' }]);
|
||||
|
||||
await sendReadReceipt(RECEIPT);
|
||||
|
||||
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(2);
|
||||
expect(server.submissions()).toEqual([]);
|
||||
});
|
||||
|
||||
it('Stalwart 1.0: still full, it fails with SubmissionQuotaError and the receipt leaves Sent', async () => {
|
||||
const server = setup('1.0', { limit: 1 });
|
||||
const [held] = server.seed('acc-1', [{ sendAt: Date.now() + DAY, status: 'pending' }]);
|
||||
|
||||
const err = await sendReadReceipt(RECEIPT).catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(SubmissionQuotaError);
|
||||
expect([...server.emails.values()].filter((e) => e.mailboxIds.sent && !e.id.startsWith('seed'))).toEqual([]);
|
||||
expect(server.submissions().map((s) => s.id)).toEqual([held]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the scheduled list', () => {
|
||||
it('Stalwart 1.0: reads every page, so the mail scheduled furthest ahead is still listed', async () => {
|
||||
const server = setup('1.0', { limit: 1000 });
|
||||
fake.maxInGet = 50;
|
||||
const now = Date.now();
|
||||
server.seed('acc-1', Array.from({ length: 120 }, (_, i) => ({ sendAt: now - HOUR - i * MINUTE, status: 'final' as const })));
|
||||
const [ahead] = server.seed('acc-1', [{ sendAt: now + 5 * DAY, status: 'pending', emailId: 'mail-ahead' }]);
|
||||
|
||||
const list = await listScheduledEmails();
|
||||
|
||||
expect(list.map((s) => s.emailSubmissionId)).toEqual([ahead]);
|
||||
expect(argsOf(server, 'EmailSubmission/query').map((a) => [a.position, a.limit, a.calculateTotal]))
|
||||
.toEqual([[0, 50, true], [50, 50, true], [100, 50, true]]);
|
||||
});
|
||||
|
||||
it('stops at a short page when the server leaves out total (0.16.23)', async () => {
|
||||
const server = setup('0.16.23', { omitTotal: true });
|
||||
fake.maxInGet = 2;
|
||||
const now = Date.now();
|
||||
const ids = server.seed('acc-1', [
|
||||
{ sendAt: now + HOUR, status: 'pending', emailId: 'mail-1' },
|
||||
{ sendAt: now + 2 * HOUR, status: 'pending', emailId: 'mail-2' },
|
||||
{ sendAt: now + 3 * HOUR, status: 'pending', emailId: 'mail-3' },
|
||||
]);
|
||||
|
||||
const list = await listScheduledEmails();
|
||||
|
||||
expect(list.map((s) => s.emailSubmissionId)).toEqual(ids);
|
||||
expect(argsOf(server, 'EmailSubmission/query').map((a) => a.position)).toEqual([0, 2]);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,9 @@ vi.mock('../jmap-client', () => ({
|
||||
learnHoldLimit: vi.fn(),
|
||||
getSubmissionAccountIds: vi.fn(() => ['acc-1']),
|
||||
hasDelayedSend: vi.fn(() => true),
|
||||
// Not Stalwart: sends keep their submission record (see
|
||||
// email-submission-cleanup.test.ts for the Stalwart paths).
|
||||
hasAccountCapability: vi.fn(() => false),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
@@ -44,13 +47,16 @@ const REPLACED = [['EmailSubmission/set', {
|
||||
}, '0']];
|
||||
const set = (body: Record<string, unknown>) => ({ methodResponses: [['EmailSubmission/set', body, '0']] });
|
||||
const CANCELLED = set({ updated: { 'sub-1': null } });
|
||||
/** The EmailSubmission/set calls made, as [create ids, update ids]. */
|
||||
/** A confirmed cancel is followed by the destroy of the canceled record. */
|
||||
const DESTROYED = (id: string) => set({ destroyed: [id] });
|
||||
/** The EmailSubmission/set calls made, as [create ids, update ids, destroyed ids]. */
|
||||
const setCalls = () => mockRequest.mock.calls
|
||||
.map((c) => c[0][0])
|
||||
.filter(([method]: [string]) => method === 'EmailSubmission/set')
|
||||
.map(([, args]: [string, { create?: object; update?: Record<string, unknown> }]) => [
|
||||
.map(([, args]: [string, { create?: object; update?: Record<string, unknown>; destroy?: string[] }]) => [
|
||||
Object.keys(args.create ?? {}),
|
||||
Object.keys(args.update ?? {}),
|
||||
args.destroy ?? [],
|
||||
]);
|
||||
|
||||
describe('rescheduleScheduledSend', () => {
|
||||
@@ -75,7 +81,8 @@ describe('rescheduleScheduledSend', () => {
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ methodResponses: REPLACED })
|
||||
.mockResolvedValueOnce(CANCELLED);
|
||||
.mockResolvedValueOnce(CANCELLED)
|
||||
.mockResolvedValueOnce(DESTROYED('sub-1'));
|
||||
|
||||
const result = await rescheduleScheduledSend(SCHEDULED, 7200);
|
||||
|
||||
@@ -105,7 +112,8 @@ describe('rescheduleScheduledSend', () => {
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ methodResponses: REPLACED })
|
||||
.mockResolvedValueOnce(CANCELLED);
|
||||
.mockResolvedValueOnce(CANCELLED)
|
||||
.mockResolvedValueOnce(DESTROYED('sub-1'));
|
||||
|
||||
await rescheduleScheduledSend(SCHEDULED, 60);
|
||||
|
||||
@@ -115,15 +123,16 @@ describe('rescheduleScheduledSend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates the replacement before cancelling the original', async () => {
|
||||
it('creates the replacement before cancelling the original, then removes the canceled record', async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce(LOOKUP)
|
||||
.mockResolvedValueOnce({ methodResponses: REPLACED })
|
||||
.mockResolvedValueOnce(CANCELLED);
|
||||
.mockResolvedValueOnce(CANCELLED)
|
||||
.mockResolvedValueOnce(DESTROYED('sub-1'));
|
||||
|
||||
await rescheduleScheduledSend(SCHEDULED, 7200);
|
||||
|
||||
expect(setCalls()).toEqual([[['replacement'], []], [[], ['sub-1']]]);
|
||||
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []], [[], [], ['sub-1']]]);
|
||||
expect(mockRequest.mock.calls[2][0][0][1].update).toEqual({ 'sub-1': { undoStatus: 'canceled' } });
|
||||
});
|
||||
|
||||
@@ -131,7 +140,8 @@ describe('rescheduleScheduledSend', () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce(LOOKUP)
|
||||
.mockResolvedValueOnce({ methodResponses: REPLACED })
|
||||
.mockResolvedValueOnce(CANCELLED);
|
||||
.mockResolvedValueOnce(CANCELLED)
|
||||
.mockResolvedValueOnce(DESTROYED('sub-1'));
|
||||
|
||||
await rescheduleScheduledSend(SCHEDULED, 0);
|
||||
|
||||
@@ -140,7 +150,7 @@ describe('rescheduleScheduledSend', () => {
|
||||
mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '1' } },
|
||||
rcptTo: [{ email: 'to@example.com' }],
|
||||
});
|
||||
expect(setCalls()).toEqual([[['replacement'], []], [[], ['sub-1']]]);
|
||||
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []], [[], [], ['sub-1']]]);
|
||||
});
|
||||
|
||||
it('leaves the original scheduled when the replacement is refused', async () => {
|
||||
@@ -150,7 +160,7 @@ describe('rescheduleScheduledSend', () => {
|
||||
|
||||
await expect(rescheduleScheduledSend(SCHEDULED, 7200)).rejects.toThrow('Quota exceeded');
|
||||
// No cancel went out.
|
||||
expect(setCalls()).toEqual([[['replacement'], []]]);
|
||||
expect(setCalls()).toEqual([[['replacement'], [], []]]);
|
||||
});
|
||||
|
||||
it('withdraws the replacement when the original has already gone out', async () => {
|
||||
@@ -160,10 +170,15 @@ describe('rescheduleScheduledSend', () => {
|
||||
.mockResolvedValueOnce({ methodResponses: REPLACED })
|
||||
.mockResolvedValueOnce(set({ notUpdated: { 'sub-1': { type: 'cannotUnsend', description: 'Already sent' } } }))
|
||||
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'final' }] }, '0']] })
|
||||
.mockResolvedValueOnce(set({ updated: { 'sub-2': null } }));
|
||||
.mockResolvedValueOnce(set({ updated: { 'sub-2': null } }))
|
||||
.mockResolvedValueOnce(DESTROYED('sub-2'));
|
||||
|
||||
await expect(rescheduleScheduledSend(SCHEDULED, 0)).rejects.toThrow('Failed to cancel the previous schedule: Already sent');
|
||||
expect(setCalls()).toEqual([[['replacement'], []], [[], ['sub-1']], [[], ['sub-2']]]);
|
||||
// The original went out, so its record stays for the prune; the
|
||||
// withdrawn replacement's goes at once.
|
||||
expect(setCalls()).toEqual([
|
||||
[['replacement'], [], []], [[], ['sub-1'], []], [[], ['sub-2'], []], [[], [], ['sub-2']],
|
||||
]);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
@@ -192,7 +207,8 @@ describe('rescheduleScheduledSend', () => {
|
||||
const result = await rescheduleScheduledSend(SCHEDULED, 7200);
|
||||
|
||||
expect(result.emailSubmissionId).toBe('sub-2');
|
||||
expect(setCalls()).toEqual([[['replacement'], []], [[], ['sub-1']]]);
|
||||
// Unconfirmed, so the original's record is left for the prune.
|
||||
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,7 +388,7 @@ describe('hold-limit rejections', () => {
|
||||
expect(err).toBeInstanceOf(ScheduleTooLateError);
|
||||
expect(jmapClient.learnHoldLimit).toHaveBeenCalledWith(172_800);
|
||||
// The original stays scheduled.
|
||||
expect(setCalls()).toEqual([[['replacement'], []]]);
|
||||
expect(setCalls()).toEqual([[['replacement'], [], []]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -445,17 +461,19 @@ describe('scheduled sends in shared accounts (webmail #874)', () => {
|
||||
|
||||
it('cancels and reschedules in the account holding the submission', async () => {
|
||||
mockRequest.mockReset();
|
||||
mockRequest.mockResolvedValueOnce({
|
||||
methodResponses: [['EmailSubmission/set', { updated: { 's-grp': null } }, '0']],
|
||||
});
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/set', { updated: { 's-grp': null } }, '0']] })
|
||||
.mockResolvedValueOnce(DESTROYED('s-grp'));
|
||||
await cancelScheduledSend('s-grp', 'grp-1');
|
||||
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('grp-1');
|
||||
// The cancel, then the destroy of the canceled record.
|
||||
expect(mockRequest.mock.calls.map((c) => c[0][0][1].accountId)).toEqual(['grp-1', 'grp-1']);
|
||||
|
||||
mockRequest
|
||||
.mockResolvedValueOnce(LOOKUP)
|
||||
.mockResolvedValueOnce(set({ created: { replacement: { id: 's-new', sendAt: FUTURE } } }))
|
||||
.mockResolvedValueOnce(set({ updated: { 's-grp': null } }));
|
||||
.mockResolvedValueOnce(set({ updated: { 's-grp': null } }))
|
||||
.mockResolvedValueOnce(DESTROYED('s-grp'));
|
||||
await rescheduleScheduledSend({ ...SCHEDULED, emailSubmissionId: 's-grp', accountId: 'grp-1' }, 0);
|
||||
expect(mockRequest.mock.calls.slice(1).map((c) => c[0][0][1].accountId)).toEqual(['grp-1', 'grp-1', 'grp-1']);
|
||||
expect(mockRequest.mock.calls.slice(2).map((c) => c[0][0][1].accountId)).toEqual(['grp-1', 'grp-1', 'grp-1', 'grp-1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,9 @@ vi.mock('../jmap-client', () => ({
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
// Not Stalwart: sends keep their submission record (see
|
||||
// email-submission-cleanup.test.ts for Stalwart).
|
||||
hasAccountCapability: vi.fn(() => false),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
// A small in-memory Stalwart for EmailSubmission tests. It models what the
|
||||
// submission cleanup depends on, per version (probed on the 0.16.23 and 1.0
|
||||
// test servers, older versions from the source):
|
||||
// - the create answer: `{id}` before 0.16.10, undoStatus "final" on
|
||||
// 0.16.10-0.16.16 even for a held send, "pending" from 0.16.17 and on 1.0;
|
||||
// - EmailSubmission/get: undoStatus computed from the queue - pending while
|
||||
// held or retrying, final once delivered, canceled - at any age by id;
|
||||
// - EmailSubmission/query: only records whose sendAt is at most three days
|
||||
// back (open-ended into the future), oldest sendAt first, `total` only with
|
||||
// calculateTotal, the before/after/undoStatus filters and operators;
|
||||
// - EmailSubmission/set: 1.0's per-account limit, which counts every record
|
||||
// and refuses creates with overQuota; cancel of a held send; destroy, with
|
||||
// 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;
|
||||
// - 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`.
|
||||
|
||||
import type { JMAPMethodCall } from '../types';
|
||||
|
||||
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.';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
interface Submission {
|
||||
id: string;
|
||||
seq: number;
|
||||
emailId: string;
|
||||
identityId: string;
|
||||
sendAt: number;
|
||||
canceled: boolean;
|
||||
/** Still in the queue with sendAt passed (a delivery being retried). */
|
||||
retrying: boolean;
|
||||
envelope?: unknown;
|
||||
}
|
||||
|
||||
interface StoredEmail {
|
||||
id: string;
|
||||
mailboxIds: Record<string, boolean>;
|
||||
keywords: Record<string, boolean>;
|
||||
subject?: string;
|
||||
to?: unknown;
|
||||
cc?: unknown;
|
||||
bcc?: unknown;
|
||||
from?: unknown;
|
||||
}
|
||||
|
||||
export interface SeedSubmission {
|
||||
sendAt: number;
|
||||
status: 'pending' | 'final' | 'canceled';
|
||||
/** A pending record whose sendAt has passed (retrying). */
|
||||
retrying?: boolean;
|
||||
emailId?: string;
|
||||
}
|
||||
|
||||
type Response = [string, Record<string, any>, string];
|
||||
|
||||
function utcDate(ms: number): string {
|
||||
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
export function createFakeSubmissionServer(options: {
|
||||
version: StalwartVersion;
|
||||
/** 1.0's `maxSubmissions` per account. */
|
||||
limit?: number;
|
||||
/** The server's cap on one /changes answer. */
|
||||
maxChanges?: number;
|
||||
/** Leave `total` out of query answers, as a server may. */
|
||||
omitTotal?: boolean;
|
||||
}) {
|
||||
const { version } = options;
|
||||
const limit = options.limit ?? 500;
|
||||
const maxChanges = options.maxChanges ?? 5000;
|
||||
const accounts = new Map<string, Map<string, Submission>>();
|
||||
const changeLogs = new Map<string, Array<{ id: string; kind: 'created' | 'updated' | 'destroyed' }>>();
|
||||
const emails = new Map<string, StoredEmail>();
|
||||
const requests: Array<{ calls: JMAPMethodCall[]; using?: string[]; responses: Response[] }> = [];
|
||||
const violations: string[] = [];
|
||||
let seq = 0;
|
||||
let emailSeq = 0;
|
||||
/** Makes the next Email/set create fail with this SetError. */
|
||||
let emailCreateFailure: { type: string; description?: string } | null = null;
|
||||
|
||||
const recordsOf = (accountId: string) => {
|
||||
let records = accounts.get(accountId);
|
||||
if (!records) {
|
||||
records = new Map();
|
||||
accounts.set(accountId, records);
|
||||
}
|
||||
return records;
|
||||
};
|
||||
const logOf = (accountId: string) => {
|
||||
let log = changeLogs.get(accountId);
|
||||
if (!log) {
|
||||
log = [];
|
||||
changeLogs.set(accountId, log);
|
||||
}
|
||||
return log;
|
||||
};
|
||||
|
||||
function status(s: Submission): 'pending' | 'final' | 'canceled' {
|
||||
if (s.canceled) return 'canceled';
|
||||
if (s.retrying) return 'pending';
|
||||
return s.sendAt > Date.now() ? 'pending' : 'final';
|
||||
}
|
||||
|
||||
function addSubmission(accountId: string, fields: Omit<Submission, 'id' | 'seq'>): Submission {
|
||||
seq += 1;
|
||||
const submission: Submission = { id: `s${seq}`, seq, ...fields };
|
||||
recordsOf(accountId).set(submission.id, submission);
|
||||
logOf(accountId).push({ id: submission.id, kind: 'created' });
|
||||
return submission;
|
||||
}
|
||||
|
||||
function view(s: Submission, properties?: string[]): Record<string, unknown> {
|
||||
const full: Record<string, unknown> = {
|
||||
id: s.id,
|
||||
emailId: s.emailId,
|
||||
identityId: s.identityId,
|
||||
threadId: `t-${s.emailId}`,
|
||||
sendAt: utcDate(s.sendAt),
|
||||
undoStatus: status(s),
|
||||
envelope: s.envelope ?? null,
|
||||
};
|
||||
if (!properties) return full;
|
||||
return Object.fromEntries(['id', ...properties].map((p) => [p, full[p]]));
|
||||
}
|
||||
|
||||
function matches(s: Submission, filter: Record<string, any> | undefined): boolean {
|
||||
if (!filter) return true;
|
||||
if ('operator' in filter) {
|
||||
const results = (filter.conditions as Array<Record<string, any>>).map((c) => matches(s, c));
|
||||
if (filter.operator === 'AND') return results.every(Boolean);
|
||||
if (filter.operator === 'OR') return results.some(Boolean);
|
||||
return !results.some(Boolean);
|
||||
}
|
||||
return Object.entries(filter).every(([key, value]) => {
|
||||
switch (key) {
|
||||
case 'before': return s.sendAt < Date.parse(value);
|
||||
case 'after': return s.sendAt > Date.parse(value);
|
||||
// The stored status: canceled is stored, pending/final are not
|
||||
// told apart until get (0.16 matches the stored status).
|
||||
case 'undoStatus': return value === 'canceled' ? s.canceled : !s.canceled;
|
||||
default: throw new Error(`fake server: unsupported filter ${key}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
function holdSeconds(create: Record<string, any>): number {
|
||||
const hold = create.envelope?.mailFrom?.parameters?.HOLDFOR;
|
||||
return hold ? Number(hold) : 0;
|
||||
}
|
||||
|
||||
function submissionSet(args: Record<string, any>, callId: string, createdEmails: Map<string, string>): Response[] {
|
||||
const accountId = args.accountId as string;
|
||||
const records = recordsOf(accountId);
|
||||
const body: Record<string, any> = { accountId, newState: `s${seq}` };
|
||||
const extra: Response[] = [];
|
||||
if (args.create) {
|
||||
// 1.0 counts once per call, before its creates (set.rs:123-136).
|
||||
const used = records.size;
|
||||
let accepted = 0;
|
||||
for (const [creationId, create] of Object.entries(args.create as Record<string, Record<string, any>>)) {
|
||||
if (version === '1.0' && used + accepted >= limit) {
|
||||
(body.notCreated ??= {})[creationId] = { type: 'overQuota', description: OVER_QUOTA_DESCRIPTION };
|
||||
continue;
|
||||
}
|
||||
const rawEmailId = String(create.emailId);
|
||||
const emailId = rawEmailId.startsWith('#') ? createdEmails.get(rawEmailId.slice(1)) : rawEmailId;
|
||||
if (!emailId || !emails.has(emailId)) {
|
||||
(body.notCreated ??= {})[creationId] = { type: 'invalidProperties', properties: ['emailId'] };
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
const submission = addSubmission(accountId, {
|
||||
emailId,
|
||||
identityId: String(create.identityId),
|
||||
sendAt: Date.now() + holdSeconds(create) * 1000,
|
||||
canceled: false,
|
||||
retrying: false,
|
||||
envelope: create.envelope,
|
||||
});
|
||||
(body.created ??= {})[creationId] = createdAnswer(submission);
|
||||
const update = args.onSuccessUpdateEmail?.[`#${creationId}`];
|
||||
if (update) {
|
||||
const email = emails.get(emailId)!;
|
||||
if (update.mailboxIds) email.mailboxIds = update.mailboxIds;
|
||||
if ('keywords/$draft' in update) delete email.keywords.$draft;
|
||||
extra.push(['Email/set', { accountId, updated: { [emailId]: null } }, callId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, any>>)) {
|
||||
const s = records.get(id);
|
||||
if (!s) {
|
||||
(body.notUpdated ??= {})[id] = { type: 'notFound' };
|
||||
} else if (patch.undoStatus === 'canceled' && status(s) === 'pending' && !s.retrying) {
|
||||
s.canceled = true;
|
||||
logOf(accountId).push({ id, kind: 'updated' });
|
||||
(body.updated ??= {})[id] = null;
|
||||
} else {
|
||||
(body.notUpdated ??= {})[id] = { type: 'cannotUnsend', description: 'The message has already been sent.' };
|
||||
}
|
||||
}
|
||||
for (const id of (args.destroy ?? []) as string[]) {
|
||||
const s = records.get(id);
|
||||
if (!s) {
|
||||
(body.notDestroyed ??= {})[id] = { type: 'notFound' };
|
||||
continue;
|
||||
}
|
||||
if (status(s) === 'pending' && s.sendAt > Date.now()) violations.push(id);
|
||||
records.delete(id);
|
||||
logOf(accountId).push({ id, kind: 'destroyed' });
|
||||
(body.destroyed ??= []).push(id);
|
||||
}
|
||||
body.newState = `s${seq}`;
|
||||
return [['EmailSubmission/set', body, callId], ...extra];
|
||||
}
|
||||
|
||||
function submissionChanges(args: Record<string, any>, callId: string): Response {
|
||||
const accountId = args.accountId as string;
|
||||
const log = logOf(accountId);
|
||||
const since = String(args.sinceState);
|
||||
if (since !== 'n' && !since.startsWith('r')) {
|
||||
return ['error', { type: 'cannotCalculateChanges' }, callId];
|
||||
}
|
||||
// Net changes over the whole log, one entry per id.
|
||||
const created = new Map<string, true>();
|
||||
const updated = new Map<string, true>();
|
||||
const destroyed = new Map<string, true>();
|
||||
for (const { id, kind } of log) {
|
||||
if (kind === 'created') created.set(id, true);
|
||||
else if (kind === 'updated') {
|
||||
if (!created.has(id)) updated.set(id, true);
|
||||
} else if (created.has(id)) created.delete(id);
|
||||
else {
|
||||
updated.delete(id);
|
||||
destroyed.set(id, true);
|
||||
}
|
||||
}
|
||||
const all: Array<['created' | 'updated' | 'destroyed', string]> = [
|
||||
...[...created.keys()].map((id) => ['created', id] as ['created', string]),
|
||||
...[...updated.keys()].map((id) => ['updated', id] as ['updated', string]),
|
||||
...[...destroyed.keys()].map((id) => ['destroyed', id] as ['destroyed', string]),
|
||||
];
|
||||
const offset = since === 'n' ? 0 : Number(since.slice(1));
|
||||
const pageSize = Math.min(maxChanges, (args.maxChanges as number | undefined) ?? maxChanges);
|
||||
const page = all.slice(offset, offset + pageSize);
|
||||
const hasMoreChanges = offset + pageSize < all.length;
|
||||
return ['EmailSubmission/changes', {
|
||||
accountId,
|
||||
oldState: since,
|
||||
newState: hasMoreChanges ? `r${offset + pageSize}` : `s${seq}`,
|
||||
hasMoreChanges,
|
||||
created: page.filter(([kind]) => kind === 'created').map(([, id]) => id),
|
||||
updated: page.filter(([kind]) => kind === 'updated').map(([, id]) => id),
|
||||
destroyed: page.filter(([kind]) => kind === 'destroyed').map(([, id]) => id),
|
||||
}, callId];
|
||||
}
|
||||
|
||||
async function request(calls: JMAPMethodCall[], using?: string[]): Promise<{ methodResponses: Response[] }> {
|
||||
const responses: Response[] = [];
|
||||
requests.push({ calls, using, responses });
|
||||
const createdEmails = 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') {
|
||||
const body: Record<string, any> = { accountId };
|
||||
for (const [creationId, create] of Object.entries((args.create ?? {}) as Record<string, Record<string, any>>)) {
|
||||
if (emailCreateFailure) {
|
||||
(body.notCreated ??= {})[creationId] = emailCreateFailure;
|
||||
emailCreateFailure = null;
|
||||
continue;
|
||||
}
|
||||
emailSeq += 1;
|
||||
const id = `e${emailSeq}`;
|
||||
emails.set(id, {
|
||||
id,
|
||||
mailboxIds: { ...(create.mailboxIds ?? {}) },
|
||||
keywords: { ...(create.keywords ?? {}) },
|
||||
subject: create.subject,
|
||||
to: create.to,
|
||||
cc: create.cc,
|
||||
bcc: create.bcc,
|
||||
from: create.from,
|
||||
});
|
||||
createdEmails.set(creationId, id);
|
||||
(body.created ??= {})[creationId] = { id };
|
||||
}
|
||||
for (const id of (args.destroy ?? []) as string[]) {
|
||||
if (emails.delete(id)) (body.destroyed ??= []).push(id);
|
||||
else (body.notDestroyed ??= {})[id] = { type: 'notFound' };
|
||||
}
|
||||
responses.push(['Email/set', body, callId]);
|
||||
} else if (name === 'Email/import') {
|
||||
const body: Record<string, any> = { accountId };
|
||||
for (const [creationId, item] of Object.entries(args.emails as Record<string, Record<string, any>>)) {
|
||||
emailSeq += 1;
|
||||
const id = `e${emailSeq}`;
|
||||
emails.set(id, { id, mailboxIds: { ...(item.mailboxIds ?? {}) }, keywords: { ...(item.keywords ?? {}) } });
|
||||
createdEmails.set(creationId, id);
|
||||
(body.created ??= {})[creationId] = { id };
|
||||
}
|
||||
responses.push(['Email/import', body, callId]);
|
||||
} else if (name === 'Email/get') {
|
||||
const list = ((args.ids ?? []) as string[]).flatMap((id) => {
|
||||
const email = emails.get(id);
|
||||
return email ? [email] : [];
|
||||
});
|
||||
responses.push(['Email/get', { accountId, list, notFound: [] }, callId]);
|
||||
} else if (name === 'EmailSubmission/query') {
|
||||
const records = [...recordsOf(accountId).values()]
|
||||
.filter((s) => s.sendAt >= Date.now() - 3 * DAY_MS)
|
||||
.filter((s) => matches(s, args.filter))
|
||||
.sort((a, b) => a.sendAt - b.sendAt || a.seq - b.seq);
|
||||
const position = (args.position as number | undefined) ?? 0;
|
||||
const pageLimit = (args.limit as number | undefined) ?? records.length;
|
||||
const body: Record<string, any> = {
|
||||
accountId,
|
||||
queryState: `s${seq}`,
|
||||
canCalculateChanges: true,
|
||||
position,
|
||||
ids: records.slice(position, position + pageLimit).map((s) => s.id),
|
||||
};
|
||||
if (args.calculateTotal && !options.omitTotal) body.total = records.length;
|
||||
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[]);
|
||||
const records = recordsOf(accountId);
|
||||
responses.push(['EmailSubmission/get', {
|
||||
accountId,
|
||||
state: `s${seq}`,
|
||||
list: ids.flatMap((id) => {
|
||||
const s = records.get(id);
|
||||
return s ? [view(s, args.properties as string[] | undefined)] : [];
|
||||
}),
|
||||
notFound: ids.filter((id) => !records.has(id)),
|
||||
}, callId]);
|
||||
} else if (name === 'EmailSubmission/set') {
|
||||
responses.push(...submissionSet(args, callId, createdEmails));
|
||||
} else if (name === 'EmailSubmission/changes') {
|
||||
responses.push(submissionChanges(args, callId));
|
||||
} else {
|
||||
throw new Error(`fake server: unexpected ${name}`);
|
||||
}
|
||||
}
|
||||
return { methodResponses: responses };
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
requests,
|
||||
violations,
|
||||
emails,
|
||||
/** Records of an account, whatever their age, as get would report them. */
|
||||
submissions(accountId = 'acc-1') {
|
||||
return [...recordsOf(accountId).values()].map((s) => ({ id: s.id, sendAt: s.sendAt, undoStatus: status(s) }));
|
||||
},
|
||||
seed(accountId: string, items: SeedSubmission[]): string[] {
|
||||
return items.map((item) => {
|
||||
const emailId = item.emailId ?? `seed-${seq + 1}`;
|
||||
if (!emails.has(emailId)) emails.set(emailId, { id: emailId, mailboxIds: { sent: true }, keywords: {} });
|
||||
return addSubmission(accountId, {
|
||||
emailId,
|
||||
identityId: 'id-1',
|
||||
sendAt: item.sendAt,
|
||||
canceled: item.status === 'canceled',
|
||||
retrying: item.status === 'pending' && item.sendAt <= Date.now(),
|
||||
}).id;
|
||||
});
|
||||
},
|
||||
failNextEmailCreate(failure: { type: string; description?: string }) {
|
||||
emailCreateFailure = failure;
|
||||
},
|
||||
/** The method names of every request, in order. */
|
||||
methods(): string[][] {
|
||||
return requests.map(({ calls }) => calls.map(([name]) => name));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type FakeSubmissionServer = ReturnType<typeof createFakeSubmissionServer>;
|
||||
@@ -5,6 +5,9 @@ vi.mock('../jmap-client', () => ({
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
// Not Stalwart: a sent receipt keeps its submission record (see
|
||||
// email-submission-cleanup.test.ts for Stalwart).
|
||||
hasAccountCapability: vi.fn(() => false),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
+131
-25
@@ -1,6 +1,12 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { assertSetResult, batched, parseHoldLimit, requireMethodResult, ScheduleTooLateError } from './jmap-result';
|
||||
import {
|
||||
assertSetResult, batched, parseHoldLimit, requireMethodResult, ScheduleTooLateError, SubmissionQuotaError,
|
||||
} from './jmap-result';
|
||||
import { keywordPointer, mailboxPointer } from './patch-pointer';
|
||||
import {
|
||||
destroyEmailSubmissions, isStalwartAccount, makeRoomForSubmission, readSubmissionDestroy,
|
||||
submissionCreateError, submissionDestroyCall, SUBMISSION_ONLY_USING, SUBMISSION_SCAN_MAX_PAGES,
|
||||
} from './submissions';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Attachment, Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
@@ -1173,15 +1179,19 @@ export async function destroyEmails(ids: string[], accountIdOverride?: string):
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
for (const slice of batched(ids, maxInSet())) {
|
||||
const res = await jmapClient.request([['Email/set', { accountId, destroy: slice }, '0']]);
|
||||
const body = requireMethodResult(res, '0', 'Email/set');
|
||||
const notDestroyed = (body.notDestroyed ?? {}) as Record<string, { type?: string; description?: string }>;
|
||||
const real = Object.entries(notDestroyed).filter(([, err]) => err?.type !== 'notFound');
|
||||
if (real.length > 0) {
|
||||
const [id, err] = real[0];
|
||||
throw new Error(
|
||||
`Failed to delete ${real.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`,
|
||||
);
|
||||
}
|
||||
assertEmailsDestroyed(requireMethodResult(res, '0', 'Email/set'));
|
||||
}
|
||||
}
|
||||
|
||||
/** Throw when an Email/set destroy left messages behind; `notFound` counts as gone. */
|
||||
function assertEmailsDestroyed(body: Record<string, any>): void {
|
||||
const notDestroyed = (body.notDestroyed ?? {}) as Record<string, { type?: string; description?: string }>;
|
||||
const real = Object.entries(notDestroyed).filter(([, err]) => err?.type !== 'notFound');
|
||||
if (real.length > 0) {
|
||||
const [id, err] = real[0];
|
||||
throw new Error(
|
||||
`Failed to delete ${real.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1509,9 +1519,13 @@ function buildEmailCreate(email: OutgoingEmail): Record<string, unknown> {
|
||||
/**
|
||||
* The error for a submission the server refused. A hold beyond the MTA's
|
||||
* limit becomes `ScheduleTooLateError`, and the limit it names is remembered
|
||||
* so the pickers stop offering such times (webmail parity).
|
||||
* so the pickers stop offering such times (webmail parity). A full
|
||||
* submission quota (Stalwart 1.0, still full after making room) becomes
|
||||
* `SubmissionQuotaError`, which the UI explains instead of showing the
|
||||
* server's "please delete some".
|
||||
*/
|
||||
function submissionError(err: { description?: string; type?: string }, fallback: string): Error {
|
||||
if (err.type === 'overQuota') return new SubmissionQuotaError(err.description);
|
||||
const limit = parseHoldLimit(err.description);
|
||||
if (limit === null) return new Error(err.description ?? err.type ?? fallback);
|
||||
jmapClient.learnHoldLimit(limit);
|
||||
@@ -1584,7 +1598,7 @@ export async function sendEmail(
|
||||
};
|
||||
}
|
||||
|
||||
const res = await jmapClient.request(
|
||||
let res = await jmapClient.request(
|
||||
[
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
['EmailSubmission/set', submissionArgs, '1'],
|
||||
@@ -1592,6 +1606,23 @@ export async function sendEmail(
|
||||
SUBMISSION_USING,
|
||||
);
|
||||
|
||||
// Stalwart 1.0 refuses a submission once the account holds `maxSubmissions`
|
||||
// records (overQuota). The message exists by then, so make room and submit
|
||||
// that copy once more instead of failing the send.
|
||||
const copyCreated = res.methodResponses.find(([name, , callId]) => name === 'Email/set' && callId === '0');
|
||||
const copyId = (copyCreated?.[1] as { created?: Record<string, { id?: string }> } | undefined)?.created?.draft?.id;
|
||||
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']],
|
||||
SUBMISSION_USING,
|
||||
);
|
||||
res = { ...retry, methodResponses: [copyCreated, ...(retry.methodResponses ?? [])] };
|
||||
}
|
||||
|
||||
let emailId: string | undefined;
|
||||
let emailSubmissionId: string | undefined;
|
||||
let sendAt: string | undefined;
|
||||
@@ -1659,15 +1690,42 @@ export async function sendEmail(
|
||||
throw failure;
|
||||
}
|
||||
|
||||
// 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
|
||||
// once an account holds `maxSubmissions` records, so the record goes now;
|
||||
// Stalwart's destroy only removes the record, never the queued message.
|
||||
// Other servers are left to the prune. Decided by the request, not by the
|
||||
// undoStatus the create returned, which 0.16.10-0.16.16 report as final
|
||||
// even for a held send.
|
||||
const recordToDestroy = holdFor === 0 && emailSubmissionId && isStalwartAccount(accountId)
|
||||
? emailSubmissionId
|
||||
: undefined;
|
||||
|
||||
// The message is out (or scheduled) - now it is safe to drop the old draft.
|
||||
// A failure here leaves an orphan in Drafts, which is a filing warning
|
||||
// rather than a failed send (#849).
|
||||
// rather than a failed send (#849). The submission record rides along, and
|
||||
// nothing about it may turn into a warning.
|
||||
if (opts?.draftId && emailSubmissionId) {
|
||||
try {
|
||||
await destroyEmails([opts.draftId], accountId);
|
||||
if (recordToDestroy) {
|
||||
const cleanup = await jmapClient.request(
|
||||
[
|
||||
['Email/set', { accountId, destroy: [opts.draftId] }, '0'],
|
||||
submissionDestroyCall([recordToDestroy], accountId, '1'),
|
||||
],
|
||||
SUBMISSION_USING,
|
||||
);
|
||||
readSubmissionDestroy(cleanup, '1');
|
||||
assertEmailsDestroyed(requireMethodResult(cleanup, '0', 'Email/set'));
|
||||
} else {
|
||||
await destroyEmails([opts.draftId], accountId);
|
||||
}
|
||||
} catch (err) {
|
||||
filingWarning = filingWarning ?? `old draft cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
} else if (recordToDestroy) {
|
||||
await destroyEmailSubmissions([recordToDestroy], accountId);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1754,13 +1812,29 @@ export async function listScheduledEmails(): Promise<ScheduledEmail[]> {
|
||||
async function listScheduledEmailsIn(accountId: string): Promise<ScheduledEmail[]> {
|
||||
const now = Date.now();
|
||||
|
||||
const queryRes = await jmapClient.request(
|
||||
[['EmailSubmission/query', { accountId, limit: 200 }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
const [queryName, queryBody] = queryRes.methodResponses[0];
|
||||
if (queryName === 'error' || queryName.endsWith('/error')) return [];
|
||||
const ids = (queryBody.ids as string[]) ?? [];
|
||||
// Every page of the query. It lists records oldest sendAt first, so a
|
||||
// single page of 200 lost the mail scheduled furthest ahead once the
|
||||
// three-day window held more records than that. `total` only comes back
|
||||
// with calculateTotal.
|
||||
const pageSize = Math.min(500, maxInGet());
|
||||
const found: string[] = [];
|
||||
for (let page = 0; page < SUBMISSION_SCAN_MAX_PAGES; page++) {
|
||||
const queryRes = await jmapClient.request(
|
||||
[['EmailSubmission/query', { accountId, position: found.length, limit: pageSize, calculateTotal: true }, '0']],
|
||||
SUBMISSION_ONLY_USING,
|
||||
);
|
||||
const [queryName, queryBody] = queryRes.methodResponses[0];
|
||||
if (queryName === 'error' || queryName.endsWith('/error')) {
|
||||
if (page === 0) return [];
|
||||
break;
|
||||
}
|
||||
const pageIds = (queryBody.ids as string[]) ?? [];
|
||||
found.push(...pageIds);
|
||||
const done = typeof queryBody.total === 'number' ? found.length >= queryBody.total : pageIds.length < pageSize;
|
||||
if (pageIds.length === 0 || done) break;
|
||||
}
|
||||
// A record created between two pages shifts the later ones by one.
|
||||
const ids = Array.from(new Set(found));
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const submissions: Array<{
|
||||
@@ -1826,8 +1900,9 @@ async function listScheduledEmailsIn(accountId: string): Promise<ScheduledEmail[
|
||||
}
|
||||
|
||||
// Cancel a pending scheduled send. The held message copy stays in Sent; only
|
||||
// delivery is stopped (matches the webmail behaviour). `accountIdOverride` is
|
||||
// the account holding the submission (see `ScheduledEmail.accountId`).
|
||||
// delivery is stopped (matches the webmail behaviour), and the canceled
|
||||
// submission record is removed. `accountIdOverride` is the account holding
|
||||
// the submission (see `ScheduledEmail.accountId`).
|
||||
export async function cancelScheduledSend(emailSubmissionId: string, accountIdOverride?: string): Promise<void> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
@@ -1844,6 +1919,13 @@ export async function cancelScheduledSend(emailSubmissionId: string, accountIdOv
|
||||
if (failure) {
|
||||
throw new Error(failure.description ?? failure.type ?? 'Failed to cancel scheduled send');
|
||||
}
|
||||
// The canceled record is dead weight against Stalwart 1.0's submission
|
||||
// limit. It goes in a request of its own, and only once the cancel is
|
||||
// confirmed: had the two gone together and the cancel failed, the mail
|
||||
// would still be queued with no record left to cancel it through.
|
||||
if (body.updated && emailSubmissionId in body.updated) {
|
||||
await destroyEmailSubmissions([emailSubmissionId], accountId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1904,16 +1986,25 @@ export async function rescheduleScheduledSend(
|
||||
},
|
||||
};
|
||||
|
||||
const res = await jmapClient.request(
|
||||
const createReplacement = () => jmapClient.request(
|
||||
[['EmailSubmission/set', { accountId, create: { replacement: create } }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
let res = await createReplacement();
|
||||
// A full submission quota (Stalwart 1.0): make room and try once more. The
|
||||
// original is still pending, so making room never touches it.
|
||||
if (submissionCreateError(res, 'replacement')?.type === 'overQuota') {
|
||||
await makeRoomForSubmission(accountId);
|
||||
res = await createReplacement();
|
||||
}
|
||||
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
|
||||
const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined;
|
||||
if (notCreated) throw submissionError(notCreated, 'Failed to reschedule');
|
||||
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');
|
||||
|
||||
// Either cancel below also removes the record it canceled, so a reschedule
|
||||
// leaves only the replacement behind.
|
||||
try {
|
||||
await cancelScheduledSend(scheduled.emailSubmissionId, accountId);
|
||||
} catch (err) {
|
||||
@@ -1994,8 +2085,9 @@ export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise<str
|
||||
const { uploadBytes } = await import('./blob');
|
||||
const upload = await uploadBytes(bytes, 'message/rfc822', accountId);
|
||||
const emailId = await importEmailBlob(upload.blobId, opts.sentMailboxId, { $seen: true }, accountId);
|
||||
let submissionId: string | undefined;
|
||||
try {
|
||||
const res = await jmapClient.request(
|
||||
const submit = () => jmapClient.request(
|
||||
[
|
||||
['EmailSubmission/set', {
|
||||
accountId,
|
||||
@@ -2013,13 +2105,27 @@ export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise<str
|
||||
],
|
||||
SUBMISSION_USING,
|
||||
);
|
||||
let res = await submit();
|
||||
// A full submission quota (Stalwart 1.0): make room and try once more.
|
||||
if (submissionCreateError(res, 'mdn')?.type === 'overQuota') {
|
||||
await makeRoomForSubmission(accountId);
|
||||
res = await submit();
|
||||
}
|
||||
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
|
||||
const refused = body.notCreated?.mdn as { type?: string; description?: string } | undefined;
|
||||
if (refused?.type === 'overQuota') throw new SubmissionQuotaError(refused.description);
|
||||
assertSetResult(body, ['mdn'], 'read receipt');
|
||||
submissionId = body.created?.mdn?.id as string | undefined;
|
||||
} catch (err) {
|
||||
await destroyEmails([emailId], accountId).catch((cleanupErr) => {
|
||||
console.warn('[mdn] failed to remove the unsent receipt from Sent:', cleanupErr);
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
// A receipt goes out without a hold, so its record can go at once, as after
|
||||
// an immediate send (see sendEmail).
|
||||
if (submissionId && isStalwartAccount(accountId)) {
|
||||
await destroyEmailSubmissions([submissionId], accountId);
|
||||
}
|
||||
return emailId;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,8 @@ function canonicalUrl(url: string): string {
|
||||
// Shared response helpers live in ./jmap-result (dependency-free) and are
|
||||
// re-exported here for convenience.
|
||||
export {
|
||||
JMAPMethodError, requireMethodResult, assertSetResult, batched, ScheduleTooLateError, parseHoldLimit,
|
||||
JMAPMethodError, requireMethodResult, assertSetResult, batched, ScheduleTooLateError, SubmissionQuotaError,
|
||||
parseHoldLimit,
|
||||
} from './jmap-result';
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,6 +89,21 @@ export function assertSetResult(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server refused a new EmailSubmission because the account holds too many
|
||||
* submission records, and removing the ones that can no longer be cancelled
|
||||
* did not make room. Stalwart 1.0 counts every record, whatever its status or
|
||||
* age, against `maxSubmissions` (500 by default) and answers the create with
|
||||
* `overQuota` - not to be confused with Email/set's `overQuota`, which means
|
||||
* the mailbox storage is full.
|
||||
*/
|
||||
export class SubmissionQuotaError extends JMAPMethodError {
|
||||
constructor(description?: string) {
|
||||
super('overQuota', description || 'There are too many email submissions');
|
||||
this.name = 'SubmissionQuotaError';
|
||||
}
|
||||
}
|
||||
|
||||
/** A scheduled send later than the server's hold limit. */
|
||||
export class ScheduleTooLateError extends Error {
|
||||
maxSeconds?: number;
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// EmailSubmission records. Every send leaves one behind. Stalwart 1.0 counts
|
||||
// all of them, whatever their status or age, against a per-account limit
|
||||
// (`maxSubmissions`, 500 by default), and its own purge of old records never
|
||||
// runs, so an account that never removes them stops sending for good.
|
||||
// Destroying a record only removes the record - on 0.16 and 1.0 it neither
|
||||
// cancels nor stops the delivery - but a still-pending submission whose record
|
||||
// is gone can no longer be cancelled. So only records that can't be cancelled
|
||||
// any more are ever destroyed. Port of the webmail's client (48382170).
|
||||
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { batched, requireMethodResult } from './jmap-result';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { JMAPMethodCall, JMAPResponseBody } from './types';
|
||||
|
||||
/** `using` for EmailSubmission calls that neither read nor write an Email. */
|
||||
export const SUBMISSION_ONLY_USING = [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION];
|
||||
|
||||
/** How often the background prune may sweep one account's records. */
|
||||
const PRUNE_INTERVAL_MS = 10 * 60 * 1000;
|
||||
/** Upper bound on the pages one prune, change-log walk or scheduled list reads. */
|
||||
export const SUBMISSION_SCAN_MAX_PAGES = 40;
|
||||
|
||||
/** When each account's records were last pruned, keyed by server and account. */
|
||||
const prunedAt = new Map<string, number>();
|
||||
|
||||
function pruneKey(accountId: string): string {
|
||||
return `${jmapClient.serverUrl ?? ''}|${accountId}`;
|
||||
}
|
||||
|
||||
/** Test hook. */
|
||||
export function resetSubmissionPrune(): void {
|
||||
prunedAt.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account is on Stalwart, whose destroy is known to leave the
|
||||
* delivery alone on every version. Only there does a send destroy its own
|
||||
* record right away; elsewhere the prune gets to it once it is final.
|
||||
*/
|
||||
export function isStalwartAccount(accountId: string): boolean {
|
||||
return jmapClient.hasAccountCapability('urn:stalwart:jmap', accountId);
|
||||
}
|
||||
|
||||
export interface SubmissionStatus {
|
||||
id: string;
|
||||
sendAt?: string | null;
|
||||
undoStatus?: string;
|
||||
}
|
||||
|
||||
interface SetError {
|
||||
type?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The submissions that can no longer be cancelled, going by the undoStatus
|
||||
* EmailSubmission/get reports: canceled ones, and final ones whose sendAt has
|
||||
* passed. Stalwart computes that status from its queue on every version, so
|
||||
* it is the one to trust - unlike the status a create returns (0.16.10-0.16.16
|
||||
* say final for a held send) or the query's undoStatus filter (0.16 matches
|
||||
* the stored status). A pending submission is never among them, whatever its
|
||||
* sendAt: the queue still holds its mail, held or retrying, and without the
|
||||
* record it could no longer be cancelled - while destroying it would not stop
|
||||
* the delivery either.
|
||||
*/
|
||||
export function prunableSubmissionIds(submissions: SubmissionStatus[], now: number): string[] {
|
||||
return submissions
|
||||
.filter((submission) => {
|
||||
if (submission.undoStatus === 'canceled') return true;
|
||||
if (submission.undoStatus !== 'final' || !submission.sendAt) return false;
|
||||
const sendAt = Date.parse(submission.sendAt);
|
||||
return Number.isFinite(sendAt) && sendAt <= now;
|
||||
})
|
||||
.map((submission) => submission.id);
|
||||
}
|
||||
|
||||
/** The SetError an EmailSubmission/set create came back with, if it failed that way. */
|
||||
export function submissionCreateError(res: JMAPResponseBody, creationId: string): SetError | undefined {
|
||||
return res.methodResponses
|
||||
?.find(([name]) => name === 'EmailSubmission/set')?.[1]?.notCreated?.[creationId];
|
||||
}
|
||||
|
||||
export function submissionDestroyCall(ids: string[], accountId: string, callId: string): JMAPMethodCall {
|
||||
return ['EmailSubmission/set', { accountId, destroy: ids }, callId];
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids an EmailSubmission/set destroy removed. `notFound` means the record
|
||||
* is gone already - another device got there first - which is what the
|
||||
* caller wanted. Any other failure is only logged: a leftover record costs
|
||||
* quota, never mail.
|
||||
*/
|
||||
export function readSubmissionDestroy(res: JMAPResponseBody | undefined, callId: string): string[] {
|
||||
const [name, body] = res?.methodResponses?.find(([, , id]) => id === callId) ?? [];
|
||||
if (name !== 'EmailSubmission/set') {
|
||||
console.warn('[submissions] destroy failed:', body);
|
||||
return [];
|
||||
}
|
||||
const failures = Object.entries((body?.notDestroyed ?? {}) as Record<string, SetError>)
|
||||
.filter(([, error]) => error?.type !== 'notFound');
|
||||
if (failures.length > 0) {
|
||||
console.warn('[submissions] not destroyed:', Object.fromEntries(failures));
|
||||
}
|
||||
return Array.isArray(body?.destroyed) ? (body.destroyed as string[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy EmailSubmission records, in batches the server accepts. Callers
|
||||
* must only pass records that can no longer be cancelled.
|
||||
*
|
||||
* Resolves with the ids the server destroyed. Ids it no longer has count as
|
||||
* done; other failures are logged. Never rejects, so no send fails over it.
|
||||
*/
|
||||
export async function destroyEmailSubmissions(ids: string[], accountId: string): Promise<string[]> {
|
||||
const destroyed: string[] = [];
|
||||
for (const slice of batched([...new Set(ids)], jmapClient.getMaxObjectsInSet())) {
|
||||
try {
|
||||
const res = await jmapClient.request([submissionDestroyCall(slice, accountId, '0')], SUBMISSION_ONLY_USING);
|
||||
destroyed.push(...readSubmissionDestroy(res, '0'));
|
||||
} catch (err) {
|
||||
console.warn('[submissions] destroy failed:', err);
|
||||
}
|
||||
}
|
||||
return destroyed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* EmailSubmission/query only sees records whose sendAt is at most three days
|
||||
* back, so older ones are left to makeRoomForSubmission.
|
||||
*
|
||||
* Runs at most once per account every ten minutes unless `force` is set.
|
||||
* Resolves with the number of records destroyed; never rejects. The app
|
||||
* starts it at launch, on returning to the foreground and after sends,
|
||||
* without waiting for it.
|
||||
*/
|
||||
export async function pruneEmailSubmissions(options: { force?: boolean } = {}): Promise<number> {
|
||||
let destroyed = 0;
|
||||
try {
|
||||
// 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);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[submissions] prune failed:', err);
|
||||
}
|
||||
return destroyed;
|
||||
}
|
||||
|
||||
async function pruneSubmissionAccount(accountId: string, force: boolean): Promise<number> {
|
||||
const now = Date.now();
|
||||
const key = pruneKey(accountId);
|
||||
const lastRun = prunedAt.get(key);
|
||||
if (!force && lastRun !== undefined && now - lastRun < PRUNE_INTERVAL_MS) return 0;
|
||||
prunedAt.set(key, now);
|
||||
try {
|
||||
const prunable = await findPrunableSubmissions(accountId, now);
|
||||
if (prunable.length === 0) return 0;
|
||||
return (await destroyEmailSubmissions(prunable, accountId)).length;
|
||||
} catch (err) {
|
||||
console.warn(`[submissions] pruning ${accountId} failed:`, err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Page through the records the query can see that might go - sendAt passed
|
||||
* or canceled, a superset either way - and let EmailSubmission/get decide.
|
||||
*/
|
||||
async function findPrunableSubmissions(accountId: string, now: number): Promise<string[]> {
|
||||
const pageSize = Math.min(500, jmapClient.getMaxObjectsInGet());
|
||||
// A UTCDate in whole seconds, like the sendAt it is compared with.
|
||||
const before = new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const prunable: string[] = [];
|
||||
let position = 0;
|
||||
for (let page = 0; page < SUBMISSION_SCAN_MAX_PAGES; page++) {
|
||||
const res = await jmapClient.request([
|
||||
['EmailSubmission/query', {
|
||||
accountId,
|
||||
filter: { operator: 'OR', conditions: [{ before }, { undoStatus: 'canceled' }] },
|
||||
position,
|
||||
limit: pageSize,
|
||||
calculateTotal: true,
|
||||
}, '0'],
|
||||
['EmailSubmission/get', {
|
||||
accountId,
|
||||
'#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' },
|
||||
properties: ['id', 'sendAt', 'undoStatus'],
|
||||
}, '1'],
|
||||
], SUBMISSION_ONLY_USING);
|
||||
const query = requireMethodResult(res, '0', 'EmailSubmission/query');
|
||||
const ids = (query.ids as string[] | undefined) ?? [];
|
||||
prunable.push(...prunableSubmissionIds(submissionList(res, '1'), now));
|
||||
position += ids.length;
|
||||
// `total` only comes back with calculateTotal; without it, a short page
|
||||
// is the last one.
|
||||
const done = typeof query.total === 'number' ? position >= query.total : ids.length < pageSize;
|
||||
if (ids.length === 0 || done) break;
|
||||
}
|
||||
return prunable;
|
||||
}
|
||||
|
||||
/** The list of an EmailSubmission/get response; throws when the get failed. */
|
||||
function submissionList(res: JMAPResponseBody, callId: string): SubmissionStatus[] {
|
||||
return (requireMethodResult(res, callId, 'EmailSubmission/get').list as SubmissionStatus[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make room after an EmailSubmission/set create came back overQuota. Prunes
|
||||
* the account at once; when that frees nothing, the records taking up the
|
||||
* room are older than the query can see (sendAt more than three days back).
|
||||
* On Stalwart their ids are still in the change log (the last 10,000 changes
|
||||
* by default), and get and destroy by id work at any age, so they are found
|
||||
* there and pruned by the same rules. Never rejects: the caller retries the
|
||||
* create either way, since another device may have made room meanwhile.
|
||||
*/
|
||||
export async function makeRoomForSubmission(accountId: string): Promise<void> {
|
||||
const freed = await pruneSubmissionAccount(accountId, true);
|
||||
if (freed > 0) return;
|
||||
try {
|
||||
if (!isStalwartAccount(accountId)) return;
|
||||
const ids = await submissionIdsFromChangeLog(accountId);
|
||||
const now = Date.now();
|
||||
const prunable: string[] = [];
|
||||
for (const slice of batched(ids, Math.min(500, jmapClient.getMaxObjectsInGet()))) {
|
||||
const res = await jmapClient.request([
|
||||
['EmailSubmission/get', { accountId, ids: slice, properties: ['id', 'sendAt', 'undoStatus'] }, '0'],
|
||||
], SUBMISSION_ONLY_USING);
|
||||
prunable.push(...prunableSubmissionIds(submissionList(res, '0'), now));
|
||||
}
|
||||
if (prunable.length > 0) await destroyEmailSubmissions(prunable, accountId);
|
||||
} catch (err) {
|
||||
console.warn(`[submissions] looking up older submissions of ${accountId} failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every submission id still in the account's change log. Stalwart reads
|
||||
* sinceState "n" as its initial state and answers with the whole log, a
|
||||
* record created and destroyed within it left out, in pages chained through
|
||||
* `newState` while `hasMoreChanges` is set.
|
||||
*/
|
||||
async function submissionIdsFromChangeLog(accountId: string): Promise<string[]> {
|
||||
const ids = new Set<string>();
|
||||
let sinceState = 'n';
|
||||
for (let page = 0; page < SUBMISSION_SCAN_MAX_PAGES; page++) {
|
||||
const res = await jmapClient.request(
|
||||
[['EmailSubmission/changes', { accountId, sinceState }, '0']],
|
||||
SUBMISSION_ONLY_USING,
|
||||
);
|
||||
const changes = requireMethodResult(res, '0', 'EmailSubmission/changes');
|
||||
for (const id of [...(changes.created ?? []), ...(changes.updated ?? [])] as string[]) ids.add(id);
|
||||
for (const id of (changes.destroyed ?? []) as string[]) ids.delete(id);
|
||||
const next = changes.newState as string | undefined;
|
||||
if (!changes.hasMoreChanges || !next || next === sinceState) break;
|
||||
sinceState = next;
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import { toast } from '../../stores/toast-store';
|
||||
import { useSendUndoStore } from '../../stores/send-undo-store';
|
||||
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 { buildReplySubject } from '../../lib/subject-prefix';
|
||||
import { computeReplyThreadingHeaders } from '../../lib/email-threading';
|
||||
import { resolveReplyIdentity } from '../../lib/reply-identity';
|
||||
@@ -126,6 +128,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
|
||||
accountId: jmapAccountId,
|
||||
from: [{ name: identity.name, email: identity.email }],
|
||||
});
|
||||
void pruneEmailSubmissions();
|
||||
try {
|
||||
await patchKeywordsForEmails([email.id], { $answered: true }, jmapAccountId);
|
||||
} catch { /* the reply is out; the flag is cosmetic */ }
|
||||
@@ -136,7 +139,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
|
||||
// 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'));
|
||||
} catch (err) {
|
||||
Alert.alert(t('email_composer.send_failed', 'Failed to send'), err instanceof Error ? err.message : String(err));
|
||||
Alert.alert(t('email_composer.send_failed', 'Failed to send'), sendFailureMessage(err, t));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import { useEmailStore } from '../../stores/email-store';
|
||||
import { toast } from '../../stores/toast-store';
|
||||
import { sendReadReceipt, patchKeywordsForEmails } from '../../api/email';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { pruneEmailSubmissions } from '../../api/submissions';
|
||||
import { findReceivingIdentity } from '../../lib/email-headers';
|
||||
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
|
||||
import { sendFailureMessage } from '../../lib/send-failure-message';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
@@ -94,6 +96,7 @@ export function ReadReceiptBanner({ email, requestedBy, jmapAccountId, currentMa
|
||||
{ recipient: identity.email },
|
||||
),
|
||||
});
|
||||
void pruneEmailSubmissions();
|
||||
await flagSent();
|
||||
}, [identity, mailboxes, requestedBy, jmapAccountId, email.messageId, email.subject, t, flagSent]);
|
||||
|
||||
@@ -109,7 +112,7 @@ export function ReadReceiptBanner({ email, requestedBy, jmapAccountId, currentMa
|
||||
// Not retried, so say so rather than fail silently (webmail parity).
|
||||
toast.error(
|
||||
t('email_viewer.read_receipt.send_failed', 'Read receipt could not be sent'),
|
||||
err instanceof Error ? err.message : String(err),
|
||||
sendFailureMessage(err, t),
|
||||
);
|
||||
});
|
||||
}, [readReceiptResponse, shouldOffer, active, jmapAccountId, email.id, send, t]);
|
||||
@@ -124,7 +127,7 @@ export function ReadReceiptBanner({ email, requestedBy, jmapAccountId, currentMa
|
||||
} catch (err) {
|
||||
Alert.alert(
|
||||
t('email_viewer.read_receipt.send_failed', 'Read receipt could not be sent'),
|
||||
err instanceof Error ? err.message : String(err),
|
||||
sendFailureMessage(err, t),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { sendEmail } from '../../api/email';
|
||||
import { pruneEmailSubmissions } from '../../api/submissions';
|
||||
import { isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe } from '../../lib/unsubscribe';
|
||||
import type { ListHeaders } from '../../lib/email-headers';
|
||||
import { findReceivingIdentity } from '../../lib/email-headers';
|
||||
@@ -119,6 +120,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
||||
undefined,
|
||||
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
|
||||
);
|
||||
void pruneEmailSubmissions();
|
||||
}
|
||||
setState('done');
|
||||
setTimeout(dismiss, 3000);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RequestTimeoutError } from '../../api/jmap-client';
|
||||
import { JMAPMethodError, ScheduleTooLateError, SubmissionQuotaError } from '../../api/jmap-result';
|
||||
import { sendFailureMessage } from '../send-failure-message';
|
||||
|
||||
// The English fallback stands in for the catalog.
|
||||
const t = (key: string, fallback?: string) => fallback ?? key;
|
||||
|
||||
describe('sendFailureMessage', () => {
|
||||
it('explains a full submission quota instead of showing Stalwart 1.0\'s "please delete some"', () => {
|
||||
const quota = new SubmissionQuotaError(
|
||||
'There are too many email submissions, please delete some before adding a new one.',
|
||||
);
|
||||
expect(quota).toBeInstanceOf(JMAPMethodError);
|
||||
expect(quota.type).toBe('overQuota');
|
||||
expect(sendFailureMessage(quota, t)).toBe(
|
||||
'The server has too many sent-message records for this account. Bulwark removed the ones it could; if this keeps happening, ask your administrator to raise the email submission limit.',
|
||||
);
|
||||
});
|
||||
|
||||
it('points at Sent after a timeout, and names the hold limit', () => {
|
||||
expect(sendFailureMessage(new RequestTimeoutError(30_000), t)).toMatch(/Check your Sent folder/);
|
||||
expect(sendFailureMessage(new ScheduleTooLateError(172_800), t)).toMatch(/later than this server allows/);
|
||||
});
|
||||
|
||||
it("keeps the server's own words for anything else", () => {
|
||||
expect(sendFailureMessage(new Error('Not your address'), t)).toBe('Not your address');
|
||||
// A full mailbox is Email/set's overQuota, not the submission quota.
|
||||
expect(sendFailureMessage(new JMAPMethodError('overQuota', 'Account quota exceeded.'), t)).toBe('Account quota exceeded.');
|
||||
expect(sendFailureMessage('boom', t)).toBe('Failed to send email');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { RequestTimeoutError } from '../api/jmap-client';
|
||||
import { ScheduleTooLateError, SubmissionQuotaError } from '../api/jmap-result';
|
||||
|
||||
type Translate = (key: string, fallback?: string) => string;
|
||||
|
||||
/**
|
||||
* What to tell the user about a send (or reschedule) that failed (webmail
|
||||
* send-failure-message). Most errors carry the server's own description;
|
||||
* these are the ones it would misrepresent or leave unexplained:
|
||||
* - a full submission quota: Stalwart asks the user to "delete some", but
|
||||
* the app has already removed every record it may, so only an
|
||||
* administrator can help;
|
||||
* - a timeout, which is not a clean failure: the message may have gone out
|
||||
* with only the answer lost, so point at Sent rather than invite a re-send
|
||||
* (#702);
|
||||
* - a hold beyond the server's limit.
|
||||
*/
|
||||
export function sendFailureMessage(error: unknown, t: Translate): string {
|
||||
if (error instanceof SubmissionQuotaError) {
|
||||
return t(
|
||||
'email_composer.submission_quota',
|
||||
'The server has too many sent-message records for this account. Bulwark removed the ones it could; if this keeps happening, ask your administrator to raise the email submission limit.',
|
||||
);
|
||||
}
|
||||
if (error instanceof RequestTimeoutError) {
|
||||
return t(
|
||||
'email_composer.send_timeout_body',
|
||||
'The message may already have gone out. Check your Sent folder before sending it again.',
|
||||
);
|
||||
}
|
||||
if (error instanceof ScheduleTooLateError) {
|
||||
return t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.');
|
||||
}
|
||||
return error instanceof Error && error.message
|
||||
? error.message
|
||||
: t('notifications.error_sending', 'Failed to send email');
|
||||
}
|
||||
@@ -41,7 +41,9 @@ import {
|
||||
sendEmail, createDraft, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail,
|
||||
} from '../api/email';
|
||||
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 { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients';
|
||||
import { buildReplySubject, buildForwardSubject } from '../lib/subject-prefix';
|
||||
import { computeReplyThreadingHeaders, generateMessageId, stripMessageIdBrackets } from '../lib/email-threading';
|
||||
@@ -2114,6 +2116,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
);
|
||||
draftIdRef.current = null;
|
||||
lastSavedRef.current = null;
|
||||
// Each send leaves a submission record; sweep the ones that can go
|
||||
// (throttled per account).
|
||||
void pruneEmailSubmissions();
|
||||
if (result.filingWarning) {
|
||||
console.warn('[compose] post-send filing warning:', result.filingWarning);
|
||||
}
|
||||
@@ -2180,10 +2185,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
Alert.alert(
|
||||
t('email_composer.send_failed', 'Send failed'),
|
||||
e instanceof Error ? e.message : t('notifications.error_sending', 'Failed to send email'),
|
||||
);
|
||||
Alert.alert(t('email_composer.send_failed', 'Send failed'), sendFailureMessage(e, t));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
getFullEmail,
|
||||
type ScheduledEmail,
|
||||
} from '../api/email';
|
||||
import { jmapClient, ScheduleTooLateError } from '../api/jmap-client';
|
||||
import { jmapClient, ScheduleTooLateError, SubmissionQuotaError } from '../api/jmap-client';
|
||||
import { sendFailureMessage } from '../lib/send-failure-message';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
@@ -85,6 +86,12 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// "Send now" and a reschedule create a submission, which a full quota
|
||||
// refuses; the message stays scheduled as it was.
|
||||
if (e instanceof SubmissionQuotaError) {
|
||||
Alert.alert(failTitle, sendFailureMessage(e, t));
|
||||
return;
|
||||
}
|
||||
Alert.alert(failTitle, e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../../api/email', () => ({
|
||||
cancelScheduledSend: vi.fn(async () => undefined),
|
||||
@@ -7,13 +7,25 @@ vi.mock('../../api/email', () => ({
|
||||
getFullEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({ jmapClient: { accountId: 'primary' } }));
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { accountId: 'primary' },
|
||||
RequestTimeoutError: class RequestTimeoutError extends Error {},
|
||||
}));
|
||||
|
||||
// Background work nobody waits for: it never settles here.
|
||||
vi.mock('../../api/submissions', () => ({
|
||||
pruneEmailSubmissions: vi.fn(() => new Promise<number>(() => undefined)),
|
||||
}));
|
||||
|
||||
import * as emailApi from '../../api/email';
|
||||
import * as submissions from '../../api/submissions';
|
||||
import { 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';
|
||||
|
||||
const api = vi.mocked(emailApi);
|
||||
const prune = vi.mocked(submissions.pruneEmailSubmissions);
|
||||
|
||||
const HELD = { scheduled: true, emailSubmissionId: 'sub-1', emailId: 'mail-1', sendAt: '2026-09-24T10:00:10Z' };
|
||||
const SENDER = { name: 'Me', email: 'me@example.com' };
|
||||
@@ -56,9 +68,14 @@ function entry(overrides: Partial<PendingUndoSend> = {}): PendingUndoSend {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSendUndoStore.setState({ pending: null, busy: false, restoredEmailId: null });
|
||||
useToastStore.getState().clearToasts();
|
||||
api.getFullEmail.mockResolvedValue({ id: 'mail-1', subject: 'Re: hi' } as Email);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('send-undo-store', () => {
|
||||
describe('recordHeldSend', () => {
|
||||
it('offers undo for a send held by the delay', () => {
|
||||
@@ -109,12 +126,44 @@ describe('send-undo-store', () => {
|
||||
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', undefined);
|
||||
});
|
||||
|
||||
it('keeps the entry when the cancel fails', async () => {
|
||||
it('keeps the entry when the cancel fails, and says so', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
api.cancelScheduledSend.mockRejectedValueOnce(new Error('gone'));
|
||||
const pending = entry();
|
||||
useSendUndoStore.getState().setPending(pending);
|
||||
await expect(useSendUndoStore.getState().undo()).resolves.toBe(false);
|
||||
expect(useSendUndoStore.getState()).toMatchObject({ pending, busy: false });
|
||||
// Silence would read as "undone" while the mail may be on its way.
|
||||
expect(useToastStore.getState().toasts).toMatchObject([{ type: 'error', title: 'Undo send failed', message: 'gone' }]);
|
||||
expect(prune).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts a prune of submission records after an undo and after send now, without waiting for it', async () => {
|
||||
useSendUndoStore.getState().setPending(entry());
|
||||
await expect(useSendUndoStore.getState().undo()).resolves.toBe(true);
|
||||
expect(prune).toHaveBeenCalledTimes(1);
|
||||
|
||||
useSendUndoStore.getState().setPending(entry());
|
||||
await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(true);
|
||||
expect(prune).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('explains a send now refused for a full submission quota (Stalwart 1.0)', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
api.rescheduleScheduledSend.mockRejectedValueOnce(new SubmissionQuotaError(
|
||||
'There are too many email submissions, please delete some before adding a new one.',
|
||||
));
|
||||
useSendUndoStore.getState().setPending(entry());
|
||||
|
||||
await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(false);
|
||||
|
||||
expect(useToastStore.getState().toasts).toMatchObject([{
|
||||
type: 'error',
|
||||
title: 'Could not send now',
|
||||
message: expect.stringContaining('ask your administrator to raise the email submission limit'),
|
||||
}]);
|
||||
// Still held: it goes out when the delay ends.
|
||||
expect(useSendUndoStore.getState().pending).not.toBeNull();
|
||||
});
|
||||
|
||||
it('releases the held message from the account holding it', async () => {
|
||||
|
||||
@@ -10,10 +10,14 @@ import {
|
||||
type SendEmailResult,
|
||||
} from '../api/email';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
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 type { ComposeDraftContext } from '../navigation/types';
|
||||
import { t } from './locale-store';
|
||||
import { toast } from './toast-store';
|
||||
|
||||
export interface PendingUndoSend {
|
||||
emailSubmissionId: string;
|
||||
@@ -83,9 +87,15 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
|
||||
try {
|
||||
await cancelScheduledSend(entry.emailSubmissionId, entry.accountId);
|
||||
set({ pending: null, busy: false, restoredEmailId: entry.emailId });
|
||||
void pruneEmailSubmissions();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[send-undo] cancel failed', err);
|
||||
// Silence here reads as "undone" while the mail may be on its way.
|
||||
toast.error(
|
||||
t('email_viewer.undo_send_failed', 'Undo send failed'),
|
||||
err instanceof Error ? err.message : undefined,
|
||||
);
|
||||
set({ busy: false });
|
||||
return false;
|
||||
}
|
||||
@@ -108,9 +118,13 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
|
||||
0,
|
||||
);
|
||||
set({ pending: null, busy: false });
|
||||
void pruneEmailSubmissions();
|
||||
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));
|
||||
set({ busy: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user