diff --git a/locales/ar/common.json b/locales/ar/common.json index f5c7b2c7d..c9f3ad948 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -880,6 +880,24 @@ "send_anyway": "الإرسال على أي حال", "back": "رجوع" }, + "phishing_report": { + "action": "الإبلاغ عن تصيّد احتيالي…", + "confirm_title": "الإبلاغ عن تصيّد احتيالي؟", + "confirm_message": "{count, plural, zero {لا توجد رسائل لإرسالها إلى {address}.} one {ستُرسل الرسالة إلى {address} وتُنقل إلى البريد المزعج.} two {ستُرسل كل من الرسالتين إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} few {ستُرسل كل رسالة من # رسائل إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} many {ستُرسل كل رسالة من # رسالة إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} other {ستُرسل كل رسالة من # رسالة إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.}}", + "confirm": "إبلاغ", + "cancel": "إلغاء", + "sending": "جارٍ الإبلاغ…", + "done": "{count, plural, zero {لم تُرسل أي رسالة إلى {address}.} one {تم إبلاغ {address} عن الرسالة ونُقلت إلى البريد المزعج.} two {تم إبلاغ {address} عن رسالتين ونُقلتا إلى البريد المزعج.} few {تم إبلاغ {address} عن # رسائل ونُقلت إلى البريد المزعج.} many {تم إبلاغ {address} عن # رسالة ونُقلت إلى البريد المزعج.} other {تم إبلاغ {address} عن # رسالة ونُقلت إلى البريد المزعج.}}", + "not_moved": "{count, plural, zero {تم إبلاغ {address} ونُقلت كل الرسائل إلى البريد المزعج.} one {تم إبلاغ {address}، لكن تعذّر نقل الرسالة إلى البريد المزعج.} two {تم إبلاغ {address}، لكن تعذّر نقل رسالتين إلى البريد المزعج.} few {تم إبلاغ {address}، لكن تعذّر نقل # رسائل إلى البريد المزعج.} many {تم إبلاغ {address}، لكن تعذّر نقل # رسالة إلى البريد المزعج.} other {تم إبلاغ {address}، لكن تعذّر نقل # رسالة إلى البريد المزعج.}}", + "failed": "{count, plural, zero {تم الإبلاغ عن كل الرسائل.} one {تعذّر الإبلاغ عن الرسالة.} two {تعذّر الإبلاغ عن رسالتين.} few {تعذّر الإبلاغ عن # رسائل.} many {تعذّر الإبلاغ عن # رسالة.} other {تعذّر الإبلاغ عن # رسالة.}}", + "undo": "تراجع", + "undone": "{count, plural, zero {تمت الإعادة إلى مكانها.} one {أُعيدت الرسالة إلى مكانها. البلاغ أُرسل بالفعل.} two {أُعيدت الرسالتان إلى مكانهما. البلاغان أُرسلا بالفعل.} few {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.} many {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.} other {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.}}", + "undo_failed": "تعذّرت الإعادة إلى المكان السابق.", + "banner": "أبلغت عن هذه الرسالة كتصيّد احتيالي. تم تعطيل روابطها.", + "link_off": "الرابط معطّل", + "link_off_note": "(الرابط معطّل)", + "no_subject": "(بلا موضوع)" + }, "email_composer": { "select_from_directory": "الاختيار من الدليل", "minimize": "تصغير", diff --git a/locales/ca/common.json b/locales/ca/common.json index c5e8f90dc..80d7f5830 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -880,6 +880,24 @@ "send_anyway": "Envia igualment", "back": "Enrere" }, + "phishing_report": { + "action": "Denuncia com a pesca de credencials…", + "confirm_title": "Voleu denunciar-ho com a pesca de credencials?", + "confirm_message": "{count, plural, one {El missatge s'envia a {address} i es mou al correu brossa.} other {Cadascun dels # missatges s'envia a {address} com a denúncia independent i es mou al correu brossa.}}", + "confirm": "Denuncia", + "cancel": "Cancel·la", + "sending": "S'està denunciant…", + "done": "{count, plural, one {S'ha denunciat a {address} i s'ha mogut al correu brossa.} other {S'han denunciat # missatges a {address} i s'han mogut al correu brossa.}}", + "not_moved": "{count, plural, one {S'ha denunciat a {address}, però el missatge no s'ha pogut moure al correu brossa.} other {S'ha denunciat a {address}, però # missatges no s'han pogut moure al correu brossa.}}", + "failed": "{count, plural, one {No s'ha pogut denunciar el missatge.} other {No s'han pogut denunciar # missatges.}}", + "undo": "Desfés", + "undone": "{count, plural, one {S'ha tornat al lloc. La denúncia ja s'havia enviat.} other {S'han tornat al lloc. Les denúncies ja s'havien enviat.}}", + "undo_failed": "No s'ha pogut tornar al lloc.", + "banner": "Heu denunciat aquest missatge com a pesca de credencials. Els enllaços estan desactivats.", + "link_off": "enllaç desactivat", + "link_off_note": "(enllaç desactivat)", + "no_subject": "(sense assumpte)" + }, "email_composer": { "select_from_directory": "Selecciona del directori", "minimize": "Minimitza", diff --git a/locales/cs/common.json b/locales/cs/common.json index 470fff17a..51088aab6 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -880,6 +880,24 @@ "send_anyway": "Přesto odeslat", "back": "Zpět" }, + "phishing_report": { + "action": "Nahlásit phishing…", + "confirm_title": "Nahlásit phishing?", + "confirm_message": "{count, plural, one {Zpráva bude odeslána na {address} a přesunuta do složky Spam.} few {# zprávy budou odeslány na {address}, každá jako samostatné hlášení, a přesunuty do složky Spam.} many {# zprávy bude odesláno na {address}, každá jako samostatné hlášení, a přesunuto do složky Spam.} other {# zpráv bude odesláno na {address}, každá jako samostatné hlášení, a přesunuto do složky Spam.}}", + "confirm": "Nahlásit", + "cancel": "Zrušit", + "sending": "Nahlašování…", + "done": "{count, plural, one {Nahlášeno na {address} a přesunuto do složky Spam.} few {# zprávy nahlášeny na {address} a přesunuty do složky Spam.} many {# zprávy nahlášeno na {address} a přesunuto do složky Spam.} other {# zpráv nahlášeno na {address} a přesunuto do složky Spam.}}", + "not_moved": "{count, plural, one {Nahlášeno na {address}, ale zprávu se nepodařilo přesunout do složky Spam.} few {Nahlášeno na {address}, ale # zprávy se nepodařilo přesunout do složky Spam.} many {Nahlášeno na {address}, ale # zprávy se nepodařilo přesunout do složky Spam.} other {Nahlášeno na {address}, ale # zpráv se nepodařilo přesunout do složky Spam.}}", + "failed": "{count, plural, one {Zprávu se nepodařilo nahlásit.} few {# zprávy se nepodařilo nahlásit.} many {# zprávy se nepodařilo nahlásit.} other {# zpráv se nepodařilo nahlásit.}}", + "undo": "Zpět", + "undone": "{count, plural, one {Vráceno zpět. Hlášení už bylo odesláno.} few {Vráceno zpět. Hlášení už byla odeslána.} many {Vráceno zpět. Hlášení už byla odeslána.} other {Vráceno zpět. Hlášení už byla odeslána.}}", + "undo_failed": "Nepodařilo se vrátit zpět.", + "banner": "Tuto zprávu jste nahlásili jako phishing. Odkazy v ní jsou vypnuté.", + "link_off": "odkaz vypnut", + "link_off_note": "(odkaz vypnut)", + "no_subject": "(bez předmětu)" + }, "email_composer": { "select_from_directory": "Vybrat z adresáře", "minimize": "Minimalizovat", diff --git a/locales/da/common.json b/locales/da/common.json index 7a3de8339..8fe00c36d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -880,6 +880,24 @@ "send_anyway": "Send alligevel", "back": "Tilbage" }, + "phishing_report": { + "action": "Rapportér phishing…", + "confirm_title": "Rapportér phishing?", + "confirm_message": "{count, plural, one {Beskeden sendes til {address} og flyttes til Spam.} other {Hver af de # beskeder sendes til {address} som en separat rapport og flyttes til Spam.}}", + "confirm": "Rapportér", + "cancel": "Annuller", + "sending": "Rapporterer…", + "done": "{count, plural, one {Rapporteret til {address} og flyttet til Spam.} other {# beskeder rapporteret til {address} og flyttet til Spam.}}", + "not_moved": "{count, plural, one {Rapporteret til {address}, men beskeden kunne ikke flyttes til Spam.} other {Rapporteret til {address}, men # beskeder kunne ikke flyttes til Spam.}}", + "failed": "{count, plural, one {Beskeden kunne ikke rapporteres.} other {# beskeder kunne ikke rapporteres.}}", + "undo": "Fortryd", + "undone": "{count, plural, one {Flyttet tilbage. Rapporten var allerede sendt.} other {Flyttet tilbage. Rapporterne var allerede sendt.}}", + "undo_failed": "Kunne ikke flyttes tilbage.", + "banner": "Du har rapporteret denne besked som phishing. Links i den er slået fra.", + "link_off": "link slået fra", + "link_off_note": "(link slået fra)", + "no_subject": "(intet emne)" + }, "email_composer": { "select_from_directory": "Vælg fra adressefortegnelse", "minimize": "Minimer", diff --git a/locales/de/common.json b/locales/de/common.json index 86879832b..ba52917ff 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -880,6 +880,24 @@ "send_anyway": "Trotzdem senden", "back": "Zurück" }, + "phishing_report": { + "action": "Phishing melden…", + "confirm_title": "Phishing melden?", + "confirm_message": "{count, plural, one {Die Nachricht geht an {address} und wird in den Spam-Ordner verschoben.} other {Jede der # Nachrichten geht als eigene Meldung an {address} und wird in den Spam-Ordner verschoben.}}", + "confirm": "Melden", + "cancel": "Abbrechen", + "sending": "Wird gemeldet…", + "done": "{count, plural, one {An {address} gemeldet und in den Spam-Ordner verschoben.} other {# Nachrichten an {address} gemeldet und in den Spam-Ordner verschoben.}}", + "not_moved": "{count, plural, one {An {address} gemeldet, aber die Nachricht konnte nicht in den Spam-Ordner verschoben werden.} other {An {address} gemeldet, aber # Nachrichten konnten nicht in den Spam-Ordner verschoben werden.}}", + "failed": "{count, plural, one {Die Nachricht konnte nicht gemeldet werden.} other {# Nachrichten konnten nicht gemeldet werden.}}", + "undo": "Rückgängig", + "undone": "{count, plural, one {Zurückverschoben. Die Meldung wurde bereits gesendet.} other {Zurückverschoben. Die Meldungen wurden bereits gesendet.}}", + "undo_failed": "Konnte nicht zurückverschoben werden.", + "banner": "Sie haben diese Nachricht als Phishing gemeldet. Die Links darin sind deaktiviert.", + "link_off": "Link deaktiviert", + "link_off_note": "(Link deaktiviert)", + "no_subject": "(kein Betreff)" + }, "email_composer": { "select_from_directory": "Aus Verzeichnis auswählen", "minimize": "Minimieren", diff --git a/locales/en/common.json b/locales/en/common.json index c164315f3..c53db4a67 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -880,6 +880,24 @@ "send_anyway": "Send anyway", "back": "Back" }, + "phishing_report": { + "action": "Report phishing…", + "confirm_title": "Report phishing?", + "confirm_message": "{count, plural, one {The message goes to {address} and moves to Junk.} other {Each of the # messages goes to {address} as its own report and moves to Junk.}}", + "confirm": "Report", + "cancel": "Cancel", + "sending": "Reporting…", + "done": "{count, plural, one {Reported to {address} and moved to Junk.} other {# messages reported to {address} and moved to Junk.}}", + "not_moved": "{count, plural, one {Reported to {address}, but the message couldn't be moved to Junk.} other {Reported to {address}, but # messages couldn't be moved to Junk.}}", + "failed": "{count, plural, one {The message couldn't be reported.} other {# messages couldn't be reported.}}", + "undo": "Undo", + "undone": "{count, plural, one {Moved back. The report was already sent.} other {Moved back. The reports were already sent.}}", + "undo_failed": "Couldn't move it back.", + "banner": "You reported this message as phishing. Its links are turned off.", + "link_off": "link turned off", + "link_off_note": "(link turned off)", + "no_subject": "(no subject)" + }, "email_composer": { "select_from_directory": "Select from directory", "minimize": "Minimize", diff --git a/locales/es/common.json b/locales/es/common.json index 6752de642..e82f5a1ae 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -880,6 +880,24 @@ "send_anyway": "Enviar de todos modos", "back": "Atrás" }, + "phishing_report": { + "action": "Reportar phishing…", + "confirm_title": "¿Reportar phishing?", + "confirm_message": "{count, plural, one {El mensaje se enviará a {address} y se moverá a Spam.} other {Cada uno de los # mensajes se enviará a {address} como un reporte aparte y se moverá a Spam.}}", + "confirm": "Reportar", + "cancel": "Cancelar", + "sending": "Reportando…", + "done": "{count, plural, one {Reportado a {address} y movido a Spam.} other {# mensajes reportados a {address} y movidos a Spam.}}", + "not_moved": "{count, plural, one {Reportado a {address}, pero el mensaje no se pudo mover a Spam.} other {Reportados a {address}, pero # mensajes no se pudieron mover a Spam.}}", + "failed": "{count, plural, one {No se pudo reportar el mensaje.} other {No se pudieron reportar # mensajes.}}", + "undo": "Deshacer", + "undone": "{count, plural, one {Devuelto a su sitio. El reporte ya se había enviado.} other {Devueltos a su sitio. Los reportes ya se habían enviado.}}", + "undo_failed": "No se pudo devolver a su sitio.", + "banner": "Has reportado este mensaje como phishing. Sus enlaces están desactivados.", + "link_off": "enlace desactivado", + "link_off_note": "(enlace desactivado)", + "no_subject": "(sin asunto)" + }, "email_composer": { "select_from_directory": "Seleccionar del directorio", "minimize": "Minimizar", diff --git a/locales/et/common.json b/locales/et/common.json index f1c378c57..d4063e553 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -880,6 +880,24 @@ "send_anyway": "Saada ikkagi", "back": "Tagasi" }, + "phishing_report": { + "action": "Teata õngitsusest…", + "confirm_title": "Teatada õngitsusest?", + "confirm_message": "{count, plural, one {Kiri saadetakse aadressile {address} ja teisaldatakse rämpsposti.} other {Kõik # kirja saadetakse aadressile {address} eraldi teadetena ja teisaldatakse rämpsposti.}}", + "confirm": "Teata", + "cancel": "Tühista", + "sending": "Teatamine…", + "done": "{count, plural, one {Teatatud aadressile {address} ja teisaldatud rämpsposti.} other {# kirjast teatatud aadressile {address} ja teisaldatud rämpsposti.}}", + "not_moved": "{count, plural, one {Teatatud aadressile {address}, kuid kirja ei õnnestunud rämpsposti teisaldada.} other {Teatatud aadressile {address}, kuid # kirja ei õnnestunud rämpsposti teisaldada.}}", + "failed": "{count, plural, one {Kirjast ei õnnestunud teatada.} other {# kirjast ei õnnestunud teatada.}}", + "undo": "Võta tagasi", + "undone": "{count, plural, one {Tõstetud tagasi. Teade oli juba saadetud.} other {Tõstetud tagasi. Teated olid juba saadetud.}}", + "undo_failed": "Tagasi tõstmine ei õnnestunud.", + "banner": "Teatasid sellest kirjast kui õngitsusest. Selle lingid on välja lülitatud.", + "link_off": "link välja lülitatud", + "link_off_note": "(link välja lülitatud)", + "no_subject": "(teema puudub)" + }, "email_composer": { "select_from_directory": "Vali kataloogist", "minimize": "Minimeeri", diff --git a/locales/fa/common.json b/locales/fa/common.json index 2da9a6e5a..a2d33add5 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -880,6 +880,24 @@ "send_anyway": "ارسال به هر حال", "back": "بازگشت" }, + "phishing_report": { + "action": "گزارش فیشینگ…", + "confirm_title": "به‌عنوان فیشینگ گزارش شود؟", + "confirm_message": "{count, plural, one {پیام به {address} فرستاده و به هرزنامه منتقل می‌شود.} other {هر یک از # پیام به‌صورت گزارشی جداگانه به {address} فرستاده و به هرزنامه منتقل می‌شود.}}", + "confirm": "گزارش", + "cancel": "انصراف", + "sending": "در حال گزارش…", + "done": "{count, plural, one {به {address} گزارش و به هرزنامه منتقل شد.} other {# پیام به {address} گزارش و به هرزنامه منتقل شد.}}", + "not_moved": "{count, plural, one {به {address} گزارش شد، اما پیام به هرزنامه منتقل نشد.} other {به {address} گزارش شد، اما # پیام به هرزنامه منتقل نشد.}}", + "failed": "{count, plural, one {گزارش پیام ممکن نشد.} other {گزارش # پیام ممکن نشد.}}", + "undo": "بازگردانی", + "undone": "{count, plural, one {به جای قبلی برگردانده شد. گزارش قبلاً فرستاده شده است.} other {به جای قبلی برگردانده شدند. گزارش‌ها قبلاً فرستاده شده‌اند.}}", + "undo_failed": "برگرداندن ممکن نشد.", + "banner": "شما این پیام را به‌عنوان فیشینگ گزارش کردید. پیوندهای آن غیرفعال شده‌اند.", + "link_off": "پیوند غیرفعال شد", + "link_off_note": "(پیوند غیرفعال شد)", + "no_subject": "(بدون موضوع)" + }, "email_composer": { "select_from_directory": "انتخاب از فهرست", "minimize": "کوچک کردن", diff --git a/locales/fr/common.json b/locales/fr/common.json index c41f6a286..14268c1f9 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -880,6 +880,24 @@ "send_anyway": "Envoyer quand même", "back": "Retour" }, + "phishing_report": { + "action": "Signaler comme hameçonnage…", + "confirm_title": "Signaler comme hameçonnage ?", + "confirm_message": "{count, plural, one {Le message sera envoyé à {address} et déplacé dans le dossier Spam.} other {Chacun des # messages sera envoyé à {address} dans un signalement distinct et déplacé dans le dossier Spam.}}", + "confirm": "Signaler", + "cancel": "Annuler", + "sending": "Signalement en cours…", + "done": "{count, plural, one {Signalé à {address} et déplacé dans le dossier Spam.} other {# messages signalés à {address} et déplacés dans le dossier Spam.}}", + "not_moved": "{count, plural, one {Signalé à {address}, mais le message n'a pas pu être déplacé dans le dossier Spam.} other {Signalés à {address}, mais # messages n'ont pas pu être déplacés dans le dossier Spam.}}", + "failed": "{count, plural, one {Le message n'a pas pu être signalé.} other {# messages n'ont pas pu être signalés.}}", + "undo": "Annuler", + "undone": "{count, plural, one {Remis à sa place. Le signalement a déjà été envoyé.} other {Remis à leur place. Les signalements ont déjà été envoyés.}}", + "undo_failed": "Impossible de le remettre à sa place.", + "banner": "Vous avez signalé ce message comme hameçonnage. Ses liens sont désactivés.", + "link_off": "lien désactivé", + "link_off_note": "(lien désactivé)", + "no_subject": "(sans objet)" + }, "email_composer": { "select_from_directory": "Choisir dans l'annuaire", "minimize": "Réduire", diff --git a/locales/he/common.json b/locales/he/common.json index dee262172..64d929b56 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -845,6 +845,24 @@ "send_anyway": "שלח בכל זאת", "back": "חזרה" }, + "phishing_report": { + "action": "דווח על פישינג…", + "confirm_title": "לדווח על פישינג?", + "confirm_message": "{count, plural, one {ההודעה תישלח אל {address} ותועבר לספאם.} two {כל אחת משתי ההודעות תישלח אל {address} כדיווח נפרד ותועבר לספאם.} other {כל אחת מ-# ההודעות תישלח אל {address} כדיווח נפרד ותועבר לספאם.}}", + "confirm": "דווח", + "cancel": "ביטול", + "sending": "שולח דיווח…", + "done": "{count, plural, one {ההודעה דווחה אל {address} והועברה לספאם.} two {שתי הודעות דווחו אל {address} והועברו לספאם.} other {# הודעות דווחו אל {address} והועברו לספאם.}}", + "not_moved": "{count, plural, one {דווח אל {address}, אבל לא ניתן היה להעביר את ההודעה לספאם.} two {דווח אל {address}, אבל לא ניתן היה להעביר שתי הודעות לספאם.} other {דווח אל {address}, אבל לא ניתן היה להעביר # הודעות לספאם.}}", + "failed": "{count, plural, one {לא ניתן היה לדווח על ההודעה.} two {לא ניתן היה לדווח על שתי הודעות.} other {לא ניתן היה לדווח על # הודעות.}}", + "undo": "בטל", + "undone": "{count, plural, one {הוחזרה למקומה. הדיווח כבר נשלח.} two {הוחזרו למקומן. הדיווחים כבר נשלחו.} other {הוחזרו למקומן. הדיווחים כבר נשלחו.}}", + "undo_failed": "לא ניתן היה להחזיר למקום.", + "banner": "דיווחת על ההודעה הזו כפישינג. הקישורים שבה מושבתים.", + "link_off": "הקישור מושבת", + "link_off_note": "(הקישור מושבת)", + "no_subject": "(ללא נושא)" + }, "email_composer": { "select_from_directory": "בחירה מהספרייה", "minimize": "מזעור", diff --git a/locales/hu/common.json b/locales/hu/common.json index 2acb340f5..87369bedb 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -880,6 +880,24 @@ "send_anyway": "Küldés így is", "back": "Vissza" }, + "phishing_report": { + "action": "Adathalászat jelentése…", + "confirm_title": "Jelented adathalászatként?", + "confirm_message": "{count, plural, one {Az üzenet a(z) {address} címre megy, és a Spam mappába kerül.} other {Mind a(z) # üzenet külön jelentésként megy a(z) {address} címre, és a Spam mappába kerül.}}", + "confirm": "Jelentés", + "cancel": "Mégse", + "sending": "Jelentés folyamatban…", + "done": "{count, plural, one {Jelentve a(z) {address} címre, és áthelyezve a Spam mappába.} other {# üzenet jelentve a(z) {address} címre, és áthelyezve a Spam mappába.}}", + "not_moved": "{count, plural, one {Jelentve a(z) {address} címre, de az üzenetet nem sikerült áthelyezni a Spam mappába.} other {Jelentve a(z) {address} címre, de # üzenetet nem sikerült áthelyezni a Spam mappába.}}", + "failed": "{count, plural, one {Az üzenetet nem sikerült jelenteni.} other {# üzenetet nem sikerült jelenteni.}}", + "undo": "Visszavonás", + "undone": "{count, plural, one {Visszahelyezve. A jelentés már elment.} other {Visszahelyezve. A jelentések már elmentek.}}", + "undo_failed": "Nem sikerült visszahelyezni.", + "banner": "Ezt az üzenetet adathalászatként jelentetted. A benne lévő linkek ki vannak kapcsolva.", + "link_off": "link kikapcsolva", + "link_off_note": "(link kikapcsolva)", + "no_subject": "(nincs tárgy)" + }, "email_composer": { "select_from_directory": "Kiválasztás a címtárból", "minimize": "Kis méret", diff --git a/locales/it/common.json b/locales/it/common.json index 3c24fa252..4c2dc2af5 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -880,6 +880,24 @@ "send_anyway": "Invia comunque", "back": "Indietro" }, + "phishing_report": { + "action": "Segnala come phishing…", + "confirm_title": "Segnalare come phishing?", + "confirm_message": "{count, plural, one {Il messaggio viene inviato a {address} e spostato in Spam.} other {Ognuno dei # messaggi viene inviato a {address} come segnalazione separata e spostato in Spam.}}", + "confirm": "Segnala", + "cancel": "Annulla", + "sending": "Segnalazione in corso…", + "done": "{count, plural, one {Segnalato a {address} e spostato in Spam.} other {# messaggi segnalati a {address} e spostati in Spam.}}", + "not_moved": "{count, plural, one {Segnalato a {address}, ma non è stato possibile spostare il messaggio in Spam.} other {Segnalati a {address}, ma non è stato possibile spostare # messaggi in Spam.}}", + "failed": "{count, plural, one {Non è stato possibile segnalare il messaggio.} other {Non è stato possibile segnalare # messaggi.}}", + "undo": "Annulla", + "undone": "{count, plural, one {Rimesso al suo posto. La segnalazione era già stata inviata.} other {Rimessi al loro posto. Le segnalazioni erano già state inviate.}}", + "undo_failed": "Impossibile rimetterlo al suo posto.", + "banner": "Hai segnalato questo messaggio come phishing. I suoi link sono disattivati.", + "link_off": "link disattivato", + "link_off_note": "(link disattivato)", + "no_subject": "(nessun oggetto)" + }, "email_composer": { "select_from_directory": "Seleziona dalla rubrica aziendale", "minimize": "Riduci a icona", diff --git a/locales/ja/common.json b/locales/ja/common.json index a192cfc9b..2ba057fb3 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -880,6 +880,24 @@ "send_anyway": "そのまま送信", "back": "戻る" }, + "phishing_report": { + "action": "フィッシングを報告…", + "confirm_title": "フィッシングとして報告しますか?", + "confirm_message": "{count, plural, =1 {メールを {address} に送信し、迷惑メールに移動します。} other {# 件のメールをそれぞれ個別の報告として {address} に送信し、迷惑メールに移動します。}}", + "confirm": "報告", + "cancel": "キャンセル", + "sending": "報告中…", + "done": "{count, plural, =1 {{address} に報告し、迷惑メールに移動しました。} other {# 件のメールを {address} に報告し、迷惑メールに移動しました。}}", + "not_moved": "{count, plural, =1 {{address} に報告しましたが、メールを迷惑メールに移動できませんでした。} other {{address} に報告しましたが、# 件のメールを迷惑メールに移動できませんでした。}}", + "failed": "{count, plural, =1 {メールを報告できませんでした。} other {# 件のメールを報告できませんでした。}}", + "undo": "元に戻す", + "undone": "{count, plural, =1 {元の場所に戻しました。報告はすでに送信済みです。} other {元の場所に戻しました。報告はすでに送信済みです。}}", + "undo_failed": "元に戻せませんでした。", + "banner": "このメールをフィッシングとして報告しました。メール内のリンクは無効になっています。", + "link_off": "リンクは無効です", + "link_off_note": "(リンク無効)", + "no_subject": "(件名なし)" + }, "email_composer": { "select_from_directory": "ディレクトリから選択", "minimize": "最小化", diff --git a/locales/ko/common.json b/locales/ko/common.json index 42a1e7baf..8dcc5f6af 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -880,6 +880,24 @@ "send_anyway": "그래도 보내기", "back": "뒤로" }, + "phishing_report": { + "action": "피싱 신고…", + "confirm_title": "피싱으로 신고할까요?", + "confirm_message": "{count, plural, =1 {메시지를 {address} 주소로 보내고 스팸함으로 옮겨요.} other {메시지 #개를 각각 별도의 신고로 {address} 주소로 보내고 스팸함으로 옮겨요.}}", + "confirm": "신고", + "cancel": "취소", + "sending": "신고하는 중…", + "done": "{count, plural, =1 {{address} 주소로 신고하고 스팸함으로 옮겼어요.} other {메시지 #개를 {address} 주소로 신고하고 스팸함으로 옮겼어요.}}", + "not_moved": "{count, plural, =1 {{address} 주소로 신고했지만 메시지를 스팸함으로 옮기지 못했어요.} other {{address} 주소로 신고했지만 메시지 #개를 스팸함으로 옮기지 못했어요.}}", + "failed": "{count, plural, =1 {메시지를 신고하지 못했어요.} other {메시지 #개를 신고하지 못했어요.}}", + "undo": "실행 취소", + "undone": "{count, plural, =1 {원래 위치로 되돌렸어요. 신고는 이미 보내졌어요.} other {원래 위치로 되돌렸어요. 신고는 이미 보내졌어요.}}", + "undo_failed": "되돌리지 못했어요.", + "banner": "이 메시지를 피싱으로 신고했어요. 메시지의 링크는 꺼져 있어요.", + "link_off": "링크 꺼짐", + "link_off_note": "(링크 꺼짐)", + "no_subject": "(제목 없음)" + }, "email_composer": { "select_from_directory": "디렉터리에서 선택", "minimize": "최소화", diff --git a/locales/lv/common.json b/locales/lv/common.json index 9b30806e2..c913858c7 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -880,6 +880,24 @@ "send_anyway": "Sūtīt tik un tā", "back": "Atpakaļ" }, + "phishing_report": { + "action": "Ziņot par pikšķerēšanu…", + "confirm_title": "Ziņot par pikšķerēšanu?", + "confirm_message": "{count, plural, =1 {Ziņojums tiks nosūtīts uz {address} un pārvietots uz mapi Mēstules.} zero {# ziņojumi tiks nosūtīti uz {address}, katrs atsevišķi, un pārvietoti uz mapi Mēstules.} one {# ziņojums tiks nosūtīts uz {address}, katrs atsevišķi, un pārvietots uz mapi Mēstules.} other {# ziņojumi tiks nosūtīti uz {address}, katrs atsevišķi, un pārvietoti uz mapi Mēstules.}}", + "confirm": "Ziņot", + "cancel": "Atcelt", + "sending": "Notiek ziņošana…", + "done": "{count, plural, =1 {Nosūtīts uz {address} un pārvietots uz mapi Mēstules.} zero {# ziņojumi nosūtīti uz {address} un pārvietoti uz mapi Mēstules.} one {# ziņojums nosūtīts uz {address} un pārvietots uz mapi Mēstules.} other {# ziņojumi nosūtīti uz {address} un pārvietoti uz mapi Mēstules.}}", + "not_moved": "{count, plural, =1 {Nosūtīts uz {address}, bet ziņojumu neizdevās pārvietot uz mapi Mēstules.} zero {Nosūtīti uz {address}, bet # ziņojumus neizdevās pārvietot uz mapi Mēstules.} one {Nosūtīti uz {address}, bet # ziņojumu neizdevās pārvietot uz mapi Mēstules.} other {Nosūtīti uz {address}, bet # ziņojumus neizdevās pārvietot uz mapi Mēstules.}}", + "failed": "{count, plural, =1 {Neizdevās ziņot par ziņojumu.} zero {Neizdevās ziņot par # ziņojumiem.} one {Neizdevās ziņot par # ziņojumu.} other {Neizdevās ziņot par # ziņojumiem.}}", + "undo": "Atsaukt", + "undone": "{count, plural, =1 {Pārvietots atpakaļ. Ziņa par pikšķerēšanu jau ir nosūtīta.} zero {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.} one {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.} other {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.}}", + "undo_failed": "Neizdevās pārvietot atpakaļ.", + "banner": "Jūs ziņojāt par šo ziņojumu kā par pikšķerēšanu. Tā saites ir izslēgtas.", + "link_off": "saite izslēgta", + "link_off_note": "(saite izslēgta)", + "no_subject": "(bez temata)" + }, "email_composer": { "select_from_directory": "Izvēlēties no kataloga", "minimize": "Minimizēt", diff --git a/locales/mn/common.json b/locales/mn/common.json index 5cb6a933f..7ca539df7 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -880,6 +880,24 @@ "send_anyway": "Ямар ч байсан илгээх", "back": "Буцах" }, + "phishing_report": { + "action": "Фишинг мэдээлэх…", + "confirm_title": "Фишинг гэж мэдээлэх үү?", + "confirm_message": "{count, plural, one {Мессеж {address} хаяг руу илгээгдэж, Спам руу шилжинэ.} other {# мессеж тус бүр тусдаа мэдээлэл болж {address} хаяг руу илгээгдэж, Спам руу шилжинэ.}}", + "confirm": "Мэдээлэх", + "cancel": "Цуцлах", + "sending": "Мэдээлж байна…", + "done": "{count, plural, one {{address} хаяг руу мэдээлж, Спам руу шилжүүллээ.} other {# мессежийг {address} хаяг руу мэдээлж, Спам руу шилжүүллээ.}}", + "not_moved": "{count, plural, one {{address} хаяг руу мэдээлсэн ч мессежийг Спам руу шилжүүлж чадсангүй.} other {{address} хаяг руу мэдээлсэн ч # мессежийг Спам руу шилжүүлж чадсангүй.}}", + "failed": "{count, plural, one {Мессежийг мэдээлж чадсангүй.} other {# мессежийг мэдээлж чадсангүй.}}", + "undo": "Буцаах", + "undone": "{count, plural, one {Буцааж шилжүүллээ. Мэдээлэл аль хэдийн илгээгдсэн.} other {Буцааж шилжүүллээ. Мэдээллүүд аль хэдийн илгээгдсэн.}}", + "undo_failed": "Буцааж шилжүүлж чадсангүй.", + "banner": "Та энэ мессежийг фишинг гэж мэдээлсэн. Үүний холбоосуудыг идэвхгүй болгосон.", + "link_off": "холбоосыг идэвхгүй болгосон", + "link_off_note": "(холбоосыг идэвхгүй болгосон)", + "no_subject": "(сэдэв байхгүй)" + }, "email_composer": { "select_from_directory": "Лавлахаас сонгох", "minimize": "Багасгах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 708cf47ab..da729598c 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -880,6 +880,24 @@ "send_anyway": "Send likevel", "back": "Tilbake" }, + "phishing_report": { + "action": "Rapporter nettfisking…", + "confirm_title": "Rapportere nettfisking?", + "confirm_message": "{count, plural, one {Meldingen sendes til {address} og flyttes til Søppelpost.} other {Hver av de # meldingene sendes til {address} som en egen rapport og flyttes til Søppelpost.}}", + "confirm": "Rapporter", + "cancel": "Avbryt", + "sending": "Rapporterer…", + "done": "{count, plural, one {Rapportert til {address} og flyttet til Søppelpost.} other {# meldinger rapportert til {address} og flyttet til Søppelpost.}}", + "not_moved": "{count, plural, one {Rapportert til {address}, men meldingen kunne ikke flyttes til Søppelpost.} other {Rapportert til {address}, men # meldinger kunne ikke flyttes til Søppelpost.}}", + "failed": "{count, plural, one {Meldingen kunne ikke rapporteres.} other {# meldinger kunne ikke rapporteres.}}", + "undo": "Angre", + "undone": "{count, plural, one {Flyttet tilbake. Rapporten var allerede sendt.} other {Flyttet tilbake. Rapportene var allerede sendt.}}", + "undo_failed": "Kunne ikke flytte den tilbake.", + "banner": "Du har rapportert denne meldingen som nettfisking. Lenkene i den er slått av.", + "link_off": "lenke slått av", + "link_off_note": "(lenke slått av)", + "no_subject": "(uten emne)" + }, "email_composer": { "select_from_directory": "Velg fra katalog", "minimize": "Minimer", diff --git a/locales/nl/common.json b/locales/nl/common.json index 0290e9e32..08ffeff6d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -880,6 +880,24 @@ "send_anyway": "Toch verzenden", "back": "Terug" }, + "phishing_report": { + "action": "Phishing melden…", + "confirm_title": "Phishing melden?", + "confirm_message": "{count, plural, one {Het bericht gaat naar {address} en wordt naar Spam verplaatst.} other {Elk van de # berichten gaat als aparte melding naar {address} en wordt naar Spam verplaatst.}}", + "confirm": "Melden", + "cancel": "Annuleren", + "sending": "Bezig met melden…", + "done": "{count, plural, one {Gemeld bij {address} en naar Spam verplaatst.} other {# berichten gemeld bij {address} en naar Spam verplaatst.}}", + "not_moved": "{count, plural, one {Gemeld bij {address}, maar het bericht kon niet naar Spam worden verplaatst.} other {Gemeld bij {address}, maar # berichten konden niet naar Spam worden verplaatst.}}", + "failed": "{count, plural, one {Het bericht kon niet worden gemeld.} other {# berichten konden niet worden gemeld.}}", + "undo": "Ongedaan maken", + "undone": "{count, plural, one {Teruggezet. De melding was al verstuurd.} other {Teruggezet. De meldingen waren al verstuurd.}}", + "undo_failed": "Kon niet worden teruggezet.", + "banner": "Je hebt dit bericht als phishing gemeld. De links erin zijn uitgeschakeld.", + "link_off": "link uitgeschakeld", + "link_off_note": "(link uitgeschakeld)", + "no_subject": "(geen onderwerp)" + }, "email_composer": { "select_from_directory": "Kiezen uit adresboek van de organisatie", "minimize": "Minimaliseren", diff --git a/locales/pl/common.json b/locales/pl/common.json index 897ef6039..6480f8af7 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -880,6 +880,24 @@ "send_anyway": "Wyślij mimo to", "back": "Wstecz" }, + "phishing_report": { + "action": "Zgłoś phishing…", + "confirm_title": "Zgłosić phishing?", + "confirm_message": "{count, plural, one {Wiadomość trafi do {address} i zostanie przeniesiona do folderu Spam.} few {# wiadomości trafią do {address}, każda jako osobne zgłoszenie, i zostaną przeniesione do folderu Spam.} many {# wiadomości trafi do {address}, każda jako osobne zgłoszenie, i zostanie przeniesionych do folderu Spam.} other {# wiadomości trafi do {address}, każda jako osobne zgłoszenie, i zostanie przeniesionych do folderu Spam.}}", + "confirm": "Zgłoś", + "cancel": "Anuluj", + "sending": "Zgłaszanie…", + "done": "{count, plural, one {Zgłoszono do {address} i przeniesiono do folderu Spam.} few {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.} many {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.} other {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.}}", + "not_moved": "{count, plural, one {Zgłoszono do {address}, ale nie udało się przenieść wiadomości do folderu Spam.} few {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.} many {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.} other {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.}}", + "failed": "{count, plural, one {Nie udało się zgłosić wiadomości.} few {Nie udało się zgłosić # wiadomości.} many {Nie udało się zgłosić # wiadomości.} other {Nie udało się zgłosić # wiadomości.}}", + "undo": "Cofnij", + "undone": "{count, plural, one {Przeniesiono z powrotem. Zgłoszenie zostało już wysłane.} few {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.} many {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.} other {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.}}", + "undo_failed": "Nie udało się przenieść z powrotem.", + "banner": "Ta wiadomość została zgłoszona jako phishing. Linki w niej są wyłączone.", + "link_off": "link wyłączony", + "link_off_note": "(link wyłączony)", + "no_subject": "(brak tematu)" + }, "email_composer": { "select_from_directory": "Wybierz z katalogu", "minimize": "Minimalizuj", diff --git a/locales/pt/common.json b/locales/pt/common.json index 73f060dbd..f7b2c47d0 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -880,6 +880,24 @@ "send_anyway": "Enviar mesmo assim", "back": "Voltar" }, + "phishing_report": { + "action": "Reportar phishing…", + "confirm_title": "Reportar phishing?", + "confirm_message": "{count, plural, one {A mensagem vai para {address} e é movida para Spam.} other {Cada uma das # mensagens vai para {address} como uma denúncia separada e é movida para Spam.}}", + "confirm": "Reportar", + "cancel": "Cancelar", + "sending": "Reportando…", + "done": "{count, plural, one {Reportada para {address} e movida para Spam.} other {# mensagens reportadas para {address} e movidas para Spam.}}", + "not_moved": "{count, plural, one {Reportada para {address}, mas não foi possível mover a mensagem para Spam.} other {Reportadas para {address}, mas não foi possível mover # mensagens para Spam.}}", + "failed": "{count, plural, one {Não foi possível reportar a mensagem.} other {Não foi possível reportar # mensagens.}}", + "undo": "Desfazer", + "undone": "{count, plural, one {Devolvida ao lugar. A denúncia já foi enviada.} other {Devolvidas ao lugar. As denúncias já foram enviadas.}}", + "undo_failed": "Não foi possível devolver ao lugar.", + "banner": "Você reportou esta mensagem como phishing. Os links dela estão desativados.", + "link_off": "link desativado", + "link_off_note": "(link desativado)", + "no_subject": "(sem assunto)" + }, "email_composer": { "select_from_directory": "Selecionar do diretório", "minimize": "Minimizar", diff --git a/locales/ro/common.json b/locales/ro/common.json index 26633ecc4..8b1103df9 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -880,6 +880,24 @@ "send_anyway": "Trimite oricum", "back": "Înapoi" }, + "phishing_report": { + "action": "Raportează ca phishing…", + "confirm_title": "Raportați ca phishing?", + "confirm_message": "{count, plural, one {Mesajul ajunge la {address} și este mutat în Spam.} few {Fiecare dintre cele # mesaje ajunge la {address} ca raport separat și este mutat în Spam.} other {Fiecare dintre cele # de mesaje ajunge la {address} ca raport separat și este mutat în Spam.}}", + "confirm": "Raportează", + "cancel": "Anulează", + "sending": "Se raportează…", + "done": "{count, plural, one {Raportat la {address} și mutat în Spam.} few {# mesaje raportate la {address} și mutate în Spam.} other {# de mesaje raportate la {address} și mutate în Spam.}}", + "not_moved": "{count, plural, one {Raportat la {address}, dar mesajul nu a putut fi mutat în Spam.} few {Raportate la {address}, dar # mesaje nu au putut fi mutate în Spam.} other {Raportate la {address}, dar # de mesaje nu au putut fi mutate în Spam.}}", + "failed": "{count, plural, one {Mesajul nu a putut fi raportat.} few {# mesaje nu au putut fi raportate.} other {# de mesaje nu au putut fi raportate.}}", + "undo": "Anulează", + "undone": "{count, plural, one {Mutat înapoi. Raportul a fost deja trimis.} few {Mutate înapoi. Rapoartele au fost deja trimise.} other {Mutate înapoi. Rapoartele au fost deja trimise.}}", + "undo_failed": "Nu a putut fi mutat înapoi.", + "banner": "Ați raportat acest mesaj ca phishing. Linkurile din el sunt dezactivate.", + "link_off": "link dezactivat", + "link_off_note": "(link dezactivat)", + "no_subject": "(fără subiect)" + }, "email_composer": { "select_from_directory": "Selectează din director", "minimize": "Minimizează", diff --git a/locales/ru/common.json b/locales/ru/common.json index b0d758057..6c87b2187 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -880,6 +880,24 @@ "send_anyway": "Всё равно отправить", "back": "Назад" }, + "phishing_report": { + "action": "Сообщить о фишинге…", + "confirm_title": "Сообщить о фишинге?", + "confirm_message": "{count, plural, =1 {Письмо будет отправлено на {address} и перемещено в папку «Спам».} one {# письмо будет отправлено на {address}, каждое отдельным отчётом, и перемещено в папку «Спам».} few {# письма будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».} many {# писем будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».} other {# письма будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».}}", + "confirm": "Сообщить", + "cancel": "Отмена", + "sending": "Отправка отчёта…", + "done": "{count, plural, =1 {Отправлено на {address} и перемещено в папку «Спам».} one {# письмо отправлено на {address} и перемещено в папку «Спам».} few {# письма отправлены на {address} и перемещены в папку «Спам».} many {# писем отправлено на {address} и перемещено в папку «Спам».} other {# письма отправлены на {address} и перемещены в папку «Спам».}}", + "not_moved": "{count, plural, =1 {Отчёт отправлен на {address}, но письмо не удалось переместить в папку «Спам».} one {Отчёты отправлены на {address}, но # письмо не удалось переместить в папку «Спам».} few {Отчёты отправлены на {address}, но # письма не удалось переместить в папку «Спам».} many {Отчёты отправлены на {address}, но # писем не удалось переместить в папку «Спам».} other {Отчёты отправлены на {address}, но # письма не удалось переместить в папку «Спам».}}", + "failed": "{count, plural, =1 {Не удалось сообщить о письме.} one {Не удалось сообщить о # письме.} few {Не удалось сообщить о # письмах.} many {Не удалось сообщить о # письмах.} other {Не удалось сообщить о # письмах.}}", + "undo": "Отменить", + "undone": "{count, plural, =1 {Возвращено на место. Отчёт уже был отправлен.} one {Возвращено на место. Отчёты уже были отправлены.} few {Возвращено на место. Отчёты уже были отправлены.} many {Возвращено на место. Отчёты уже были отправлены.} other {Возвращено на место. Отчёты уже были отправлены.}}", + "undo_failed": "Не удалось вернуть на место.", + "banner": "Вы сообщили, что это письмо фишинговое. Ссылки в нём отключены.", + "link_off": "ссылка отключена", + "link_off_note": "(ссылка отключена)", + "no_subject": "(без темы)" + }, "email_composer": { "select_from_directory": "Выбрать из справочника", "minimize": "Свернуть", diff --git a/locales/sk/common.json b/locales/sk/common.json index 46e9c0440..246c57548 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -880,6 +880,24 @@ "send_anyway": "Odoslať aj tak", "back": "Späť" }, + "phishing_report": { + "action": "Nahlásiť phishing…", + "confirm_title": "Nahlásiť phishing?", + "confirm_message": "{count, plural, one {Správa sa odošle na {address} a presunie do priečinka Spam.} few {# správy sa odošlú na {address}, každá ako samostatné hlásenie, a presunú do priečinka Spam.} many {# správy sa odošle na {address}, každá ako samostatné hlásenie, a presunie do priečinka Spam.} other {# správ sa odošle na {address}, každá ako samostatné hlásenie, a presunie do priečinka Spam.}}", + "confirm": "Nahlásiť", + "cancel": "Zrušiť", + "sending": "Nahlasuje sa…", + "done": "{count, plural, one {Nahlásené na {address} a presunuté do priečinka Spam.} few {# správy nahlásené na {address} a presunuté do priečinka Spam.} many {# správy nahlásené na {address} a presunuté do priečinka Spam.} other {# správ nahlásených na {address} a presunutých do priečinka Spam.}}", + "not_moved": "{count, plural, one {Nahlásené na {address}, ale správu sa nepodarilo presunúť do priečinka Spam.} few {Nahlásené na {address}, ale # správy sa nepodarilo presunúť do priečinka Spam.} many {Nahlásené na {address}, ale # správy sa nepodarilo presunúť do priečinka Spam.} other {Nahlásené na {address}, ale # správ sa nepodarilo presunúť do priečinka Spam.}}", + "failed": "{count, plural, one {Správu sa nepodarilo nahlásiť.} few {# správy sa nepodarilo nahlásiť.} many {# správy sa nepodarilo nahlásiť.} other {# správ sa nepodarilo nahlásiť.}}", + "undo": "Späť", + "undone": "{count, plural, one {Presunuté späť. Hlásenie už bolo odoslané.} few {Presunuté späť. Hlásenia už boli odoslané.} many {Presunuté späť. Hlásenia už boli odoslané.} other {Presunuté späť. Hlásenia už boli odoslané.}}", + "undo_failed": "Nepodarilo sa presunúť späť.", + "banner": "Túto správu ste nahlásili ako phishing. Odkazy v nej sú vypnuté.", + "link_off": "odkaz vypnutý", + "link_off_note": "(odkaz vypnutý)", + "no_subject": "(bez predmetu)" + }, "email_composer": { "select_from_directory": "Vybrať z adresára", "minimize": "Minimalizovať", diff --git a/locales/th/common.json b/locales/th/common.json index 98dff5de8..622d6816d 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1814,6 +1814,24 @@ "send_anyway": "ส่งต่อไป", "back": "ย้อนกลับ" }, + "phishing_report": { + "action": "รายงานฟิชชิง…", + "confirm_title": "รายงานเป็นฟิชชิงหรือไม่", + "confirm_message": "{count, plural, =1 {ข้อความจะถูกส่งไปยัง {address} และย้ายไปที่จดหมายขยะ} other {ข้อความแต่ละฉบับจากทั้งหมด # ฉบับจะถูกส่งไปยัง {address} เป็นรายงานแยกกัน และย้ายไปที่จดหมายขยะ}}", + "confirm": "รายงาน", + "cancel": "ยกเลิก", + "sending": "กำลังรายงาน…", + "done": "{count, plural, =1 {รายงานไปยัง {address} และย้ายไปที่จดหมายขยะแล้ว} other {รายงานข้อความ # ฉบับไปยัง {address} และย้ายไปที่จดหมายขยะแล้ว}}", + "not_moved": "{count, plural, =1 {รายงานไปยัง {address} แล้ว แต่ไม่สามารถย้ายข้อความไปที่จดหมายขยะได้} other {รายงานไปยัง {address} แล้ว แต่ไม่สามารถย้ายข้อความ # ฉบับไปที่จดหมายขยะได้}}", + "failed": "{count, plural, =1 {ไม่สามารถรายงานข้อความได้} other {ไม่สามารถรายงานข้อความ # ฉบับได้}}", + "undo": "เลิกทำ", + "undone": "{count, plural, =1 {ย้ายกลับแล้ว รายงานถูกส่งไปแล้ว} other {ย้ายกลับแล้ว รายงานถูกส่งไปแล้ว}}", + "undo_failed": "ไม่สามารถย้ายกลับได้", + "banner": "คุณรายงานข้อความนี้ว่าเป็นฟิชชิง ลิงก์ในข้อความถูกปิดใช้งานแล้ว", + "link_off": "ปิดใช้งานลิงก์แล้ว", + "link_off_note": "(ปิดใช้งานลิงก์แล้ว)", + "no_subject": "(ไม่มีเรื่อง)" + }, "welcome": { "title": "ยินดีต้อนรับสู่กล่องจดหมายของคุณ", "tip_compose": "กด c เพื่อเขียนอีเมลใหม่", diff --git a/locales/tr/common.json b/locales/tr/common.json index 7110da678..772084797 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -880,6 +880,24 @@ "send_anyway": "Yine de gönder", "back": "Geri" }, + "phishing_report": { + "action": "Kimlik avı bildir…", + "confirm_title": "Kimlik avı olarak bildirilsin mi?", + "confirm_message": "{count, plural, one {İleti {address} adresine gönderilir ve Spam klasörüne taşınır.} other {# iletinin her biri ayrı bir bildirim olarak {address} adresine gönderilir ve Spam klasörüne taşınır.}}", + "confirm": "Bildir", + "cancel": "İptal", + "sending": "Bildiriliyor…", + "done": "{count, plural, one {{address} adresine bildirildi ve Spam klasörüne taşındı.} other {# ileti {address} adresine bildirildi ve Spam klasörüne taşındı.}}", + "not_moved": "{count, plural, one {{address} adresine bildirildi ancak ileti Spam klasörüne taşınamadı.} other {{address} adresine bildirildi ancak # ileti Spam klasörüne taşınamadı.}}", + "failed": "{count, plural, one {İleti bildirilemedi.} other {# ileti bildirilemedi.}}", + "undo": "Geri al", + "undone": "{count, plural, one {Geri taşındı. Bildirim zaten gönderilmişti.} other {Geri taşındı. Bildirimler zaten gönderilmişti.}}", + "undo_failed": "Geri taşınamadı.", + "banner": "Bu iletiyi kimlik avı olarak bildirdiniz. İçindeki bağlantılar devre dışı.", + "link_off": "bağlantı devre dışı", + "link_off_note": "(bağlantı devre dışı)", + "no_subject": "(konu yok)" + }, "email_composer": { "select_from_directory": "Dizinden seç", "minimize": "Simge durumuna küçült", diff --git a/locales/uk/common.json b/locales/uk/common.json index 7e9164679..3d598ab3e 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -880,6 +880,24 @@ "send_anyway": "Усе одно надіслати", "back": "Назад" }, + "phishing_report": { + "action": "Повідомити про фішинг…", + "confirm_title": "Повідомити про фішинг?", + "confirm_message": "{count, plural, =1 {Лист буде надіслано на {address} і переміщено до папки «Спам».} one {# лист буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} few {# листи буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} many {# листів буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} other {# листа буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».}}", + "confirm": "Повідомити", + "cancel": "Скасувати", + "sending": "Надсилання звіту…", + "done": "{count, plural, =1 {Надіслано на {address} і переміщено до папки «Спам».} one {# лист надіслано на {address} і переміщено до папки «Спам».} few {# листи надіслано на {address} і переміщено до папки «Спам».} many {# листів надіслано на {address} і переміщено до папки «Спам».} other {# листа надіслано на {address} і переміщено до папки «Спам».}}", + "not_moved": "{count, plural, =1 {Звіт надіслано на {address}, але лист не вдалося перемістити до папки «Спам».} one {Звіти надіслано на {address}, але # лист не вдалося перемістити до папки «Спам».} few {Звіти надіслано на {address}, але # листи не вдалося перемістити до папки «Спам».} many {Звіти надіслано на {address}, але # листів не вдалося перемістити до папки «Спам».} other {Звіти надіслано на {address}, але # листа не вдалося перемістити до папки «Спам».}}", + "failed": "{count, plural, =1 {Не вдалося повідомити про лист.} one {Не вдалося повідомити про # лист.} few {Не вдалося повідомити про # листи.} many {Не вдалося повідомити про # листів.} other {Не вдалося повідомити про # листа.}}", + "undo": "Скасувати", + "undone": "{count, plural, =1 {Повернуто на місце. Звіт уже надіслано.} one {Повернуто на місце. Звіти вже надіслано.} few {Повернуто на місце. Звіти вже надіслано.} many {Повернуто на місце. Звіти вже надіслано.} other {Повернуто на місце. Звіти вже надіслано.}}", + "undo_failed": "Не вдалося повернути на місце.", + "banner": "Ви повідомили, що цей лист фішинговий. Посилання в ньому вимкнено.", + "link_off": "посилання вимкнено", + "link_off_note": "(посилання вимкнено)", + "no_subject": "(без теми)" + }, "email_composer": { "select_from_directory": "Вибрати з довідника", "minimize": "Згорнути", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1d61146b2..f54ef854c 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -880,6 +880,24 @@ "send_anyway": "仍然傳送", "back": "返回" }, + "phishing_report": { + "action": "回報網路釣魚…", + "confirm_title": "要回報為網路釣魚嗎?", + "confirm_message": "{count, plural, =1 {這封郵件會傳送至 {address},並移至垃圾郵件。} other {# 封郵件會各自作為單獨的回報傳送至 {address},並移至垃圾郵件。}}", + "confirm": "回報", + "cancel": "取消", + "sending": "正在回報…", + "done": "{count, plural, =1 {已回報至 {address},並移至垃圾郵件。} other {已將 # 封郵件回報至 {address},並移至垃圾郵件。}}", + "not_moved": "{count, plural, =1 {已回報至 {address},但無法將郵件移至垃圾郵件。} other {已回報至 {address},但有 # 封郵件無法移至垃圾郵件。}}", + "failed": "{count, plural, =1 {無法回報這封郵件。} other {有 # 封郵件無法回報。}}", + "undo": "復原", + "undone": "{count, plural, =1 {已移回。回報已送出。} other {已移回。回報已送出。}}", + "undo_failed": "無法移回。", + "banner": "您已將這封郵件回報為網路釣魚。其中的連結已停用。", + "link_off": "連結已停用", + "link_off_note": "(連結已停用)", + "no_subject": "(無主題)" + }, "email_composer": { "select_from_directory": "從目錄中選取", "minimize": "最小化", diff --git a/locales/zh/common.json b/locales/zh/common.json index da51c1acf..f1d6d3d98 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -880,6 +880,24 @@ "send_anyway": "仍然发送", "back": "返回" }, + "phishing_report": { + "action": "举报网络钓鱼…", + "confirm_title": "举报为网络钓鱼?", + "confirm_message": "{count, plural, =1 {该邮件将发送至 {address},并移至垃圾邮件。} other {# 封邮件将分别作为单独的举报发送至 {address},并移至垃圾邮件。}}", + "confirm": "举报", + "cancel": "取消", + "sending": "正在举报…", + "done": "{count, plural, =1 {已举报至 {address},并移至垃圾邮件。} other {已将 # 封邮件举报至 {address},并移至垃圾邮件。}}", + "not_moved": "{count, plural, =1 {已举报至 {address},但无法将邮件移至垃圾邮件。} other {已举报至 {address},但有 # 封邮件无法移至垃圾邮件。}}", + "failed": "{count, plural, =1 {无法举报该邮件。} other {有 # 封邮件无法举报。}}", + "undo": "撤销", + "undone": "{count, plural, =1 {已移回。举报已发出。} other {已移回。举报已发出。}}", + "undo_failed": "无法移回。", + "banner": "您已将此邮件举报为网络钓鱼。其中的链接已停用。", + "link_off": "链接已停用", + "link_off_note": "(链接已停用)", + "no_subject": "(无主题)" + }, "email_composer": { "select_from_directory": "从目录中选择", "minimize": "最小化", diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index e6d13c7c2..f8cc774b5 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -93,6 +93,7 @@ const MAP = { FileText: 'file-text', FileVideo: 'video', Filter: 'filter', + Fish: 'fish', Flag: 'flag', FlaskConical: 'flask', Folder: 'folder', diff --git a/src/api/__tests__/phishing-report.test.ts b/src/api/__tests__/phishing-report.test.ts new file mode 100644 index 000000000..b2773b7f7 --- /dev/null +++ b/src/api/__tests__/phishing-report.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Sending a phishing report (#124): one message to the admin's address with +// the original attached unchanged, from the login's own account, and no copy +// kept (onSuccessDestroyEmail in place of the move to Sent). + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-own', + username: 'alice@example.org', + request: vi.fn(), + learnHoldLimit: vi.fn(), + getSubmissionAccountIds: vi.fn(() => ['acc-own']), + hasDelayedSend: vi.fn(() => true), + hasAccountCapability: vi.fn(() => false), + getMaxCallsInRequest: vi.fn(() => 16), + getMaxObjectsInGet: vi.fn(() => 500), + getMaxObjectsInSet: vi.fn(() => 500), + getMaxSizeUpload: vi.fn(() => 50_000_000), + getAccountCapability: vi.fn(), + }, +})); +vi.mock('../identity', () => ({ + getIdentities: vi.fn(async () => [ + { id: 'id-team', name: 'Team', email: 'team@example.org', mayDelete: true }, + { id: 'id-me', name: 'Alice', email: 'alice@example.org', mayDelete: false }, + ]), +})); +vi.mock('../blob', () => ({ uploadBytes: vi.fn(async () => ({ blobId: 'blob-arf' })) })); +vi.mock('../../lib/email-export', () => ({ + fetchBlobBytes: vi.fn(async () => new TextEncoder().encode('From: bad@phish.example\r\n\r\nclick\r\n')), +})); + +import { jmapClient } from '../jmap-client'; +import { uploadBytes } from '../blob'; +import { fetchBlobBytes } from '../../lib/email-export'; +import { markAsSpam } from '../email'; +import { sendPhishingReport } from '../phishing-report'; +import type { Email } from '../types'; + +const mockRequest = jmapClient.request as ReturnType; + +const ORIGINAL = { + id: 'e1', blobId: 'blob-orig', size: 1234, subject: 'Your account is locked', receivedAt: '2026-11-12T09:00:00Z', + from: [{ email: 'bad@phish.example' }], keywords: {}, mailboxIds: { inbox: true }, +} as unknown as Email; + +const SENT = { + methodResponses: [ + ['Email/set', { created: { draft: { id: 'e-report' } } }, '0'], + ['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'], + ['Email/set', { destroyed: ['e-report'] }, '1'], + ], +}; + +function call(name: string, n = 0): Record { + const calls = mockRequest.mock.calls.flatMap(([c]) => c as Array<[string, Record, string]>); + return calls.filter(([method]) => method === name)[n]![1]; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockRequest.mockResolvedValue(SENT); +}); + +describe('a forwarded report', () => { + it('attaches the original by its blob, from the login\'s own address, and keeps no copy', async () => { + await sendPhishingReport(ORIGINAL, { + sourceAccountId: 'acc-own', + setup: { address: 'security@example.org', format: 'forward' }, + stagingMailboxId: 'drafts', + appVersion: '0.1.95', + }); + const create = call('Email/set').create.draft; + expect(create.from).toEqual([{ name: 'Alice', email: 'alice@example.org' }]); + expect(create.to).toEqual([{ email: 'security@example.org' }]); + expect(create.subject).toBe('Phishing: Your account is locked'); + expect(create.bodyValues.text.value).toContain('Reported as phishing by Alice '); + expect(create.attachments).toEqual([expect.objectContaining({ + blobId: 'blob-orig', type: 'message/rfc822', size: 1234, disposition: 'attachment', + })]); + expect(create.attachments[0].name).toMatch(/\.eml$/); + expect(create.mailboxIds).toEqual({ drafts: true }); + const submission = call('EmailSubmission/set'); + expect(submission.create['sub-1'].identityId).toBe('id-me'); + expect(submission.onSuccessDestroyEmail).toEqual(['#sub-1']); + expect(submission.onSuccessUpdateEmail).toBeUndefined(); + expect(mockRequest.mock.calls.flatMap(([c]) => c).some(([m]: [string]) => m === 'Blob/copy')).toBe(false); + }); + + it('copies the blob of a shared mailbox\'s message to the sending account first', async () => { + mockRequest.mockResolvedValueOnce({ methodResponses: [['Blob/copy', { copied: { 'blob-orig': 'blob-copy' } }, '0']] }); + await sendPhishingReport(ORIGINAL, { + sourceAccountId: 'grp-1', + setup: { address: 'security@example.org', format: 'forward' }, + stagingMailboxId: 'drafts', + appVersion: '0.1.95', + }); + expect(call('Blob/copy')).toEqual({ fromAccountId: 'grp-1', accountId: 'acc-own', blobIds: ['blob-orig'] }); + expect(call('Email/set').create.draft.attachments[0].blobId).toBe('blob-copy'); + }); + + it('throws when the server refuses the submission, and removes the copy it made', async () => { + mockRequest.mockResolvedValueOnce({ + methodResponses: [ + ['Email/set', { created: { draft: { id: 'e-report' } } }, '0'], + ['EmailSubmission/set', { notCreated: { 'sub-1': { type: 'forbiddenFrom' } } }, '1'], + ], + }); + mockRequest.mockResolvedValueOnce({ methodResponses: [['Email/set', { destroyed: ['e-report'] }, '0']] }); + await expect(sendPhishingReport(ORIGINAL, { + sourceAccountId: 'acc-own', + setup: { address: 'security@example.org', format: 'forward' }, + stagingMailboxId: 'drafts', + appVersion: '0.1.95', + })).rejects.toThrow(); + expect(call('Email/set', 1).destroy).toEqual(['e-report']); + }); +}); + +describe('an ARF report', () => { + it('downloads the original from its own account, uploads the report and submits it raw, with no copy', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Email/import', { created: { draft: { id: 'e-report' } } }, '0'], + ['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'], + ], + }); + await sendPhishingReport(ORIGINAL, { + sourceAccountId: 'grp-1', + setup: { address: 'security@example.org', format: 'arf' }, + stagingMailboxId: 'drafts', + appVersion: '0.1.95', + }); + expect(fetchBlobBytes).toHaveBeenCalledWith('blob-orig', 'message.eml', 'message/rfc822', 'grp-1'); + const [bytes, type, accountId] = (uploadBytes as ReturnType).mock.calls[0]; + expect(type).toBe('message/rfc822'); + expect(accountId).toBe('acc-own'); + const text = new TextDecoder().decode(bytes as Uint8Array); + expect(text).toContain('report-type=feedback-report'); + expect(text).toContain('User-Agent: Bulwark/0.1.95'); + expect(text).toContain('From: bad@phish.example\r\n\r\nclick\r\n'); + expect(call('Email/import').emails.draft).toEqual(expect.objectContaining({ blobId: 'blob-arf', mailboxIds: { drafts: true } })); + const submission = call('EmailSubmission/set'); + expect(submission.create['sub-1'].envelope.rcptTo).toEqual([{ email: 'security@example.org' }]); + expect(submission.onSuccessDestroyEmail).toEqual(['#sub-1']); + }); + + it('is refused before downloading a message too big for the server\'s upload limit', async () => { + (jmapClient.getMaxSizeUpload as ReturnType).mockReturnValueOnce(1000); + await expect(sendPhishingReport(ORIGINAL, { + sourceAccountId: 'acc-own', + setup: { address: 'security@example.org', format: 'arf' }, + stagingMailboxId: 'drafts', + appVersion: '0.1.95', + })).rejects.toThrow(/too big/); + expect(fetchBlobBytes).not.toHaveBeenCalled(); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); + +describe('markAsSpam', () => { + it('sets a report\'s further keywords with $junk', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['Email/set', { updated: { e1: null } }, '0']] }); + await markAsSpam(['e1'], 'junk', 'acc-own', { markRead: true, keywords: ['$phishing'] }); + expect(call('Email/set').update.e1).toEqual({ + mailboxIds: { junk: true }, + 'keywords/$junk': true, + 'keywords/$notjunk': null, + 'keywords/$seen': true, + 'keywords/$phishing': true, + }); + }); +}); diff --git a/src/api/email.ts b/src/api/email.ts index 5e8d17481..ba4cd7a18 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1184,13 +1184,14 @@ export async function moveEmails( /** * File messages into Junk and flip `$junk`/`$notjunk` so the server's * classifier and other clients learn (#850). Optionally also marks them read - * (the "trash-and-read" delete action). + * (the "trash-and-read" delete action, a phishing report) and sets further + * keywords (a report's `$phishing`, #124). */ export async function markAsSpam( ids: string[], junkMailboxId: string, accountIdOverride?: string, - opts?: { markRead?: boolean }, + opts?: { markRead?: boolean; keywords?: string[] }, ): Promise { if (ids.length === 0) return; const accountId = accountIdOverride ?? jmapClient.accountId; @@ -1201,6 +1202,8 @@ export async function markAsSpam( [keywordPointer('$junk')]: true, [keywordPointer('$notjunk')]: null, ...(opts?.markRead ? { [keywordPointer('$seen')]: true } : {}), + // A report's `$phishing` (#124). + ...Object.fromEntries((opts?.keywords ?? []).map((keyword) => [keywordPointer(keyword), true])), }; } await emailSetBatched(accountId, { update }); @@ -1761,6 +1764,12 @@ export interface SendEmailOptions { * since Bcc is not in its headers. The webmail's sendRawEmail. */ rawBlobId?: string; + /** + * Keep no copy: the message is created as usual and destroyed once the + * submission is made (`onSuccessDestroyEmail`), so it never reaches Sent. + * For a phishing report (#124): the admin's mailbox is the record. + */ + noSentCopy?: boolean; } export async function sendEmail( @@ -1822,11 +1831,15 @@ export async function sendEmail( accountId, create: { 'sub-1': submissionCreate }, }; - submissionArgs.onSuccessUpdateEmail = { - '#sub-1': viaDrafts - ? { mailboxIds: { [sentMailboxId]: true }, [keywordPointer('$draft')]: null } - : { [keywordPointer('$draft')]: null }, - }; + if (opts?.noSentCopy) { + submissionArgs.onSuccessDestroyEmail = ['#sub-1']; + } else { + submissionArgs.onSuccessUpdateEmail = { + '#sub-1': viaDrafts + ? { mailboxIds: { [sentMailboxId]: true }, [keywordPointer('$draft')]: null } + : { [keywordPointer('$draft')]: null }, + }; + } let res = await jmapClient.request( [ @@ -1903,6 +1916,13 @@ export async function sendEmail( const first = Object.values(notUpdated)[0]; filingWarning = filingWarning ?? (first?.description || first?.type || 'post-send filing failed'); } + // With noSentCopy the copy is destroyed instead; one left behind is a + // filing problem too (it sits in Drafts). + const notDestroyed = (result as { notDestroyed?: Record }).notDestroyed; + if (notDestroyed && Object.keys(notDestroyed).length) { + const first = Object.values(notDestroyed)[0]; + filingWarning = filingWarning ?? (first?.description || first?.type || 'post-send cleanup failed'); + } } if (methodName === 'EmailSubmission/set') { const notCreated = (result as { notCreated?: Record }).notCreated?.['sub-1']; diff --git a/src/api/phishing-report.ts b/src/api/phishing-report.ts new file mode 100644 index 000000000..5ac960447 --- /dev/null +++ b/src/api/phishing-report.ts @@ -0,0 +1,114 @@ +// Sending a phishing report (#124): the JMAP side of the webmail's +// sendPhishingReport (lib/phishing-report.ts there). The pure parts are in +// lib/phishing-report and lib/phishing-report-arf. + +import { jmapClient } from './jmap-client'; +import { requireMethodResult } from './jmap-result'; +import { copyBlobs, sendEmail, type SendEmailResult } from './email'; +import { getIdentities } from './identity'; +import { uploadBytes } from './blob'; +import type { Email } from './types'; +import { + reportAttachmentName, reportSubject, reportText, reporterIdentity, type PhishingReportSetup, +} from '../lib/phishing-report'; +import { assembleArfReport, buildArfParts, deliveryRecipient, rawEncoding } from '../lib/phishing-report-arf'; +import { fetchBlobBytes } from '../lib/email-export'; + +// What a report needs of the original. List rows carry no blobId, and the +// ARF report reads the delivery headers. +const REPORT_PROPERTIES = ['id', 'blobId', 'size', 'subject', 'from', 'to', 'receivedAt', 'keywords', 'mailboxIds', 'headers']; + +/** The messages as a report needs them, by id; a message gone from the server is left out. */ +export async function getReportOriginals(ids: string[], accountId: string): Promise> { + const res = await jmapClient.request([ + ['Email/get', { accountId, ids, properties: REPORT_PROPERTIES }, '0'], + ]); + const body = requireMethodResult(res, '0', 'Email/get'); + return new Map(((body.list as Email[] | undefined) ?? []).map((email) => [email.id, email])); +} + +export interface ReportContext { + /** The JMAP account the message lives in (a shared one, for a group mailbox). */ + sourceAccountId: string; + setup: PhishingReportSetup; + /** + * A folder of the login's own account the report is created in until it + * is submitted (Drafts, else Sent); it is destroyed then. + */ + stagingMailboxId: string; + /** For the ARF report's User-Agent. */ + appVersion: string; +} + +/** + * Sends one report for `email` from the login's own account. Throws when + * nothing went out; resolves once the server took the submission. + */ +export async function sendPhishingReport(email: Email, ctx: ReportContext): Promise { + const { setup } = ctx; + if (!email.blobId) throw new Error('The message has no content to report'); + const accountId = jmapClient.accountId; + const identity = reporterIdentity(await getIdentities(accountId), jmapClient.username ?? ''); + if (!identity) throw new Error('No sender address to report from'); + const from = [{ name: identity.name || undefined, email: identity.email }]; + const to = [{ email: setup.address }]; + const subject = reportSubject(email.subject); + + if (setup.format === 'arf') { + // The report is uploaded: one bigger than the server takes fails anyway, + // so it isn't downloaded either. + const maxUpload = jmapClient.getMaxSizeUpload(); + if (maxUpload > 0 && email.size + 8192 > maxUpload) { + throw new Error('The message is too big for an ARF report on this server'); + } + const original = await fetchBlobBytes(email.blobId, 'message.eml', 'message/rfc822', ctx.sourceAccountId); + const parts = buildArfParts({ + email, + reporter: identity, + to: setup.address, + encoding: rawEncoding(original), + appVersion: ctx.appVersion, + recipient: deliveryRecipient(email, identity.email), + }); + const report = assembleArfReport(parts, original); + if (maxUpload > 0 && report.byteLength > maxUpload) { + throw new Error('The message is too big for an ARF report on this server'); + } + const upload = await uploadBytes(report, 'message/rfc822', accountId); + warnLeftover(await sendEmail({ from, to, subject }, identity.id, ctx.stagingMailboxId, 0, { + accountId, + draftsMailboxId: ctx.stagingMailboxId, + rawBlobId: upload.blobId, + noSentCopy: true, + })); + return; + } + + // The original's own blob, attached by id; copied over when the message + // lives in another account than the one the report goes out from. + const blobId = (await copyBlobs(ctx.sourceAccountId, [email.blobId], accountId))[email.blobId]; + warnLeftover(await sendEmail( + { + from, + to, + subject, + textBody: reportText(identity), + attachments: [{ + blobId, + name: reportAttachmentName(email), + type: 'message/rfc822', + size: email.size, + disposition: 'attachment', + }], + }, + identity.id, + ctx.stagingMailboxId, + 0, + { accountId, draftsMailboxId: ctx.stagingMailboxId, noSentCopy: true }, + )); +} + +// The report went out; a copy the server didn't destroy stays in Drafts. +function warnLeftover(result: SendEmailResult): void { + if (result.filingWarning) console.warn('[phishing-report] the report went out, but its copy was not removed:', result.filingWarning); +} diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 36eadf919..ff622b7b7 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -28,6 +28,7 @@ import { spacing, typography, type ThemePalette } from '../theme/tokens'; import { useShouldAnimate, useTypography } from '../theme/dynamic'; import { useColors, useResolvedTheme } from '../theme/colors'; import { perfEndAfterPaint, perfLap } from '../lib/perf-marks'; +import { usePhishingMarked } from '../stores/phishing-report-store'; import type { RootStackParamList } from '../navigation/types'; type Nav = NativeStackNavigationProp; @@ -624,6 +625,10 @@ export default function EmailBodyView({ () => (resolvedTheme === 'dark' ? { background: c.background, text: c.text, link: c.textLink } : undefined), [resolvedTheme, c.background, c.text, c.textLink], ); + // Reported as phishing (`$phishing`, #124): links off, with the + // webmail's banner, and the WebView opens none (below). + const linksOff = usePhishingMarked(email, jmapAccountId); + const linkOffTitle = linksOff ? t('phishing_report.link_off', 'link turned off') : undefined; const prepared = React.useMemo(() => bodyDocument({ key: `${jmapAccountId ?? ''}|${email.id}`, rawHtml, @@ -638,9 +643,10 @@ export default function EmailBodyView({ textFactor, plainDarkColors, appFont, + linksOff: linkOffTitle, }), [ jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark, - messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors, appFont, + messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors, appFont, linkOffTitle, ]); const source = React.useMemo( @@ -756,6 +762,13 @@ export default function EmailBodyView({ )} )} + {linksOff && ( + + + {t('phishing_report.banner', 'You reported this message as phishing. Its links are turned off.')} + + + )} {showBanner && ( @@ -839,6 +852,8 @@ export default function EmailBodyView({ // any other) navigation would replace the email body with no way // back, so it is refused; images are reachable via the chips. if (!url || url === 'about:blank') return true; + // A message reported as phishing opens nothing (#124). + if (linksOff) return false; // A message opens links only when the user taps one. The page // runs no script of its own, so nothing else should navigate; // iOS says how a navigation started, so anything but a tap is diff --git a/src/components/email/AttachmentChips.tsx b/src/components/email/AttachmentChips.tsx index b8055e4b9..b17fcf74a 100644 --- a/src/components/email/AttachmentChips.tsx +++ b/src/components/email/AttachmentChips.tsx @@ -33,6 +33,8 @@ interface Props { extracted?: ExtractedAttachment[]; /** Server-listed parts to leave out (a decrypted PGP/MIME message's control parts). */ hidePart?: (att: Attachment) => boolean; + /** The message was reported as phishing: previewed files have their links off (#124). */ + linksOff?: boolean; } type Item = @@ -47,7 +49,7 @@ const MAX_BUNDLE_BYTES = 100 * 1024 * 1024; * save), long-press opens Preview / Open / Save / Share, and "Download all" * bundles every part into a zip named by the export template. */ -export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tnefUnpacked, extracted, hidePart }: Props) { +export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tnefUnpacked, extracted, hidePart, linksOff }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -283,7 +285,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne onClose={() => setSheetItem(null)} /> - + makeStyles(c), [c]); @@ -212,13 +215,13 @@ export function AttachmentPreviewModal({ id: `file-preview-${item.kind}-${item.fileUri ?? item.name}`, threadId: '', mailboxIds: {}, - keywords: {}, + keywords: linksOff ? { [PHISHING_KEYWORD]: true } : ({} as Record), size: 0, receivedAt: item.eml?.date ?? new Date().toISOString(), hasAttachment: false, subject: item.eml?.subject ?? item.name, }; - }, [item]); + }, [item, linksOff]); const failure = item?.fileUri && failed?.uri === item.fileUri ? failed : null; // Android's WebView zooms; React Native's Image there doesn't. iOS zooms @@ -431,7 +434,7 @@ export function AttachmentPreviewModal({ {renderBody()} - {nested.modalProps.item || nested.modalProps.loading ? : null} + {nested.modalProps.item || nested.modalProps.loading ? : null} ); if (docked) return item || loading ? content : null; diff --git a/src/components/email/ListAttachmentChips.tsx b/src/components/email/ListAttachmentChips.tsx index 493a5940b..add43a6da 100644 --- a/src/components/email/ListAttachmentChips.tsx +++ b/src/components/email/ListAttachmentChips.tsx @@ -19,6 +19,8 @@ import { getAttachmentDisplayName } from '../../lib/attachment-display'; import { attachmentSaveName, downloadAttachment } from '../../lib/email-export'; import { AttachmentPreviewModal } from './AttachmentPreviewModal'; import { useFilePreview } from './use-file-preview'; +import { PHISHING_KEYWORD } from '../../lib/phishing-report'; +import { reportedKey, usePhishingReportStore } from '../../stores/phishing-report-store'; /** How many chips a row shows before collapsing the rest into a count. */ const MAX_CHIPS = 2; @@ -129,6 +131,8 @@ export const ListAttachmentOpener = React.forwardRef const t = useLocaleStore((s) => s.t); const preview = useFilePreview(); const busy = React.useRef(false); + // The row's message was reported as phishing (#124): links off in what is previewed. + const [linksOff, setLinksOff] = React.useState(false); const run = React.useCallback(async (fn: () => Promise) => { if (busy.current) return; @@ -152,20 +156,26 @@ export const ListAttachmentOpener = React.forwardRef void run(() => downloadAttachment(attachment.blobId, attachment.name, attachment.type, email, accountId)); return; } - void run(() => openPreview({ - from: 'blob', - blobId: attachment.blobId, - accountId, - email, - name: getAttachmentDisplayName(attachment.name, attachment.type), - type: attachment.type || 'application/octet-stream', - size: attachment.size, - saveName: attachmentSaveName(email, attachment.name, attachment.type), - })); + const reported = email.keywords?.[PHISHING_KEYWORD] === true + || usePhishingReportStore.getState().reported[reportedKey(email.id, accountId)] === true; + void run(() => { + // Set only once this preview is the one opening (run drops a second tap). + setLinksOff(reported); + return openPreview({ + from: 'blob', + blobId: attachment.blobId, + accountId, + email, + name: getAttachmentDisplayName(attachment.name, attachment.type), + type: attachment.type || 'application/octet-stream', + size: attachment.size, + saveName: attachmentSaveName(email, attachment.name, attachment.type), + }); + }); }, }), [run, openPreview]); - return ; + return ; }, ); diff --git a/src/components/email/MessageContent.tsx b/src/components/email/MessageContent.tsx index e1e0b8fcc..36297b4dd 100644 --- a/src/components/email/MessageContent.tsx +++ b/src/components/email/MessageContent.tsx @@ -15,6 +15,7 @@ import { useBodyOverride } from './use-body-override'; import { useSecureMessage } from './use-secure-message'; import { SecureMessageBanner } from './SecureMessageBanner'; import { deriveHeaderInfo } from '../../lib/email-headers'; +import { usePhishingMarked } from '../../stores/phishing-report-store'; import { calendarBannerShownFor } from '../../lib/attachment-display'; import { hidesPgpMimeControlParts, pgpMimeControlParts } from '../../lib/pgp-mime-parts'; @@ -68,6 +69,8 @@ export function MessageContent({ const attachmentPosition = useSettingsStore((s) => s.attachmentPosition); const calendarParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled); const headerInfo = React.useMemo(() => deriveHeaderInfo(email), [email]); + // Reported as phishing (#124): its links and the sender's actions are off. + const phishingMarked = usePhishingMarked(email, jmapAccountId); const unwrap = useBodyOverride(email, jmapAccountId); // PGP / S/MIME (#58): the decrypted or verified body replaces the // message's own; an encrypted one that can't be opened shows nothing but @@ -99,6 +102,7 @@ export function MessageContent({ tnefUnpacked={unwrap.tnefUnpacked} extracted={extracted} hidePart={hidePart} + linksOff={phishingMarked} /> ); @@ -128,7 +132,8 @@ export function MessageContent({ onHandled={onEmailPatched} /> )} - {headerInfo.list.listUnsubscribe?.preferred && ( + {/* A phishing mail's unsubscribe link and cards are the sender's (#124). */} + {headerInfo.list.listUnsubscribe?.preferred && !phishingMarked && ( } - + {!phishingMarked && } {deferBody ? ( diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index ed6a9c9fa..b906cca10 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -85,6 +85,7 @@ const G = { 'file-text': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M9 9l1 0","M9 13l6 0","M9 17l6 0"], 'file-zip': ["M6 20.735a2 2 0 0 1 -1 -1.735v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-1","M11 17a2 2 0 0 1 2 2v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a2 2 0 0 1 2 -2","M11 5l-1 0","M13 7l-1 0","M11 9l-1 0","M13 11l-1 0","M11 13l-1 0","M13 15l-1 0"], 'filter': ["M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227"], + 'fish': ["M16.69 7.44a6.973 6.973 0 0 0 -1.69 4.56c0 1.747 .64 3.345 1.699 4.571","M2 9.504c7.715 8.647 14.75 10.265 20 2.498c-5.25 -7.761 -12.285 -6.142 -20 2.504","M18 11v.01","M11.5 10.5c-.667 1 -.667 2 0 3"], 'flag': ["M5 5a5 5 0 0 1 7 0a5 5 0 0 0 7 0v9a5 5 0 0 1 -7 0a5 5 0 0 0 -7 0v-9","M5 21v-7"], 'flask': ["M9 3l6 0","M10 9l4 0","M10 3v6l-4 11a.7 .7 0 0 0 .5 1h11a.7 .7 0 0 0 .5 -1l-4 -11v-6"], 'folder': ["M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"], @@ -261,6 +262,7 @@ export const FileSymlink = makeIcon('FileSymlink', G['file-symlink']); export const FileText = makeIcon('FileText', G['file-text']); export const FileVideo = makeIcon('FileVideo', G['video']); export const Filter = makeIcon('Filter', G['filter']); +export const Fish = makeIcon('Fish', G['fish']); export const Flag = makeIcon('Flag', G['flag']); export const FlaskConical = makeIcon('FlaskConical', G['flask']); export const Folder = makeIcon('Folder', G['folder']); diff --git a/src/lib/__tests__/email-html-links-off.test.ts b/src/lib/__tests__/email-html-links-off.test.ts new file mode 100644 index 000000000..9bc1891c4 --- /dev/null +++ b/src/lib/__tests__/email-html-links-off.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { plainTextToSafeHtml, prepareEmailHtml, turnOffLinks, wrapPlainTextEmail } from '../email-html'; +import { buildBodyDocument } from '../email-body-document'; + +// A message reported as phishing (`$phishing`, #124) has its links turned +// off, as in the webmail's lib/phishing-links: no address, a "turned off" +// title, and the look of text that was a link. + +describe('turnOffLinks', () => { + it('takes the address and its companions off every link and says so', () => { + const out = turnOffLinks( + '

Sign in

', + 'link turned off', + ); + expect(out).not.toMatch(/href|target=|rel=|tabindex|ping=|Sign in"/); + expect(out).toContain('data-link-off'); + expect(out).toContain('aria-disabled="true"'); + expect(out).toContain('title="link turned off"'); + expect(out).toContain('text-decoration:underline dotted !important'); + expect(out).toContain('>Sign in

'); + }); + + it('keeps a link\'s own style ahead of the turned-off look, and turns off image-map areas', () => { + const out = turnOffLinks('a', 'off'); + expect(out).toContain('style="color:red;color:inherit !important;'); + expect(out).toMatch(//); + expect(out).not.toMatch(/href/i); + }); + + it('escapes the title it writes and leaves markup without links alone', () => { + expect(turnOffLinks('a', 'A & "B"')).toContain('title="A & "B""'); + const plain = '

No links here

'; + expect(turnOffLinks(plain, 'off')).toBe(plain); + }); +}); + +describe('a reported message\'s document', () => { + it('has no link address in an HTML body', () => { + const { html } = prepareEmailHtml('Pay now', { linksOff: 'off' }); + expect(html).not.toContain('evil.example'); + expect(prepareEmailHtml('Pay now').html).toContain('https://evil.example'); + }); + + it('has no link address in a plain-text body either', () => { + const body = plainTextToSafeHtml('Pay at https://evil.example/pay now'); + expect(wrapPlainTextEmail(body, { linksOff: 'off' })).not.toContain('href="https://evil.example'); + expect(wrapPlainTextEmail(body)).toContain('href="https://evil.example/pay"'); + }); + + it('is built apart from the same message before it was reported', () => { + const input = { + key: 'acc|e1', rawHtml: null, text: 'https://evil.example', emptyLabel: '', blockRemoteImages: true, cidMap: {}, + isDark: false, messageSpacing: 'auto' as const, plainTextFont: 'sans' as const, quoteLabels: { show: '', hide: '' }, + }; + expect(buildBodyDocument(input).html).toContain('href='); + expect(buildBodyDocument({ ...input, linksOff: 'off' }).html).not.toContain('href='); + }); +}); diff --git a/src/lib/__tests__/phishing-report-arf.test.ts b/src/lib/__tests__/phishing-report-arf.test.ts new file mode 100644 index 000000000..c639b2858 --- /dev/null +++ b/src/lib/__tests__/phishing-report-arf.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import type { Email } from '../../api/types'; +import { assembleArfReport, buildArfParts, deliveryRecipient, rawEncoding } from '../phishing-report-arf'; + +// The ARF report (RFC 5965), ported with the webmail's tests: multipart/report +// with report-type feedback-report, a text part, message/feedback-report +// with Feedback-Type: fraud, User-Agent and Version: 1, and the original +// whole as message/rfc822. + +const email = { + id: 'e1', blobId: 'blob-1', size: 40, subject: 'Konto gesperrt – jetzt prüfen', receivedAt: '2026-10-01T10:00:00Z', + from: [{ email: 'bad@phish.example' }], keywords: {}, mailboxIds: { inbox: true }, +} as unknown as Email; +const reporter = { name: 'Alice', email: 'alice@example.org' }; +const ORIGINAL = 'From: bad@phish.example\r\nSubject: x\r\n\r\nclick here\r\n'; + +describe('buildArfParts', () => { + it('has the three parts and the required feedback fields', () => { + const { head, tail } = buildArfParts({ + email, reporter, to: 'abuse@example.org', encoding: '7bit', appVersion: '0.1.95', + recipient: 'alice@example.org', now: new Date('2026-11-01T08:00:00Z'), + }); + expect(head).toMatch(/^Date: Sun, 01 Nov 2026 08:00:00 \+0000\r\n/); + expect(head).toContain('From: Alice \r\n'); + expect(head).toContain('To: abuse@example.org\r\n'); + // Non-ASCII subject as an encoded word, after "Phishing:". + const subject = /\r\nSubject: =\?UTF-8\?B\?([A-Za-z0-9+/=]+)\?=\r\n/.exec(head)![1]; + expect(new TextDecoder().decode(Uint8Array.from(atob(subject), (c) => c.charCodeAt(0)))).toBe('Phishing: Konto gesperrt – jetzt prüfen'); + expect(head).toContain('Content-Type: multipart/report; report-type=feedback-report;'); + const boundary = /boundary="([^"]+)"/.exec(head)![1]; + expect(head.split(`--${boundary}\r\n`)).toHaveLength(4); + expect(head).toContain('Content-Type: message/feedback-report\r\n'); + expect(head).toContain('\r\nFeedback-Type: fraud\r\n'); + expect(head).toContain('\r\nUser-Agent: Bulwark/0.1.95\r\n'); + expect(head).toContain('\r\nVersion: 1\r\n'); + expect(head).toContain('\r\nArrival-Date: Thu, 01 Oct 2026 10:00:00 +0000\r\n'); + expect(head).toContain('\r\nOriginal-Rcpt-To: alice@example.org\r\n'); + expect(head).toContain('\r\nReported-Domain: phish.example\r\n'); + expect(head).toMatch(/Content-Type: message\/rfc822\r\n[\s\S]*Content-Transfer-Encoding: 7bit\r\n\r\n$/); + expect(tail).toBe(`\r\n--${boundary}--\r\n`); + }); + + it('leaves Original-Rcpt-To out when the delivery address is unknown', () => { + const { head } = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '7bit', appVersion: '1' }); + expect(head).not.toContain('Original-Rcpt-To'); + }); + + it('keeps line breaks out of the header lines it builds', () => { + const { head } = buildArfParts({ + email: { ...email, subject: 'Hi\r\nBcc: victim@example.org' }, + reporter: { name: 'Eve\r\nX-Evil: 1', email: 'alice@example.org' }, + to: 'abuse@example.org\r\nBcc: x@example.org', + encoding: '7bit', + appVersion: '1\r\nX-Evil: 2', + }); + expect(head).not.toMatch(/\r\n(Bcc|X-Evil):/); + }); +}); + +describe('rawEncoding', () => { + it('says binary for bare line breaks or long lines, 8bit for 8-bit bytes, never base64', () => { + const enc = (text: string) => rawEncoding(new TextEncoder().encode(text)); + expect(enc('a\r\nb\r\n')).toBe('7bit'); + expect(enc('Grüße\r\n')).toBe('8bit'); + expect(enc('a\nb')).toBe('binary'); + expect(enc('a\rb')).toBe('binary'); + expect(enc(`${'x'.repeat(999)}\r\n`)).toBe('binary'); + expect(enc('a\u0000b')).toBe('binary'); + }); +}); + +describe('assembleArfReport', () => { + it('carries the original byte for byte, with one line break before the closing boundary', () => { + const parts = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '7bit', appVersion: '1' }); + const boundary = /boundary="([^"]+)"/.exec(parts.head)![1]; + const text = new TextDecoder().decode(assembleArfReport(parts, new TextEncoder().encode(ORIGINAL))); + expect(text).toBe(`${parts.head}${ORIGINAL}--${boundary}--\r\n`); + const unterminated = new TextDecoder().decode(assembleArfReport(parts, new TextEncoder().encode('x'))); + expect(unterminated.endsWith(`x\r\n--${boundary}--\r\n`)).toBe(true); + }); +}); + +describe('deliveryRecipient', () => { + it('takes the topmost delivery header, else the To address that is the reporter', () => { + expect(deliveryRecipient({ + headers: [ + { name: 'Received', value: 'from x' }, + { name: 'Delivered-To', value: ' alice+inbox@example.org' }, + { name: 'Delivered-To', value: 'list@example.org' }, + ], + to: [{ email: 'alice@example.org' }], + }, 'alice@example.org')).toBe('alice+inbox@example.org'); + expect(deliveryRecipient({ headers: [{ name: 'X-Original-To', value: '' }] }, 'b@example.org')).toBe('a@example.org'); + expect(deliveryRecipient({ to: [{ email: 'bob@example.org' }, { email: 'Alice@example.org' }] }, 'alice@example.org')).toBe('Alice@example.org'); + expect(deliveryRecipient({ to: [{ email: 'bob@example.org' }] }, 'alice@example.org')).toBeUndefined(); + }); +}); diff --git a/src/lib/__tests__/phishing-report-flow.test.ts b/src/lib/__tests__/phishing-report-flow.test.ts new file mode 100644 index 000000000..2fa8966e2 --- /dev/null +++ b/src/lib/__tests__/phishing-report-flow.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// The report flow (#124): one report per message, and only the messages +// whose report went out move to Junk; a failed one stays where it is, and +// nothing starts offline (the webmail's lib/phishing-report-flow.ts). + +vi.mock('expo-constants', () => ({ default: { expoConfig: { version: '0.1.95' } } })); +vi.mock('../../api/jmap-client', () => ({ jmapClient: { accountId: 'acc-own' } })); +vi.mock('../../api/phishing-report', () => ({ + getReportOriginals: vi.fn(async (ids: string[]) => new Map(ids.map((id) => [id, { id, blobId: `b-${id}`, size: 10, subject: id }]))), + sendPhishingReport: vi.fn(async () => undefined), +})); +vi.mock('../../stores/locale-store', () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, +})); +vi.mock('../../stores/policy-store', () => ({ usePolicyStore: vi.fn() })); +const fileReported = vi.fn(async (targets: Array<{ email: { id: string }; accountId?: string }>, _opts?: unknown) => + targets.map((target) => `${target.accountId ?? ''}:${target.email.id}`)); +const auth = { activeAccountId: 'login-1' as string | null }; +vi.mock('../../stores/auth-store', () => ({ useAuthStore: { getState: () => auth } })); +vi.mock('../../stores/email-store', () => ({ + useEmailStore: { + getState: () => ({ + fileReported, + mailboxes: [ + { id: 'in', role: 'inbox', isShared: false }, + { id: 'dr', role: 'drafts', isShared: false }, + { id: 'grp-1:dr', originalId: 'dr', role: 'drafts', isShared: true, accountId: 'grp-1' }, + ], + }), + }, +})); +const network = { online: true }; +vi.mock('../../stores/network-store', () => ({ useNetworkStore: { getState: () => network } })); + +import { getReportOriginals, sendPhishingReport } from '../../api/phishing-report'; +import { toast } from '../../stores/toast-store'; +import { reportAsPhishing } from '../phishing-report-flow'; +import { reportedKey, usePhishingReportStore } from '../../stores/phishing-report-store'; +import type { Email } from '../../api/types'; + +const mockSend = sendPhishingReport as ReturnType; +const SETUP = { address: 'security@example.org', format: 'forward' as const }; +const row = (id: string) => ({ id, subject: `Subject ${id}`, keywords: {}, mailboxIds: { in: true } }) as unknown as Email; + +beforeEach(() => { + vi.clearAllMocks(); + network.online = true; + auth.activeAccountId = 'login-1'; + usePhishingReportStore.setState({ reported: {} }); +}); + +describe('reportAsPhishing', () => { + it('sends one report per message, staged in the own Drafts, then files them all with one Undo', async () => { + const outcome = await reportAsPhishing([{ email: row('m1') }, { email: row('m2'), accountId: 'grp-1' }], SETUP); + expect(getReportOriginals).toHaveBeenCalledWith(['m1'], 'acc-own'); + expect(getReportOriginals).toHaveBeenCalledWith(['m2'], 'grp-1'); + expect(mockSend).toHaveBeenCalledTimes(2); + expect(mockSend.mock.calls[0][1]).toMatchObject({ sourceAccountId: 'acc-own', stagingMailboxId: 'dr', appVersion: '0.1.95' }); + expect(mockSend.mock.calls[1][1]).toMatchObject({ sourceAccountId: 'grp-1', stagingMailboxId: 'dr' }); + expect(fileReported).toHaveBeenCalledWith( + [{ email: row('m1') }, { email: row('m2'), accountId: 'grp-1' }], + expect.objectContaining({ keywords: ['$phishing'], markRead: true }), + ); + expect(outcome).toMatchObject({ moved: 2, failed: [] }); + expect(usePhishingReportStore.getState().reported).toEqual({ ':m1': true, 'grp-1:m2': true }); + }); + + it('leaves a message whose report failed where it is, and names it', async () => { + mockSend.mockRejectedValueOnce(new Error('forbiddenFrom')); + const error = vi.spyOn(toast, 'error'); + const outcome = await reportAsPhishing([{ email: row('m1') }, { email: row('m2') }], SETUP); + expect(fileReported).toHaveBeenCalledWith([{ email: row('m2') }], expect.anything()); + expect(usePhishingReportStore.getState().reported).toEqual({ ':m2': true }); + expect(outcome.failed.map((target) => target.email.id)).toEqual(['m1']); + expect(error).toHaveBeenCalledWith("The message couldn't be reported.", { message: 'Subject m1' }); + error.mockRestore(); + }); + + it('moves nothing when every report failed', async () => { + mockSend.mockRejectedValue(new Error('down')); + await reportAsPhishing([{ email: row('m1') }], SETUP); + expect(fileReported).not.toHaveBeenCalled(); + expect(usePhishingReportStore.getState().reported).toEqual({}); + mockSend.mockResolvedValue(undefined); + }); + + it('sends nothing and moves nothing offline', async () => { + network.online = false; + const outcome = await reportAsPhishing([{ email: row('m1') }], SETUP); + expect(mockSend).not.toHaveBeenCalled(); + expect(fileReported).not.toHaveBeenCalled(); + expect(outcome.failed).toHaveLength(1); + }); + + it('counts a message reported when the move failed: no second report, links off', async () => { + fileReported.mockResolvedValueOnce([':m1']); + const warning = vi.spyOn(toast, 'warning'); + const outcome = await reportAsPhishing([{ email: row('m1') }, { email: row('m2') }], SETUP); + expect(outcome.moved).toBe(1); + expect(warning).toHaveBeenCalledTimes(1); + expect(usePhishingReportStore.getState().reported[reportedKey('m1')]).toBe(true); + expect(usePhishingReportStore.getState().reported[reportedKey('m2')]).toBe(true); + warning.mockRestore(); + }); + + it('clears on Undo only the marks of the messages it moved back, and says the report already went', async () => { + fileReported.mockResolvedValueOnce([':m1']); + const info = vi.spyOn(toast, 'info'); + await reportAsPhishing([{ email: row('m1') }, { email: row('m2') }], SETUP); + const opts = (fileReported.mock.calls[0] as unknown as [unknown, { afterUndo: () => void }])[1]; + opts.afterUndo(); + // m2's move was refused: it has no $phishing on the server, so it stays reported here. + expect(usePhishingReportStore.getState().reported).toEqual({ ':m2': true }); + expect(info).toHaveBeenCalledWith('Moved back. The report was already sent.'); + info.mockRestore(); + }); + + it('stops when another login is opened meanwhile: the rest count as failed, nothing is filed', async () => { + mockSend.mockImplementationOnce(async () => { auth.activeAccountId = 'login-2'; }); + const outcome = await reportAsPhishing([{ email: row('m1') }, { email: row('m2') }], SETUP); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(outcome.reported.map((target) => target.email.id)).toEqual(['m1']); + expect(outcome.failed.map((target) => target.email.id)).toEqual(['m2']); + expect(fileReported).not.toHaveBeenCalled(); + expect(usePhishingReportStore.getState().reported).toEqual({ ':m1': true }); + }); +}); diff --git a/src/lib/__tests__/phishing-report.test.ts b/src/lib/__tests__/phishing-report.test.ts new file mode 100644 index 000000000..9f2c05ac2 --- /dev/null +++ b/src/lib/__tests__/phishing-report.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import type { Email, Identity } from '../../api/types'; +import { + PHISHING_KEYWORD, folderRolesOf, isReportable, phishingReportSetup, reportSubject, reportText, reporterIdentity, reportableOf, +} from '../phishing-report'; + + +// "Report phishing" (#124), the webmail's lib/phishing-report.ts: the +// admin's setup from the policy, which messages can be reported, and what +// the report says. + +describe('phishingReportSetup', () => { + it('is off without an organisation section or an address', () => { + expect(phishingReportSetup(null)).toBeNull(); + expect(phishingReportSetup({ organisation: {} })).toBeNull(); + expect(phishingReportSetup({ organisation: { phishingReport: { address: '', format: 'arf' } } })).toBeNull(); + expect(phishingReportSetup({ organisation: { phishingReport: { address: ' ' } } })).toBeNull(); + }); + + it('reads the address and the format, forward unless ARF', () => { + expect(phishingReportSetup({ organisation: { phishingReport: { address: ' Security@Example.org ', format: 'arf' } } })) + .toEqual({ address: 'security@example.org', format: 'arf' }); + expect(phishingReportSetup({ organisation: { phishingReport: { address: 'security@example.org', format: 'zip' } } })) + .toEqual({ address: 'security@example.org', format: 'forward' }); + }); + + it('refuses what is not one address (policy.json is written by hand)', () => { + for (const address of ['security', 'a@b', 'a@b.c, x@y.z', 'Sec ', 'a b@c.d', 42]) { + expect(phishingReportSetup({ organisation: { phishingReport: { address } } })).toBeNull(); + } + expect(phishingReportSetup({ organisation: { phishingReport: ['security@example.org'] } })).toBeNull(); + }); +}); + +describe('isReportable', () => { + const mail = { keywords: {} }; + + it('takes received mail, also in Junk', () => { + expect(isReportable(mail, 'inbox')).toBe(true); + expect(isReportable(mail, 'junk')).toBe(true); + expect(isReportable(mail, null)).toBe(true); + }); + + it("leaves out the user's own mail, drafts, and a message reported already", () => { + expect(isReportable(mail, 'sent')).toBe(false); + expect(isReportable(mail, 'drafts')).toBe(false); + expect(isReportable(mail, 'scheduled')).toBe(false); + expect(isReportable(mail, null, ['inbox', 'sent'])).toBe(false); + expect(isReportable({ keywords: { $draft: true } }, 'inbox')).toBe(false); + expect(isReportable({ keywords: { [PHISHING_KEYWORD]: true } }, 'junk')).toBe(false); + }); + + it('reads the folders a search hit is in from the mailboxes the store knows', () => { + const mailboxes = [ + { id: 'in', role: 'inbox' }, + { id: 'grp-1:s', originalId: 's', role: 'sent' }, + ] as never; + const sent = { keywords: {}, mailboxIds: { s: true } } as unknown as Email; + const received = { keywords: {}, mailboxIds: { in: true, s: false } } as unknown as Email; + expect(folderRolesOf(sent, mailboxes)).toEqual(['sent']); + expect(folderRolesOf(received, mailboxes)).toEqual(['inbox']); + expect(reportableOf([sent, received], null, mailboxes)).toEqual([received]); + }); +}); + +describe('the report text', () => { + it('puts "Phishing:" before the subject, on one line', () => { + expect(reportSubject('Your account\r\nis locked')).toBe('Phishing: Your account is locked'); + expect(reportSubject('')).toBe('Phishing'); + expect(reportSubject(undefined)).toBe('Phishing'); + }); + + it('names the reporter', () => { + expect(reportText({ name: 'Alice', email: 'alice@example.org' })).toBe( + 'Reported as phishing by Alice with Bulwark.\r\nThe reported message is attached unchanged.\r\n', + ); + expect(reportText({ name: ' ', email: 'alice@example.org' })).toContain('by alice@example.org with'); + }); +}); + +describe('reporterIdentity', () => { + const identity = (id: string, email: string, mayDelete = true) => ({ id, name: '', email, mayDelete }) as Identity; + + it("sends from the login's own address, its canonical identity first", () => { + const list = [ + identity('team', 'team@example.org'), + identity('alias', 'alice@example.org', true), + identity('own', 'alice@example.org', false), + ]; + expect(reporterIdentity(list, 'alice@example.org')?.id).toBe('own'); + expect(reporterIdentity(list, 'alice')?.id).toBe('own'); + }); + + it('never a wildcard identity; null when none can send', () => { + expect(reporterIdentity([identity('w', '*@example.org'), identity('t', 'team@example.org')], 'alice@example.org')?.id).toBe('t'); + expect(reporterIdentity([identity('w', '*@example.org')], 'alice@example.org')).toBeNull(); + }); +}); diff --git a/src/lib/email-body-document.ts b/src/lib/email-body-document.ts index 697a86f7b..5ae0da8d4 100644 --- a/src/lib/email-body-document.ts +++ b/src/lib/email-body-document.ts @@ -36,6 +36,8 @@ export interface BodyDocumentInput { plainDarkColors?: PlainTextColors; /** The reading font (Settings > Accessibility > Font) for a plain-text body. */ appFont?: AppFont; + /** A message reported as phishing: its links are off, each saying this (#124). */ + linksOff?: string; } export interface BodyDocument { @@ -57,6 +59,7 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument { cidMap: input.cidMap, isDark, messageSpacing: input.messageSpacing, + linksOff: input.linksOff, }); return { ...res, isHtml: true }; } @@ -72,6 +75,7 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument { fontPx: mailBaseFontPx(input.textFactor ?? 1), darkColors: input.plainDarkColors, readingFont: input.appFont, + linksOff: input.linksOff, }), recolorDark: false, hasNativeDark: false, @@ -101,7 +105,7 @@ export function bodyDocument(input: BodyDocumentInput): BodyDocument { const key = JSON.stringify([ input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont, input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, input.textFactor ?? 1, - input.plainDarkColors ?? null, input.appFont ?? 'default', + input.plainDarkColors ?? null, input.appFont ?? 'default', input.linksOff ?? null, ]); const hit = documents.get(key); if (hit && sameSources(hit.input, input)) { diff --git a/src/lib/email-html.ts b/src/lib/email-html.ts index 6bad9fb1b..614827c6a 100644 --- a/src/lib/email-html.ts +++ b/src/lib/email-html.ts @@ -853,6 +853,36 @@ function replaceCidRefs(html: string, cidMap: Record): string { }); } +// ─── Links turned off ──────────────────────────────────────────────────── + +const LINK_OFF_ATTRS = new Set(['href', 'xlink:href', 'target', 'rel', 'ping', 'download', 'tabindex', 'title']); +const LINK_OFF_STYLE = 'color:inherit !important;text-decoration:underline dotted !important;cursor:not-allowed !important'; + +/** + * Links in a message the user reported as phishing (`$phishing`, RFC 9051 + * §2.3.2: clients should warn and disable links) are turned off, as in the + * webmail's lib/phishing-links: each `` and `` loses its address, + * looks like text that was a link and says it is off (`title`). Runs on the + * sanitised body; the WebView refuses every link of such a message besides + * (EmailBodyView). + */ +export function turnOffLinks(html: string, title: string): string { + if (!/<(a|area)\b/i.test(html)) return html; + const safeTitle = title.replace(/&/g, '&'); + return mapTags(html, (tag, attrs) => { + if (tag !== 'a' && tag !== 'area') return null; + const kept = attrs.filter((attr) => !LINK_OFF_ATTRS.has(asciiLower(attr.name))); + if (tag === 'a') { + const style = kept.find((attr) => asciiLower(attr.name) === 'style'); + if (style && style.value) style.value = `${style.value};${LINK_OFF_STYLE}`; + else if (style) style.value = LINK_OFF_STYLE; + else kept.push({ name: 'style', value: LINK_OFF_STYLE }); + } + kept.push({ name: 'data-link-off', value: null }, { name: 'aria-disabled', value: 'true' }, { name: 'title', value: safeTitle }); + return kept; + }); +} + // ─── Document assembly ─────────────────────────────────────────────────── export type MessageSpacing = 'auto' | 'always' | 'edge'; @@ -865,6 +895,8 @@ export interface WrapOptions { * fallback colours and the body stays hidden until the recolour pass ran. */ isDark?: boolean; messageSpacing?: MessageSpacing; + /** A reported message's links are turned off (turnOffLinks); the text each says. */ + linksOff?: string; } export interface PreparedEmailHtml { @@ -909,8 +941,9 @@ function buildCsp(strict: boolean): string { * decision was native #49). */ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}): PreparedEmailHtml { - const { blockRemoteImages = false, cidMap, isDark = true, messageSpacing = 'auto' } = options; - const cleaned = stripDangerousTags(innerHtml); + const { blockRemoteImages = false, cidMap, isDark = true, messageSpacing = 'auto', linksOff } = options; + const sanitized = stripDangerousTags(innerHtml); + const cleaned = linksOff ? turnOffLinks(sanitized, linksOff) : sanitized; const withCids = cidMap ? replaceCidRefs(cleaned, cidMap) : cleaned; let processed = withCids; let blockedExternal = false; @@ -990,10 +1023,13 @@ export function wrapPlainTextEmail( * body in the app document; monospace stays monospace. */ readingFont?: AppFont; + /** A reported message's links are turned off (turnOffLinks); the text each says. */ + linksOff?: string; } = {}, ): string { - const { isDark = true, font = 'sans', fontPx = 14, darkColors, readingFont } = options; - const cleaned = stripDangerousTags(innerHtml); + const { isDark = true, font = 'sans', fontPx = 14, darkColors, readingFont, linksOff } = options; + const sanitized = stripDangerousTags(innerHtml); + const cleaned = linksOff ? turnOffLinks(sanitized, linksOff) : sanitized; const reading = font === 'sans' ? readingFont : undefined; // In dark mode the body takes the app theme's ground, text and link colours diff --git a/src/lib/mdn.ts b/src/lib/mdn.ts index 6ef101cd3..52e547f98 100644 --- a/src/lib/mdn.ts +++ b/src/lib/mdn.ts @@ -70,7 +70,7 @@ export function utf8ToBase64(value: string): string { } /** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */ -function base64Body(text: string): string { +export function base64Body(text: string): string { return (utf8ToBase64(text).match(/.{1,76}/g) || []).join('\r\n'); } @@ -92,7 +92,7 @@ function stripHeaderControls(value: string): string { } /** Address-like atoms (mailboxes, message-ids) may contain no whitespace at all. */ -function headerAtom(value: string): string { +export function headerAtom(value: string): string { // eslint-disable-next-line no-control-regex return value.replace(/[\x00-\x20\x7F]+/g, ''); } @@ -108,7 +108,7 @@ export function encodeHeaderWord(value: string): string { } /** Display-name for a mailbox: encoded-word if non-ASCII, quoted if it uses specials. */ -function displayName(value: string): string { +export function displayName(value: string): string { const encoded = encodeHeaderWord(value); if (encoded.startsWith('=?') || !/[()<>[\]:;@\\,."]/.test(encoded)) return encoded; return `"${encoded.replace(/["\\]/g, '\\$&')}"`; @@ -122,7 +122,7 @@ function ensureAngles(messageId: string | string[] | null | undefined): string { return trimmed.startsWith('<') ? trimmed : `<${trimmed}>`; } -function randomToken(): string { +export function randomToken(): string { const rnd = Math.random().toString(36).slice(2); return `${Date.now().toString(36)}.${rnd}`; } diff --git a/src/lib/phishing-report-arf.ts b/src/lib/phishing-report-arf.ts new file mode 100644 index 000000000..f37f0a69d --- /dev/null +++ b/src/lib/phishing-report-arf.ts @@ -0,0 +1,164 @@ +/** + * The ARF format of a phishing report (RFC 5965, an admin option of + * Organisation > Security > Phishing reports), ported from the webmail's + * lib/phishing-report-arf.ts: a multipart/report with + * report-type=feedback-report and three parts, a short text for people, the + * machine-readable message/feedback-report (Feedback-Type: fraud, which + * RFC 5965 §7.3 defines as "fraud or phishing"), and the original whole and + * unchanged as message/rfc822. + * + * JMAP can't set the report-type parameter through Email/set, so this is + * raw MIME: the original's bytes are downloaded, wrapped and uploaded, then + * imported and submitted without a Sent copy. The upload counts against + * the server's upload quota and is limited by maxSizeUpload. + */ + +import type { Email } from '../api/types'; +import { base64Body, displayName, encodeHeaderWord, headerAtom, randomToken, rfc5322Date } from './mdn'; +import { reportSubject } from './phishing-report'; + +export interface ArfParts { + /** Everything before the original's bytes. */ + head: string; + /** Everything after them. */ + tail: string; +} + +/** "Reported-Domain": the domain of the original's From, when it has one. */ +function senderDomain(email: Pick): string | null { + const address = email.from?.[0]?.email ?? ''; + const domain = address.slice(address.lastIndexOf('@') + 1); + return address.includes('@') && /^[A-Za-z0-9.-]+$/.test(domain) ? domain.toLowerCase() : null; +} + +/** How the original goes in: message/rfc822 may not be base64-encoded (RFC 2046 §5.2.1). */ +export type RawEncoding = '7bit' | '8bit' | 'binary'; + +/** + * The report around the original. `encoding`: what the original's bytes + * are (rawEncoding). `recipient`: the address the original was delivered + * to, for Original-Rcpt-To; left out when unknown. + */ +export function buildArfParts(input: { + email: Pick; + reporter: { name?: string; email: string }; + to: string; + encoding: RawEncoding; + appVersion: string; + recipient?: string; + now?: Date; +}): ArfParts { + const { email, reporter } = input; + const fromEmail = headerAtom(reporter.email); + const domain = fromEmail.split('@')[1] || 'localhost'; + const boundary = `----=_ARF_${randomToken()}`; + const fromName = reporter.name?.trim() ? displayName(reporter.name.trim()) : ''; + const arrival = email.receivedAt ? new Date(email.receivedAt) : null; + const reportedDomain = senderDomain(email); + const human = [ + `This is a phishing report (RFC 5965) sent by ${fromEmail} with Bulwark.`, + '', + 'The reported message is attached unchanged.', + ].join('\r\n'); + const report = [ + 'Feedback-Type: fraud', + `User-Agent: Bulwark/${headerAtom(input.appVersion) || 'unknown'}`, + 'Version: 1', + ...(arrival && !Number.isNaN(arrival.getTime()) ? [`Arrival-Date: ${rfc5322Date(arrival)}`] : []), + ...(input.recipient ? [`Original-Rcpt-To: ${headerAtom(input.recipient)}`] : []), + ...(reportedDomain ? [`Reported-Domain: ${reportedDomain}`] : []), + ].join('\r\n'); + + const head = [ + `Date: ${rfc5322Date(input.now ?? new Date())}`, + `From: ${fromName ? `${fromName} <${fromEmail}>` : fromEmail}`, + `To: ${headerAtom(input.to)}`, + `Subject: ${encodeHeaderWord(reportSubject(email.subject))}`, + `Message-ID: `, + 'MIME-Version: 1.0', + 'Content-Type: multipart/report; report-type=feedback-report;', + `\tboundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset=utf-8', + 'Content-Transfer-Encoding: base64', + '', + base64Body(human), + '', + `--${boundary}`, + 'Content-Type: message/feedback-report', + 'Content-Transfer-Encoding: 7bit', + '', + report, + '', + `--${boundary}`, + 'Content-Type: message/rfc822', + 'Content-Disposition: attachment; filename="reported-message.eml"', + `Content-Transfer-Encoding: ${input.encoding}`, + '', + '', + ].join('\r\n'); + const tail = `\r\n--${boundary}--\r\n`; + return { head, tail }; +} + +/** + * 7bit or 8bit lines of at most 998 octets ending in CRLF (RFC 2045 §2.7, + * §2.8); anything else (a NUL, a bare CR or LF, a longer line) is binary. + */ +export function rawEncoding(bytes: Uint8Array): RawEncoding { + let eightBit = false; + let line = 0; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + if (b === 0x00) return 'binary'; + if (b === 0x0d) { + if (bytes[i + 1] !== 0x0a) return 'binary'; + i++; + line = 0; + continue; + } + if (b === 0x0a) return 'binary'; + if (b > 0x7f) eightBit = true; + if (++line > 998) return 'binary'; + } + return eightBit ? '8bit' : '7bit'; +} + +const DELIVERY_HEADERS = ['delivered-to', 'x-original-to', 'x-delivered-to', 'x-original-delivered-to']; +const ANGLE_ADDRESS = /<\s*([^<>\s@]+@[^<>\s@]+)\s*>/; +const BARE_ADDRESS = /([^\s<>"',;:@]+@[^\s<>"',;:@]+)/; + +/** + * The address the message was delivered to, for Original-Rcpt-To: the + * topmost delivery header (the last hop, our own server), else the To + * address that is the reporter's. The webmail's deliveryAddresses. + */ +export function deliveryRecipient( + email: Pick, + reporterEmail: string, +): string | undefined { + for (const name of DELIVERY_HEADERS) { + for (const header of email.headers ?? []) { + if (header.name.toLowerCase() !== name) continue; + const match = header.value.match(ANGLE_ADDRESS) ?? header.value.match(BARE_ADDRESS); + if (match) return match[1].replace(/\.$/, ''); + } + } + const own = reporterEmail.toLowerCase(); + return email.to?.find((addr) => addr.email?.toLowerCase() === own)?.email; +} + +/** Joins the report around the original's bytes. */ +export function assembleArfReport(parts: ArfParts, original: Uint8Array): Uint8Array { + const encoder = new TextEncoder(); + // The original goes in byte for byte; the closing boundary needs a line break before it. + const endsWithBreak = original.length >= 2 && original[original.length - 2] === 0x0d && original[original.length - 1] === 0x0a; + const head = encoder.encode(parts.head); + const tail = encoder.encode(endsWithBreak ? parts.tail.slice(2) : parts.tail); + const out = new Uint8Array(head.length + original.length + tail.length); + out.set(head, 0); + out.set(original, head.length); + out.set(tail, head.length + original.length); + return out; +} diff --git a/src/lib/phishing-report-flow.ts b/src/lib/phishing-report-flow.ts new file mode 100644 index 000000000..c8411a49d --- /dev/null +++ b/src/lib/phishing-report-flow.ts @@ -0,0 +1,156 @@ +/** + * The app side of "Report phishing" (#124), ported from the webmail's + * lib/phishing-report-flow.ts and components/email/phishing-report-host.tsx: + * the question, one report per message, then the move to Junk with + * `$phishing`, and what the user is told. + * + * A message moves only once its report went out; one whose report fails + * stays where it is. The reports need the network, so nothing starts + * offline (the move alone could be queued, the report can't). + */ + +import { useMemo } from 'react'; +import { Alert } from 'react-native'; +import Constants from 'expo-constants'; +import type { Email, Mailbox } from '../api/types'; +import { jmapClient } from '../api/jmap-client'; +import { getReportOriginals, sendPhishingReport } from '../api/phishing-report'; +import { useEmailStore } from '../stores/email-store'; +import { reportedKey, usePhishingReportStore } from '../stores/phishing-report-store'; +import { useNetworkStore } from '../stores/network-store'; +import { useAuthStore } from '../stores/auth-store'; +import { usePolicyStore } from '../stores/policy-store'; +import { toast } from '../stores/toast-store'; +import { t } from '../stores/locale-store'; +import { ownMailboxes } from './mailbox-tree'; +import { PHISHING_KEYWORD, phishingReportSetup, type PhishingReportSetup } from './phishing-report'; + +/** A message to report and the JMAP account it lives in (undefined = the user's own). */ +export interface ReportTarget { + email: Email; + accountId?: string; +} + +/** The report setup of the active account's policy; null while reporting is off. */ +export function usePhishingReportSetup(): PhishingReportSetup | null { + const policy = usePolicyStore((s) => s.effective); + return useMemo(() => phishingReportSetup(policy), [policy]); +} + +/** "Report phishing?" with the address; resolves true on Report. */ +export function confirmPhishingReport(count: number, address: string): Promise { + return new Promise((resolve) => { + Alert.alert( + t('phishing_report.confirm_title', 'Report phishing?'), + t('phishing_report.confirm_message', `The message goes to ${address} and moves to Junk.`, { count, address }), + [ + { text: t('phishing_report.cancel', 'Cancel'), style: 'cancel', onPress: () => resolve(false) }, + { text: t('phishing_report.confirm', 'Report'), style: 'destructive', onPress: () => resolve(true) }, + ], + { cancelable: true, onDismiss: () => resolve(false) }, + ); + }); +} + +// The login's own folder a report is created in until it is submitted. +function stagingMailboxId(mailboxes: Mailbox[]): string | null { + const own = ownMailboxes(mailboxes); + const box = own.find((m) => m.role === 'drafts') ?? own.find((m) => m.role === 'sent'); + return box ? box.originalId ?? box.id : null; +} + +export interface PhishingReportOutcome { + reported: ReportTarget[]; + failed: ReportTarget[]; + /** How many of the reported messages were filed in Junk. */ + moved: number; +} + +/** + * Reports each message on its own. Each one whose report went out counts as + * reported at once (its links go off, it isn't offered again); then they are + * filed in their accounts' Junk with `$phishing` and `$seen` through the + * store's "Mark as spam" path (one Undo for all). A message whose report + * failed stays where it is. + */ +export async function reportAsPhishing(targets: ReportTarget[], setup: PhishingReportSetup): Promise { + const outcome: PhishingReportOutcome = { reported: [], failed: [], moved: 0 }; + if (targets.length === 0) return outcome; + const staging = stagingMailboxId(useEmailStore.getState().mailboxes); + if (!useNetworkStore.getState().online || !staging) { + outcome.failed = targets; + showOutcome(outcome, setup.address); + return outcome; + } + + // The reports go out from the login that is open now; switching login + // points the client at another one, so the rest are left (as failed). + const login = useAuthStore.getState().activeAccountId; + const sameLogin = () => useAuthStore.getState().activeAccountId === login; + + // The originals as the report needs them (list rows carry no blobId), + // one Email/get per account. + const own = jmapClient.accountId; + const originals = new Map(); + for (const accountId of new Set(targets.map((target) => target.accountId ?? own))) { + const ids = targets.filter((target) => (target.accountId ?? own) === accountId).map((target) => target.email.id); + try { + for (const [id, email] of await getReportOriginals(ids, accountId)) originals.set(`${accountId}:${id}`, email); + } catch (err) { + console.warn('[phishing-report] could not read the messages to report', err); + } + } + + const appVersion = Constants.expoConfig?.version ?? 'unknown'; + for (const target of targets) { + const sourceAccountId = target.accountId ?? own; + const original = originals.get(`${sourceAccountId}:${target.email.id}`); + try { + if (!sameLogin()) throw new Error('Another login was opened'); + if (!original) throw new Error('The message is gone from the server'); + await sendPhishingReport(original, { sourceAccountId, setup, stagingMailboxId: staging, appVersion }); + } catch (err) { + console.warn('[phishing-report] report failed', err); + outcome.failed.push(target); + continue; + } + outcome.reported.push(target); + usePhishingReportStore.getState().markReported([reportedKey(target.email.id, target.accountId)], true); + } + + if (outcome.reported.length > 0 && sameLogin()) { + const address = setup.address; + // Undo unmarks only the ones it moves back: one the server wouldn't + // move stays reported. + let filed: string[] = []; + filed = await useEmailStore.getState().fileReported(outcome.reported, { + keywords: [PHISHING_KEYWORD], + markRead: true, + label: (count) => t('phishing_report.done', `Reported to ${address} and moved to Junk.`, { count, address }), + afterUndo: () => { + usePhishingReportStore.getState().markReported(filed, false); + toast.info(t('phishing_report.undone', 'Moved back. The report was already sent.', { count: filed.length })); + }, + }); + outcome.moved = filed.length; + } + showOutcome(outcome, setup.address); + return outcome; +} + +// The Undo bar already says "Reported to … and moved to Junk."; what else +// happened goes in a toast. +function showOutcome(outcome: PhishingReportOutcome, address: string): void { + const { reported, failed, moved } = outcome; + if (moved < reported.length) { + toast.warning(t('phishing_report.not_moved', `Reported to ${address}, but the message couldn't be moved to Junk.`, { + address, + count: reported.length - moved, + })); + } + if (failed.length > 0) { + toast.error(t('phishing_report.failed', "The message couldn't be reported.", { count: failed.length }), { + message: failed.map(({ email }) => email.subject || t('phishing_report.no_subject', '(no subject)')).join(', '), + }); + } +} diff --git a/src/lib/phishing-report.ts b/src/lib/phishing-report.ts new file mode 100644 index 000000000..1cc3cd1d2 --- /dev/null +++ b/src/lib/phishing-report.ts @@ -0,0 +1,122 @@ +/** + * "Report phishing" (#124), ported from the webmail's lib/phishing-report.ts: + * sends a message to the address the admin set (policy.json + * `organisation.phishingReport`), one report per message. + * + * - One report per message, also for a selection: report desks (Microsoft + * Defender among them) discard reports with several messages attached, + * and RFC 5965 §2(e) says one message per report. + * - No copy in Sent: a live phishing message shouldn't sit there to be + * clicked again; the admin's mailbox is the record. + * - The forward format attaches the original by its blobId, so nothing is + * uploaded. A message of another account (a shared or group mailbox) has + * its blob copied to the sending account first (Blob/copy). + * - The report skips the composer's send checks, signature and send delay: + * the address is the admin's, and the report is not the user's mail. + */ + +import type { Email, Identity, Mailbox } from '../api/types'; +import type { OrgPolicy } from './org-policy'; +import { emailExportFilename } from './download-filename'; + +/** RFC 9051 §2.3.2: the user reported the message as phishing. */ +export const PHISHING_KEYWORD = '$phishing'; + +export type PhishingReportFormat = 'forward' | 'arf'; + +export interface PhishingReportSetup { + address: string; + format: PhishingReportFormat; +} + +// The webmail's sanitiser keeps only an address it accepts; policy.json is +// written by hand, so the app checks again (lib/org-policy). +const REPORT_ADDRESS = /^[^\s@<>()",;:\\]+@[^\s@<>()",;:\\]+\.[^\s@<>()",;:\\]+$/; + +/** The setup in a policy; null while reporting is off (no address). */ +export function phishingReportSetup(policy: Pick | null | undefined): PhishingReportSetup | null { + const section = policy?.organisation?.phishingReport; + if (!section || typeof section !== 'object' || Array.isArray(section)) return null; + const { address, format } = section as { address?: unknown; format?: unknown }; + if (typeof address !== 'string') return null; + const clean = address.trim().toLowerCase(); + if (!clean || clean.length > 320 || !REPORT_ADDRESS.test(clean)) return null; + return { address: clean, format: format === 'arf' ? 'arf' : 'forward' }; +} + +/** Folders whose messages are the user's own: nothing to report there. */ +const OWN_MAIL_ROLES = new Set(['sent', 'drafts', 'scheduled']); + +/** + * Whether `email`, shown in a folder of `mailboxRole`, can be reported. + * `folderRoles`: the roles of the folders the message itself is in (search + * results and All mail show the user's own sent mail too). List rows carry + * no blobId; the report fetches it, so it isn't asked for here. + */ +export function isReportable( + email: Pick, + mailboxRole?: string | null, + folderRoles: readonly string[] = [], +): boolean { + if (mailboxRole && OWN_MAIL_ROLES.has(mailboxRole)) return false; + if (folderRoles.some((role) => OWN_MAIL_ROLES.has(role))) return false; + if (email.keywords?.$draft) return false; + // Reported already: it is in Junk with its links off. + return email.keywords?.[PHISHING_KEYWORD] !== true; +} + +/** The roles of the folders a message is in, from the folders the store knows. */ +export function folderRolesOf(email: Pick, mailboxes: Mailbox[]): string[] { + const roles: string[] = []; + for (const id of Object.keys(email.mailboxIds ?? {})) { + if (!email.mailboxIds[id]) continue; + const box = mailboxes.find((m) => m.id === id || m.originalId === id); + if (box?.role) roles.push(box.role); + } + return roles; +} + +/** The messages of `emails` that can be reported from a folder of `mailboxRole`. */ +export function reportableOf>( + emails: T[], + mailboxRole: string | null | undefined, + mailboxes: Mailbox[], +): T[] { + return emails.filter((email) => isReportable(email, mailboxRole, folderRolesOf(email, mailboxes))); +} + +/** The subject of the report: Microsoft's reporting mailboxes read "Phishing:" as the verdict. */ +export function reportSubject(subject: string | undefined | null): string { + const clean = (subject ?? '').replace(/[\r\n]+/g, ' ').trim(); + return clean ? `Phishing: ${clean}` : 'Phishing'; +} + +/** The short text of a forwarded report. English: it is read by the security team, not the user. */ +export function reportText(reporter: { name?: string; email: string }): string { + const who = reporter.name?.trim() ? `${reporter.name.trim()} <${reporter.email}>` : reporter.email; + return `Reported as phishing by ${who} with Bulwark.\r\nThe reported message is attached unchanged.\r\n`; +} + +/** True when `email` is the login's own address; a bare login ("user") matches its local part. */ +function matchesLogin(email: string, username: string): boolean { + const address = email.trim().toLowerCase(); + const login = username.trim().toLowerCase(); + if (address === login) return true; + return !login.includes('@') && address.split('@')[0] === login; +} + +/** + * The identity a report goes out from: the login's own address first, its + * canonical identity (which cannot be deleted) ahead of an alias; wildcard + * identities can't send. The webmail's sortIdentities. + */ +export function reporterIdentity(identities: Identity[], username: string): Identity | null { + const usable = identities.filter((identity) => identity.email && !identity.email.includes('*')); + const rank = (identity: Identity) => (matchesLogin(identity.email, username) ? (identity.mayDelete ? 1 : 0) : 2); + return [...usable].sort((a, b) => rank(a) - rank(b))[0] ?? null; +} + +/** The name the original goes by as an attachment of the report. */ +export function reportAttachmentName(email: Email): string { + return emailExportFilename(email, { template: '{date}-{subject}' }); +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 49d2488da..dda55a68e 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -7,7 +7,7 @@ import { Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X, Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays, Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide, - Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check, Globe, MoreVertical, + Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check, Globe, MoreVertical, Fish, } from '../components/icons'; import { GlobalSearchSheet } from '../components/search/GlobalSearchSheet'; import { ActionSheet } from '../components/email/ActionSheet'; @@ -91,6 +91,9 @@ import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/ import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { searchEverythingPlacement } from '../lib/search-everything-entry'; import { fitToolbarActions } from '../lib/selection-toolbar'; +import { reportableOf } from '../lib/phishing-report'; +import { reportedKey, usePhishingReportStore } from '../stores/phishing-report-store'; +import { confirmPhishingReport, reportAsPhishing, usePhishingReportSetup } from '../lib/phishing-report-flow'; import { setPromptListColumn } from '../lib/push-prompt-frame'; // The server refuses keywords per message (a read-only shared folder; @@ -1080,6 +1083,27 @@ export default function EmailListScreen({ const canSpamSelection = currentRole !== 'sent' && currentRole !== 'drafts' && (inJunk || junkMailboxId != null); + // Report phishing for the selection, one report per message (#124). + const phishingSetup = usePhishingReportSetup(); + const reportedThisSession = usePhishingReportStore((s) => s.reported); + const phishingSelection = React.useMemo(() => { + if (!phishingSetup) return []; + // Each row's folders are looked up in its own account: raw folder ids + // repeat across accounts. + return selectedEmails.filter((email) => { + const accountId = accountIdOfRow(email); + return !reportedThisSession[reportedKey(email.id, accountId)] + && reportableOf([email], currentRole, mailboxesOfAccount(mailboxes, accountId)).length > 0; + }); + }, [phishingSetup, selectedEmails, currentRole, mailboxes, reportedThisSession]); + const handleBulkReportPhishing = async () => { + const rows = phishingSelection; + if (!phishingSetup || rows.length === 0) return; + if (!(await confirmPhishingReport(rows.length, phishingSetup.address))) return; + clearSelection(); + await reportAsPhishing(rows.map((email) => ({ email, accountId: accountIdOfRow(email) })), phishingSetup); + }; + const handleBatchMovePick = (toId: string) => { const ids = selectedMessageIds; setBatchMoveOpen(false); @@ -1369,7 +1393,7 @@ export default function EmailListScreen({ // The selection toolbar's actions, in bar order. What doesn't fit the // list column goes into a "More" menu, the least used first. - type SelectionAction = 'star' | 'read' | 'tag' | 'move' | 'spam' | 'archive' | 'delete'; + type SelectionAction = 'star' | 'read' | 'tag' | 'move' | 'spam' | 'phishing' | 'archive' | 'delete'; const selectionActions: Array<{ key: SelectionAction; label: string; icon: React.ReactNode; onPress: () => void }> = [ { key: 'star', @@ -1409,6 +1433,12 @@ export default function EmailListScreen({ icon: inJunk ? : , onPress: () => reportFailure(handleBulkSpam()), }] : []), + ...(phishingSelection.length > 0 ? [{ + key: 'phishing' as const, + label: t('phishing_report.action', 'Report phishing…'), + icon: , + onPress: () => reportFailure(handleBulkReportPhishing()), + }] : []), ...(canArchiveSelection ? [{ key: 'archive' as const, label: t('context_menu.archive', 'Archive'), @@ -1427,7 +1457,7 @@ export default function EmailListScreen({ // Padding (sm each side), the close button and the count (min 24 + xs each side). reserved: spacing.sm * 2 + componentSizes.buttonLg + 24 + spacing.xs * 2, button: componentSizes.buttonLg, - overflowOrder: ['spam', 'tag', 'star', 'read', 'move', 'archive', 'delete'], + overflowOrder: ['phishing', 'spam', 'tag', 'star', 'read', 'move', 'archive', 'delete'], }); const renderSearchBar = (inHeader: boolean) => ( diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 2bee6f76b..546f581b6 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -10,10 +10,15 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward, ChevronLeft, ChevronRight, Archive, Mail, MailOpen, - FolderInput, ShieldAlert, ShieldCheck, X, Check, + FolderInput, ShieldAlert, ShieldCheck, X, Check, Fish, Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter, FileText, Type, } from '../components/icons'; import { pickEmailBody } from '../lib/email-body'; +import { folderRolesOf, isReportable } from '../lib/phishing-report'; +import { usePhishingMarked } from '../stores/phishing-report-store'; +import { + confirmPhishingReport, reportAsPhishing, usePhishingReportSetup, +} from '../lib/phishing-report-flow'; import { confirmMailRemoval } from '../lib/mail-removal-confirm'; import { useTagUndoGroup } from '../lib/tag-undo-group'; import { cardReplyActions } from '../lib/thread-card-actions'; @@ -495,6 +500,12 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr const canArchive = archiveEnabled && !!archiveMailbox && sourceMailbox?.id !== archiveMailbox.id; // Not spam files back into the Inbox, so it needs one. const canToggleSpam = isInJunk ? !!inboxMailbox : !!junkMailbox; + // Report phishing (#124): while the admin set an address, for a message + // that isn't the user's own mail and wasn't reported already. + const phishingSetup = usePhishingReportSetup(); + const phishingMarked = usePhishingMarked(email, ownerAccountId); + const canReportPhishing = !!phishingSetup && !!email && !phishingMarked + && isReportable(email, sourceMailbox?.role, folderRolesOf(email, scopedMailboxes)); const onToggleKeyword = (token: string) => { if (!email) return; @@ -608,6 +619,19 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr navigation.goBack(); }; + // One report to the admin's address, then into Junk with `$phishing`, + // through the same store path as Report spam (#124). + const onReportPhishing = () => { + setMoreMenuOpen(false); + if (!email || !phishingSetup || !canReportPhishing) return; + const target = { email, accountId: ownerAccountId }; + void confirmPhishingReport(1, phishingSetup.address).then((ok) => { + if (!ok) return; + void reportAsPhishing([target], phishingSetup); + navigation.goBack(); + }); + }; + const onMoveToMailbox = (toId: string) => { if (!email || !sourceMailbox || toId === sourceMailbox.id) return; setMoveMenuOpen(false); @@ -933,6 +957,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr canMove={!!sourceMailbox} showSpam={canToggleSpam} isInJunk={isInJunk} + canReportPhishing={canReportPhishing} canViewSource={!!email?.blobId} canExport={!!email?.blobId} canTag @@ -949,6 +974,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }} onTag={() => { setMoreMenuOpen(false); setTagMenuOpen(true); }} onToggleSpam={onToggleSpam} + onReportPhishing={onReportPhishing} onToggleTheme={() => { setMoreMenuOpen(false); if (!email) return; @@ -1389,6 +1415,8 @@ interface MoreMenuSheetProps { canTag: boolean; showSpam: boolean; isInJunk: boolean; + /** Report phishing (#124): the admin set an address and the message can be reported. */ + canReportPhishing: boolean; canViewSource: boolean; canExport: boolean; renderDark: boolean; @@ -1401,6 +1429,7 @@ interface MoreMenuSheetProps { onMove: () => void; onTag: () => void; onToggleSpam: () => void; + onReportPhishing: () => void; onToggleTheme: () => void; onSenderActions: () => void; onForwardAsAttachment: () => void; @@ -1412,8 +1441,8 @@ interface MoreMenuSheetProps { function MoreMenuSheet({ visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag, - showSpam, isInJunk, canViewSource, canExport, renderDark, plainView, onTogglePlainView, hasSender, - onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onToggleTheme, onSenderActions, + showSpam, isInJunk, canReportPhishing, canViewSource, canExport, renderDark, plainView, onTogglePlainView, hasSender, + onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onReportPhishing, onToggleTheme, onSenderActions, onForwardAsAttachment, onRules, onViewSource, onExport, }: MoreMenuSheetProps) { const c = useColors(); @@ -1519,6 +1548,13 @@ function MoreMenuSheet({ onPress={onToggleSpam} /> )} + {canReportPhishing && ( + } + label={t('phishing_report.action', 'Report phishing…')} + onPress={onReportPhishing} + /> + )} {plainView !== null && ( : } diff --git a/src/stores/__tests__/email-store-viewer-actions.test.ts b/src/stores/__tests__/email-store-viewer-actions.test.ts index 682f115a0..2baa6860a 100644 --- a/src/stores/__tests__/email-store-viewer-actions.test.ts +++ b/src/stores/__tests__/email-store-viewer-actions.test.ts @@ -85,6 +85,7 @@ vi.mock('../../api/jmap-client', () => ({ jmapClient: { isConnected: true, accountId: 'acc-1', + connectedAccountId: 'acc-1', username: 'test@example.com', serverUrl: 'https://mail.example.com', getMaxObjectsInGet: () => 500, @@ -231,6 +232,86 @@ describe('viewer Spam takes the list and swipe path (#695)', () => { }); }); +describe('Report phishing files the reported messages by account and id (#124)', () => { + const afterUndo = vi.fn(); + const REPORT = { + keywords: ['$phishing'], + markRead: true, + label: (count: number) => `Reported (${count})`, + afterUndo, + }; + const OWN_SECOND = { id: 'e2', subject: 'Invoice', keywords: {}, mailboxIds: { a: true } } as unknown as Email; + + it('sets $phishing and $seen with $junk whatever the delete action, and takes the rows off the list', async () => { + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [OWN_ROW, OWN_SECOND] }); + + const filed = await useEmailStore.getState().fileReported([{ email: OWN_ROW, accountId: undefined }], REPORT); + + expect(filed).toEqual([':e1']); + expect(mockMarkAsSpam).toHaveBeenCalledWith(['e1'], 'j', undefined, { markRead: true, keywords: ['$phishing'] }); + expect(useEmailStore.getState().emails).toEqual([OWN_SECOND]); + expect(useEmailStore.getState().pendingUndo).toMatchObject({ + kind: 'spam', + label: 'Reported (1)', + keywordPatch: { $junk: true, $notjunk: null, $seen: true, $phishing: true }, + }); + }); + + it('files a team message in the team Junk even when the open folder is now another account\'s, and leaves its same-id row alone', async () => { + // The reports took a while; meanwhile the user opened their own Inbox, + // which holds another message with the same id. + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [OWN_ROW] }); + + await useEmailStore.getState().fileReported([{ email: TEAM_MESSAGE, accountId: 'grp-1' }], REPORT); + + expect(mockMarkAsSpam).toHaveBeenCalledWith(['e1'], 'j', 'grp-1', { markRead: true, keywords: ['$phishing'] }); + expect(useEmailStore.getState().emails).toEqual([OWN_ROW]); + }); + + it('files an own message in the own Junk while a team folder is open', async () => { + const teamRow = { ...TEAM_MESSAGE } as Email; + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'grp-1:a', emails: [teamRow] }); + + await useEmailStore.getState().fileReported([{ email: OWN_ROW, accountId: undefined }], REPORT); + + expect(mockMarkAsSpam).toHaveBeenCalledWith(['e1'], 'j', undefined, { markRead: true, keywords: ['$phishing'] }); + expect(useEmailStore.getState().emails).toEqual([teamRow]); + }); + + it('moves them back on Undo, into the list they left, clears $phishing and $seen, and runs afterUndo', async () => { + const flagged = { ...OWN_ROW, keywords: { $flagged: true } } as unknown as Email; + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [flagged] }); + + await useEmailStore.getState().fileReported([{ email: flagged }], REPORT); + expect(useEmailStore.getState().emails).toEqual([]); + await useEmailStore.getState().undoLast(); + + expect(mockRestore).toHaveBeenCalledWith([{ id: 'e1', mailboxIds: { a: true } }], undefined); + expect(emailApi.patchKeywordsPerEmail).toHaveBeenCalledWith( + [{ id: 'e1', patch: { $junk: null, $notjunk: null, $seen: null, $phishing: null } }], + undefined, + ); + expect(useEmailStore.getState().emails.map((e) => [e.id, e.jmapAccountId])).toEqual([['e1', undefined]]); + expect(afterUndo).toHaveBeenCalledTimes(1); + }); + + it('files none without a Junk folder', async () => { + useEmailStore.setState({ mailboxes: [own('a', 'inbox')], currentMailboxId: 'a', emails: [OWN_ROW] }); + expect(await useEmailStore.getState().fileReported([{ email: OWN_ROW }], REPORT)).toEqual([]); + expect(mockMarkAsSpam).not.toHaveBeenCalled(); + expect(useEmailStore.getState().emails).toEqual([OWN_ROW]); + }); + + it('leaves plain Report spam as it was', async () => { + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [OWN_ROW] }); + await useEmailStore.getState().markSpam(['e1']); + const undo = useEmailStore.getState().pendingUndo!; + expect(undo.label).toBe('Marked as spam'); + expect(undo.afterUndo).toBeUndefined(); + expect(undo.keywordPatch).toEqual({ $junk: true, $notjunk: null }); + }); +}); + describe('viewer Archive of a message the list does not hold', () => { const RECEIVED = '2026-09-01T10:00:00Z'; diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 27160b1a9..b5ccd293e 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -144,6 +144,18 @@ function currentAccountId(state: EmailState): string | undefined { return refFor(state.mailboxes, state.currentMailboxId).accountId; } +/** How "Report phishing" (#124) files the reported messages (fileReported). */ +export interface SpamOptions { + /** Keywords set besides `$junk` (`$phishing`); Undo clears them again. */ + keywords?: string[]; + /** Mark read whatever the delete action says: a report shouldn't count as new mail in Junk. */ + markRead?: boolean; + /** The Undo bar's text in place of "Marked as spam". */ + label?: (count: number) => string; + /** Runs once Undo has moved them back (UndoEntry.afterUndo). */ + afterUndo?: () => void; +} + /** * A message acted on from the viewer: the copy the viewer holds and the JMAP * account it lives in (undefined = the user's own). The loaded list only @@ -366,6 +378,8 @@ export interface UndoEntry { undoGroup?: string; /** JMAP account the messages live under; unset for the user's own mail. */ accountId?: string; + /** Runs once the undo is done: a report says it already went out (#124). */ + afterUndo?: () => void; /** * Keywords the action also changed (spam / not spam flip * `$junk`/`$notjunk`). Undo puts just these back to each item's @@ -536,6 +550,14 @@ export interface EmailState { markSpam: (emailIds: string[], viewed?: ViewedEmail) => Promise; /** Inverse of markSpam: back to Inbox with `$notjunk`. */ unmarkSpam: (emailIds: string[], viewed?: ViewedEmail) => Promise; + /** + * File reported messages (#124) into their own accounts' Junk with + * markSpam's options, by account and id rather than by the open list: + * a selection's reports take a while, and the user may have opened + * another folder meanwhile. One Undo for all. Resolves to the ones filed, + * as `:` (accountId empty for the user's own). + */ + fileReported: (targets: ViewedEmail[], opts: SpamOptions) => Promise; // ── Batch (multi-select) actions ────────────────────────────── archiveEmailsBatch: (emailIds: string[]) => Promise; moveEmailsToMailbox: (emailIds: string[], toMailboxId: string) => Promise; @@ -1720,6 +1742,40 @@ export const useEmailStore = create()( for (const e of targets) patchCache(e.id, { mailboxIds: junkTarget, keywords: keywordPatch }, junk.accountId); }, + fileReported: async (targets, opts) => { + if (targets.length === 0) return []; + const own = jmapClient.connectedAccountId ?? undefined; + // Stamped with their account, as rows of a list spanning accounts are. + const stamped = targets.map(({ email, accountId }) => ({ ...email, jmapAccountId: accountId ?? own })); + const before = get().pendingUndo; + try { + await fileAcrossAccounts('spam', stamped, undefined, opts); + } catch (err) { + // Refused for some: the others are filed, and the caller counts them. + console.warn('[email-store] filing reported messages failed for some', err); + } + const entry = get().pendingUndo; + if (!entry || entry === before) return []; + const filed = new Set(entry.items.map((it) => `${it.accountId ?? ''}:${it.email.id}`)); + const unstamped = new Map(targets.map(({ email, accountId }) => [`${accountId ?? ''}:${email.id}`, email])); + const accounts = new Set(entry.items.map((it) => it.accountId ?? '')); + set({ + // The open list's rows of them go, in whatever account it shows. + emails: get().emails.filter((e) => !filed.has(`${rowAccountId(get(), e) ?? ''}:${e.id}`)), + // Undo puts them back into the list they came from, a single folder's + // rows unstamped as they were. + pendingUndo: { + ...entry, + ...(accounts.size === 1 ? { accountId: entry.items[0].accountId } : {}), + items: entry.items.map((it) => ({ + ...it, + email: unstamped.get(`${it.accountId ?? ''}:${it.email.id}`) ?? it.email, + })), + }, + }); + return [...filed]; + }, + unmarkSpam: async (emailIds, viewed) => { const state = get(); // A list spanning accounts files each row in its own account (#1082); @@ -2418,11 +2474,16 @@ export const useEmailStore = create()( mailboxIds: it.originalMailboxIds, ...(patch ? { keywords: patch } : {}), }, accountOf(it)); + // A message opened again reads its keywords from the detail cache + // (a report's `$phishing` turns its links off). + const held = patch ? peekDetail(it.email.id, accountOf(it)) : undefined; + if (held && patch) patchDetail(it.email.id, accountOf(it), { keywords: applyKeywordPatch(held.keywords, patch) }); } } catch (err) { set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') }); return; } + entry.afterUndo?.(); // Re-insert each restored email into the visible list if its original // mailboxIds include the current view. Server is the source of truth for @@ -2627,7 +2688,28 @@ type SpanningAction = 'archive' | 'delete' | 'spam' | 'notSpam' | 'move'; // list is not one folder it could be taken out of. `move` goes to the picked // folder, copying across accounts when it is another account's. One undo // covers all accounts. -async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMailboxId?: string): Promise { +// The keywords "Mark as spam" sets: `$junk`, `$seen` for the trash-and-read +// delete action or a report, and a report's own (`$phishing`). +function spamKeywordPatch(opts?: SpamOptions): KeywordPatch { + const markRead = opts?.markRead || useSettingsStore.getState().deleteAction === 'trash-and-read'; + const patch: KeywordPatch = { $junk: true, $notjunk: null, ...(markRead ? { $seen: true } : {}) }; + for (const keyword of opts?.keywords ?? []) patch[keyword] = true; + return patch; +} + +function spamLabel(count: number, opts?: SpamOptions): string { + if (opts?.label) return opts.label(count); + return count === 1 + ? t('email_list.marked_as_spam', 'Marked as spam') + : t('email_list.marked_as_spam_count', `${count} emails marked as spam`, { count }); +} + +async function fileAcrossAccounts( + action: SpanningAction, + targets: Email[], + toMailboxId?: string, + spam?: SpamOptions, +): Promise { const get = useEmailStore.getState; const set = useEmailStore.setState; const state = get(); @@ -2649,7 +2731,7 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa let missing: string | null = null; let refreshFolders = false; const keywordPatch: KeywordPatch | undefined = - action === 'spam' ? { $junk: true, $notjunk: null, ...(settings.deleteAction === 'trash-and-read' ? { $seen: true } : {}) } + action === 'spam' ? spamKeywordPatch(spam) : action === 'notSpam' ? { $junk: null, $notjunk: true } : undefined; @@ -2756,7 +2838,7 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa fileInto(accountId, emails, destId, keywordPatch); runs.push({ run: () => (action === 'spam' - ? apiMarkAsSpam(ids(emails), destId, accountId, { markRead: settings.deleteAction === 'trash-and-read' }) + ? apiMarkAsSpam(ids(emails), destId, accountId, { markRead: keywordPatch?.$seen === true, keywords: spam?.keywords }) : apiUndoSpam(ids(emails), destId, accountId)), accountId, ids: ids(emails), @@ -2833,7 +2915,7 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa : action === 'delete' ? (count === 1 ? t('email_list.moved_to_trash', 'Email moved to Trash') : t('email_list.moved_to_trash_count', `${count} emails moved to Trash`, { count })) : action === 'spam' - ? (count === 1 ? t('email_list.marked_as_spam', 'Marked as spam') : t('email_list.marked_as_spam_count', `${count} emails marked as spam`, { count })) + ? spamLabel(count, spam) : action === 'notSpam' ? (count === 1 ? t('email_list.marked_not_spam', 'Marked as not spam') : t('email_list.marked_not_spam_count', `${count} emails marked as not spam`, { count })) : targetName @@ -2850,6 +2932,7 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa pendingUndo: { kind: action === 'notSpam' ? 'spam' : action, label, + ...(action === 'spam' && spam?.afterUndo ? { afterUndo: spam.afterUndo } : {}), createdAt: Date.now(), ...(keywordPatch ? { keywordPatch } : {}), items, diff --git a/src/stores/phishing-report-store.ts b/src/stores/phishing-report-store.ts new file mode 100644 index 000000000..f2717d53e --- /dev/null +++ b/src/stores/phishing-report-store.ts @@ -0,0 +1,37 @@ +import { create } from 'zustand'; +import type { Email } from '../api/types'; + +/** + * Messages reported as phishing in this session (#124), the webmail's + * usePhishingReportStore.reported: every view of them turns its links off + * at once, before the server's `$phishing` reaches it, and a message whose + * report went out but couldn't be moved to Junk still counts as reported + * (no second report to the admin). Keyed by reportedKey. + */ +interface PhishingReportState { + reported: Record; + markReported: (keys: string[], reported: boolean) => void; +} + +/** A message's key: its JMAP account (undefined = the user's own) and id. */ +export function reportedKey(id: string, accountId?: string): string { + return `${accountId ?? ''}:${id}`; +} + +export const usePhishingReportStore = create()((set) => ({ + reported: {}, + markReported: (keys, reported) => set((state) => { + const next = { ...state.reported }; + for (const key of keys) { + if (reported) next[key] = true; + else delete next[key]; + } + return { reported: next }; + }), +})); + +/** Whether a message shows as reported: its `$phishing`, or reported in this session. */ +export function usePhishingMarked(email: Pick | null | undefined, accountId?: string): boolean { + const reported = usePhishingReportStore((s) => (email ? s.reported[reportedKey(email.id, accountId)] === true : false)); + return email?.keywords?.$phishing === true || reported; +}