Compare commits

..
Author SHA1 Message Date
Linus Rath ab7e0b90d4 test: list each failed step of an ios flow with its error from maestro 2's command log 2026-11-03 01:42:52 +01:00
Linus Rath a3e3ae9ee1 test: spot a dead maestro driver in its log and keep the failure reasons in the ios flow output 2026-11-03 01:42:52 +01:00
Linus Rath 0558b4157d Merge branch 'main' into rel-ios-e2e 2026-11-02 23:41:52 +01:00
Linus Rath 0fb480bfd9 Merge branch 'rel-fixes' 2026-11-02 23:41:25 +01:00
Linus Rath b9c3f79401 fix: send a quick reply with the first tap while the keyboard is open 2026-11-02 23:41:19 +01:00
Linus Rath 78b1838d14 fix: cancel a send later on the server when it is taken back from the outbox after an unanswered attempt 2026-11-02 23:17:30 +01:00
Linus Rath a0a5aa3f38 docs: describe ios push and client certificates as they are now #3 2026-11-02 23:07:34 +01:00
Linus Rath ac312352ba fix: offer and keep a key from a message only when it names the sender alone #58 2026-11-02 23:07:34 +01:00
Linus Rath 4d21adcec6 fix: keep an unsaved draft on the device when the session expires and save it to drafts later 2026-11-02 23:07:33 +01:00
Linus Rath 762ec6bab0 fix: leave archive off the android widget when the organisation turns archiving off 2026-11-02 22:50:45 +01:00
Linus Rath f173358e86 fix: show no sender favicons when the webmail allows no third-party traffic 2026-11-02 22:45:04 +01:00
Linus Rath f11aea8a2a fix: keep remote content blocked until the webmail's config says third-party traffic is allowed 2026-11-02 22:45:04 +01:00
Linus Rath b58c9dd719 fix: hide the custom From in the composer when the organisation turns it off 2026-11-02 22:38:43 +01:00
Linus Rath ef31d6f1c9 test: search the storage settings page by a word only it has so landscape taps the row, not the keyboard 2026-11-02 22:28:57 +01:00
Linus Rath fc73bf8334 test: wait for the selection bar to close after a bulk star in the ios move flow 2026-11-02 22:28:56 +01:00
Linus Rath 923f9d8e87 test: run an ios flow once more when the maestro driver dies during it 2026-11-02 22:28:56 +01:00
65 changed files with 1257 additions and 169 deletions
+12 -11
View File
@@ -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
+4 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "Для переміщення між обліковими записами потрібне з'єднання",
+5 -1
View File
@@ -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
View File
@@ -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": "在账户之间移动需要网络连接",
+4 -2
View File
@@ -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
View File
@@ -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);
});
+32 -10
View File
@@ -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
View File
@@ -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> {
+6 -1
View File
@@ -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')),
+38
View File
@@ -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();
});
});
+130
View File
@@ -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;
}
+52
View File
@@ -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 };
}
+4 -2
View File
@@ -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' })]));
+7 -6
View File
@@ -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 {
+11
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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');
});
});
+55 -8
View File
@@ -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);
+64 -2
View File
@@ -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');
+3
View File
@@ -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
+94 -4
View File
@@ -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
View File
@@ -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();
});
}
});
+7
View File
@@ -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);
});
});
+14
View File
@@ -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' });
+7 -1
View File
@@ -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);
+26
View File
@@ -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.
+9 -5
View File
@@ -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))} />
+5
View File
@@ -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[];