diff --git a/docs/ios-release.md b/docs/ios-release.md index 972ffe89..b02271b2 100644 --- a/docs/ios-release.md +++ b/docs/ios-release.md @@ -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 diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 6d511c2a..a818145e 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -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": "يتطلب النقل بين الحسابات اتصالًا بالإنترنت", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index ab98f05a..33b10519 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -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ó", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 7fcb02f1..4e55fb51 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -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í", diff --git a/locales/rn/da.json b/locales/rn/da.json index efdb45f0..1d3ca801 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -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", diff --git a/locales/rn/de.json b/locales/rn/de.json index 0984c211..87f59eb1 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -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", diff --git a/locales/rn/en.json b/locales/rn/en.json index 3f93b8cf..c040cfe3 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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.", diff --git a/locales/rn/es.json b/locales/rn/es.json index 67e31fcf..7465030e 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -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", diff --git a/locales/rn/et.json b/locales/rn/et.json index 1a371ff1..a32cca48 100644 --- a/locales/rn/et.json +++ b/locales/rn/et.json @@ -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", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index d057e491..402d7bef 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -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": "انتقال بین حساب‌ها به اتصال اینترنت نیاز دارد", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 464c6ccc..e506305f 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -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", diff --git a/locales/rn/he.json b/locales/rn/he.json index fc880fe0..a4ff0e4b 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -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": "העברה בין חשבונות דורשת חיבור", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 9d3fe34a..3779ea46 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -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", diff --git a/locales/rn/it.json b/locales/rn/it.json index cc9065c1..12fe9982 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -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", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index cfe62450..0805a25d 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -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": "アカウント間の移動には接続が必要です", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index bc8c3724..efa74b23 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -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": "계정 간 이동은 인터넷 연결이 필요해요", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 7b4d1f2b..cf3abae3 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -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", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 94d80221..0d2f45bc 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -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": "Бүртгэл хооронд зөөхөд холболт шаардлагатай", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index d7cc52d6..8a2e4e98 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -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", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index 19def41d..c371d0c7 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -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", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index afd5f9d4..82bbdb35 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -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", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index b490f2c2..a33bcb75 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -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", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 80fae9b5..ac9da850 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -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", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index dbc93570..668c5868 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -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": "Для перемещения между аккаунтами нужно подключение", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index a8fe02c4..dc950cc6 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -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", diff --git a/locales/rn/th.json b/locales/rn/th.json index f4a4ebe7..d8046018 100644 --- a/locales/rn/th.json +++ b/locales/rn/th.json @@ -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": "การย้ายระหว่างบัญชีต้องใช้การเชื่อมต่อ", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 66ab5c47..42de84a8 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -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", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 0540a277..8074e8be 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -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": "Для переміщення між обліковими записами потрібне з'єднання", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 11007890..e64df039 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -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": "在帳號之間移動需要網路連線", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 220da131..91f87be6 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -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": "在账户之间移动需要网络连接", diff --git a/src/api/__tests__/live-stalwart-send-queue.test.ts b/src/api/__tests__/live-stalwart-send-queue.test.ts index f14b0881..8525c29e 100644 --- a/src/api/__tests__/live-stalwart-send-queue.test.ts +++ b/src/api/__tests__/live-stalwart-send-queue.test.ts @@ -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); }); diff --git a/src/api/__tests__/queued-send.test.ts b/src/api/__tests__/queued-send.test.ts index 333b2c1f..a3817e88 100644 --- a/src/api/__tests__/queued-send.test.ts +++ b/src/api/__tests__/queued-send.test.ts @@ -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; @@ -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(); }); }); diff --git a/src/api/queued-send.ts b/src/api/queued-send.ts index c7641987..51752054 100644 --- a/src/api/queued-send.ts +++ b/src/api/queued-send.ts @@ -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 { - 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 { + 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 { diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index 618dada8..0bf36f7b 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -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(() => { diff --git a/src/components/__tests__/custom-from-third-party-wiring.test.ts b/src/components/__tests__/custom-from-third-party-wiring.test.ts index 7d648ed3..7235a859 100644 --- a/src/components/__tests__/custom-from-third-party-wiring.test.ts +++ b/src/components/__tests__/custom-from-third-party-wiring.test.ts @@ -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+\s+\{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'); diff --git a/src/components/settings/__tests__/keyboard-persist-taps.test.ts b/src/components/settings/__tests__/keyboard-persist-taps.test.ts index 103db3bf..a2c695ad 100644 --- a/src/components/settings/__tests__/keyboard-persist-taps.test.ts +++ b/src/components/settings/__tests__/keyboard-persist-taps.test.ts @@ -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')), diff --git a/src/lib/__tests__/compose-sender.test.ts b/src/lib/__tests__/compose-sender.test.ts new file mode 100644 index 00000000..bfe47496 --- /dev/null +++ b/src/lib/__tests__/compose-sender.test.ts @@ -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(); + }); +}); diff --git a/src/lib/compose-rescue.ts b/src/lib/compose-rescue.ts new file mode 100644 index 00000000..12a736ba --- /dev/null +++ b/src/lib/compose-rescue.ts @@ -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 { + 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 { + 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>(); +function serial(accountId: string, fn: () => Promise): Promise { + 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 { + 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 { + 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 { + return serial(accountId, () => AsyncStorage.removeItem(key(accountId))).catch(() => undefined); +} + +export function rescuedDrafts(accountId: string): Promise { + return serial(accountId, () => read(accountId)); +} + +// Composers open in this process, which save their own drafts. +const openComposers = new Set(); + +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; + /** Called once per flush that saved at least one draft. */ + saved: (count: number) => void; +} + +const flushing = new Set(); + +/** + * 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 { + 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; +} diff --git a/src/lib/compose-sender.ts b/src/lib/compose-sender.ts new file mode 100644 index 00000000..c55bfb86 --- /dev/null +++ b/src/lib/compose-sender.ts @@ -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 }; +} diff --git a/src/lib/crypto/__tests__/helpers.ts b/src/lib/crypto/__tests__/helpers.ts index 984c4960..c82228c2 100644 --- a/src/lib/crypto/__tests__/helpers.ts +++ b/src/lib/crypto/__tests__/helpers.ts @@ -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 { +export async function makeSmimeCert(email: string, opts: { notAfter?: Date; extraEmails?: string[] } = {}): Promise { 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()); diff --git a/src/lib/crypto/__tests__/secure-message-keys.test.ts b/src/lib/crypto/__tests__/secure-message-keys.test.ts index 7ba6ec67..92151e68 100644 --- a/src/lib/crypto/__tests__/secure-message-keys.test.ts +++ b/src/lib/crypto/__tests__/secure-message-keys.test.ts @@ -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' })])); diff --git a/src/lib/crypto/keyring.ts b/src/lib/crypto/keyring.ts index d13d022a..e31fd728 100644 --- a/src/lib/crypto/keyring.ts +++ b/src/lib/crypto/keyring.ts @@ -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 { 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 { diff --git a/src/lib/crypto/pgp.ts b/src/lib/crypto/pgp.ts index 1475167e..74c6d873 100644 --- a/src/lib/crypto/pgp.ts +++ b/src/lib/crypto/pgp.ts @@ -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, 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 { diff --git a/src/lib/crypto/secure-message.ts b/src/lib/crypto/secure-message.ts index c2671ce6..23f7deda 100644 --- a/src/lib/crypto/secure-message.ts +++ b/src/lib/crypto/secure-message.ts @@ -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 diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 737e5c1f..bfd65d20 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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} > - + {fromDisplay} {identities.length > 1 && } @@ -3160,7 +3221,7 @@ export default function ComposeScreen({ route, navigation }: Props) { accessibilityRole="button" accessibilityLabel={t('email_composer.from_options', 'Sender options')} > - + )} @@ -3683,7 +3744,7 @@ export default function ComposeScreen({ route, navigation }: Props) { {t('email_composer.from', 'From')} - {!overrideEnabled && ( + {!(overrideEnabled && customFromEnabled) && ( <> {t('identities.sub_address.popover_title', 'Add Sub-Address Tag')} )} - - {t('email_composer.from_override.toggle_off', 'Override')} - - - {overrideEnabled && ( + {/* The organisation can turn custom From off (webmail email-composer.tsx customFromEnabled). */} + {customFromEnabled && ( + + {t('email_composer.from_override.toggle_off', 'Override')} + + + )} + {overrideEnabled && customFromEnabled && ( <> {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.')} 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({ - + {canArchive && ( } diff --git a/src/screens/__tests__/compose-rescue-wiring.test.ts b/src/screens/__tests__/compose-rescue-wiring.test.ts new file mode 100644 index 00000000..73b4c4f4 --- /dev/null +++ b/src/screens/__tests__/compose-rescue-wiring.test.ts @@ -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;/); + }); +}); diff --git a/src/stores/__tests__/outbox-rescued-drafts.test.ts b/src/stores/__tests__/outbox-rescued-drafts.test.ts new file mode 100644 index 00000000..737cbefb --- /dev/null +++ b/src/stores/__tests__/outbox-rescued-drafts.test.ts @@ -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 { + return { + id, + savedAt: 1, + outgoing: { from: [{ email: 'user1@example.org' }], to: [{ email: 'bob@example.org' }], subject: 'Plans', htmlBody: '

Hi

' } 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'); + }); +}); diff --git a/src/stores/__tests__/outbox-send-queue.test.ts b/src/stores/__tests__/outbox-send-queue.test.ts index c7555456..b5ef8ff6 100644 --- a/src/stores/__tests__/outbox-send-queue.test.ts +++ b/src/stores/__tests__/outbox-send-queue.test.ts @@ -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 => ({ kind: 'sent', result: { scheduled: false } })); -const wasQueuedSendSent = vi.fn(async (..._a: any[]) => false); +const takeBackQueuedSend = vi.fn(async (..._a: any[]): Promise => ({ 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); diff --git a/src/stores/__tests__/policy-store.test.ts b/src/stores/__tests__/policy-store.test.ts index 85bb7970..e5a5bcd8 100644 --- a/src/stores/__tests__/policy-store.test.ts +++ b/src/stores/__tests__/policy-store.test.ts @@ -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'); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 07380e49..060f6169 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -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 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 diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts index a052bb2c..5f1371fe 100644 --- a/src/stores/outbox-store.ts +++ b/src/stores/outbox-store.ts @@ -449,6 +449,9 @@ export const useOutboxStore = create((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((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((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 { + 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 { + 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 { try { diff --git a/src/stores/policy-store.ts b/src/stores/policy-store.ts index 50115421..78807fe4 100644 --- a/src/stores/policy-store.ts +++ b/src/stores/policy-store.ts @@ -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; + configByAccount: Record; /** 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()( 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()( 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()( 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()( 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): 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(); + }); } }); diff --git a/src/widgets/__tests__/actions.test.ts b/src/widgets/__tests__/actions.test.ts index a6a7ff7d..8a436e21 100644 --- a/src/widgets/__tests__/actions.test.ts +++ b/src/widgets/__tests__/actions.test.ts @@ -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']); + }); }); diff --git a/src/widgets/__tests__/archive-policy.test.ts b/src/widgets/__tests__/archive-policy.test.ts new file mode 100644 index 00000000..04411c14 --- /dev/null +++ b/src/widgets/__tests__/archive-policy.test.ts @@ -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, 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); + }); +}); diff --git a/src/widgets/__tests__/layouts.test.ts b/src/widgets/__tests__/layouts.test.ts index 00033fdd..74f2d19a 100644 --- a/src/widgets/__tests__/layouts.test.ts +++ b/src/widgets/__tests__/layouts.test.ts @@ -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' }); diff --git a/src/widgets/actions.ts b/src/widgets/actions.ts index b9ffea6a..d514122e 100644 --- a/src/widgets/actions.ts +++ b/src/widgets/actions.ts @@ -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); diff --git a/src/widgets/build.ts b/src/widgets/build.ts index ed5478f5..7c0ae1e1 100644 --- a/src/widgets/build.ts +++ b/src/widgets/build.ts @@ -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 { + if (!account?.webmailUrl) return true; + try { + const raw = await AsyncStorage.getItem('org-policy-cache'); + const byAccount = raw + ? (JSON.parse(raw) as { state?: { byAccount?: Record } }).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 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. diff --git a/src/widgets/layouts/mail.tsx b/src/widgets/layouts/mail.tsx index ff925653..4af81a33 100644 --- a/src/widgets/layouts/mail.tsx +++ b/src/widgets/layouts/mail.tsx @@ -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 {notice ? : null} -