mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 16:07:32 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab7e0b90d4 | ||
|
|
a3e3ae9ee1 | ||
|
|
0558b4157d | ||
|
|
0fb480bfd9 | ||
|
|
b9c3f79401 | ||
|
|
78b1838d14 | ||
|
|
a0a5aa3f38 | ||
|
|
ac312352ba | ||
|
|
4d21adcec6 | ||
|
|
762ec6bab0 | ||
|
|
f173358e86 | ||
|
|
f11aea8a2a | ||
|
|
b58c9dd719 | ||
|
|
ef31d6f1c9 | ||
|
|
fc73bf8334 | ||
|
|
923f9d8e87 |
@@ -81,23 +81,24 @@ tags:
|
||||
commands:
|
||||
- tapOn: "More actions.*"
|
||||
- tapOn: "Star|Unstar"
|
||||
# A bulk action ends selection mode by itself.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Clear selection"
|
||||
commands:
|
||||
- tapOn: "Clear selection"
|
||||
# A bulk action ends selection mode by itself once the server has the
|
||||
# change, so the bar stays up for a moment: wait for it to go rather than
|
||||
# tap Clear selection (the tap can land after the bar is gone).
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Clear selection"
|
||||
timeout: 15000
|
||||
- longPressOn:
|
||||
id: email-row
|
||||
text: ".*Grüße aus München.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Clear selection"
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Unstar|Star"
|
||||
commands:
|
||||
- tapOn: "More actions.*"
|
||||
- tapOn: "Unstar|Star"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Clear selection"
|
||||
commands:
|
||||
- tapOn: "Clear selection"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Clear selection"
|
||||
timeout: 15000
|
||||
|
||||
@@ -16,6 +16,9 @@ tags:
|
||||
# Each Q matches its page near the top. A PROOF the search lists under the
|
||||
# row would pass on the iPad without the page, so those differ. The Themes
|
||||
# cards are one element each, named by the theme: check the page's text.
|
||||
# "Storage" also finds Account and Files, which puts Storage & backup third,
|
||||
# under the keyboard in landscape (a tap there types on the keys): search
|
||||
# "Offline", which only that page has.
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", Q: "New folder", PROOF: ".*Inbox.*"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", Q: "Tags", PROOF: "Email Tags"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", Q: "Downloads", PROOF: "Email .*eml.* filename"}}
|
||||
@@ -26,7 +29,7 @@ tags:
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", Q: "Files", PROOF: "Default View"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", Q: "Sidebar", PROOF: "Keep Apps Loaded"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", Q: "Themes", PROOF: "Pick a colou?r theme.*"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Storage", PROOF: "Offline mail.*"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Offline", PROOF: "Offline mail.*"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", Q: "About", PROOF: "This device"}}
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
|
||||
@@ -18,8 +18,8 @@ appId: org.bulwarkmail.mobile
|
||||
- inputText: ${Q}
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
# Now and then the simulator drops or garbles a typed character
|
||||
# (seen: "Storagew,"): type it again, a few times if need be.
|
||||
# Now and then the simulator drops or garbles a typed character:
|
||||
# type it again, a few times if need be.
|
||||
- repeat:
|
||||
while:
|
||||
notVisible: ${ROW}
|
||||
|
||||
+8
-7
@@ -158,13 +158,14 @@ Holder has to accept an updated agreement under Business in App Store Connect.
|
||||
|
||||
## What does not work on iOS yet
|
||||
|
||||
- **Push notifications.** The push path is a hand-written Android module talking
|
||||
to FCM (`BulwarkFcmModule`, `BulwarkMessagingService`). iOS needs an APNs key
|
||||
and an equivalent native module; until then `push-notifications.ts` short-
|
||||
circuits and the app polls like a device with push disabled.
|
||||
- **Client certificates.** `BulwarkClientCertModule` is Android-only.
|
||||
`secureFetch` falls back to plain `fetch`, so cert-authenticated servers will
|
||||
not connect.
|
||||
- **Client certificates, partly (#3).** The app imports a .p12 into its own
|
||||
keychain (plugins/ios-client-cert) and every request of the app answers the
|
||||
server's certificate request with it. Two places can't: the notification
|
||||
service extension (plugins/ios-push) uses a plain URLSession, so behind
|
||||
client certificate authentication notifications show only the placeholder
|
||||
text and push renewal fails; and sign-in through the server's page runs in
|
||||
the system browser, which can't use the app's certificate (password sign-in
|
||||
in the app works). Not yet run on a device.
|
||||
- **In-app updates.** Hidden on purpose - see `supportsSideloadUpdates` in
|
||||
[src/lib/platform-capabilities.ts](../src/lib/platform-capabilities.ts).
|
||||
Downloading and installing a binary is forbidden by App Store Review guideline
|
||||
|
||||
+5
-1
@@ -421,7 +421,11 @@
|
||||
"undo_send_other_login": "أُرسلت هذه الرسالة من حساب آخر",
|
||||
"undo_send_other_login_body": "عُد إلى {account} للتراجع عنها أو إرسالها الآن. ستُرسل عند انتهاء المهلة.",
|
||||
"upload_login_switched": "لم يتم الرفع: تم تبديل الحساب",
|
||||
"use_template": "قالب"
|
||||
"use_template": "قالب",
|
||||
"draft_kept_title": "حُفظت على هذا الجهاز",
|
||||
"draft_kept_body": "سينتقل إلى المسودات عندما تسجّل الدخول وتتصل بالإنترنت مجددًا.",
|
||||
"rescued_draft_title": "حُفظت في المسودات",
|
||||
"rescued_draft_body": "تعذّر حفظ رسالة كنت تكتبها في وقت سابق. إنها الآن في المسودات."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "يتطلب النقل بين الحسابات اتصالًا بالإنترنت",
|
||||
|
||||
+5
-1
@@ -426,7 +426,11 @@
|
||||
"undo_send_other_login": "Aquest missatge s'ha enviat des d'un altre compte",
|
||||
"undo_send_other_login_body": "Torneu a {account} per desfer-lo o enviar-lo ara. Sortirà quan s'acabi el retard.",
|
||||
"upload_login_switched": "No s'ha pujat: s'ha canviat de compte",
|
||||
"use_template": "Plantilla"
|
||||
"use_template": "Plantilla",
|
||||
"draft_kept_title": "Desat en aquest dispositiu",
|
||||
"draft_kept_body": "Anirà a Esborranys quan tornis a tenir la sessió iniciada i connexió.",
|
||||
"rescued_draft_title": "Desat a Esborranys",
|
||||
"rescued_draft_body": "Abans no s'ha pogut desar un missatge que escrivies. Ara és a Esborranys."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Per moure entre comptes cal connexió",
|
||||
|
||||
+5
-1
@@ -426,7 +426,11 @@
|
||||
"undo_send_other_login": "Tato zpráva byla odeslána z jiného účtu",
|
||||
"undo_send_other_login_body": "Chcete-li odeslání vrátit nebo zprávu odeslat hned, přepněte zpět na {account}. Odejde po uplynutí prodlevy.",
|
||||
"upload_login_switched": "Nenahráno: účet byl přepnut",
|
||||
"use_template": "Šablona"
|
||||
"use_template": "Šablona",
|
||||
"draft_kept_title": "Uloženo v tomto zařízení",
|
||||
"draft_kept_body": "Do Konceptů se uloží, až budete znovu přihlášeni a online.",
|
||||
"rescued_draft_title": "Uloženo do Konceptů",
|
||||
"rescued_draft_body": "Zprávu, kterou jste psali, se dříve nepodařilo uložit. Teď je v Konceptech."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Přesun mezi účty vyžaduje připojení",
|
||||
|
||||
+5
-1
@@ -423,7 +423,11 @@
|
||||
"undo_send_other_login": "Denne besked blev sendt fra en anden konto",
|
||||
"undo_send_other_login_body": "Skift tilbage til {account} for at fortryde den eller sende den nu. Den sendes, når forsinkelsen er udløbet.",
|
||||
"upload_login_switched": "Ikke uploadet: kontoen blev skiftet",
|
||||
"use_template": "Skabelon"
|
||||
"use_template": "Skabelon",
|
||||
"draft_kept_title": "Gemt på denne enhed",
|
||||
"draft_kept_body": "Den kommer i Kladder, når du er logget ind og online igen.",
|
||||
"rescued_draft_title": "Gemt i Kladder",
|
||||
"rescued_draft_body": "En besked, du skrev, kunne ikke gemmes tidligere. Den ligger nu i Kladder."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Flytning mellem konti kræver forbindelse",
|
||||
|
||||
+5
-1
@@ -431,7 +431,11 @@
|
||||
"undo_send_other_login": "Diese Nachricht wurde von einem anderen Konto gesendet",
|
||||
"undo_send_other_login_body": "Wechseln Sie zurück zu {account}, um das Senden rückgängig zu machen oder sofort zu senden. Sie geht raus, wenn die Verzögerung abläuft.",
|
||||
"upload_login_switched": "Nicht hochgeladen: Das Konto wurde gewechselt",
|
||||
"use_template": "Vorlage"
|
||||
"use_template": "Vorlage",
|
||||
"draft_kept_title": "Auf diesem Gerät gespeichert",
|
||||
"draft_kept_body": "Er kommt in die Entwürfe, sobald Sie wieder angemeldet und online sind.",
|
||||
"rescued_draft_title": "In Entwürfen gespeichert",
|
||||
"rescued_draft_body": "Eine Nachricht, die Sie geschrieben haben, konnte vorhin nicht gespeichert werden. Sie ist jetzt in den Entwürfen."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Verschieben zwischen Konten erfordert eine Verbindung",
|
||||
|
||||
@@ -352,6 +352,8 @@
|
||||
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||
"attachment_reminder_title": "Forgot an attachment?",
|
||||
"attachments_too_large_total": "The attachments would exceed the {max} this server allows per message.",
|
||||
"draft_kept_body": "It goes to Drafts once you are signed in and online again.",
|
||||
"draft_kept_title": "Saved on this device",
|
||||
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Attach} one {Attach 1 file} other {Attach # files}}",
|
||||
@@ -421,6 +423,8 @@
|
||||
"recipients_partly_refused_title": "Not sent to everyone",
|
||||
"recipients_refused": "Not sent: the server refused {recipients}.",
|
||||
"remove_attachment": "Remove attachment",
|
||||
"rescued_draft_body": "A message you were writing couldn't be saved earlier. It is in Drafts now.",
|
||||
"rescued_draft_title": "Saved to Drafts",
|
||||
"save_draft": "Save Draft",
|
||||
"save_failed_no_drafts": "There is no Drafts folder to save this draft to.",
|
||||
"save_failed_no_identity": "Your sender identities could not be loaded, so this draft cannot be saved. Check your connection, or copy your text before closing.",
|
||||
|
||||
+5
-1
@@ -432,7 +432,11 @@
|
||||
"undo_send_other_login": "Este mensaje se envió desde otra cuenta",
|
||||
"undo_send_other_login_body": "Vuelve a {account} para deshacerlo o enviarlo ahora. Se enviará cuando termine la espera.",
|
||||
"upload_login_switched": "No se ha subido: se cambió de cuenta",
|
||||
"use_template": "Plantilla"
|
||||
"use_template": "Plantilla",
|
||||
"draft_kept_title": "Guardado en este dispositivo",
|
||||
"draft_kept_body": "Pasará a Borradores cuando vuelvas a tener la sesión iniciada y conexión.",
|
||||
"rescued_draft_title": "Guardado en Borradores",
|
||||
"rescued_draft_body": "Antes no se pudo guardar un mensaje que escribías. Ahora está en Borradores."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Mover entre cuentas requiere conexión",
|
||||
|
||||
+5
-1
@@ -1252,7 +1252,11 @@
|
||||
"use_template": "Mall",
|
||||
"validation": {
|
||||
"subject_required": "Lisa teema"
|
||||
}
|
||||
},
|
||||
"draft_kept_title": "Salvestatud selles seadmes",
|
||||
"draft_kept_body": "See läheb mustanditesse, kui oled jälle sisse logitud ja võrgus.",
|
||||
"rescued_draft_title": "Salvestatud mustanditesse",
|
||||
"rescued_draft_body": "Kirjutatud sõnumit ei õnnestunud varem salvestada. See on nüüd mustandites."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Kontode vahel teisaldamiseks on vaja ühendust",
|
||||
|
||||
+5
-1
@@ -420,7 +420,11 @@
|
||||
"undo_send_other_login": "این پیام از حساب دیگری ارسال شده است",
|
||||
"undo_send_other_login_body": "برای بازگردانی یا ارسال فوری آن، به {account} برگردید. پس از پایان تأخیر ارسال میشود.",
|
||||
"upload_login_switched": "بارگذاری نشد: حساب عوض شد",
|
||||
"use_template": "قالب"
|
||||
"use_template": "قالب",
|
||||
"draft_kept_title": "روی این دستگاه ذخیره شد",
|
||||
"draft_kept_body": "وقتی دوباره وارد شوید و آنلاین باشید، به پیشنویسها میرود.",
|
||||
"rescued_draft_title": "در پیشنویسها ذخیره شد",
|
||||
"rescued_draft_body": "پیامی که مینوشتید قبلاً ذخیره نشد. اکنون در پیشنویسها است."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "انتقال بین حسابها به اتصال اینترنت نیاز دارد",
|
||||
|
||||
+5
-1
@@ -430,7 +430,11 @@
|
||||
"undo_send_other_login": "Ce message a été envoyé depuis un autre compte",
|
||||
"undo_send_other_login_body": "Revenez à {account} pour l'annuler ou l'envoyer maintenant. Il partira à la fin du délai.",
|
||||
"upload_login_switched": "Non téléversé : le compte a été changé",
|
||||
"use_template": "Modèle"
|
||||
"use_template": "Modèle",
|
||||
"draft_kept_title": "Enregistré sur cet appareil",
|
||||
"draft_kept_body": "Il ira dans les Brouillons dès que vous serez de nouveau connecté et en ligne.",
|
||||
"rescued_draft_title": "Enregistré dans les Brouillons",
|
||||
"rescued_draft_body": "Un message que vous écriviez n'a pas pu être enregistré plus tôt. Il est maintenant dans les Brouillons."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Le déplacement entre comptes nécessite une connexion",
|
||||
|
||||
+5
-1
@@ -424,7 +424,11 @@
|
||||
"undo_send_other_login": "ההודעה הזו נשלחה מחשבון אחר",
|
||||
"undo_send_other_login_body": "חזור אל {account} כדי לבטל אותה או לשלוח אותה עכשיו. היא תישלח כשההשהיה תסתיים.",
|
||||
"upload_login_switched": "לא הועלה: החשבון הוחלף",
|
||||
"use_template": "תבנית"
|
||||
"use_template": "תבנית",
|
||||
"draft_kept_title": "נשמר במכשיר הזה",
|
||||
"draft_kept_body": "הוא יעבור לטיוטות כשתתחבר שוב ותהיה מחובר לרשת.",
|
||||
"rescued_draft_title": "נשמר בטיוטות",
|
||||
"rescued_draft_body": "לא ניתן היה לשמור קודם הודעה שכתבת. היא נמצאת עכשיו בטיוטות."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "העברה בין חשבונות דורשת חיבור",
|
||||
|
||||
+5
-1
@@ -422,7 +422,11 @@
|
||||
"undo_send_other_login": "Ezt az üzenetet egy másik fiókból küldted",
|
||||
"undo_send_other_login_body": "Válts vissza erre a fiókra: {account}, hogy visszavond vagy most elküldd. A késleltetés végén kimegy.",
|
||||
"upload_login_switched": "Nincs feltöltve: fiókot váltottál",
|
||||
"use_template": "Sablon"
|
||||
"use_template": "Sablon",
|
||||
"draft_kept_title": "Mentve ezen az eszközön",
|
||||
"draft_kept_body": "A Piszkozatok közé kerül, amint újra bejelentkezik és online lesz.",
|
||||
"rescued_draft_title": "Mentve a Piszkozatok közé",
|
||||
"rescued_draft_body": "Egy üzenetet, amelyet írt, korábban nem sikerült menteni. Most a Piszkozatok között van."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "A fiókok közötti áthelyezéshez kapcsolat szükséges",
|
||||
|
||||
+5
-1
@@ -428,7 +428,11 @@
|
||||
"undo_send_other_login": "Questo messaggio è stato inviato da un altro account",
|
||||
"undo_send_other_login_body": "Torna a {account} per annullarlo o inviarlo subito. Parte al termine del ritardo.",
|
||||
"upload_login_switched": "Non caricato: l'account è stato cambiato",
|
||||
"use_template": "Modello"
|
||||
"use_template": "Modello",
|
||||
"draft_kept_title": "Salvato su questo dispositivo",
|
||||
"draft_kept_body": "Andrà nelle Bozze appena sarai di nuovo connesso e online.",
|
||||
"rescued_draft_title": "Salvato nelle Bozze",
|
||||
"rescued_draft_body": "Prima non è stato possibile salvare un messaggio che stavi scrivendo. Ora è nelle Bozze."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Per spostare tra account serve una connessione",
|
||||
|
||||
+5
-1
@@ -426,7 +426,11 @@
|
||||
"undo_send_other_login": "このメッセージは別のアカウントから送信されました",
|
||||
"undo_send_other_login_body": "取り消すか今すぐ送信するには、{account} に戻してください。待機時間が終わると送信されます。",
|
||||
"upload_login_switched": "アップロードされていません:アカウントが切り替わりました",
|
||||
"use_template": "テンプレート"
|
||||
"use_template": "テンプレート",
|
||||
"draft_kept_title": "このデバイスに保存しました",
|
||||
"draft_kept_body": "再びサインインしてオンラインになると、下書きに保存されます。",
|
||||
"rescued_draft_title": "下書きに保存しました",
|
||||
"rescued_draft_body": "書いていたメッセージを先ほど保存できませんでした。今は下書きにあります。"
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "アカウント間の移動には接続が必要です",
|
||||
|
||||
+5
-1
@@ -424,7 +424,11 @@
|
||||
"undo_send_other_login": "이 메시지는 다른 계정에서 보냈어요",
|
||||
"undo_send_other_login_body": "취소하거나 지금 보내려면 {account}(으)로 다시 전환해 주세요. 대기 시간이 끝나면 전송돼요.",
|
||||
"upload_login_switched": "업로드되지 않음: 계정이 전환되었어요",
|
||||
"use_template": "템플릿"
|
||||
"use_template": "템플릿",
|
||||
"draft_kept_title": "이 기기에 저장됨",
|
||||
"draft_kept_body": "다시 로그인하고 온라인이 되면 임시 보관함으로 옮겨집니다.",
|
||||
"rescued_draft_title": "임시 보관함에 저장됨",
|
||||
"rescued_draft_body": "작성하던 메시지를 앞서 저장하지 못했습니다. 이제 임시 보관함에 있습니다."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "계정 간 이동은 인터넷 연결이 필요해요",
|
||||
|
||||
+5
-1
@@ -425,7 +425,11 @@
|
||||
"undo_send_other_login": "Šī vēstule tika nosūtīta no cita konta",
|
||||
"undo_send_other_login_body": "Pārslēdzieties atpakaļ uz {account}, lai to atsauktu vai nosūtītu tagad. Tā tiks nosūtīta, kad beigsies aizture.",
|
||||
"upload_login_switched": "Nav augšupielādēts: konts tika pārslēgts",
|
||||
"use_template": "Veidne"
|
||||
"use_template": "Veidne",
|
||||
"draft_kept_title": "Saglabāts šajā ierīcē",
|
||||
"draft_kept_body": "Tas nonāks melnrakstos, kad atkal būsiet pierakstījies un tiešsaistē.",
|
||||
"rescued_draft_title": "Saglabāts melnrakstos",
|
||||
"rescued_draft_body": "Ziņojumu, ko rakstījāt, iepriekš neizdevās saglabāt. Tagad tas ir melnrakstos."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Pārvietošanai starp kontiem nepieciešams savienojums",
|
||||
|
||||
+5
-1
@@ -421,7 +421,11 @@
|
||||
"undo_send_other_login": "Энэ мессежийг өөр бүртгэлээс илгээсэн",
|
||||
"undo_send_other_login_body": "Үүнийг буцаах эсвэл одоо илгээхийн тулд {account} бүртгэл рүү буцаж шилжинэ үү. Саатал дуусахад илгээгдэнэ.",
|
||||
"upload_login_switched": "Байршуулагдаагүй: бүртгэл солигдсон",
|
||||
"use_template": "Загвар"
|
||||
"use_template": "Загвар",
|
||||
"draft_kept_title": "Энэ төхөөрөмж дээр хадгалсан",
|
||||
"draft_kept_body": "Дахин нэвтэрч, онлайн болмогц Ноорог руу орно.",
|
||||
"rescued_draft_title": "Ноорогт хадгалсан",
|
||||
"rescued_draft_body": "Таны бичиж байсан захидлыг өмнө нь хадгалж чадсангүй. Одоо Ноорогт байна."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Бүртгэл хооронд зөөхөд холболт шаардлагатай",
|
||||
|
||||
+5
-1
@@ -423,7 +423,11 @@
|
||||
"undo_send_other_login": "Denne meldingen ble sendt fra en annen konto",
|
||||
"undo_send_other_login_body": "Bytt tilbake til {account} for å angre eller sende den nå. Den sendes når forsinkelsen er over.",
|
||||
"upload_login_switched": "Ikke lastet opp: kontoen ble byttet",
|
||||
"use_template": "Mal"
|
||||
"use_template": "Mal",
|
||||
"draft_kept_title": "Lagret på denne enheten",
|
||||
"draft_kept_body": "Den havner i Utkast når du er logget inn og på nett igjen.",
|
||||
"rescued_draft_title": "Lagret i Utkast",
|
||||
"rescued_draft_body": "En melding du skrev, kunne ikke lagres tidligere. Den ligger nå i Utkast."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Du må være tilkoblet for å flytte mellom kontoer",
|
||||
|
||||
+5
-1
@@ -426,7 +426,11 @@
|
||||
"undo_send_other_login": "Dit bericht is verzonden vanuit een ander account",
|
||||
"undo_send_other_login_body": "Ga terug naar {account} om het ongedaan te maken of nu te verzenden. Het gaat de deur uit wanneer de vertraging voorbij is.",
|
||||
"upload_login_switched": "Niet geüpload: er is van account gewisseld",
|
||||
"use_template": "Sjabloon"
|
||||
"use_template": "Sjabloon",
|
||||
"draft_kept_title": "Opgeslagen op dit apparaat",
|
||||
"draft_kept_body": "Het gaat naar Concepten zodra je weer bent ingelogd en online bent.",
|
||||
"rescued_draft_title": "Opgeslagen in Concepten",
|
||||
"rescued_draft_body": "Een bericht dat je schreef, kon eerder niet worden opgeslagen. Het staat nu in Concepten."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Verplaatsen tussen accounts vereist een verbinding",
|
||||
|
||||
+5
-1
@@ -426,7 +426,11 @@
|
||||
"undo_send_other_login": "Ta wiadomość została wysłana z innego konta",
|
||||
"undo_send_other_login_body": "Przełącz się z powrotem na {account}, aby cofnąć wysłanie lub wysłać ją teraz. Zostanie wysłana po upływie opóźnienia.",
|
||||
"upload_login_switched": "Nie przesłano: przełączono konto",
|
||||
"use_template": "Szablon"
|
||||
"use_template": "Szablon",
|
||||
"draft_kept_title": "Zapisano na tym urządzeniu",
|
||||
"draft_kept_body": "Trafi do Wersji roboczych, gdy znów się zalogujesz i będziesz online.",
|
||||
"rescued_draft_title": "Zapisano w Wersjach roboczych",
|
||||
"rescued_draft_body": "Wiadomości, którą pisałeś, nie udało się wcześniej zapisać. Jest teraz w Wersjach roboczych."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Przenoszenie między kontami wymaga połączenia",
|
||||
|
||||
+5
-1
@@ -429,7 +429,11 @@
|
||||
"undo_send_other_login": "Esta mensagem foi enviada de outra conta",
|
||||
"undo_send_other_login_body": "Volte para {account} para desfazer ou enviar agora. Ela sai quando o atraso terminar.",
|
||||
"upload_login_switched": "Não enviado: a conta foi trocada",
|
||||
"use_template": "Modelo"
|
||||
"use_template": "Modelo",
|
||||
"draft_kept_title": "Guardado neste dispositivo",
|
||||
"draft_kept_body": "Vai para Rascunhos assim que voltar a ter sessão iniciada e ligação.",
|
||||
"rescued_draft_title": "Guardado em Rascunhos",
|
||||
"rescued_draft_body": "Não foi possível guardar antes uma mensagem que estava a escrever. Está agora em Rascunhos."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Mover entre contas requer conexão",
|
||||
|
||||
+5
-1
@@ -426,7 +426,11 @@
|
||||
"undo_send_other_login": "Acest mesaj a fost trimis din alt cont",
|
||||
"undo_send_other_login_body": "Reveniți la {account} pentru a anula trimiterea sau a-l trimite acum. Mesajul pleacă la sfârșitul întârzierii.",
|
||||
"upload_login_switched": "Neîncărcat: contul a fost schimbat",
|
||||
"use_template": "Șablon"
|
||||
"use_template": "Șablon",
|
||||
"draft_kept_title": "Salvat pe acest dispozitiv",
|
||||
"draft_kept_body": "Ajunge în Ciorne după ce te conectezi din nou și ești online.",
|
||||
"rescued_draft_title": "Salvat în Ciorne",
|
||||
"rescued_draft_body": "Un mesaj pe care îl scriai nu a putut fi salvat mai devreme. Acum este în Ciorne."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Mutarea între conturi necesită conexiune",
|
||||
|
||||
+5
-1
@@ -429,7 +429,11 @@
|
||||
"undo_send_other_login": "Это письмо отправлено из другого аккаунта",
|
||||
"undo_send_other_login_body": "Вернитесь в аккаунт {account}, чтобы отменить отправку или отправить письмо сейчас. Оно уйдёт, когда закончится задержка.",
|
||||
"upload_login_switched": "Не загружено: аккаунт сменён",
|
||||
"use_template": "Шаблон"
|
||||
"use_template": "Шаблон",
|
||||
"draft_kept_title": "Сохранено на этом устройстве",
|
||||
"draft_kept_body": "Оно попадёт в черновики, когда вы снова войдёте и будете в сети.",
|
||||
"rescued_draft_title": "Сохранено в черновиках",
|
||||
"rescued_draft_body": "Сообщение, которое вы писали, раньше не удалось сохранить. Теперь оно в черновиках."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Для перемещения между аккаунтами нужно подключение",
|
||||
|
||||
+5
-1
@@ -425,7 +425,11 @@
|
||||
"undo_send_other_login": "Táto správa bola odoslaná z iného účtu",
|
||||
"undo_send_other_login_body": "Ak ju chcete vrátiť alebo odoslať hneď, prepnite sa späť na účet {account}. Odíde po uplynutí oneskorenia.",
|
||||
"upload_login_switched": "Nenahrané: účet bol prepnutý",
|
||||
"use_template": "Šablóna"
|
||||
"use_template": "Šablóna",
|
||||
"draft_kept_title": "Uložené v tomto zariadení",
|
||||
"draft_kept_body": "Do Konceptov sa uloží, keď budete znova prihlásení a online.",
|
||||
"rescued_draft_title": "Uložené do Konceptov",
|
||||
"rescued_draft_body": "Správu, ktorú ste písali, sa predtým nepodarilo uložiť. Teraz je v Konceptoch."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Presun medzi účtami vyžaduje pripojenie",
|
||||
|
||||
+5
-1
@@ -393,7 +393,11 @@
|
||||
"use_template": "เทมเพลต",
|
||||
"validation": {
|
||||
"subject_required": "เพิ่มเรื่อง"
|
||||
}
|
||||
},
|
||||
"draft_kept_title": "บันทึกไว้ในอุปกรณ์นี้แล้ว",
|
||||
"draft_kept_body": "จะย้ายไปที่ฉบับร่างเมื่อคุณลงชื่อเข้าใช้และออนไลน์อีกครั้ง",
|
||||
"rescued_draft_title": "บันทึกในฉบับร่างแล้ว",
|
||||
"rescued_draft_body": "ก่อนหน้านี้บันทึกข้อความที่คุณเขียนไม่ได้ ตอนนี้อยู่ในฉบับร่างแล้ว"
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "การย้ายระหว่างบัญชีต้องใช้การเชื่อมต่อ",
|
||||
|
||||
+5
-1
@@ -427,7 +427,11 @@
|
||||
"undo_send_other_login": "Bu ileti başka bir hesaptan gönderildi",
|
||||
"undo_send_other_login_body": "Geri almak veya hemen göndermek için {account} hesabına geri dönün. İleti, gecikme süresi dolunca gönderilir.",
|
||||
"upload_login_switched": "Yüklenmedi: hesap değiştirildi",
|
||||
"use_template": "Şablon"
|
||||
"use_template": "Şablon",
|
||||
"draft_kept_title": "Bu cihaza kaydedildi",
|
||||
"draft_kept_body": "Yeniden oturum açıp çevrimiçi olduğunuzda Taslaklar'a gider.",
|
||||
"rescued_draft_title": "Taslaklar'a kaydedildi",
|
||||
"rescued_draft_body": "Yazdığınız bir ileti daha önce kaydedilemedi. Artık Taslaklar'da."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Hesaplar arasında taşımak için bağlantı gerekiyor",
|
||||
|
||||
+5
-1
@@ -428,7 +428,11 @@
|
||||
"undo_send_other_login": "Цей лист надіслано з іншого облікового запису",
|
||||
"undo_send_other_login_body": "Поверніться до {account}, щоб скасувати надсилання або надіслати лист зараз. Він піде, коли закінчиться затримка.",
|
||||
"upload_login_switched": "Не завантажено: обліковий запис змінено",
|
||||
"use_template": "Шаблон"
|
||||
"use_template": "Шаблон",
|
||||
"draft_kept_title": "Збережено на цьому пристрої",
|
||||
"draft_kept_body": "Він потрапить у чернетки, щойно ви знову ввійдете й будете в мережі.",
|
||||
"rescued_draft_title": "Збережено в чернетках",
|
||||
"rescued_draft_body": "Повідомлення, яке ви писали, раніше не вдалося зберегти. Тепер воно в чернетках."
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "Для переміщення між обліковими записами потрібне з'єднання",
|
||||
|
||||
@@ -425,7 +425,11 @@
|
||||
"undo_send_other_login": "這封郵件是從另一個帳號寄出的",
|
||||
"undo_send_other_login_body": "切換回 {account} 即可取消傳送或立即寄出。延遲結束後郵件就會寄出。",
|
||||
"upload_login_switched": "未上傳:已切換帳號",
|
||||
"use_template": "範本"
|
||||
"use_template": "範本",
|
||||
"draft_kept_title": "已儲存在此裝置上",
|
||||
"draft_kept_body": "重新登入並連上網路後,它會儲存到草稿匣。",
|
||||
"rescued_draft_title": "已儲存到草稿匣",
|
||||
"rescued_draft_body": "你之前寫的一封郵件當時未能儲存,現在已在草稿匣中。"
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "在帳號之間移動需要網路連線",
|
||||
|
||||
+5
-1
@@ -425,7 +425,11 @@
|
||||
"undo_send_other_login": "这封邮件是从另一个账户发送的",
|
||||
"undo_send_other_login_body": "切换回 {account} 即可撤销或立即发送。延迟结束后邮件将发出。",
|
||||
"upload_login_switched": "未上传:账户已切换",
|
||||
"use_template": "模板"
|
||||
"use_template": "模板",
|
||||
"draft_kept_title": "已保存在此设备上",
|
||||
"draft_kept_body": "重新登录并联网后,它会保存到草稿箱。",
|
||||
"rescued_draft_title": "已保存到草稿箱",
|
||||
"rescued_draft_body": "你之前写的一封邮件当时未能保存,现在已在草稿箱中。"
|
||||
},
|
||||
"email_list": {
|
||||
"cross_account_move_offline": "在账户之间移动需要网络连接",
|
||||
|
||||
@@ -43,14 +43,16 @@ function findHierarchy(obj, seen = new Set()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const file of walkFiles(root).filter((f) => /commands-.*\.json$/.test(f))) {
|
||||
// Maestro 1 writes commands-<flow>.json, Maestro 2 <flow>/commands.json.
|
||||
for (const file of walkFiles(root).filter((f) => /(^|[\\/])commands(-[^\\/]*)?\.json$/.test(f))) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
out.push(`== ${path.basename(file)}`);
|
||||
const base = path.basename(file);
|
||||
out.push(`== ${base === 'commands.json' ? `${path.basename(path.dirname(file))} / ${base}` : base}`);
|
||||
for (const entry of Array.isArray(data) ? data : []) {
|
||||
const command = entry.command || {};
|
||||
const type = Object.keys(command).find((k) => command[k]) || '?';
|
||||
|
||||
+31
-11
@@ -8,8 +8,8 @@
|
||||
# $OUT/screenshots, $OUT/run.mp4, $OUT/app.log, $OUT/steps.txt.
|
||||
#
|
||||
# Each flow runs in its own Maestro process with a time cap: when the XCTest
|
||||
# driver dies (it does, now and then), only that flow fails and the next one
|
||||
# starts a fresh driver.
|
||||
# driver dies (it does, now and then), only that flow is affected: it runs
|
||||
# once more with a fresh driver, and the next flow starts a fresh one too.
|
||||
#
|
||||
# Secrets: Maestro's debug output holds every typed text, passwords included,
|
||||
# and artifacts of this public repo are public. Only the warm-up (which types
|
||||
@@ -75,23 +75,43 @@ if [ $STATUS -eq 0 ]; then
|
||||
fi
|
||||
|
||||
FAILED=()
|
||||
RETRIED=()
|
||||
for f in "${FLOWS[@]}"; do
|
||||
name=$(basename "$f" .yaml)
|
||||
started=$(date +%s)
|
||||
echo "::group::$name"
|
||||
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
|
||||
--format junit --output "$OUT/junit/$name.xml" \
|
||||
--debug-output "$DEBUG_DIR/$name" \
|
||||
"${ARGS[@]}" "$f")
|
||||
rc=$?
|
||||
echo "::endgroup::"
|
||||
echo "$name: exit $rc after $(( $(date +%s) - started )) s"
|
||||
# When the XCTest driver dies during a flow ("Device became unreachable"),
|
||||
# the flow gets one more try in a fresh Maestro process, which starts a
|
||||
# new driver. Any other failure (an assertion, a missing element, the
|
||||
# time cap) stays a failure. The driver's death shows in Maestro's log in
|
||||
# the flow's debug output (never uploaded; grep -q prints nothing). Piping
|
||||
# Maestro's console output instead loses the failure reasons it prints.
|
||||
for attempt in 1 2; do
|
||||
suffix=""
|
||||
[ $attempt -gt 1 ] && suffix="-retry"
|
||||
started=$(date +%s)
|
||||
echo "::group::$name$suffix"
|
||||
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
|
||||
--format junit --output "$OUT/junit/$name.xml" \
|
||||
--debug-output "$DEBUG_DIR/$name$suffix" \
|
||||
"${ARGS[@]}" "$f")
|
||||
rc=$?
|
||||
echo "::endgroup::"
|
||||
echo "$name$suffix: exit $rc after $(( $(date +%s) - started )) s"
|
||||
[ $rc -eq 0 ] && break
|
||||
[ $attempt -eq 2 ] && break
|
||||
grep -rqE 'DeviceUnreachableException|Device became unreachable|Transport unreachable' \
|
||||
"$DEBUG_DIR/$name$suffix" 2>/dev/null || break
|
||||
echo "::warning::$name: the Maestro driver became unreachable, running the flow once more"
|
||||
RETRIED+=("$name")
|
||||
done
|
||||
if [ $rc -ne 0 ]; then
|
||||
FAILED+=("$name")
|
||||
# What the screen showed when it failed.
|
||||
xcrun simctl io "$UDID" screenshot "$OUT/screenshots/FAILED-$name.png" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
if [ ${#RETRIED[@]} -gt 0 ]; then
|
||||
echo "::warning::Flows run again after the driver died: ${RETRIED[*]}"
|
||||
fi
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
echo "::error::Failed flows: ${FAILED[*]}"
|
||||
STATUS=1
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
import { JMAPClient } from '../jmap-client';
|
||||
import { destroyEmails, getMailboxes, saveDraftVersion, sendEmail } from '../email';
|
||||
import { cancelScheduledSend, destroyEmails, getMailboxes, saveDraftVersion, sendEmail } from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { findCopiesByMessageId, runQueuedSend } from '../queued-send';
|
||||
import { findCopiesByMessageId, runQueuedSend, takeBackQueuedSend } from '../queued-send';
|
||||
import { cancelScheduledToDrafts } from '../../lib/compose-scheduled';
|
||||
import { generateMessageId } from '../../lib/email-threading';
|
||||
import { newQueuedSend } from '../../lib/send-queue';
|
||||
import type { Identity, Mailbox } from '../types';
|
||||
@@ -125,4 +126,44 @@ live('live Stalwart: offline send queue', () => {
|
||||
const res = await singleton.request([['Email/get', { accountId: singleton.accountId, ids: [copies[0].id], properties: ['mailboxIds'] }, '0']]);
|
||||
expect(res.methodResponses[0][1].list[0].mailboxIds[sent]).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
// A "send later" whose answer was lost: the server holds it, and taking it
|
||||
// back from the Outbox must cancel it there, not only drop the queue entry.
|
||||
const submissionOf = async (emailId: string) => {
|
||||
const res = await singleton.request([
|
||||
['EmailSubmission/query', { accountId: singleton.accountId, filter: { emailIds: [emailId] } }, '0'],
|
||||
['EmailSubmission/get', {
|
||||
accountId: singleton.accountId, '#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' }, properties: ['id', 'undoStatus'],
|
||||
}, '1'],
|
||||
], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
|
||||
return res.methodResponses[1][1].list as Array<{ id: string; undoStatus: string }>;
|
||||
};
|
||||
|
||||
it('finds a held send an unanswered attempt left, cancels it and moves it back to Drafts', async () => {
|
||||
const outgoing = message('held');
|
||||
const at = new Date(Date.now() + 3600_000).toISOString();
|
||||
const first = await sendEmail(outgoing, identity.id, sent, 3600, { draftsMailboxId: drafts });
|
||||
expect(first.scheduled).toBe(true);
|
||||
const queued = newQueuedSend('q4', { ...payloadFor(outgoing), scheduledAt: at }, Date.now(), true);
|
||||
const found = await takeBackQueuedSend(queued, boxes);
|
||||
expect(found).toEqual({ kind: 'held', accountId: singleton.accountId, emailId: first.emailId, emailSubmissionId: first.emailSubmissionId });
|
||||
if (found.kind !== 'held') return;
|
||||
const { inDrafts } = await cancelScheduledToDrafts(found, boxes, singleton.accountId);
|
||||
expect(inDrafts).toBe(true);
|
||||
// Cancelled, and its record removed.
|
||||
expect((await submissionOf(first.emailId!)).filter((x) => x.undoStatus === 'pending')).toEqual([]);
|
||||
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
||||
expect(copies.map((c) => c.id)).toEqual([first.emailId]);
|
||||
expect(copies[0].keywords?.$draft).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it('says released for a send the server let go, and the cancel is refused', async () => {
|
||||
const outgoing = message('released');
|
||||
const first = await sendEmail(outgoing, identity.id, sent, undefined, { draftsMailboxId: drafts });
|
||||
const found = await takeBackQueuedSend(newQueuedSend('q5', payloadFor(outgoing), Date.now(), true), boxes);
|
||||
expect(found).toEqual({ kind: 'released' });
|
||||
// What the Outbox meets when the server lets a held send go between the lookup and the cancel.
|
||||
const subs = await submissionOf(first.emailId!);
|
||||
if (subs[0]) await expect(cancelScheduledSend(subs[0].id)).rejects.toMatchObject({ name: 'JMAPSetError' });
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock('../../lib/compose-send-account', () => ({
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { findCopiesByMessageId, runQueuedSend, wasQueuedSendSent } from '../queued-send';
|
||||
import { findCopiesByMessageId, runQueuedSend, takeBackQueuedSend } from '../queued-send';
|
||||
import { newQueuedSend, type QueuedSendPayload } from '../../lib/send-queue';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
@@ -85,27 +85,49 @@ describe('runQueuedSend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('wasQueuedSendSent', () => {
|
||||
it('finds a copy an earlier attempt sent, and removes the old draft', async () => {
|
||||
mockRequest.mockResolvedValueOnce(found([
|
||||
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
|
||||
{ id: 'sent-1', keywords: { $seen: true }, messageId: ['m1@x.de'] },
|
||||
]));
|
||||
expect(await wasQueuedSendSent(newQueuedSend('q', PAYLOAD, 0, true), [])).toBe(true);
|
||||
const submissions = (list: Array<{ id: string; emailId: string; undoStatus: string }>) => ({
|
||||
methodResponses: [
|
||||
['EmailSubmission/query', { ids: list.map((x) => x.id) }, '0'],
|
||||
['EmailSubmission/get', { list }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
describe('takeBackQueuedSend', () => {
|
||||
const sentCopy = () => found([
|
||||
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
|
||||
{ id: 'sent-1', keywords: { $seen: true }, messageId: ['m1@x.de'] },
|
||||
]);
|
||||
|
||||
it('finds the submission an earlier attempt left held, to be cancelled', async () => {
|
||||
mockRequest.mockResolvedValueOnce(sentCopy());
|
||||
mockRequest.mockResolvedValueOnce(submissions([{ id: 'sub-1', emailId: 'sent-1', undoStatus: 'pending' }]));
|
||||
expect(await takeBackQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), [])).toEqual({
|
||||
kind: 'held', accountId: 'acc-1', emailId: 'sent-1', emailSubmissionId: 'sub-1',
|
||||
});
|
||||
expect(mockRequest.mock.calls[1][0][0]).toEqual(
|
||||
['EmailSubmission/query', { accountId: 'acc-1', filter: { emailIds: ['sent-1'] } }, '0'],
|
||||
);
|
||||
expect(destroyEmails).toHaveBeenCalledWith(['draft-1'], 'acc-1');
|
||||
});
|
||||
|
||||
it('says released when the server let the copy go already', async () => {
|
||||
mockRequest.mockResolvedValueOnce(sentCopy());
|
||||
mockRequest.mockResolvedValueOnce(submissions([{ id: 'sub-1', emailId: 'sent-1', undoStatus: 'final' }]));
|
||||
expect(await takeBackQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), [])).toEqual({ kind: 'released' });
|
||||
});
|
||||
|
||||
it('says not sent when only drafts are there, and removes nothing', async () => {
|
||||
mockRequest.mockResolvedValueOnce(found([
|
||||
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
|
||||
{ id: 'left-1', keywords: { $draft: true, $seen: true }, messageId: ['m1@x.de'] },
|
||||
]));
|
||||
expect(await wasQueuedSendSent(newQueuedSend('q', PAYLOAD, 0, true), [])).toBe(false);
|
||||
expect(await takeBackQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), [])).toEqual({ kind: 'notSent' });
|
||||
expect(destroyEmails).not.toHaveBeenCalled();
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not look for a message no attempt reached the server with', async () => {
|
||||
expect(await wasQueuedSendSent(newQueuedSend('q', PAYLOAD, 0, false), [])).toBe(false);
|
||||
expect(await takeBackQueuedSend(newQueuedSend('q', PAYLOAD, 0, false), [])).toEqual({ kind: 'notSent' });
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+42
-7
@@ -4,6 +4,7 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { destroyEmails, sendEmail, type SendEmailResult } from './email';
|
||||
import { requireMethodResult } from './jmap-result';
|
||||
import { SUBMISSION_ONLY_USING } from './submissions';
|
||||
import type { Mailbox } from './types';
|
||||
import { sendTargetFor } from '../lib/compose-send-account';
|
||||
import { supportsSubmissionExtension } from '../lib/submission-options';
|
||||
@@ -108,14 +109,48 @@ async function earlierAttempt(send: QueuedSend, r: Resolved): Promise<CopyVerdic
|
||||
}
|
||||
|
||||
/**
|
||||
* Before a queued message is taken back (moved to Drafts): true when an
|
||||
* earlier attempt that may have reached the server did send it. A message no
|
||||
* attempt reached the server with is never looked up.
|
||||
* What taking back a queued message finds on the server:
|
||||
* - `notSent`: no earlier attempt reached it (or none was looked up).
|
||||
* - `held`: an earlier attempt did, as a send held for later (a "send
|
||||
* later", or the undo-send delay) that the server still holds; the caller
|
||||
* cancels `emailSubmissionId` in `accountId` and decides where the copy
|
||||
* `emailId` goes.
|
||||
* - `released`: an earlier attempt did, and the server already let it go.
|
||||
*/
|
||||
export async function wasQueuedSendSent(send: QueuedSend, mailboxes: Mailbox[]): Promise<boolean> {
|
||||
if (!send.maybeSent || !send.payload.outgoing.messageId) return false;
|
||||
const verdict = await earlierAttempt(send, await queuedSendTarget(send, mailboxes));
|
||||
return verdict.kind === 'sent';
|
||||
export type QueuedSendTakeBack =
|
||||
| { kind: 'notSent' }
|
||||
| { kind: 'held'; accountId: string; emailId: string; emailSubmissionId: string }
|
||||
| { kind: 'released' };
|
||||
|
||||
/**
|
||||
* Before a queued message is taken back (moved to Drafts or discarded): a
|
||||
* message no attempt reached the server with is never looked up. One that
|
||||
* went out is looked for among the submissions of its account, so a held one
|
||||
* can still be cancelled.
|
||||
*/
|
||||
export async function takeBackQueuedSend(send: QueuedSend, mailboxes: Mailbox[]): Promise<QueuedSendTakeBack> {
|
||||
if (!send.maybeSent || !send.payload.outgoing.messageId) return { kind: 'notSent' };
|
||||
const resolved = await queuedSendTarget(send, mailboxes);
|
||||
const verdict = await earlierAttempt(send, resolved);
|
||||
if (verdict.kind !== 'sent') return { kind: 'notSent' };
|
||||
const accountId = resolved.accountId;
|
||||
// EmailSubmission/query by emailIds finds it on 0.16.27 and 1.0.1.
|
||||
const res = await jmapClient.request([
|
||||
['EmailSubmission/query', { accountId, filter: { emailIds: [verdict.copyId] } }, '0'],
|
||||
['EmailSubmission/get', {
|
||||
accountId,
|
||||
'#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' },
|
||||
properties: ['id', 'emailId', 'undoStatus'],
|
||||
}, '1'],
|
||||
], SUBMISSION_ONLY_USING);
|
||||
requireMethodResult(res, '0', 'EmailSubmission/query');
|
||||
const list = (requireMethodResult(res, '1', 'EmailSubmission/get').list ?? []) as Array<{
|
||||
id: string; emailId?: string; undoStatus?: string;
|
||||
}>;
|
||||
const pending = list.find((sub) => sub.emailId === verdict.copyId && sub.undoStatus === 'pending');
|
||||
return pending
|
||||
? { kind: 'held', accountId, emailId: verdict.copyId, emailSubmissionId: pending.id }
|
||||
: { kind: 'released' };
|
||||
}
|
||||
|
||||
export async function runQueuedSend(send: QueuedSend, mailboxes: Mailbox[], now = Date.now()): Promise<QueuedSendOutcome> {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useContactPhotoSource } from '../lib/use-contact-photo';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { useNoThirdPartyTraffic } from '../stores/policy-store';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { ShieldAlert } from './icons';
|
||||
|
||||
@@ -61,6 +62,10 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
const c = useColors();
|
||||
const warned = !!senderWarning;
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;
|
||||
// Favicons come from an outside host (DuckDuckGo), which the webmail's
|
||||
// "no third-party traffic" mode rules out (webmail lib/outgoing-hosts.ts).
|
||||
// BIMI logos come from the webmail itself.
|
||||
const noThirdParty = useNoThirdPartyTraffic();
|
||||
// Compared shallowly, so only avatars whose photo changed re-render when the
|
||||
// contacts change; the index behind it is built once per contacts array.
|
||||
const indexedPhoto = useContactsStore(
|
||||
@@ -94,7 +99,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
senderWarning,
|
||||
hasPhoto: !disableImages && !!photoSource,
|
||||
showBrand,
|
||||
showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
|
||||
showFavicon: senderFavicons && !noThirdParty && !!faviconDomain && !imgError && !domainFailed,
|
||||
});
|
||||
|
||||
const handleError = React.useCallback(() => {
|
||||
|
||||
@@ -20,6 +20,14 @@ describe('customFromEnabled', () => {
|
||||
it('matches only exact identities in the composer when custom From is off', () => {
|
||||
expect(read('screens/ComposeScreen.tsx')).toMatch(/matchMode: customFromEnabled \? replyIdentityMatch : 'exact',/);
|
||||
});
|
||||
|
||||
it('offers the custom From in the sender sheet only when allowed, and sends without it otherwise', () => {
|
||||
const src = read('screens/ComposeScreen.tsx');
|
||||
expect(src).toMatch(/\{customFromEnabled && \(\s+<View style=\{styles\.switchRow\}>\s+<Text style=\{styles\.switchLabel\}>\{t\('email_composer\.from_override\.toggle_off'/);
|
||||
expect(src).toMatch(/\{overrideEnabled && customFromEnabled && \(/);
|
||||
expect(src).toMatch(/if \(overrideEnabled && customFromEnabled\) \{/);
|
||||
expect(src).toMatch(/composeSenderAddress\(identity, \{ fromOverride, customFromEnabled, subAddressTag, subAddressDelimiter \}\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no third-party traffic', () => {
|
||||
@@ -46,6 +54,12 @@ describe('no third-party traffic', () => {
|
||||
expect(src).toMatch(/t\('third_party\.remote_content_blocked'/);
|
||||
});
|
||||
|
||||
it('shows no sender favicons from the outside host', () => {
|
||||
const src = read('components/SenderAvatar.tsx');
|
||||
expect(src).toMatch(/const noThirdParty = useNoThirdPartyTraffic\(\);/);
|
||||
expect(src).toMatch(/showFavicon: senderFavicons && !noThirdParty && /);
|
||||
});
|
||||
|
||||
it('keeps remote images blocked in booking cards and quoted originals', () => {
|
||||
expect(read('components/email/structured-mail/StructuredMailBanner.tsx')).toMatch(/\(\) => !remoteBlockedByOrg && \(policy === 'allow' \|\|/);
|
||||
const compose = read('screens/ComposeScreen.tsx');
|
||||
|
||||
@@ -26,6 +26,8 @@ const GRIDS = new Set(['MonthScrollView.tsx', 'TimeGridScrollView.tsx', 'WeekVie
|
||||
const files = [
|
||||
path.join(SRC, 'screens', 'SettingsScreen.tsx'),
|
||||
path.join(SRC, 'screens', 'FilesScreen.tsx'),
|
||||
// The reader's page pager sits around the quick reply box.
|
||||
path.join(SRC, 'screens', 'EmailThreadScreen.tsx'),
|
||||
...walk(path.join(SRC, 'components', 'settings')),
|
||||
...walk(path.join(SRC, 'components', 'calendar')).filter((f) => !GRIDS.has(path.basename(f))),
|
||||
...walk(path.join(SRC, 'components', 'files')),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activeFromOverride, composeSenderAddress } from '../compose-sender';
|
||||
import type { Identity } from '../../api/types';
|
||||
|
||||
const identity: Identity = { id: 'i1', name: 'Ada Lovelace', email: 'ada@example.org', mayDelete: true };
|
||||
const override = { name: 'Support', email: 'support@example.org' };
|
||||
|
||||
describe('composeSenderAddress', () => {
|
||||
it('writes the custom From and keeps the identity as envelope sender', () => {
|
||||
expect(composeSenderAddress(identity, { fromOverride: override, customFromEnabled: true, subAddressTag: '' })).toEqual({
|
||||
from: { name: 'Support', email: 'support@example.org' },
|
||||
envelopeMailFrom: 'ada@example.org',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends from the identity when the organisation turned custom From off', () => {
|
||||
expect(composeSenderAddress(identity, { fromOverride: override, customFromEnabled: false, subAddressTag: '' })).toEqual({
|
||||
from: { name: 'Ada Lovelace', email: 'ada@example.org' },
|
||||
envelopeMailFrom: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the sub-address tag when custom From is off', () => {
|
||||
const { from, envelopeMailFrom } = composeSenderAddress(identity, {
|
||||
fromOverride: override, customFromEnabled: false, subAddressTag: 'shop', subAddressDelimiter: '+',
|
||||
});
|
||||
expect(from).toEqual({ name: 'Ada Lovelace', email: 'ada+shop@example.org' });
|
||||
expect(envelopeMailFrom).toBe('ada@example.org');
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeFromOverride', () => {
|
||||
it('drops an override when custom From is off', () => {
|
||||
expect(activeFromOverride(override, true)).toBe(override);
|
||||
expect(activeFromOverride(override, false)).toBeNull();
|
||||
expect(activeFromOverride(null, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { OutgoingEmail, SavedDraftVersion } from '../api/email';
|
||||
|
||||
// A composer whose draft can't reach the server (the session expired, or the
|
||||
// connection is gone) keeps it on the device here, so closing the composer or
|
||||
// losing the app process doesn't lose the text. Once the account's session is
|
||||
// up again (the Outbox flush: session up, server checked, online), each kept
|
||||
// draft goes to the account's Drafts and the user is told it is there. A
|
||||
// composer still open saves its own draft and is skipped.
|
||||
|
||||
const KEY_PREFIX = 'compose-rescue:v1:';
|
||||
|
||||
export interface RescuedDraft {
|
||||
/** The composer it came from. */
|
||||
id: string;
|
||||
savedAt: number;
|
||||
/** The message as the composer built it for a draft. */
|
||||
outgoing: OutgoingEmail;
|
||||
draftsMailboxId: string;
|
||||
/** JMAP account of a group's Drafts; absent for the login's own. */
|
||||
jmapAccountId?: string;
|
||||
/** The draft version saved before, replaced when this one is saved. */
|
||||
previousDraftId?: string;
|
||||
previousDraftAccountId?: string;
|
||||
}
|
||||
|
||||
/** The server didn't take the draft, and it was kept on the device instead. */
|
||||
export class DraftKeptOnDeviceError extends Error {
|
||||
constructor(readonly reason: unknown) {
|
||||
super(reason instanceof Error ? reason.message : String(reason));
|
||||
this.name = 'DraftKeptOnDeviceError';
|
||||
}
|
||||
}
|
||||
|
||||
const key = (accountId: string) => `${KEY_PREFIX}${accountId}`;
|
||||
|
||||
async function read(accountId: string): Promise<RescuedDraft[]> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(key(accountId));
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? (parsed as RescuedDraft[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function write(accountId: string, drafts: RescuedDraft[]): Promise<void> {
|
||||
if (drafts.length === 0) await AsyncStorage.removeItem(key(accountId));
|
||||
else await AsyncStorage.setItem(key(accountId), JSON.stringify(drafts));
|
||||
}
|
||||
|
||||
// Reads and writes of one account's list run one after another.
|
||||
const queues = new Map<string, Promise<unknown>>();
|
||||
function serial<T>(accountId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const run = (queues.get(accountId) ?? Promise.resolve()).catch(() => undefined).then(fn);
|
||||
queues.set(accountId, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Keep (or replace) a composer's draft on the device for an account (app registry id). */
|
||||
export function rescueDraft(accountId: string, draft: RescuedDraft): Promise<void> {
|
||||
return serial(accountId, async () => {
|
||||
const drafts = (await read(accountId)).filter((d) => d.id !== draft.id);
|
||||
await write(accountId, [...drafts, draft]);
|
||||
}).catch((err) => console.warn('[compose-rescue] keep failed', err));
|
||||
}
|
||||
|
||||
/** Drop a composer's kept draft: it reached the server, was sent or discarded. */
|
||||
export function forgetRescuedDraft(accountId: string, id: string): Promise<void> {
|
||||
return serial(accountId, async () => {
|
||||
const drafts = await read(accountId);
|
||||
if (drafts.some((d) => d.id === id)) await write(accountId, drafts.filter((d) => d.id !== id));
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Drop every kept draft of an account that was signed out. */
|
||||
export function clearRescuedDrafts(accountId: string): Promise<void> {
|
||||
return serial(accountId, () => AsyncStorage.removeItem(key(accountId))).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function rescuedDrafts(accountId: string): Promise<RescuedDraft[]> {
|
||||
return serial(accountId, () => read(accountId));
|
||||
}
|
||||
|
||||
// Composers open in this process, which save their own drafts.
|
||||
const openComposers = new Set<string>();
|
||||
|
||||
export function composerOpened(id: string): void {
|
||||
openComposers.add(id);
|
||||
}
|
||||
|
||||
export function composerClosed(id: string): void {
|
||||
openComposers.delete(id);
|
||||
}
|
||||
|
||||
export interface FlushDeps {
|
||||
save: (draft: RescuedDraft) => Promise<SavedDraftVersion | unknown>;
|
||||
/** Called once per flush that saved at least one draft. */
|
||||
saved: (count: number) => void;
|
||||
}
|
||||
|
||||
const flushing = new Set<string>();
|
||||
|
||||
/**
|
||||
* Save the kept drafts of an account to its Drafts. Call only while the
|
||||
* client serves that account's session. A draft the server doesn't take
|
||||
* stays for the next try. Resolves to how many were saved.
|
||||
*/
|
||||
export async function flushRescuedDrafts(accountId: string, deps: FlushDeps): Promise<number> {
|
||||
if (flushing.has(accountId)) return 0;
|
||||
flushing.add(accountId);
|
||||
let count = 0;
|
||||
try {
|
||||
for (const draft of await rescuedDrafts(accountId)) {
|
||||
if (openComposers.has(draft.id)) continue;
|
||||
try {
|
||||
await deps.save(draft);
|
||||
} catch (err) {
|
||||
console.warn('[compose-rescue] save failed', err);
|
||||
continue;
|
||||
}
|
||||
await forgetRescuedDraft(accountId, draft.id);
|
||||
count += 1;
|
||||
}
|
||||
} finally {
|
||||
flushing.delete(accountId);
|
||||
}
|
||||
if (count > 0) deps.saved(count);
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { EmailAddress, Identity } from '../api/types';
|
||||
import { generateSubAddress } from './sub-addressing';
|
||||
import { sanitizeDisplayName } from './rfc5322-mailbox';
|
||||
|
||||
export interface FromOverride {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer's custom From, or null when the organisation turned custom
|
||||
* From off (`customFromEnabled`, webmail email-composer.tsx
|
||||
* fromOverrideEnabled). An override set earlier, by hand or by the reply
|
||||
* identity match, then no longer applies.
|
||||
*/
|
||||
export function activeFromOverride(
|
||||
override: FromOverride | null,
|
||||
customFromEnabled: boolean,
|
||||
): FromOverride | null {
|
||||
return customFromEnabled && override ? override : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The From header and envelope sender of an outgoing message: the custom From
|
||||
* when one applies, else the identity's address with its sub-address tag. A
|
||||
* From that isn't the identity's own address still goes out through the
|
||||
* identity's envelope sender.
|
||||
*/
|
||||
export function composeSenderAddress(
|
||||
identity: Identity,
|
||||
opts: {
|
||||
fromOverride: FromOverride | null;
|
||||
customFromEnabled: boolean;
|
||||
subAddressTag: string;
|
||||
subAddressDelimiter?: string;
|
||||
},
|
||||
): { from: EmailAddress; envelopeMailFrom?: string } {
|
||||
const override = activeFromOverride(opts.fromOverride, opts.customFromEnabled);
|
||||
let from: EmailAddress;
|
||||
if (override?.email.trim()) {
|
||||
const overrideName = sanitizeDisplayName(override.name);
|
||||
from = overrideName ? { name: overrideName, email: override.email.trim() } : { email: override.email.trim() };
|
||||
} else {
|
||||
const name = sanitizeDisplayName(identity.name);
|
||||
const email = opts.subAddressTag
|
||||
? generateSubAddress(identity.email, opts.subAddressTag, opts.subAddressDelimiter)
|
||||
: identity.email;
|
||||
from = name ? { name, email } : { email };
|
||||
}
|
||||
const envelopeMailFrom = from.email.toLowerCase() !== identity.email.toLowerCase() ? identity.email : undefined;
|
||||
return { from, envelopeMailFrom };
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export interface TestCert {
|
||||
const engine = () => new pkijs.CryptoEngine({ name: 'test', crypto: globalThis.crypto });
|
||||
|
||||
/** A self-signed RSA-2048 S/MIME certificate for `email`. */
|
||||
export async function makeSmimeCert(email: string, opts: { notAfter?: Date } = {}): Promise<TestCert> {
|
||||
export async function makeSmimeCert(email: string, opts: { notAfter?: Date; extraEmails?: string[] } = {}): Promise<TestCert> {
|
||||
const subtle = globalThis.crypto.subtle;
|
||||
const keys = await subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
@@ -67,7 +67,9 @@ export async function makeSmimeCert(email: string, opts: { notAfter?: Date } = {
|
||||
new pkijs.Extension({ extnID: '2.5.29.15', critical: true, extnValue: ku.toBER(false), parsedValue: ku }),
|
||||
new pkijs.Extension({
|
||||
extnID: '2.5.29.17',
|
||||
extnValue: new pkijs.GeneralNames({ names: [new pkijs.GeneralName({ type: 1, value: email })] }).toSchema().toBER(false),
|
||||
extnValue: new pkijs.GeneralNames({
|
||||
names: [email, ...(opts.extraEmails ?? [])].map((value) => new pkijs.GeneralName({ type: 1, value })),
|
||||
}).toSchema().toBER(false),
|
||||
}),
|
||||
];
|
||||
await cert.subjectPublicKeyInfo.importKey(keys.publicKey, engine());
|
||||
|
||||
@@ -75,6 +75,21 @@ describe('a PGP key in a message', () => {
|
||||
expect(stored()).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('is neither stored nor offered when it also names another address', async () => {
|
||||
// Mallory's own address and the boss's on one key: saved for Mallory,
|
||||
// it would be encrypted to for the boss and verify mail forged from them.
|
||||
const { privateKey, publicKey } = await openpgp.generateKey({
|
||||
type: 'ecc', curve: 'curve25519Legacy',
|
||||
userIDs: [{ name: 'Mallory', email: mallory.email }, { name: 'Boss', email: 'boss@example.org' }],
|
||||
format: 'armored',
|
||||
});
|
||||
const signer = await openpgp.readPrivateKey({ armoredKey: privateKey });
|
||||
const res = await openSecureMessage(await inlineSigned(mallory.email, signer, publicKey), mallory.email, keyringAccess());
|
||||
expect(res?.offeredKey).toBeUndefined();
|
||||
await expect(savePgpKeyFor(publicKey, mallory.email)).rejects.toThrow(/not for mallory@example.org alone/);
|
||||
expect(stored()).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('is neither stored nor offered when the signature fails', async () => {
|
||||
await importPgpKey(alice.publicKey);
|
||||
const res = await openSecureMessage(await inlineSigned(alice.email, alice.unlocked, alice.publicKey, true), alice.email, keyringAccess());
|
||||
@@ -119,6 +134,19 @@ describe('an S/MIME signer certificate', () => {
|
||||
expect(stored()).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('is not kept when it also names another address', async () => {
|
||||
const both = await makeSmimeCert('mallory@example.org', { extraEmails: ['boss@example.org'] });
|
||||
const raw = await buildSecureMessage(
|
||||
{ from: { email: both.email }, to: [{ email: 'bob@example.org' }], subject: 's', textBody: 'signed' },
|
||||
{ mode: 'smime', sign: true, encrypt: false },
|
||||
{ smime: { signer: { pkcs8: both.pkcs8, certificate: both.certificate, chain: [], algorithm: 'RSA-2048' } } },
|
||||
);
|
||||
const res = await openSecureMessage(raw, both.email, keyringAccess());
|
||||
expect(res?.status.signature).toBe('valid');
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(stored()).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('is kept when it names the From address', async () => {
|
||||
await openSecureMessage(await signed(), signer.email, keyringAccess());
|
||||
await vi.waitFor(() => expect(stored()).toEqual([expect.objectContaining({ kind: 'smime', emails: [signer.email], source: 'message' })]));
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type * as OpenPgp from 'openpgp';
|
||||
import { useCryptoKeysStore, upsertRecord, keysForEmail, isRecordCurrent, type CryptoKeyRecord, type PgpKeyRecord, type SmimeCertRecord } from '../../stores/crypto-keys-store';
|
||||
import { setSecret, getSecret, deleteSecret } from './secret-store';
|
||||
import { inspectKey, unlockPrivateKey, privateKeyNeedsPassphrase, type PgpKeyInfo } from './pgp';
|
||||
import { inspectKey, keyNamesOnly, unlockPrivateKey, privateKeyNeedsPassphrase, type PgpKeyInfo } from './pgp';
|
||||
import { base64Decode, base64Encode } from './mime';
|
||||
|
||||
const pgpSecretId = (id: string) => `pgp.${id}`;
|
||||
@@ -227,10 +227,8 @@ export function allPgpPublicKeys(): string[] {
|
||||
export async function savePgpKeyFor(armored: string, email: string): Promise<PgpKeyRecord> {
|
||||
const info = await inspectKey(armored);
|
||||
if (info.isPrivate) throw new Error('Only a public key can be saved from a message');
|
||||
const address = email.trim().toLowerCase();
|
||||
if (!address || !info.userIDs.some((u) => u.email.trim().toLowerCase() === address)) {
|
||||
throw new Error('The key is not for ' + email);
|
||||
}
|
||||
// Only a key for the sender alone (see keyNamesOnly).
|
||||
if (!keyNamesOnly(info, email)) throw new Error('The key is not for ' + email + ' alone');
|
||||
const known = records().find((k): k is PgpKeyRecord => k.kind === 'pgp' && k.id === info.fingerprint.toLowerCase());
|
||||
if (known) return known;
|
||||
const record = pgpRecord(info, false, 'message');
|
||||
@@ -250,7 +248,10 @@ export async function rememberSignerCertificate(der: Uint8Array, fromEmail: stri
|
||||
const { describeCertificate } = await import('./smime/certificate');
|
||||
const info = await describeCertificate(der);
|
||||
const from = fromEmail.trim().toLowerCase();
|
||||
if (!from || !info.emailAddresses.some((e) => e.toLowerCase() === from)) return;
|
||||
// Only one for the sender alone: a certificate that also names another
|
||||
// address would be encrypted to for it (see keyNamesOnly).
|
||||
const named = info.emailAddresses.map((e) => e.trim().toLowerCase()).filter(Boolean);
|
||||
if (!from || named.length === 0 || !named.every((e) => e === from)) return;
|
||||
if (records().some((k) => k.kind === 'smime' && k.id === info.fingerprint)) return;
|
||||
upsert(smimeRecord(info, der, [], false, 'message'));
|
||||
} catch {
|
||||
|
||||
@@ -26,6 +26,17 @@ export interface PgpKeyInfo {
|
||||
algorithm: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key names `email` and no other address. A key that arrives in a
|
||||
* message is offered and saved only then: one that also named another
|
||||
* address would be encrypted to for it and would verify mail forged from it.
|
||||
*/
|
||||
export function keyNamesOnly(info: Pick<PgpKeyInfo, 'userIDs'>, email: string): boolean {
|
||||
const address = email.trim().toLowerCase();
|
||||
const named = info.userIDs.map((u) => u.email.trim().toLowerCase()).filter(Boolean);
|
||||
return !!address && named.length > 0 && named.every((e) => e === address);
|
||||
}
|
||||
|
||||
export type PgpSignatureStatus = 'none' | 'valid' | 'invalid' | 'unknown';
|
||||
|
||||
export interface PgpSignatureSummary {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { BodyPart, Email } from '../../api/types';
|
||||
import { binaryToBytes, bytesToBinary, decodeBody, findNode, parseEntity, type MimeNode } from './mime';
|
||||
import {
|
||||
detectPgp, decryptMessage, verifyDetached, verifyCleartext, encryptionKeyIds, keyHasId, canonicalSignedBytes,
|
||||
extractPublicKeyBlock, inspectKey, PgpArgon2UnsupportedError, type PgpSignatureSummary,
|
||||
extractPublicKeyBlock, inspectKey, keyNamesOnly, PgpArgon2UnsupportedError, type PgpSignatureSummary,
|
||||
} from './pgp';
|
||||
import type { SmimeKeyMaterial, SmimeSignatureStatus } from './smime/cms';
|
||||
|
||||
@@ -220,7 +220,7 @@ async function withOfferedKey(result: SecureResult, text: string | null | undefi
|
||||
try {
|
||||
const info = await inspectKey(block);
|
||||
if (info.isPrivate) return result;
|
||||
if (!info.userIDs.some((u) => u.email.trim().toLowerCase() === from)) return result;
|
||||
if (!keyNamesOnly(info, from)) return result;
|
||||
return { ...result, offeredKey: { armored: info.publicKeyArmored, fingerprint: info.fingerprint.toLowerCase(), email: from } };
|
||||
} catch {
|
||||
// a broken embedded key: nothing to offer
|
||||
|
||||
+101
-37
@@ -98,7 +98,11 @@ import { isPickerReopenTooSoon, needsRecipientPicker } from '../lib/recipient-pi
|
||||
import { useOutboxStore } from '../stores/outbox-store';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { isTransientNetworkError } from '../lib/network-error';
|
||||
import { isAuthError, isTransientNetworkError } from '../lib/network-error';
|
||||
import { generateUUID } from '../lib/uuid';
|
||||
import {
|
||||
DraftKeptOnDeviceError, composerClosed, composerOpened, forgetRescuedDraft, rescueDraft,
|
||||
} from '../lib/compose-rescue';
|
||||
import { classifySendFailure, shouldQueueFailedSend } from '../lib/send-queue';
|
||||
import {
|
||||
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
|
||||
@@ -117,7 +121,7 @@ import {
|
||||
import {
|
||||
generateSubAddress, extractDomain, suggestTagsForDomain, getTagValidationError, MAX_TAG_LENGTH,
|
||||
} from '../lib/sub-addressing';
|
||||
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||||
import { activeFromOverride, composeSenderAddress } from '../lib/compose-sender';
|
||||
import type { EmailAddress, FileNode, Identity } from '../api/types';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
@@ -1283,6 +1287,29 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined;
|
||||
return owner?.email || owner?.displayName || '';
|
||||
};
|
||||
// While the server can't take the draft (the session expired, or offline)
|
||||
// it is kept on the device and goes to Drafts once the session is back
|
||||
// (lib/compose-rescue), also when the composer was closed or the app
|
||||
// ended meanwhile.
|
||||
const [composeId] = React.useState(() => `compose-${generateUUID()}`);
|
||||
const composeIdRef = React.useRef(composeId);
|
||||
/** The last save went to the device, not the server. */
|
||||
const keptOnDeviceRef = React.useRef(false);
|
||||
/** Closed with the draft kept on the device: it stays for the Outbox flush. */
|
||||
const closedWithKeptDraftRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
const id = composeIdRef.current;
|
||||
const login = ownerLoginRef.current;
|
||||
composerOpened(id);
|
||||
return () => {
|
||||
composerClosed(id);
|
||||
if (!login) return;
|
||||
// Sent, queued, discarded or saved: nothing left to keep. Torn down
|
||||
// without leaving (the app's stack was reset): the kept draft stays.
|
||||
if (allowLeaveRef.current && !closedWithKeptDraftRef.current) void forgetRescuedDraft(login, id);
|
||||
else if (keptOnDeviceRef.current) void useOutboxStore.getState().flush();
|
||||
};
|
||||
}, []);
|
||||
const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle');
|
||||
const [savingDraft, setSavingDraft] = React.useState(false);
|
||||
|
||||
@@ -1425,21 +1452,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
|
||||
// ── Outgoing message assembly ─────────────────────────────────────────
|
||||
|
||||
const senderAddress = React.useCallback((identity: Identity): { from: EmailAddress; envelopeMailFrom?: string } => {
|
||||
const name = sanitizeDisplayName(identity.name);
|
||||
let from: EmailAddress;
|
||||
if (fromOverride?.email.trim()) {
|
||||
const overrideName = sanitizeDisplayName(fromOverride.name);
|
||||
from = overrideName ? { name: overrideName, email: fromOverride.email.trim() } : { email: fromOverride.email.trim() };
|
||||
} else {
|
||||
const email = subAddressTag ? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter) : identity.email;
|
||||
from = name ? { name, email } : { email };
|
||||
}
|
||||
// A From that isn't the identity's own address still goes out through
|
||||
// the identity's envelope sender.
|
||||
const envelopeMailFrom = from.email.toLowerCase() !== identity.email.toLowerCase() ? identity.email : undefined;
|
||||
return { from, envelopeMailFrom };
|
||||
}, [fromOverride, subAddressTag, subAddressDelimiter]);
|
||||
// Without the organisation's custom From, an override set earlier no longer applies.
|
||||
const senderAddress = React.useCallback(
|
||||
(identity: Identity): { from: EmailAddress; envelopeMailFrom?: string } =>
|
||||
composeSenderAddress(identity, { fromOverride, customFromEnabled, subAddressTag, subAddressDelimiter }),
|
||||
[fromOverride, customFromEnabled, subAddressTag, subAddressDelimiter],
|
||||
);
|
||||
const shownFromOverride = activeFromOverride(fromOverride, customFromEnabled);
|
||||
|
||||
const buildOutgoing = React.useCallback((identity: Identity, liveHtml: string, opts: { forDraft: boolean }): OutgoingEmail => {
|
||||
const { from, envelopeMailFrom } = senderAddress(identity);
|
||||
@@ -1625,8 +1644,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
if (snapshot === lastSavedRef.current) return draftIdRef.current;
|
||||
if (snapshot === baselineRef.current && !draftIdRef.current) return null;
|
||||
setDraftStatus('saving');
|
||||
let outgoing: OutgoingEmail | null = null;
|
||||
let saveIn: { accountId: string | undefined; draftsMailboxId: string } = { accountId: undefined, draftsMailboxId: draftsMailbox.id };
|
||||
try {
|
||||
const outgoing = buildOutgoing(identity, html, { forDraft: true });
|
||||
outgoing = buildOutgoing(identity, html, { forDraft: true });
|
||||
// A draft From a group identity is saved in the group's Drafts, like
|
||||
// its Sent copy (#1090); one without a Drafts folder there in the own.
|
||||
const target = await sendTargetFor(identity, outgoing.from[0]?.email ?? identity.email, mailboxes);
|
||||
@@ -1634,9 +1655,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
setDraftStatus('idle');
|
||||
return draftIdRef.current;
|
||||
}
|
||||
const saveIn = target.accountId && target.draftsMailboxId
|
||||
? { accountId: target.accountId, draftsMailboxId: target.draftsMailboxId }
|
||||
: { accountId: undefined, draftsMailboxId: draftsMailbox.id };
|
||||
if (target.accountId && target.draftsMailboxId) {
|
||||
saveIn = { accountId: target.accountId, draftsMailboxId: target.draftsMailboxId };
|
||||
}
|
||||
const saved = await saveDraftVersion(
|
||||
outgoing,
|
||||
saveIn.draftsMailboxId,
|
||||
@@ -1659,9 +1680,29 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
// after the next pull-to-refresh.
|
||||
const thread = draftThreadOf({ replyTo, draft });
|
||||
if (thread) void refreshDraftThread(thread);
|
||||
if (keptOnDeviceRef.current && ownerLoginRef.current) {
|
||||
keptOnDeviceRef.current = false;
|
||||
void forgetRescuedDraft(ownerLoginRef.current, composeIdRef.current);
|
||||
}
|
||||
return id;
|
||||
} catch (err) {
|
||||
setDraftStatus('failed');
|
||||
// The server can't be reached or no longer takes the sign-in: keep
|
||||
// the draft on the device instead (lib/compose-rescue).
|
||||
if (outgoing && ownerLoginRef.current && !loginMoved() && (isAuthError(err) || isTransientNetworkError(err))) {
|
||||
keptOnDeviceRef.current = true;
|
||||
await rescueDraft(ownerLoginRef.current, {
|
||||
id: composeIdRef.current,
|
||||
savedAt: Date.now(),
|
||||
outgoing,
|
||||
draftsMailboxId: saveIn.draftsMailboxId,
|
||||
jmapAccountId: saveIn.accountId,
|
||||
previousDraftId: draftIdRef.current ?? undefined,
|
||||
// Undefined is the login's own account (the getter throws without a session).
|
||||
previousDraftAccountId: draftIdRef.current ? draftAccountRef.current : undefined,
|
||||
});
|
||||
throw new DraftKeptOnDeviceError(err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -1709,6 +1750,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSnapshot, draftsMailbox?.id, primaryIdentity?.id]);
|
||||
|
||||
// A draft kept on the device while the composer stayed open goes to the
|
||||
// server once the session (or the connection) is back.
|
||||
React.useEffect(() => {
|
||||
if (!sessionUp || !online || !keptOnDeviceRef.current || sendingRef.current) return;
|
||||
saveDraftRef.current({ live: true }).catch((err) => {
|
||||
console.warn('[compose] saving the kept draft failed', err);
|
||||
});
|
||||
}, [sessionUp, online]);
|
||||
|
||||
// ── Close guard ──────────────────────────────────────────────────────
|
||||
|
||||
// Why a draft can't be saved right now: the identities didn't load (e.g.
|
||||
@@ -1749,6 +1799,17 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
allowLeaveRef.current = true;
|
||||
proceed();
|
||||
} catch (err) {
|
||||
if (err instanceof DraftKeptOnDeviceError) {
|
||||
// Kept on the device: it goes to Drafts once the session is back.
|
||||
closedWithKeptDraftRef.current = true;
|
||||
allowLeaveRef.current = true;
|
||||
toast.info(
|
||||
t('email_composer.draft_kept_title', 'Saved on this device'),
|
||||
{ message: t('email_composer.draft_kept_body', 'It goes to Drafts once you are signed in and online again.') },
|
||||
);
|
||||
proceed();
|
||||
return;
|
||||
}
|
||||
// The server refused the draft: keep the text on screen rather than
|
||||
// dropping it with nothing but an error (#702).
|
||||
Alert.alert(
|
||||
@@ -2361,9 +2422,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const [overrideEmail, setOverrideEmail] = React.useState('');
|
||||
const openFromOptions = () => {
|
||||
setTagDraft(subAddressTag);
|
||||
setOverrideEnabled(!!fromOverride);
|
||||
setOverrideName(fromOverride?.name ?? primaryIdentity?.name ?? '');
|
||||
setOverrideEmail(fromOverride?.email ?? primaryIdentity?.email ?? '');
|
||||
setOverrideEnabled(!!shownFromOverride);
|
||||
setOverrideName(shownFromOverride?.name ?? primaryIdentity?.name ?? '');
|
||||
setOverrideEmail(shownFromOverride?.email ?? primaryIdentity?.email ?? '');
|
||||
setFromOptionsOpen(true);
|
||||
};
|
||||
const tagError = tagDraft ? getTagValidationError(tagDraft) : null;
|
||||
@@ -2376,7 +2437,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
return out.slice(0, 5);
|
||||
}, [finalTo]);
|
||||
const applyFromOptions = () => {
|
||||
if (overrideEnabled) {
|
||||
if (overrideEnabled && customFromEnabled) {
|
||||
const email = overrideEmail.trim();
|
||||
if (!isValidEmail(email)) {
|
||||
Alert.alert(t('email_composer.from_override.email_label', 'From email address'), t('identities.form.email_invalid', 'Please enter a valid email address'));
|
||||
@@ -3147,7 +3208,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
disabled={identities.length <= 1 && !primaryIdentity}
|
||||
style={styles.fieldContent}
|
||||
>
|
||||
<Text style={[styles.fromText, !!fromOverride && styles.fromTextOverride]} numberOfLines={1}>
|
||||
<Text style={[styles.fromText, !!shownFromOverride && styles.fromTextOverride]} numberOfLines={1}>
|
||||
{fromDisplay}
|
||||
</Text>
|
||||
{identities.length > 1 && <ChevronDown size={14} color={c.textMuted} />}
|
||||
@@ -3160,7 +3221,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('email_composer.from_options', 'Sender options')}
|
||||
>
|
||||
<Tag size={16} color={subAddressTag || fromOverride ? c.primary : c.textMuted} />
|
||||
<Tag size={16} color={subAddressTag || shownFromOverride ? c.primary : c.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -3683,7 +3744,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
<View style={styles.modalBackdrop}>
|
||||
<View style={styles.modalCard}>
|
||||
<Text style={styles.modalTitle}>{t('email_composer.from', 'From')}</Text>
|
||||
{!overrideEnabled && (
|
||||
{!(overrideEnabled && customFromEnabled) && (
|
||||
<>
|
||||
<Text style={styles.modalLabel}>{t('identities.sub_address.popover_title', 'Add Sub-Address Tag')}</Text>
|
||||
<TextInput
|
||||
@@ -3721,15 +3782,18 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>{t('email_composer.from_override.toggle_off', 'Override')}</Text>
|
||||
<ToggleSwitch
|
||||
value={overrideEnabled}
|
||||
onValueChange={setOverrideEnabled}
|
||||
accessibilityLabel={t('email_composer.from_override.toggle_off', 'Override')}
|
||||
/>
|
||||
</View>
|
||||
{overrideEnabled && (
|
||||
{/* The organisation can turn custom From off (webmail email-composer.tsx customFromEnabled). */}
|
||||
{customFromEnabled && (
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>{t('email_composer.from_override.toggle_off', 'Override')}</Text>
|
||||
<ToggleSwitch
|
||||
value={overrideEnabled}
|
||||
onValueChange={setOverrideEnabled}
|
||||
accessibilityLabel={t('email_composer.from_override.toggle_off', 'Override')}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{overrideEnabled && customFromEnabled && (
|
||||
<>
|
||||
<Text style={styles.modalLabel}>{t('email_composer.from_override.toggle_tooltip', 'Edit the From name and address freely. Mail is still sent through your identity - only the visible From header changes.')}</Text>
|
||||
<TextInput
|
||||
|
||||
@@ -835,6 +835,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
keyExtractor={(item) => item.id}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
// Else the first tap on the quick reply's Send only closes the keyboard.
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
initialScrollIndex={initialIndexRef.current}
|
||||
getItemLayout={(_, index) => ({ length: pageWidth, offset: pageWidth * index, index })}
|
||||
@@ -1467,7 +1469,7 @@ function MoreMenuSheet({
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView style={styles.sheetScroll} bounces={false}>
|
||||
<ScrollView style={styles.sheetScroll} bounces={false} keyboardShouldPersistTaps="handled">
|
||||
{canArchive && (
|
||||
<MoreMenuItem
|
||||
icon={<Archive size={18} color={c.textSecondary} />}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// An expired session (or no connection) used to leave the composer's draft
|
||||
// only in memory: Save Draft failed, and the text was lost with the app. The
|
||||
// composer now keeps it on the device (lib/compose-rescue). No React Native
|
||||
// renderer in the test env, so this checks the wiring.
|
||||
|
||||
const src = fs.readFileSync(path.resolve(__dirname, '..', 'ComposeScreen.tsx'), 'utf8');
|
||||
|
||||
describe('composer draft kept on the device', () => {
|
||||
it('keeps a draft the server could not take because of the session or the connection', () => {
|
||||
expect(src).toMatch(/\(isAuthError\(err\) \|\| isTransientNetworkError\(err\)\)\) \{\s+keptOnDeviceRef\.current = true;\s+await rescueDraft\(ownerLoginRef\.current, \{/);
|
||||
expect(src).toMatch(/throw new DraftKeptOnDeviceError\(err\);/);
|
||||
});
|
||||
|
||||
it('closes on Save Draft once the draft is kept, and says where it goes', () => {
|
||||
expect(src).toMatch(/if \(err instanceof DraftKeptOnDeviceError\) \{\s+\/\/[^\n]*\n\s+closedWithKeptDraftRef\.current = true;\s+allowLeaveRef\.current = true;/);
|
||||
expect(src).toMatch(/t\('email_composer\.draft_kept_title'/);
|
||||
});
|
||||
|
||||
it('drops the kept copy once the server has the draft, or the message is sent or discarded', () => {
|
||||
expect(src).toMatch(/keptOnDeviceRef\.current = false;\s+void forgetRescuedDraft\(ownerLoginRef\.current, composeIdRef\.current\);/);
|
||||
expect(src).toMatch(/if \(allowLeaveRef\.current && !closedWithKeptDraftRef\.current\) void forgetRescuedDraft\(login, id\);/);
|
||||
});
|
||||
|
||||
it('saves a kept draft of an open composer once the session is back', () => {
|
||||
expect(src).toMatch(/if \(!sessionUp \|\| !online \|\| !keptOnDeviceRef\.current \|\| sendingRef\.current\) return;/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// A composer whose draft couldn't reach the server (expired session, offline)
|
||||
// kept it on the device (lib/compose-rescue). The Outbox flush, which runs
|
||||
// once the account's session is up again, saves it to Drafts and says so.
|
||||
|
||||
vi.mock('@react-native-community/netinfo', () => ({
|
||||
default: {
|
||||
addEventListener: () => () => undefined,
|
||||
fetch: async () => ({ isConnected: true, isInternetReachable: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
const client = vi.hoisted(() => ({ served: null as string | null }));
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
get isConnected() { return client.served !== null; },
|
||||
get username() { return client.served ? client.served.split('@')[0] : null; },
|
||||
get serverUrl() { return client.served ? `https://${client.served.split('@')[1]}` : null; },
|
||||
},
|
||||
}));
|
||||
|
||||
const saveDraftVersion = vi.fn(async (..._a: any[]) => ({ id: 'd2', attachments: [] }));
|
||||
vi.mock('../../api/email', () => ({
|
||||
saveDraftVersion: (...a: any[]) => saveDraftVersion(...a),
|
||||
patchKeywordsForEmails: vi.fn(async () => undefined),
|
||||
setEmailMailboxes: vi.fn(async () => undefined),
|
||||
destroyEmails: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { useOutboxStore } from '../outbox-store';
|
||||
import { useNetworkStore } from '../network-store';
|
||||
import { useToastStore } from '../toast-store';
|
||||
import {
|
||||
composerClosed, composerOpened, forgetRescuedDraft, rescueDraft, rescuedDrafts, type RescuedDraft,
|
||||
} from '../../lib/compose-rescue';
|
||||
|
||||
const ACCOUNT = 'user1@mail.example.org';
|
||||
|
||||
function kept(id: string, extra: Partial<RescuedDraft> = {}): RescuedDraft {
|
||||
return {
|
||||
id,
|
||||
savedAt: 1,
|
||||
outgoing: { from: [{ email: 'user1@example.org' }], to: [{ email: 'bob@example.org' }], subject: 'Plans', htmlBody: '<p>Hi</p>' } as never,
|
||||
draftsMailboxId: 'drafts',
|
||||
previousDraftId: 'd1',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const flushed = async () => {
|
||||
await useOutboxStore.getState().flush();
|
||||
// The kept drafts are saved beside the queue's own flush.
|
||||
await vi.waitFor(async () => {
|
||||
expect((await rescuedDrafts(ACCOUNT)).filter((d) => !d.id.startsWith('open'))).toEqual([]);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
client.served = ACCOUNT;
|
||||
useNetworkStore.setState({ online: true, connected: true });
|
||||
useToastStore.setState({ toasts: [] });
|
||||
for (const d of await rescuedDrafts(ACCOUNT)) await forgetRescuedDraft(ACCOUNT, d.id);
|
||||
await useOutboxStore.getState().setAccount(null);
|
||||
await useOutboxStore.getState().setAccount(ACCOUNT);
|
||||
await useOutboxStore.getState().clear();
|
||||
await useOutboxStore.getState().resume(ACCOUNT);
|
||||
// Let the kept-draft pass of that flush finish before the test keeps any.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
saveDraftVersion.mockClear();
|
||||
});
|
||||
|
||||
describe('drafts kept on the device', () => {
|
||||
it('go to Drafts, replacing the version saved before, once the session is back', async () => {
|
||||
await rescueDraft(ACCOUNT, kept('c1'));
|
||||
await flushed();
|
||||
expect(saveDraftVersion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ subject: 'Plans' }), 'drafts', 'd1', undefined, undefined,
|
||||
);
|
||||
await vi.waitFor(() => expect(useToastStore.getState().toasts.map((t) => t.title)).toContain('Saved to Drafts'));
|
||||
});
|
||||
|
||||
it('stay while the client serves another account, or the server refuses them', async () => {
|
||||
await rescueDraft(ACCOUNT, kept('c1'));
|
||||
client.served = 'other@mail.example.org';
|
||||
await useOutboxStore.getState().flush();
|
||||
expect(saveDraftVersion).not.toHaveBeenCalled();
|
||||
expect(await rescuedDrafts(ACCOUNT)).toHaveLength(1);
|
||||
|
||||
client.served = ACCOUNT;
|
||||
saveDraftVersion.mockRejectedValueOnce(new Error('Session expired'));
|
||||
await useOutboxStore.getState().flush();
|
||||
await vi.waitFor(() => expect(saveDraftVersion).toHaveBeenCalledTimes(1));
|
||||
expect(await rescuedDrafts(ACCOUNT)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skip a composer that is still open, which saves its own draft', async () => {
|
||||
composerOpened('open-1');
|
||||
await rescueDraft(ACCOUNT, kept('open-1'));
|
||||
await rescueDraft(ACCOUNT, kept('c2', { previousDraftId: undefined }));
|
||||
await flushed();
|
||||
expect(saveDraftVersion).toHaveBeenCalledTimes(1);
|
||||
expect(saveDraftVersion.mock.calls[0][2]).toBeUndefined();
|
||||
expect((await rescuedDrafts(ACCOUNT)).map((d) => d.id)).toEqual(['open-1']);
|
||||
composerClosed('open-1');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ vi.mock('../../api/jmap-client', () => ({
|
||||
get isConnected() { return client.served !== null; },
|
||||
get username() { return client.served ? client.served.split('@')[0] : null; },
|
||||
get serverUrl() { return client.served ? `https://${client.served.split('@')[1]}` : null; },
|
||||
accountId: 'acc-own',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -22,16 +23,21 @@ vi.mock('../../api/email', () => ({
|
||||
archiveEmails: vi.fn(),
|
||||
unprefixMailboxId: (id: string) => id,
|
||||
saveDraftVersion: (...a: any[]) => saveDraftVersion(...a),
|
||||
cancelScheduledSend: (...a: any[]) => cancelScheduledSend(...a),
|
||||
restoreEmailToDraft: (...a: any[]) => restoreEmailToDraft(...a),
|
||||
}));
|
||||
const cancelScheduledSend = vi.fn(async (..._a: any[]) => undefined);
|
||||
const restoreEmailToDraft = vi.fn(async (..._a: any[]) => undefined);
|
||||
|
||||
const runQueuedSend = vi.fn(async (..._a: any[]): Promise<any> => ({ kind: 'sent', result: { scheduled: false } }));
|
||||
const wasQueuedSendSent = vi.fn(async (..._a: any[]) => false);
|
||||
const takeBackQueuedSend = vi.fn(async (..._a: any[]): Promise<any> => ({ kind: 'notSent' }));
|
||||
vi.mock('../../api/queued-send', () => ({
|
||||
runQueuedSend: (...a: any[]) => runQueuedSend(...a),
|
||||
wasQueuedSendSent: (...a: any[]) => wasQueuedSendSent(...a),
|
||||
takeBackQueuedSend: (...a: any[]) => takeBackQueuedSend(...a),
|
||||
}));
|
||||
const MAILBOXES = [{ id: 'sent', role: 'sent' }, { id: 'drafts', role: 'drafts' }];
|
||||
vi.mock('../email-store', () => ({
|
||||
useEmailStore: { getState: () => ({ mailboxes: [{ id: 'sent', role: 'sent' }] }) },
|
||||
useEmailStore: { getState: () => ({ mailboxes: MAILBOXES }) },
|
||||
}));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
vi.mock('../toast-store', () => ({ toast }));
|
||||
@@ -256,9 +262,9 @@ describe('offline send queue', () => {
|
||||
|
||||
it('does not turn a message an earlier attempt sent into a draft, and says it was sent', async () => {
|
||||
const id = useOutboxStore.getState().enqueueSend(payload('went-out'), { maybeSent: true })!;
|
||||
wasQueuedSendSent.mockResolvedValueOnce(true);
|
||||
takeBackQueuedSend.mockResolvedValueOnce({ kind: 'released' });
|
||||
expect(await useOutboxStore.getState().cancelSend(id, { keepAsDraft: true })).toBe(true);
|
||||
expect(wasQueuedSendSent).toHaveBeenCalledWith(expect.objectContaining({ id, maybeSent: true }), [{ id: 'sent', role: 'sent' }]);
|
||||
expect(takeBackQueuedSend).toHaveBeenCalledWith(expect.objectContaining({ id, maybeSent: true }), MAILBOXES);
|
||||
expect(saveDraftVersion).not.toHaveBeenCalled();
|
||||
expect(useOutboxStore.getState().sends).toEqual([]);
|
||||
await flushed();
|
||||
@@ -269,23 +275,64 @@ describe('offline send queue', () => {
|
||||
it('moves a marked message to Drafts when no earlier attempt sent it, and looks only for marked ones', async () => {
|
||||
const marked = useOutboxStore.getState().enqueueSend(payload('marked'), { maybeSent: true })!;
|
||||
expect(await useOutboxStore.getState().cancelSend(marked, { keepAsDraft: true })).toBe(true);
|
||||
expect(wasQueuedSendSent).toHaveBeenCalledTimes(1);
|
||||
expect(takeBackQueuedSend).toHaveBeenCalledTimes(1);
|
||||
expect(saveDraftVersion).toHaveBeenCalledTimes(1);
|
||||
|
||||
const plain = useOutboxStore.getState().enqueueSend(payload('plain'))!;
|
||||
expect(await useOutboxStore.getState().cancelSend(plain, { keepAsDraft: true })).toBe(true);
|
||||
expect(wasQueuedSendSent).toHaveBeenCalledTimes(1);
|
||||
expect(takeBackQueuedSend).toHaveBeenCalledTimes(1);
|
||||
expect(saveDraftVersion).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps a marked message in the Outbox when the lookup fails', async () => {
|
||||
const id = useOutboxStore.getState().enqueueSend(payload('unknown'), { maybeSent: true })!;
|
||||
wasQueuedSendSent.mockRejectedValueOnce(named('NetworkError', 'Network request failed'));
|
||||
takeBackQueuedSend.mockRejectedValueOnce(named('NetworkError', 'Network request failed'));
|
||||
expect(await useOutboxStore.getState().cancelSend(id, { keepAsDraft: true })).toBe(false);
|
||||
expect(saveDraftVersion).not.toHaveBeenCalled();
|
||||
expect(useOutboxStore.getState().sends[0]).toMatchObject({ id, status: 'failed' });
|
||||
});
|
||||
|
||||
it('cancels a send later that an earlier attempt left held on the server, and moves it back to Drafts', async () => {
|
||||
const id = useOutboxStore.getState().enqueueSend(payload('held'), { maybeSent: true })!;
|
||||
takeBackQueuedSend.mockResolvedValueOnce({ kind: 'held', accountId: 'acc-own', emailId: 'copy-1', emailSubmissionId: 'sub-1' });
|
||||
expect(await useOutboxStore.getState().cancelSend(id, { keepAsDraft: true })).toBe(true);
|
||||
expect(cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'acc-own');
|
||||
expect(restoreEmailToDraft).toHaveBeenCalledWith('copy-1', 'drafts', 'sent', 'acc-own');
|
||||
expect(saveDraftVersion).not.toHaveBeenCalled();
|
||||
expect(useOutboxStore.getState().sends).toEqual([]);
|
||||
await flushed();
|
||||
expect(toast.info).toHaveBeenCalledWith('Send cancelled', { message: 'The message will not be sent. It goes back to your Drafts.' });
|
||||
expect(patchKeywordsForEmails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels the held send on a discard too, and says the copy stays in Sent', async () => {
|
||||
const id = useOutboxStore.getState().enqueueSend(payload('held'), { maybeSent: true })!;
|
||||
takeBackQueuedSend.mockResolvedValueOnce({ kind: 'held', accountId: 'acc-own', emailId: 'copy-1', emailSubmissionId: 'sub-1' });
|
||||
expect(await useOutboxStore.getState().cancelSend(id)).toBe(true);
|
||||
expect(cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'acc-own');
|
||||
expect(restoreEmailToDraft).not.toHaveBeenCalled();
|
||||
await flushed();
|
||||
expect(toast.info).toHaveBeenCalledWith('Send cancelled', { message: 'The message will not be delivered. A copy stays in your Sent folder.' });
|
||||
});
|
||||
|
||||
it('says it was sent when the server let the held send go before the cancel', async () => {
|
||||
const { JMAPSetError } = await import('../../api/jmap-result');
|
||||
const id = useOutboxStore.getState().enqueueSend(payload('too-late'), { maybeSent: true })!;
|
||||
takeBackQueuedSend.mockResolvedValueOnce({ kind: 'held', accountId: 'acc-own', emailId: 'copy-1', emailSubmissionId: 'sub-1' });
|
||||
cancelScheduledSend.mockRejectedValueOnce(new JMAPSetError('cannotUnsend', 'already delivered'));
|
||||
expect(await useOutboxStore.getState().cancelSend(id, { keepAsDraft: true })).toBe(true);
|
||||
expect(restoreEmailToDraft).not.toHaveBeenCalled();
|
||||
await flushed();
|
||||
expect(toast.info).toHaveBeenCalledWith('This message was already sent', { message: 'too-late' });
|
||||
});
|
||||
|
||||
it('discards a message no attempt reached the server with without asking it', async () => {
|
||||
const id = useOutboxStore.getState().enqueueSend(payload('plain'))!;
|
||||
expect(await useOutboxStore.getState().cancelSend(id)).toBe(true);
|
||||
expect(takeBackQueuedSend).not.toHaveBeenCalled();
|
||||
expect(useOutboxStore.getState().sends).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops the queued messages with the rest when the server behind the account changed', async () => {
|
||||
useOutboxStore.getState().enqueueSend(payload());
|
||||
expect(await useOutboxStore.getState().clearAccount(ACCOUNT)).toBe(1);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useAuthStore } from '../auth-store';
|
||||
import { useAccountStore, type AccountEntry } from '../account-store';
|
||||
import { useSettingsStore, resetPolicyRulesForTests } from '../settings-store';
|
||||
import { useToastStore } from '../toast-store';
|
||||
import { startPolicySync, usePolicyStore, settingKeyFor } from '../policy-store';
|
||||
import { noThirdPartyTraffic, startPolicySync, usePolicyStore, settingKeyFor } from '../policy-store';
|
||||
|
||||
const WEBMAIL = 'https://webmail.example.org';
|
||||
const ADA = 'ada@example.com@mail.example.com';
|
||||
@@ -81,7 +81,7 @@ beforeEach(() => {
|
||||
defaultAccountId: ADA,
|
||||
});
|
||||
useAuthStore.setState({ activeAccountId: ADA, session: null });
|
||||
usePolicyStore.setState({ byAccount: {}, accountId: null, effective: null, links: {} });
|
||||
usePolicyStore.setState({ byAccount: {}, configByAccount: {}, accountId: null, effective: null, links: {}, configPending: true });
|
||||
useSettingsStore.setState({ density: 'regular', markAsReadDelay: 0, explicitSettings: [], hydrated: true });
|
||||
useToastStore.setState({ toasts: [] });
|
||||
});
|
||||
@@ -196,6 +196,68 @@ describe('startPolicySync', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('no third-party traffic', () => {
|
||||
const mode = () => noThirdPartyTraffic(usePolicyStore.getState());
|
||||
|
||||
it('counts as on until the config of the account\'s webmail is known', async () => {
|
||||
usePolicyStore.getState().activate(ADA);
|
||||
expect(mode()).toBe(true);
|
||||
await usePolicyStore.getState().refresh(ADA);
|
||||
expect(mode()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps it on when the config says so', async () => {
|
||||
configs[WEBMAIL] = { noThirdPartyTraffic: true };
|
||||
await usePolicyStore.getState().refresh(ADA);
|
||||
expect(mode()).toBe(true);
|
||||
});
|
||||
|
||||
it('is off for an account without a webmail', () => {
|
||||
useAccountStore.getState().updateAccount(ADA, { webmailUrl: undefined });
|
||||
usePolicyStore.getState().activate(ADA);
|
||||
expect(mode()).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the cached config on an offline start', async () => {
|
||||
await usePolicyStore.getState().refresh(ADA);
|
||||
usePolicyStore.setState({ links: {}, configPending: true });
|
||||
mockFetch.mockRejectedValue(new Error('Network request failed'));
|
||||
usePolicyStore.getState().activate(ADA);
|
||||
await usePolicyStore.getState().refresh(ADA);
|
||||
expect(mode()).toBe(false);
|
||||
expect(usePolicyStore.getState().links).toEqual({ helpUrl: 'https://help.example.org' });
|
||||
});
|
||||
|
||||
it('uses the links a policy cached by an earlier version holds', async () => {
|
||||
await usePolicyStore.getState().refresh(ADA);
|
||||
const cached = usePolicyStore.getState().byAccount[ADA];
|
||||
usePolicyStore.setState({ byAccount: { [ADA]: { ...cached, links: { noThirdPartyTraffic: true } } }, configByAccount: {} });
|
||||
usePolicyStore.getState().activate(ADA);
|
||||
expect(mode()).toBe(true);
|
||||
usePolicyStore.setState({ byAccount: { [ADA]: { ...cached, links: {} } } });
|
||||
usePolicyStore.getState().activate(ADA);
|
||||
expect(mode()).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the config of a webmail without the policy route', async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => (url.endsWith('/api/config')
|
||||
? response(url, {})
|
||||
: ({ ok: false, status: 404, headers: { get: () => null } } as unknown as Response)));
|
||||
usePolicyStore.getState().activate(ADA);
|
||||
await usePolicyStore.getState().refresh(ADA);
|
||||
expect(mode()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays on when only the policy answered', async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => (url.endsWith('/api/config')
|
||||
? Promise.reject(new Error('timeout'))
|
||||
: response(url, policies[WEBMAIL])));
|
||||
await usePolicyStore.getState().refresh(ADA);
|
||||
expect(usePolicyStore.getState().effective).not.toBeNull();
|
||||
expect(mode()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settingKeyFor', () => {
|
||||
it('maps the webmail names of settings to the app keys', () => {
|
||||
expect(settingKeyFor('firstDayOfWeek')).toBe('calendarFirstDayOfWeek');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { forgetCachedIdentities } from '../lib/identity-cache';
|
||||
import { clearRescuedDrafts } from '../lib/compose-rescue';
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
jmapClient,
|
||||
@@ -178,6 +179,8 @@ function clearDeviceWideAccountData(): void {
|
||||
// data goes too.
|
||||
async function forgetRemovedAccount(accountId: string): Promise<void> {
|
||||
void forgetCachedIdentities(accountId);
|
||||
// Drafts its composers kept on the device while signed out of the server.
|
||||
void clearRescuedDrafts(accountId);
|
||||
try {
|
||||
const feeds = useCalendarSubscriptionsStore;
|
||||
// On a cold start the stored list may not have been read yet; written
|
||||
|
||||
@@ -449,6 +449,9 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
||||
if (get().paused || !get().resumed || !get().hydrated) return;
|
||||
const accountId = get().activeAccountId;
|
||||
if (!accountId) return;
|
||||
// Drafts a composer kept on the device while the server couldn't take
|
||||
// them (lib/compose-rescue) go to Drafts now.
|
||||
if (clientServes(accountId) && useNetworkStore.getState().online) void saveRescuedDrafts(accountId);
|
||||
if (get().entries.length === 0 && !hasWaitingSends()) return;
|
||||
// Need a live client that's actually serving this account, and a network.
|
||||
if (!clientServes(accountId)) return;
|
||||
@@ -570,18 +573,44 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
||||
if (!accountId || !send || !canCancel(send)) return false;
|
||||
// Out of the queue first, so a flush can't send it while it is saved.
|
||||
removeSend(accountId, id);
|
||||
if (!opts?.keepAsDraft) return true;
|
||||
// Nothing reached the server: a discard needs no lookup.
|
||||
if (!opts?.keepAsDraft && !send.maybeSent) return true;
|
||||
const p = send.payload;
|
||||
try {
|
||||
// An attempt that may have reached the server is looked for first, as
|
||||
// before a resend: a message that went out must not come back as a
|
||||
// draft.
|
||||
// draft. One the server still holds for later (a "send later") is
|
||||
// cancelled there, or it would go out all the same.
|
||||
if (send.maybeSent) {
|
||||
const [{ wasQueuedSendSent }, { useEmailStore }] = await Promise.all([
|
||||
const [{ takeBackQueuedSend }, { useEmailStore }] = await Promise.all([
|
||||
import('../api/queued-send'),
|
||||
import('./email-store'),
|
||||
]);
|
||||
if (await wasQueuedSendSent(send, useEmailStore.getState().mailboxes)) {
|
||||
const mailboxes = useEmailStore.getState().mailboxes;
|
||||
const found = await takeBackQueuedSend(send, mailboxes);
|
||||
let released = found.kind === 'released';
|
||||
if (found.kind === 'held') {
|
||||
const [{ cancelScheduledToDrafts }, { cancelScheduledSend }, { JMAPSetError }] = await Promise.all([
|
||||
import('../lib/compose-scheduled'),
|
||||
import('../api/email'),
|
||||
import('../api/jmap-result'),
|
||||
]);
|
||||
try {
|
||||
if (opts?.keepAsDraft) {
|
||||
const { inDrafts } = await cancelScheduledToDrafts(found, mailboxes, jmapClient.accountId);
|
||||
void notifySendCancelled(inDrafts ? 'drafts' : 'notMoved');
|
||||
} else {
|
||||
await cancelScheduledSend(found.emailSubmissionId, found.accountId);
|
||||
void notifySendCancelled('sent');
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
// The server refused the cancel: it let the message go meanwhile.
|
||||
if (!(err instanceof JMAPSetError)) throw err;
|
||||
released = true;
|
||||
}
|
||||
}
|
||||
if (released) {
|
||||
if (p.original) {
|
||||
patchKeywordsForEmails([p.original.emailId], { [p.original.keyword]: true }, p.original.accountId).catch(() => undefined);
|
||||
}
|
||||
@@ -589,6 +618,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!opts?.keepAsDraft) return true;
|
||||
if (!p.draftsMailboxId) throw new Error('No Drafts folder');
|
||||
const { saveDraftVersion } = await import('../api/email');
|
||||
await saveDraftVersion(p.outgoing, p.draftsMailboxId, p.draftId, undefined, p.draftAccountId);
|
||||
@@ -759,6 +789,66 @@ async function afterQueuedSend(send: QueuedSend, outcome: QueuedSendOutcomeLike)
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the drafts composers kept on the device (lib/compose-rescue) and say where they went. */
|
||||
async function saveRescuedDrafts(accountId: string): Promise<void> {
|
||||
try {
|
||||
const { flushRescuedDrafts, rescuedDrafts } = await import('../lib/compose-rescue');
|
||||
if ((await rescuedDrafts(accountId)).length === 0) return;
|
||||
const { saveDraftVersion } = await import('../api/email');
|
||||
await flushRescuedDrafts(accountId, {
|
||||
save: async (draft) => {
|
||||
// The client moved on to another account meanwhile.
|
||||
if (!clientServes(accountId)) throw new Error('Account switched');
|
||||
return saveDraftVersion(
|
||||
draft.outgoing,
|
||||
draft.draftsMailboxId,
|
||||
draft.previousDraftId,
|
||||
draft.jmapAccountId,
|
||||
draft.previousDraftAccountId,
|
||||
);
|
||||
},
|
||||
saved: () => {
|
||||
void (async () => {
|
||||
const [{ toast }, { useLocaleStore }] = await Promise.all([import('./toast-store'), import('./locale-store')]);
|
||||
const t = useLocaleStore.getState().t;
|
||||
toast.info(t('email_composer.rescued_draft_title', 'Saved to Drafts'), {
|
||||
message: t('email_composer.rescued_draft_body', "A message you were writing couldn't be saved earlier. It is in Drafts now."),
|
||||
});
|
||||
})().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[outbox] could not save kept drafts', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Taking back a message the server held for later: the held send was
|
||||
* cancelled. Its copy went back to Drafts, stayed in Sent because the move
|
||||
* failed, or stays in Sent on a discard.
|
||||
*/
|
||||
async function notifySendCancelled(where: 'drafts' | 'notMoved' | 'sent'): Promise<void> {
|
||||
try {
|
||||
const [{ toast }, { useLocaleStore }] = await Promise.all([import('./toast-store'), import('./locale-store')]);
|
||||
const t = useLocaleStore.getState().t;
|
||||
if (where === 'drafts') {
|
||||
toast.info(t('scheduled.send_cancelled', 'Send cancelled'), {
|
||||
message: t('scheduled.cancel_to_drafts_body', 'The message will not be sent. It goes back to your Drafts.'),
|
||||
});
|
||||
} else if (where === 'notMoved') {
|
||||
toast.info(t('scheduled.cancelled_not_moved', 'Send cancelled, but not moved to Drafts'), {
|
||||
message: t('scheduled.cancelled_not_moved_body', 'The message will not be sent. It is still in Sent.'),
|
||||
});
|
||||
} else {
|
||||
toast.info(t('scheduled.send_cancelled', 'Send cancelled'), {
|
||||
message: t('scheduled.cancel_body', 'The message will not be delivered. A copy stays in your Sent folder.'),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[outbox] could not report a cancelled send', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Taking back a message found to have gone out already: nothing became a draft. */
|
||||
async function notifyAlreadySent(send: QueuedSend): Promise<void> {
|
||||
try {
|
||||
|
||||
+64
-19
@@ -47,12 +47,20 @@ export interface CachedPolicy {
|
||||
partial: boolean;
|
||||
fetchedAt: number;
|
||||
/** The webmail it came from; an account that moved to another drops it. */
|
||||
webmailUrl: string;
|
||||
/** Only in policies cached before configByAccount; the config now has its own entry. */
|
||||
links?: WebmailLinks;
|
||||
}
|
||||
|
||||
/** The last answer of the webmail's /api/config, kept apart from the policy (an older webmail has no policy route). */
|
||||
export interface CachedConfig {
|
||||
webmailUrl: string;
|
||||
links: WebmailLinks;
|
||||
}
|
||||
|
||||
interface PolicyState {
|
||||
byAccount: Record<string, CachedPolicy>;
|
||||
configByAccount: Record<string, CachedConfig>;
|
||||
/** The account `effective` was resolved for. */
|
||||
accountId: string | null;
|
||||
/** The active account's policy with its scopes applied; null without a webmail. */
|
||||
@@ -60,6 +68,12 @@ interface PolicyState {
|
||||
/** What `effective` does to the settings. */
|
||||
rules: SettingsRules;
|
||||
links: WebmailLinks;
|
||||
/**
|
||||
* The active account has a webmail whose /api/config hasn't been read
|
||||
* yet (nor cached). Its "no third-party traffic" mode counts as on
|
||||
* meanwhile, as in the webmail (hooks/use-third-party-mode.ts).
|
||||
*/
|
||||
configPending: boolean;
|
||||
|
||||
/** Resolve and apply the cached policy of this account (the active one). */
|
||||
activate: (accountId: string | null) => void;
|
||||
@@ -195,10 +209,13 @@ export const usePolicyStore = create<PolicyState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
byAccount: {},
|
||||
configByAccount: {},
|
||||
accountId: null,
|
||||
effective: null,
|
||||
rules: EMPTY_SETTINGS_RULES,
|
||||
links: {},
|
||||
// Until the cache is read and the active account known.
|
||||
configPending: true,
|
||||
|
||||
activate: (accountId) => {
|
||||
const account = accountId ? useAccountStore.getState().getAccountById(accountId) : undefined;
|
||||
@@ -207,7 +224,16 @@ export const usePolicyStore = create<PolicyState>()(
|
||||
const usable = account?.webmailUrl && cached && cached.webmailUrl === account.webmailUrl ? cached : null;
|
||||
const effective = usable && account ? resolvePolicy(usable.policy, policySubjectFor(account)) : null;
|
||||
const rules = rulesFor(effective);
|
||||
set({ accountId: accountId ?? null, effective, rules, links: usable?.links ?? {} });
|
||||
const config = accountId && account?.webmailUrl ? get().configByAccount[accountId] : undefined;
|
||||
// A policy cached before the config had its own entry came with the config's links.
|
||||
const links = config?.webmailUrl === account?.webmailUrl && config ? config.links : usable?.links;
|
||||
set({
|
||||
accountId: accountId ?? null,
|
||||
effective,
|
||||
rules,
|
||||
links: links ?? {},
|
||||
configPending: !!account?.webmailUrl && !links,
|
||||
});
|
||||
useSettingsStore.getState().applyPolicyRules(rules);
|
||||
},
|
||||
|
||||
@@ -220,21 +246,27 @@ export const usePolicyStore = create<PolicyState>()(
|
||||
if (!account || !webmailUrl) return;
|
||||
const auth = await bearerFor(account);
|
||||
const [fetched, links] = await Promise.all([fetchOrgPolicy(webmailUrl, auth), fetchWebmailLinks(webmailUrl)]);
|
||||
if (!fetched) return; // offline or no policy route: keep what is cached
|
||||
// Offline: keep what is cached. A webmail without the policy route
|
||||
// still has its config.
|
||||
if (!fetched && !links) return;
|
||||
// Signed out of the account meanwhile.
|
||||
if (!useAccountStore.getState().getAccountById(accountId)) return;
|
||||
const previous = get().byAccount[accountId];
|
||||
const legacyLinks = previous?.webmailUrl === webmailUrl ? previous.links : undefined;
|
||||
set({
|
||||
byAccount: {
|
||||
...get().byAccount,
|
||||
[accountId]: {
|
||||
policy: fetched.policy,
|
||||
partial: fetched.partial,
|
||||
fetchedAt: Date.now(),
|
||||
webmailUrl,
|
||||
links: links ?? (previous?.webmailUrl === webmailUrl ? previous.links : {}),
|
||||
...(fetched ? {
|
||||
byAccount: {
|
||||
...get().byAccount,
|
||||
[accountId]: { policy: fetched.policy, partial: fetched.partial, fetchedAt: Date.now(), webmailUrl },
|
||||
},
|
||||
},
|
||||
} : {}),
|
||||
// Config links cached inside an older policy entry move to their own.
|
||||
...(links || (fetched && legacyLinks) ? {
|
||||
configByAccount: {
|
||||
...get().configByAccount,
|
||||
[accountId]: { webmailUrl, links: (links ?? legacyLinks)! },
|
||||
},
|
||||
} : {}),
|
||||
});
|
||||
if (useAuthStore.getState().activeAccountId === accountId) get().activate(accountId);
|
||||
})().finally(() => refreshes.delete(accountId));
|
||||
@@ -244,15 +276,18 @@ export const usePolicyStore = create<PolicyState>()(
|
||||
|
||||
prune: (accountIds) => {
|
||||
const keep = new Set(accountIds);
|
||||
const current = get().byAccount;
|
||||
if (Object.keys(current).every((id) => keep.has(id))) return;
|
||||
set({ byAccount: Object.fromEntries(Object.entries(current).filter(([id]) => keep.has(id))) });
|
||||
const { byAccount, configByAccount } = get();
|
||||
if ([...Object.keys(byAccount), ...Object.keys(configByAccount)].every((id) => keep.has(id))) return;
|
||||
set({
|
||||
byAccount: Object.fromEntries(Object.entries(byAccount).filter(([id]) => keep.has(id))),
|
||||
configByAccount: Object.fromEntries(Object.entries(configByAccount).filter(([id]) => keep.has(id))),
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'org-policy-cache',
|
||||
storage: createPersistStorage(),
|
||||
partialize: (state) => ({ byAccount: state.byAccount }) as unknown as PolicyState,
|
||||
partialize: (state) => ({ byAccount: state.byAccount, configByAccount: state.configByAccount }) as unknown as PolicyState,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -270,10 +305,15 @@ export function useSettingLocked(key: string): boolean {
|
||||
/**
|
||||
* Whether the active account's webmail runs without third-party traffic
|
||||
* (webmail hooks/use-third-party-mode.ts): remote content in mail never
|
||||
* loads, whatever the setting or a trusted sender says.
|
||||
* loads, whatever the setting or a trusted sender says. On while the
|
||||
* config is still unknown, so nothing reaches out before it says it may.
|
||||
*/
|
||||
export function noThirdPartyTraffic(state: Pick<PolicyState, 'links' | 'configPending'>): boolean {
|
||||
return state.configPending || state.links.noThirdPartyTraffic === true;
|
||||
}
|
||||
|
||||
export function useNoThirdPartyTraffic(): boolean {
|
||||
return usePolicyStore((s) => s.links.noThirdPartyTraffic === true);
|
||||
return usePolicyStore(noThirdPartyTraffic);
|
||||
}
|
||||
|
||||
/** Whether the active account's policy hides a setting (by the app's key). */
|
||||
@@ -301,7 +341,8 @@ export function startPolicySync(): () => void {
|
||||
});
|
||||
});
|
||||
|
||||
let activeId: string | null = null;
|
||||
// Undefined until the first activate, which also settles configPending without an account.
|
||||
let activeId: string | null | undefined;
|
||||
let fetchedFor: string | null = null;
|
||||
const follow = () => {
|
||||
const auth = useAuthStore.getState();
|
||||
@@ -337,7 +378,11 @@ export function startPolicySync(): () => void {
|
||||
const before = id ? prev.accounts.find((a) => a.id === id) : undefined;
|
||||
if (id && now?.webmailUrl && now.webmailUrl !== before?.webmailUrl) {
|
||||
fetchedFor = null;
|
||||
whenHydrated(follow);
|
||||
// Its config is unknown until the fetch answers.
|
||||
whenHydrated(() => {
|
||||
usePolicyStore.getState().activate(id);
|
||||
follow();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -178,4 +178,11 @@ describe('widget archive', () => {
|
||||
calls.moves[0].resolve(true);
|
||||
await tap;
|
||||
});
|
||||
|
||||
it('does nothing for a tap on an older drawing once the organisation turned archiving off', async () => {
|
||||
await saveSnapshot({ ...snapshotOf(server.inbox), archiveEnabled: false });
|
||||
await handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7);
|
||||
expect(calls.moves).toEqual([]);
|
||||
expect(await shown()).toEqual(['m1', 'm2', 'm3']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { readArchiveEnabled } from '../build';
|
||||
|
||||
// The organisation's archiveActionEnabled gate reaches the widgets through
|
||||
// the policy the app cached (stores/policy-store.ts, key org-policy-cache).
|
||||
|
||||
const WEBMAIL = 'https://webmail.example.org';
|
||||
const account = { id: 'acc', email: 'ada@example.org', webmailUrl: WEBMAIL };
|
||||
|
||||
function cache(features: Record<string, unknown>, webmailUrl = WEBMAIL, scopes: unknown[] = []) {
|
||||
return AsyncStorage.setItem('org-policy-cache', JSON.stringify({
|
||||
state: {
|
||||
byAccount: {
|
||||
acc: {
|
||||
policy: { restrictions: {}, features, defaults: {}, themePolicy: {}, scopes },
|
||||
partial: false,
|
||||
fetchedAt: 1,
|
||||
webmailUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
});
|
||||
|
||||
describe('readArchiveEnabled', () => {
|
||||
it('is off when the cached policy turns archiving off', async () => {
|
||||
await cache({ archiveActionEnabled: false });
|
||||
expect(await readArchiveEnabled(account)).toBe(false);
|
||||
});
|
||||
|
||||
it('is on when the policy allows it, without a policy and without a webmail', async () => {
|
||||
expect(await readArchiveEnabled(account)).toBe(true);
|
||||
await cache({ archiveActionEnabled: true });
|
||||
expect(await readArchiveEnabled(account)).toBe(true);
|
||||
await cache({ archiveActionEnabled: false });
|
||||
expect(await readArchiveEnabled({ ...account, webmailUrl: undefined })).toBe(true);
|
||||
expect(await readArchiveEnabled(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a policy cached from another webmail', async () => {
|
||||
await cache({ archiveActionEnabled: false }, 'https://elsewhere.example.org');
|
||||
expect(await readArchiveEnabled(account)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,20 @@ describe('widget layouts', () => {
|
||||
for (const w of catalog.widgets) build(w.name, { ...sample, images }, w.cols, w.rows);
|
||||
});
|
||||
|
||||
it('leaves Archive off the triage card when the organisation turned archiving off', () => {
|
||||
const actions = (s: WidgetSnapshot, cols: number) => {
|
||||
const trees = renderFor('TriageWidget', s, { width: dp(cols), height: dp(2), widgetId: 1 }, {}, PREVIEW_NOW);
|
||||
const tree = 'light' in trees ? trees.light : trees;
|
||||
return [...JSON.stringify(buildWidgetTree(tree)).matchAll(/"clickAction":"(\w+)"/g)].map((m) => m[1]);
|
||||
};
|
||||
expect(actions(sample, 4)).toContain('archive');
|
||||
for (const cols of [3, 4, 5]) {
|
||||
const shown = actions({ ...sample, archiveEnabled: false }, cols);
|
||||
expect(shown).not.toContain('archive');
|
||||
expect(shown).toContain('trash');
|
||||
}
|
||||
});
|
||||
|
||||
it('builds the triage card on a message that is gone', () => {
|
||||
build('TriageWidget', sample, 4, 2, { triageId: 'no-such-message' });
|
||||
build('TriageWidget', { ...sample, mail: { ...sample.mail, inbox: [] } }, 4, 2, { triageId: 'm1' });
|
||||
|
||||
@@ -94,7 +94,13 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num
|
||||
case 'markRead': {
|
||||
const id = str(data.id);
|
||||
if (!id) return;
|
||||
const subject = (await currentView()).mail.inbox.find((m) => m.id === id)?.subject ?? '';
|
||||
const view = await currentView();
|
||||
// A drawing from before the organisation turned archiving off.
|
||||
if (name === 'archive' && view.archiveEnabled === false) {
|
||||
await redrawAll();
|
||||
return;
|
||||
}
|
||||
const subject = view.mail.inbox.find((m) => m.id === id)?.subject ?? '';
|
||||
// The triage card moves on to the next unread message, not back to
|
||||
// the first one.
|
||||
if (name !== 'markRead') await moveTriagePointer(widgetId, id);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getEventEndDate, getEventStartDate } from '../lib/calendar-utils';
|
||||
import { getUserParticipantId, getUserStatus } from '../lib/calendar-participants';
|
||||
import { getBirthday, getContactDisplayName, getDateParts } from '../lib/contact-utils';
|
||||
import { notificationLocale } from '../lib/push-background-task';
|
||||
import { isFeatureEnabled, resolvePolicy, sanitizePolicy } from '../lib/org-policy';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useKeywordsStore, keywordToken } from '../stores/keywords-store';
|
||||
import { LIGHT_COLORS } from '../theme/tokens';
|
||||
@@ -46,6 +47,30 @@ interface RegistryAccount {
|
||||
displayName?: string;
|
||||
username?: string;
|
||||
avatarColor?: string;
|
||||
webmailUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account's organisation lets it archive (policy
|
||||
* archiveActionEnabled), from the policy the app cached for it
|
||||
* (stores/policy-store.ts, which this runtime may not have loaded). On
|
||||
* without a webmail or a cached policy from it.
|
||||
*/
|
||||
export async function readArchiveEnabled(account: RegistryAccount | undefined): Promise<boolean> {
|
||||
if (!account?.webmailUrl) return true;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem('org-policy-cache');
|
||||
const byAccount = raw
|
||||
? (JSON.parse(raw) as { state?: { byAccount?: Record<string, { policy?: unknown; webmailUrl?: string }> } }).state?.byAccount
|
||||
: undefined;
|
||||
const cached = byAccount?.[account.id];
|
||||
const policy = cached?.webmailUrl === account.webmailUrl ? sanitizePolicy(cached.policy) : null;
|
||||
if (!policy) return true;
|
||||
const email = (account.email || account.username || '').trim().toLowerCase();
|
||||
return isFeatureEnabled(resolvePolicy(policy, { email, groups: [] }), 'archiveActionEnabled');
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRegistry(): Promise<{ accounts: RegistryAccount[]; activeAccountId: string | null }> {
|
||||
@@ -390,6 +415,7 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise<WidgetSnapshot>
|
||||
hour12: timeFormat === '12h',
|
||||
weekStart: calendarFirstDayOfWeek === 0 ? 0 : 1,
|
||||
activeAccountId,
|
||||
archiveEnabled: await readArchiveEnabled(accounts.find((a) => a.id === activeAccountId)),
|
||||
};
|
||||
if (previous.activeAccountId !== activeAccountId) {
|
||||
// Nothing of another account's data may linger in a widget.
|
||||
|
||||
@@ -112,9 +112,11 @@ export const TriageLayout: Layout = ({ s, p, f, now, width, height, widgetId, lo
|
||||
trash: f.t('widgets.mail.delete', 'Delete'),
|
||||
reply: f.t('widgets.mail.reply', 'Reply'),
|
||||
};
|
||||
const room = width - 2 - 24 - 32 - 18;
|
||||
const allFit = buttonWidth(labels.archive) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room;
|
||||
const replyFits = 2 * 32 + buttonWidth(labels.reply) <= room;
|
||||
// No Archive button when the organisation turned archiving off.
|
||||
const archive = s.archiveEnabled !== false;
|
||||
const room = width - 2 - 24 - 32 - (archive ? 18 : 12);
|
||||
const allFit = (archive ? buttonWidth(labels.archive) : 0) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room;
|
||||
const replyFits = (archive ? 2 : 1) * 32 + buttonWidth(labels.reply) <= room;
|
||||
return (
|
||||
// The buttons draw 32dp tall in a 48dp tap area (slop 8), which takes the
|
||||
// gap above them and 8dp of the bottom padding.
|
||||
@@ -146,8 +148,10 @@ export const TriageLayout: Layout = ({ s, p, f, now, width, height, widgetId, lo
|
||||
</FlexWidget>
|
||||
{notice ? <NoticeBar p={p} f={f} notice={notice} /> : null}
|
||||
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
|
||||
<Button p={p} label={allFit ? labels.archive : undefined} iconName="archive" flex={1} slop={8} click={action('archive', target)} />
|
||||
<Spacer size={6} horizontal />
|
||||
{archive ? (
|
||||
<Button p={p} label={allFit ? labels.archive : undefined} iconName="archive" flex={1} slop={8} click={action('archive', target)} />
|
||||
) : null}
|
||||
{archive ? <Spacer size={6} horizontal /> : null}
|
||||
<Button p={p} label={allFit ? labels.trash : undefined} iconName="trash" flex={1} slop={8} click={action('trash', target)} />
|
||||
<Spacer size={6} horizontal />
|
||||
<Button p={p} label={allFit || replyFits ? labels.reply : undefined} iconName="reply" variant="primary" flex={1} slop={8} click={open(links.reply(current))} />
|
||||
|
||||
@@ -200,6 +200,11 @@ export interface WidgetSnapshot {
|
||||
accounts: AccountSummary[];
|
||||
/** The signed-in account the single-account widgets show. */
|
||||
activeAccountId: string | null;
|
||||
/**
|
||||
* False when the active account's organisation turned archiving off
|
||||
* (policy archiveActionEnabled); absent counts as on.
|
||||
*/
|
||||
archiveEnabled?: boolean;
|
||||
mail: {
|
||||
folders: FolderCount[];
|
||||
inbox: MailItem[];
|
||||
|
||||
Reference in New Issue
Block a user