Merge branch 'fx/compose' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 04:20:07 +01:00
69 changed files with 5113 additions and 663 deletions
+40
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "هل نسيت إرفاق ملف؟",
"attachments_too_large_total": "ستتجاوز المرفقات الحد {max} الذي يسمح به هذا الخادم لكل رسالة.",
"editor_unavailable": "تعذّرت قراءة محتوى الرسالة. انسخ النص، ثم أغلق نافذة الإنشاء وأعد فتحها.",
"files_picker": {
"attach": "{count, plural, =0 {إرفاق} one {إرفاق ملف واحد} other {إرفاق # ملفات}}",
"empty_folder": "هذا المجلد فارغ",
"load_failed": "تعذّر تحميل ملفاتك"
},
"from_options": "خيارات المرسل",
"group_chip": "{name} ({count})",
"identity_unavailable": "الهوية غير متاحة",
@@ -292,6 +297,25 @@
"no_sent_folder": "لم يتم العثور على مجلد المرسل",
"permission_camera": "يلزم إذن الكاميرا لالتقاط صورة.",
"permission_photos": "يلزم إذن مكتبة الصور لإرفاق الصور.",
"placeholder_warning": {
"title": "التحقق من العناصر النائبة؟",
"message": "تحتوي رسالتك على نص نائب سيراه المستلمون. هل تريد تعديله قبل الإرسال؟",
"found": "العناصر النائبة التي عُثر عليها",
"more": "و{count} أخرى",
"dont_ask_again": "عدم السؤال مرة أخرى",
"send_anyway": "الإرسال على أي حال",
"back": "العودة إلى التعديل"
},
"placeholder_words": {
"before": [
"أدخل",
"ادخل",
"أضف",
"اكتب"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "إضافة إلى جهات الاتصال",
"recipient_copy": "نسخ العنوان",
"recipient_move_to": "نقل إلى {field}",
@@ -431,6 +455,14 @@
"remove_color": "إزالة الوسم",
"replied": "تم الرد عليها",
"reschedule_recipients_refused": "لم يتغيّر شيء: رفض الخادم {recipients}.",
"save_to_files": {
"title": "حفظ في الملفات",
"save_here": "حفظ هنا",
"saved": "حُفظ في الملفات",
"saved_to": "في {folder}",
"failed": "تعذّر الحفظ في الملفات",
"load_failed": "تعذّر تحميل مجلداتك. حاول مرة أخرى."
},
"sender_actions": "المرسل…",
"set_color": "تعيين وسم",
"show_all_count": "عرض الكل ({count})",
@@ -625,6 +657,10 @@
"up_to_date": "محدّث بالفعل"
}
},
"org_templates": {
"personal_in_account": "تُحفظ قوالبك في حسابك، لذا تجدها على كل جهاز تسجّل الدخول منه. يمكن لأي شخص تشاركه بريدك الوارد قراءتها.",
"section_personal": "قوالبي"
},
"push_prompt": {
"body": "فعّل الإشعارات في الخلفية لتصلك الرسائل الجديدة حتى عندما يكون التطبيق مغلقًا.",
"dismiss": "ليس الآن",
@@ -820,6 +856,10 @@
"ask_mobile": "السؤال",
"block_mobile": "الحظر"
},
"placeholder_warning": {
"label": "تحذير العناصر النائبة",
"description": "طلب تأكيد قبل إرسال رسالة لا تزال تحتوي على عناصر نائبة مثل [TIME] أو [أدخل الاسم]، أو حقول قالب فارغة، أو نص غير مكتمل مثل TODO أو TBD."
},
"plain_text_mode": {
"description_mobile": "الكتابة والقراءة بنص عادي فقط."
},
+45
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Us heu oblidat d'un fitxer adjunt?",
"attachments_too_large_total": "Els fitxers adjunts superarien els {max} que aquest servidor permet per missatge.",
"editor_unavailable": "No s'ha pogut llegir el contingut del missatge. Copieu el text i, després, tanqueu i torneu a obrir el redactor.",
"files_picker": {
"attach": "{count, plural, =0 {Adjunta} one {Adjunta 1 fitxer} other {Adjunta # fitxers}}",
"empty_folder": "Aquesta carpeta és buida",
"load_failed": "No s'han pogut carregar els teus fitxers"
},
"from_options": "Opcions del remitent",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identitat no disponible",
@@ -292,6 +297,30 @@
"no_sent_folder": "No s'ha trobat la carpeta Enviats",
"permission_camera": "Cal permís per a la càmera per fer una foto.",
"permission_photos": "Cal permís per a la galeria de fotos per adjuntar imatges.",
"placeholder_warning": {
"title": "Voleu revisar els marcadors?",
"message": "El missatge té text de marcador que veuran els destinataris. Voleu editar-lo abans d'enviar-lo?",
"found": "Marcadors trobats",
"more": "i {count} més",
"dont_ask_again": "No ho tornis a preguntar",
"send_anyway": "Envia igualment",
"back": "Torna a l'edició"
},
"placeholder_words": {
"before": [
"el teu",
"la teva",
"el vostre",
"la vostra",
"insereix",
"inseriu",
"afegeix",
"afegiu",
"escriviu"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Afegeix als contactes",
"recipient_copy": "Copia l'adreça",
"recipient_move_to": "Mou a {field}",
@@ -431,6 +460,14 @@
"remove_color": "Elimina l'etiqueta",
"replied": "Respost",
"reschedule_recipients_refused": "No s'ha canviat res: el servidor ha rebutjat {recipients}.",
"save_to_files": {
"title": "Desa a Fitxers",
"save_here": "Desa aquí",
"saved": "S'ha desat a Fitxers",
"saved_to": "A {folder}",
"failed": "No s'ha pogut desar a Fitxers",
"load_failed": "No s'han pogut carregar les carpetes. Torna-ho a provar."
},
"sender_actions": "Remitent…",
"set_color": "Estableix l'etiqueta",
"show_all_count": "Mostra-ho tot ({count})",
@@ -625,6 +662,10 @@
"up_to_date": "Ja està al dia"
}
},
"org_templates": {
"personal_in_account": "Les vostres plantilles es desen al vostre compte, així que les teniu a tots els dispositius on inicieu la sessió. Qualsevol persona amb qui compartiu la safata d'entrada les pot llegir.",
"section_personal": "Les meves plantilles"
},
"push_prompt": {
"body": "Activeu les notificacions en segon pla perquè els missatges nous us arribin amb l'aplicació tancada.",
"dismiss": "Ara no",
@@ -820,6 +861,10 @@
"ask_mobile": "Pregunta",
"block_mobile": "Bloqueja"
},
"placeholder_warning": {
"label": "Avís de marcadors",
"description": "Demana confirmació abans d'enviar un missatge que encara té marcadors com [TIME] o [El teu nom], camps de plantilla buits o text inacabat com TODO o TBD."
},
"plain_text_mode": {
"description_mobile": "Redacta i llegeix només en text sense format."
},
+45
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Nezapomněli jste přílohu?",
"attachments_too_large_total": "Přílohy by překročily limit {max}, který tento server povoluje na jednu zprávu.",
"editor_unavailable": "Obsah zprávy se nepodařilo načíst. Zkopírujte svůj text a poté editor zavřete a znovu otevřete.",
"files_picker": {
"attach": "{count, plural, =0 {Připojit} one {Připojit 1 soubor} few {Připojit # soubory} other {Připojit # souborů}}",
"empty_folder": "Tato složka je prázdná",
"load_failed": "Vaše soubory se nepodařilo načíst"
},
"from_options": "Možnosti odesílatele",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identita není k dispozici",
@@ -292,6 +297,30 @@
"no_sent_folder": "Složka Odeslané nebyla nalezena",
"permission_camera": "K pořízení fotky je potřeba oprávnění k fotoaparátu.",
"permission_photos": "K připojení obrázků je potřeba oprávnění ke knihovně fotek.",
"placeholder_warning": {
"title": "Zkontrolovat zástupné texty?",
"message": "Vaše zpráva obsahuje zástupný text, který příjemci uvidí. Chcete ji před odesláním upravit?",
"found": "Nalezené zástupné texty",
"more": "a další ({count})",
"dont_ask_again": "Znovu se neptat",
"send_anyway": "Přesto odeslat",
"back": "Zpět k úpravám"
},
"placeholder_words": {
"before": [
"vaše",
"váš",
"vaši",
"tvoje",
"tvůj",
"vložte",
"doplňte",
"zadejte",
"uveďte"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Přidat do kontaktů",
"recipient_copy": "Kopírovat adresu",
"recipient_move_to": "Přesunout do pole {field}",
@@ -431,6 +460,14 @@
"remove_color": "Odebrat štítek",
"replied": "Zodpovězeno",
"reschedule_recipients_refused": "Nic se nezměnilo: server odmítl {recipients}.",
"save_to_files": {
"title": "Uložit do Souborů",
"save_here": "Uložit sem",
"saved": "Uloženo do Souborů",
"saved_to": "Do složky {folder}",
"failed": "Nepodařilo se uložit do Souborů",
"load_failed": "Složky se nepodařilo načíst. Zkuste to znovu."
},
"sender_actions": "Odesílatel…",
"set_color": "Nastavit štítek",
"show_all_count": "Zobrazit vše ({count})",
@@ -625,6 +662,10 @@
"up_to_date": "Vše je aktuální"
}
},
"org_templates": {
"personal_in_account": "Vaše šablony se ukládají ve vašem účtu, takže je máte na každém zařízení, kde se přihlásíte. Může je číst každý, s kým sdílíte složku Doručené.",
"section_personal": "Moje šablony"
},
"push_prompt": {
"body": "Zapněte oznámení na pozadí, aby vám nové zprávy chodily, i když je aplikace zavřená.",
"dismiss": "Teď ne",
@@ -820,6 +861,10 @@
"ask_mobile": "Ptát se",
"block_mobile": "Blokovat"
},
"placeholder_warning": {
"label": "Upozornění na zástupné texty",
"description": "Před odesláním zprávy, která stále obsahuje zástupné texty jako [TIME] nebo [Vaše jméno], nevyplněná pole šablony nebo nedokončený text jako TODO či TBD, požádat o potvrzení."
},
"plain_text_mode": {
"description_mobile": "Psát a číst pouze v prostém textu."
},
+42
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Glemt en vedhæftning?",
"attachments_too_large_total": "Vedhæftningerne ville overskride de {max}, som denne server tillader pr. besked.",
"editor_unavailable": "Beskedens indhold kunne ikke læses. Kopiér din tekst, og luk og genåbn derefter editoren.",
"files_picker": {
"attach": "{count, plural, =0 {Vedhæft} one {Vedhæft 1 fil} other {Vedhæft # filer}}",
"empty_folder": "Denne mappe er tom",
"load_failed": "Dine filer kunne ikke indlæses"
},
"from_options": "Afsenderindstillinger",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identitet ikke tilgængelig",
@@ -292,6 +297,27 @@
"no_sent_folder": "Ingen Sendt-mappe fundet",
"permission_camera": "Der kræves kameratilladelse for at tage et foto.",
"permission_photos": "Der kræves tilladelse til fotobiblioteket for at vedhæfte billeder.",
"placeholder_warning": {
"title": "Tjek pladsholdere?",
"message": "Din besked indeholder pladsholdertekst, som modtagerne vil se. Vil du redigere den, før du sender?",
"found": "Fundne pladsholdere",
"more": "og {count} mere",
"dont_ask_again": "Spørg ikke igen",
"send_anyway": "Send alligevel",
"back": "Tilbage til redigering"
},
"placeholder_words": {
"before": [
"din",
"dit",
"dine",
"indsæt",
"tilføj",
"skriv"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Tilføj til kontakter",
"recipient_copy": "Kopiér adresse",
"recipient_move_to": "Flyt til {field}",
@@ -431,6 +457,14 @@
"remove_color": "Fjern tag",
"replied": "Besvaret",
"reschedule_recipients_refused": "Intet blev ændret: serveren afviste {recipients}.",
"save_to_files": {
"title": "Gem i Filer",
"save_here": "Gem her",
"saved": "Gemt i Filer",
"saved_to": "I {folder}",
"failed": "Kunne ikke gemme i Filer",
"load_failed": "Mapperne kunne ikke indlæses. Prøv igen."
},
"sender_actions": "Afsender…",
"set_color": "Sæt tag",
"show_all_count": "Vis alle ({count})",
@@ -625,6 +659,10 @@
"up_to_date": "Allerede opdateret"
}
},
"org_templates": {
"personal_in_account": "Dine skabeloner gemmes på din konto, så du har dem på alle enheder, du logger ind på. Alle, du deler din indbakke med, kan læse dem.",
"section_personal": "Mine skabeloner"
},
"push_prompt": {
"body": "Slå baggrundsnotifikationer til, så nye beskeder når frem til dig, mens appen er lukket.",
"dismiss": "Ikke nu",
@@ -820,6 +858,10 @@
"ask_mobile": "Spørg",
"block_mobile": "Blokér"
},
"placeholder_warning": {
"label": "Advarsel om pladsholdere",
"description": "Bed om bekræftelse, før en besked sendes, der stadig har pladsholdere som [TIME] eller [Dit navn], tomme skabelonfelter eller ufærdig tekst som TODO eller TBD."
},
"plain_text_mode": {
"description_mobile": "Skriv og læs kun i ren tekst."
},
+46
View File
@@ -286,6 +286,11 @@
"attachment_reminder_title": "Anhang vergessen?",
"attachments_too_large_total": "Die Anhänge würden die {max} überschreiten, die dieser Server pro Nachricht erlaubt.",
"editor_unavailable": "Der Nachrichteninhalt konnte nicht gelesen werden. Kopieren Sie Ihren Text, schließen Sie dann den Editor und öffnen Sie ihn erneut.",
"files_picker": {
"attach": "{count, plural, =0 {Anhängen} one {1 Datei anhängen} other {# Dateien anhängen}}",
"empty_folder": "Dieser Ordner ist leer",
"load_failed": "Ihre Dateien konnten nicht geladen werden"
},
"from_options": "Absenderoptionen",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identität nicht verfügbar",
@@ -294,6 +299,31 @@
"no_sent_folder": "Kein Gesendet-Ordner gefunden",
"permission_camera": "Zum Aufnehmen eines Fotos ist die Kameraberechtigung erforderlich.",
"permission_photos": "Zum Anhängen von Bildern ist die Berechtigung für die Fotomediathek erforderlich.",
"placeholder_warning": {
"title": "Platzhalter prüfen?",
"message": "Die Nachricht enthält Platzhaltertext, den die Empfänger sehen. Vor dem Senden bearbeiten?",
"found": "Gefundene Platzhalter",
"more": "und {count} weitere",
"dont_ask_again": "Nicht mehr fragen",
"send_anyway": "Trotzdem senden",
"back": "Zurück zur Bearbeitung"
},
"placeholder_words": {
"before": [
"ihr",
"ihre",
"ihren",
"dein",
"deine",
"deinen"
],
"after": [
"einfügen",
"eintragen",
"ergänzen"
],
"unfinished": []
},
"recipient_add_contact": "Zu Kontakten hinzufügen",
"recipient_copy": "Adresse kopieren",
"recipient_move_to": "Verschieben nach {field}",
@@ -437,6 +467,14 @@
"remove_color": "Label entfernen",
"replied": "Beantwortet",
"reschedule_recipients_refused": "Nichts wurde geändert: Der Server hat {recipients} abgelehnt.",
"save_to_files": {
"title": "In Dateien speichern",
"save_here": "Hier speichern",
"saved": "In Dateien gespeichert",
"saved_to": "In {folder}",
"failed": "Konnte nicht in Dateien gespeichert werden",
"load_failed": "Ihre Ordner konnten nicht geladen werden. Versuchen Sie es erneut."
},
"sender_actions": "Absender…",
"set_color": "Label setzen",
"show_all_count": "Alle anzeigen ({count})",
@@ -632,6 +670,10 @@
"up_to_date": "Bereits aktuell"
}
},
"org_templates": {
"personal_in_account": "Ihre Vorlagen werden in Ihrem Konto gespeichert, Sie haben sie also auf jedem Gerät, auf dem Sie sich anmelden. Alle, für die Sie Ihren Posteingang freigeben, können sie lesen.",
"section_personal": "Meine Vorlagen"
},
"push_prompt": {
"body": "Aktivieren Sie Hintergrundbenachrichtigungen, damit neue Nachrichten Sie auch bei geschlossener App erreichen.",
"dismiss": "Nicht jetzt",
@@ -829,6 +871,10 @@
"ask_mobile": "Fragen",
"block_mobile": "Blockieren"
},
"placeholder_warning": {
"label": "Warnung bei Platzhaltern",
"description": "Vor dem Senden einer Nachricht nachfragen, die noch Platzhalter wie [TIME] oder [Ihr Name], leere Vorlagenfelder oder unfertigen Text wie TODO oder TBD enthält."
},
"plain_text_mode": {
"description_mobile": "Nur im Klartext verfassen und lesen."
},
+60
View File
@@ -310,12 +310,18 @@
"attachment_reminder_title": "Forgot an attachment?",
"attachments_too_large_total": "The attachments would exceed the {max} this server allows per message.",
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
"files_picker": {
"attach": "{count, plural, =0 {Attach} one {Attach 1 file} other {Attach # files}}",
"empty_folder": "This folder is empty",
"load_failed": "Your files could not be loaded"
},
"format_plain_text": "Switch to plain text",
"format_rich_text": "Switch to rich text (HTML)",
"from_options": "Sender options",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identity unavailable",
"image_load_failed": "Could not load image",
"login_switched_attach_body": "You started this message in {account}. Switch back to that account to attach files.",
"login_switched_body": "You started this message in {account}. Switch back to that account to send it.",
"login_switched_save_body": "You started this message in {account}. Switch back to that account to save it.",
"login_switched_title": "Account switched",
@@ -323,6 +329,32 @@
"no_sent_folder": "No Sent folder found",
"permission_camera": "Camera permission is required to take a photo.",
"permission_photos": "Photo library permission is required to attach images.",
"placeholder_warning": {
"back": "Back to editing",
"dont_ask_again": "Don't ask again",
"found": "Placeholders found",
"message": "Your message has placeholder text that recipients will see. Do you want to edit it before sending?",
"more": "and {count} more",
"send_anyway": "Send anyway",
"title": "Check placeholders?"
},
"placeholder_words": {
"after": [],
"before": [
"your",
"insert",
"enter",
"add"
],
"unfinished": [
"TODO",
"TBD",
"TBA",
"TBC",
"FIXME",
"XXX"
]
},
"read_receipt_off": "Request a read receipt",
"recipient_add_contact": "Add to contacts",
"recipient_copy": "Copy address",
@@ -351,6 +383,8 @@
"send_now": "Send now",
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
"send_timeout_title": "No answer from the server",
"send_unconfirmed_body": "The server did not confirm the send. Check your Sent folder before sending again. Your draft has been kept.",
"send_unconfirmed_title": "Send not confirmed",
"submission_limit": "The server refused to send the message because a limit was reached: {reason}",
"submission_quota": "The server has too many sent-message records for this account. Bulwark removed the ones it could; if this keeps happening, ask your administrator to raise the email submission limit.",
"toolbar": {
@@ -358,7 +392,10 @@
"remove_link": "Remove link"
},
"undo_send_label": "Sending in {seconds}s…",
"undo_send_other_login": "This message was sent from another account",
"undo_send_other_login_body": "Switch back to {account} to undo it or send it now. It goes out when the delay ends.",
"upload_failed_short": "Upload failed",
"upload_login_switched": "Not uploaded: the account was switched",
"uploading": "Uploading...",
"uploading_pct": "Uploading {pct}%",
"use_template": "Template",
@@ -464,10 +501,20 @@
"pdf_unreadable": "This PDF can't be shown here.",
"unsupported": "No preview for this file type."
},
"quick_reply_login_switched": "This message is in another account. Switch back to it to reply.",
"quick_reply_unconfirmed": "The server did not confirm the send. Check your Sent folder before sending again.",
"received_via": "to {name}",
"remove_color": "Remove tag",
"replied": "Replied",
"reschedule_recipients_refused": "Nothing was changed: the server refused {recipients}.",
"save_to_files": {
"failed": "Couldn't save to Files",
"load_failed": "Couldn't load your folders. Try again.",
"save_here": "Save here",
"saved": "Saved to Files",
"saved_to": "In {folder}",
"title": "Save to Files"
},
"sender_actions": "Sender…",
"set_color": "Set tag",
"show_all_count": "Show all ({count})",
@@ -689,6 +736,10 @@
"up_to_date": "Already up to date"
}
},
"org_templates": {
"personal_in_account": "Your templates are kept in your account, so you have them on every device you sign in on. Anyone you share your Inbox with can read them.",
"section_personal": "My templates"
},
"push": {
"ios": {
"more_messages": "and {count} more",
@@ -702,13 +753,18 @@
"title": "Get notified about new mail"
},
"scheduled": {
"actions": "Actions",
"already_sent": "This message was already sent.",
"cancel_body": "The message will not be delivered. A copy stays in your Sent folder.",
"cancel_failed": "Cancel failed",
"cancel_to_drafts_body": "The message will not be sent. It goes back to your Drafts.",
"edit_failed": "Could not open the message for editing",
"keep_scheduled": "Keep scheduled",
"no_longer_scheduled": "This message is no longer scheduled.",
"reschedule_failed": "Reschedule failed",
"reschedule_lost": "The message is no longer scheduled. Check Drafts and Sent before sending it again.",
"reschedule_lost_drafts": "The message is no longer scheduled. It is back in Drafts, where you can send it again.",
"send_cancelled": "Send cancelled",
"send_now_failed": "Could not send now"
},
"settings": {
@@ -918,6 +974,10 @@
"ask_mobile": "Ask",
"block_mobile": "Block"
},
"placeholder_warning": {
"description": "Ask for confirmation before sending a message that still has placeholders like [TIME] or [Your Name], template fields left empty, or unfinished text like TODO or TBD.",
"label": "Placeholder warning"
},
"plain_text_mode": {
"description_mobile": "Compose and read in plain text only."
},
+51
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "¿Olvidaste un adjunto?",
"attachments_too_large_total": "Los adjuntos superarían los {max} que este servidor permite por mensaje.",
"editor_unavailable": "No se pudo leer el contenido del mensaje. Copia tu texto y luego cierra y vuelve a abrir el editor.",
"files_picker": {
"attach": "{count, plural, =0 {Adjuntar} one {Adjuntar 1 archivo} other {Adjuntar # archivos}}",
"empty_folder": "Esta carpeta está vacía",
"load_failed": "No se pudieron cargar tus archivos"
},
"from_options": "Opciones de remitente",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identidad no disponible",
@@ -292,6 +297,36 @@
"no_sent_folder": "No se encontró la carpeta Enviados",
"permission_camera": "Se necesita permiso de cámara para tomar una foto.",
"permission_photos": "Se necesita acceso a la galería de fotos para adjuntar imágenes.",
"placeholder_warning": {
"title": "¿Revisar los marcadores?",
"message": "El mensaje tiene texto de marcador que verán los destinatarios. ¿Editarlo antes de enviar?",
"found": "Marcadores encontrados",
"more": "y {count} más",
"dont_ask_again": "No volver a preguntar",
"send_anyway": "Enviar de todos modos",
"back": "Volver a editar"
},
"placeholder_words": {
"before": [
"tu",
"tus",
"su",
"sus",
"insertar",
"inserte",
"inserta",
"añadir",
"añade",
"agregar",
"agrega",
"escribe",
"escriba",
"indica",
"indique"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Añadir a contactos",
"recipient_copy": "Copiar dirección",
"recipient_move_to": "Mover a {field}",
@@ -431,6 +466,14 @@
"remove_color": "Eliminar etiqueta",
"replied": "Respondido",
"reschedule_recipients_refused": "No se cambió nada: el servidor rechazó {recipients}.",
"save_to_files": {
"title": "Guardar en Archivos",
"save_here": "Guardar aquí",
"saved": "Guardado en Archivos",
"saved_to": "En {folder}",
"failed": "No se pudo guardar en Archivos",
"load_failed": "No se pudieron cargar tus carpetas. Vuelve a intentarlo."
},
"sender_actions": "Remitente…",
"set_color": "Establecer etiqueta",
"show_all_count": "Mostrar todo ({count})",
@@ -625,6 +668,10 @@
"up_to_date": "Ya está al día"
}
},
"org_templates": {
"personal_in_account": "Tus plantillas se guardan en tu cuenta, así que las tienes en todos los dispositivos en los que inicias sesión. Cualquier persona con quien compartas tu bandeja de entrada puede leerlas.",
"section_personal": "Mis plantillas"
},
"push_prompt": {
"body": "Activa las notificaciones en segundo plano para recibir los mensajes nuevos aunque la app esté cerrada.",
"dismiss": "Ahora no",
@@ -820,6 +867,10 @@
"ask_mobile": "Preguntar",
"block_mobile": "Bloquear"
},
"placeholder_warning": {
"label": "Aviso de marcadores",
"description": "Pedir confirmación antes de enviar un mensaje que todavía tiene marcadores como [TIME] o [Tu nombre], campos de plantilla vacíos o texto sin terminar como TODO o TBD."
},
"plain_text_mode": {
"description_mobile": "Redactar y leer solo en texto sin formato."
},
+39
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "پیوست را فراموش کرده‌اید؟",
"attachments_too_large_total": "پیوست‌ها از سقف {max} که این سرور برای هر پیام مجاز می‌داند بیشتر می‌شوند.",
"editor_unavailable": "خواندن محتوای پیام ممکن نشد. متن خود را کپی کنید، سپس ویرایشگر را ببندید و دوباره باز کنید.",
"files_picker": {
"attach": "{count, plural, =0 {پیوست} one {پیوست ۱ فایل} other {پیوست # فایل}}",
"empty_folder": "این پوشه خالی است",
"load_failed": "فایل‌های شما بارگیری نشد"
},
"from_options": "گزینه‌های فرستنده",
"group_chip": "{name} ({count})",
"identity_unavailable": "هویت در دسترس نیست",
@@ -292,6 +297,24 @@
"no_sent_folder": "پوشه ارسال شده پیدا نشد",
"permission_camera": "برای گرفتن عکس، دسترسی به دوربین لازم است.",
"permission_photos": "برای پیوست کردن تصاویر، دسترسی به گالری عکس لازم است.",
"placeholder_warning": {
"title": "متن‌های جایگزین بررسی شود؟",
"message": "پیام شما متن جایگزینی دارد که گیرندگان آن را می‌بینند. می‌خواهید پیش از ارسال آن را ویرایش کنید؟",
"found": "متن‌های جایگزین یافت‌شده",
"more": "و {count} مورد دیگر",
"dont_ask_again": "دیگر نپرس",
"send_anyway": "ارسال به هر حال",
"back": "بازگشت به ویرایش"
},
"placeholder_words": {
"before": [],
"after": [
"شما",
"وارد کنید",
"اضافه کنید"
],
"unfinished": []
},
"recipient_add_contact": "افزودن به مخاطبین",
"recipient_copy": "کپی نشانی",
"recipient_move_to": "انتقال به {field}",
@@ -431,6 +454,14 @@
"remove_color": "حذف برچسب",
"replied": "پاسخ داده شده",
"reschedule_recipients_refused": "چیزی تغییر نکرد: سرور {recipients} را رد کرد.",
"save_to_files": {
"title": "ذخیره در فایل‌ها",
"save_here": "ذخیره در اینجا",
"saved": "در فایل‌ها ذخیره شد",
"saved_to": "در {folder}",
"failed": "ذخیره در فایل‌ها ممکن نشد",
"load_failed": "پوشه‌ها بارگذاری نشدند. دوباره امتحان کنید."
},
"sender_actions": "فرستنده…",
"set_color": "تنظیم برچسب",
"show_all_count": "نمایش همه ({count})",
@@ -625,6 +656,10 @@
"up_to_date": "از قبل به‌روز است"
}
},
"org_templates": {
"personal_in_account": "قالب‌های شما در حسابتان نگه داشته می‌شوند، بنابراین در هر دستگاهی که وارد شوید آن‌ها را دارید. هر کسی که صندوق ورودی خود را با او به اشتراک گذاشته‌اید می‌تواند آن‌ها را بخواند.",
"section_personal": "قالب‌های من"
},
"push_prompt": {
"body": "اعلان‌های پس‌زمینه را روشن کنید تا پیام‌های جدید حتی وقتی برنامه بسته است به دستتان برسد.",
"dismiss": "اکنون نه",
@@ -820,6 +855,10 @@
"ask_mobile": "بپرس",
"block_mobile": "مسدود"
},
"placeholder_warning": {
"label": "هشدار متن جایگزین",
"description": "پیش از ارسال پیامی که هنوز متن جایگزین مانند [TIME] یا [نام شما]، فیلدهای خالی الگو یا متن ناتمام مانند TODO یا TBD دارد، تأیید بگیرید."
},
"plain_text_mode": {
"description_mobile": "نوشتن و خواندن فقط به‌صورت متن ساده."
},
+49
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Pièce jointe oubliée ?",
"attachments_too_large_total": "Les pièces jointes dépasseraient les {max} autorisés par ce serveur pour un message.",
"editor_unavailable": "Impossible de lire le contenu du message. Copiez votre texte, puis fermez et rouvrez l'éditeur.",
"files_picker": {
"attach": "{count, plural, =0 {Joindre} one {Joindre 1 fichier} other {Joindre # fichiers}}",
"empty_folder": "Ce dossier est vide",
"load_failed": "Impossible de charger vos fichiers"
},
"from_options": "Options d'expéditeur",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identité indisponible",
@@ -292,6 +297,34 @@
"no_sent_folder": "Aucun dossier Envoyés trouvé",
"permission_camera": "L'autorisation d'accès à l'appareil photo est nécessaire pour prendre une photo.",
"permission_photos": "L'autorisation d'accès à la photothèque est nécessaire pour joindre des images.",
"placeholder_warning": {
"title": "Vérifier les espaces réservés ?",
"message": "Votre message contient du texte d'espace réservé que les destinataires verront. Voulez-vous le modifier avant l'envoi ?",
"found": "Espaces réservés trouvés",
"more": "et {count} de plus",
"dont_ask_again": "Ne plus me demander",
"send_anyway": "Envoyer quand même",
"back": "Retour à l'édition"
},
"placeholder_words": {
"before": [
"votre",
"vos",
"ton",
"ta",
"tes",
"insérer",
"insérez",
"ajouter",
"ajoutez",
"saisir",
"saisissez",
"indiquer",
"indiquez"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Ajouter aux contacts",
"recipient_copy": "Copier l'adresse",
"recipient_move_to": "Déplacer vers {field}",
@@ -431,6 +464,14 @@
"remove_color": "Retirer l'étiquette",
"replied": "Répondu",
"reschedule_recipients_refused": "Rien n’a été modifié : le serveur a refusé {recipients}.",
"save_to_files": {
"title": "Enregistrer dans Fichiers",
"save_here": "Enregistrer ici",
"saved": "Enregistré dans Fichiers",
"saved_to": "Dans {folder}",
"failed": "Impossible d'enregistrer dans Fichiers",
"load_failed": "Impossible de charger vos dossiers. Réessayez."
},
"sender_actions": "Expéditeur…",
"set_color": "Définir l'étiquette",
"show_all_count": "Tout afficher ({count})",
@@ -625,6 +666,10 @@
"up_to_date": "Déjà à jour"
}
},
"org_templates": {
"personal_in_account": "Vos modèles sont conservés dans votre compte, vous les retrouvez donc sur chaque appareil où vous vous connectez. Toute personne avec qui vous partagez votre boîte de réception peut les lire.",
"section_personal": "Mes modèles"
},
"push_prompt": {
"body": "Activez les notifications en arrière-plan pour recevoir les nouveaux messages même lorsque l'application est fermée.",
"dismiss": "Pas maintenant",
@@ -820,6 +865,10 @@
"ask_mobile": "Demander",
"block_mobile": "Bloquer"
},
"placeholder_warning": {
"label": "Avertissement d'espaces réservés",
"description": "Demander confirmation avant d'envoyer un message qui contient encore des espaces réservés comme [TIME] ou [Votre nom], des champs de modèle vides ou du texte inachevé comme TODO ou TBD."
},
"plain_text_mode": {
"description_mobile": "Rédiger et lire uniquement en texte brut."
},
+43
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "שכחת קובץ מצורף?",
"attachments_too_large_total": "הקבצים המצורפים יחרגו מהמגבלה של {max} להודעה שהשרת הזה מאפשר.",
"editor_unavailable": "לא ניתן היה לקרוא את תוכן ההודעה. העתק את הטקסט, ואז סגור ופתח מחדש את חלון הכתיבה.",
"files_picker": {
"attach": "{count, plural, =0 {צירוף} one {צירוף קובץ 1} other {צירוף # קבצים}}",
"empty_folder": "התיקייה הזו ריקה",
"load_failed": "לא ניתן היה לטעון את הקבצים שלך"
},
"from_options": "אפשרויות שולח",
"group_chip": "{name} ({count})",
"identity_unavailable": "הזהות אינה זמינה",
@@ -292,6 +297,28 @@
"no_sent_folder": "לא נמצאה תיקיית \"נשלח\"",
"permission_camera": "נדרשת הרשאת מצלמה כדי לצלם תמונה.",
"permission_photos": "נדרשת הרשאה לספריית התמונות כדי לצרף תמונות.",
"placeholder_warning": {
"title": "לבדוק מצייני מיקום?",
"message": "ההודעה מכילה טקסט מציין מיקום שהנמענים יראו. לערוך אותה לפני השליחה?",
"found": "מצייני מיקום שנמצאו",
"more": "ועוד {count}",
"dont_ask_again": "אל תשאל שוב",
"send_anyway": "שלח בכל זאת",
"back": "חזור לעריכה"
},
"placeholder_words": {
"before": [
"הכנס",
"הכניסו",
"הזן",
"הוסף"
],
"after": [
"שלך",
"שלכם"
],
"unfinished": []
},
"recipient_add_contact": "הוסף לאנשי הקשר",
"recipient_copy": "העתק כתובת",
"recipient_move_to": "העבר אל {field}",
@@ -431,6 +458,14 @@
"remove_color": "הסר תג",
"replied": "נענה",
"reschedule_recipients_refused": "דבר לא השתנה: השרת דחה את {recipients}.",
"save_to_files": {
"title": "שמירה בקבצים",
"save_here": "שמירה כאן",
"saved": "נשמר בקבצים",
"saved_to": "ב{folder}",
"failed": "לא ניתן היה לשמור בקבצים",
"load_failed": "לא ניתן היה לטעון את התיקיות. נסו שוב."
},
"sender_actions": "שולח…",
"set_color": "הגדר תג",
"show_all_count": "הצג הכול ({count})",
@@ -625,6 +660,10 @@
"up_to_date": "כבר מעודכן"
}
},
"org_templates": {
"personal_in_account": "התבניות שלך נשמרות בחשבון שלך, כך שהן זמינות בכל מכשיר שאתה מתחבר ממנו. כל מי ששיתפת איתו את הדואר הנכנס יכול לקרוא אותן.",
"section_personal": "התבניות שלי"
},
"push_prompt": {
"body": "הפעל התראות ברקע כדי שהודעות חדשות יגיעו אליך גם כשהאפליקציה סגורה.",
"dismiss": "לא עכשיו",
@@ -820,6 +859,10 @@
"ask_mobile": "שאל",
"block_mobile": "חסום"
},
"placeholder_warning": {
"label": "אזהרה על מצייני מיקום",
"description": "בקש אישור לפני שליחת הודעה שעדיין יש בה מצייני מיקום כמו [TIME] או [השם שלך], שדות תבנית ריקים או טקסט לא גמור כמו TODO או TBD."
},
"plain_text_mode": {
"description_mobile": "כתיבה וקריאה בטקסט רגיל בלבד."
},
+41
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Elfelejtetted a csatolmányt?",
"attachments_too_large_total": "A csatolmányok túllépnék a szerver által üzenetenként engedélyezett méretet ({max}).",
"editor_unavailable": "Nem sikerült beolvasni az üzenet tartalmát. Másold ki a szöveget, majd zárd be és nyisd meg újra a szerkesztőt.",
"files_picker": {
"attach": "{count, plural, =0 {Csatolás} one {1 fájl csatolása} other {# fájl csatolása}}",
"empty_folder": "Ez a mappa üres",
"load_failed": "A fájlokat nem sikerült betölteni"
},
"from_options": "Feladó beállításai",
"group_chip": "{name} ({count})",
"identity_unavailable": "Az azonosság nem érhető el",
@@ -292,6 +297,26 @@
"no_sent_folder": "Nem található Elküldött mappa",
"permission_camera": "Fénykép készítéséhez kameraengedély szükséges.",
"permission_photos": "Képek csatolásához hozzáférés szükséges a fotókhoz.",
"placeholder_warning": {
"title": "Ellenőrzi a helykitöltőket?",
"message": "Az üzenet olyan helykitöltő szöveget tartalmaz, amelyet a címzettek is látni fognak. Szerkeszti küldés előtt?",
"found": "Talált helykitöltők",
"more": "és még {count}",
"dont_ask_again": "Ne kérdezze többé",
"send_anyway": "Küldés így is",
"back": "Vissza a szerkesztéshez"
},
"placeholder_words": {
"before": [
"az ön",
"a te",
"írja be",
"illessze be",
"adja meg"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Hozzáadás a névjegyekhez",
"recipient_copy": "Cím másolása",
"recipient_move_to": "Áthelyezés ide: {field}",
@@ -431,6 +456,14 @@
"remove_color": "Címke eltávolítása",
"replied": "Megválaszolva",
"reschedule_recipients_refused": "Semmi sem változott: a kiszolgáló elutasította a következőt: {recipients}.",
"save_to_files": {
"title": "Mentés a Fájlokba",
"save_here": "Mentés ide",
"saved": "Mentve a Fájlokba",
"saved_to": "Ide: {folder}",
"failed": "Nem sikerült menteni a Fájlokba",
"load_failed": "Nem sikerült betölteni a mappákat. Próbálja újra."
},
"sender_actions": "Feladó…",
"set_color": "Címke beállítása",
"show_all_count": "Összes megjelenítése ({count})",
@@ -625,6 +658,10 @@
"up_to_date": "Már minden naprakész"
}
},
"org_templates": {
"personal_in_account": "A sablonjaidat a fiókod tárolja, így minden eszközön megvannak, ahol bejelentkezel. Bárki elolvashatja őket, akivel megosztod a Beérkező leveleket.",
"section_personal": "Saját sablonok"
},
"push_prompt": {
"body": "Kapcsold be a háttérértesítéseket, hogy az új üzenetekről akkor is értesülj, amikor az alkalmazás be van zárva.",
"dismiss": "Később",
@@ -820,6 +857,10 @@
"ask_mobile": "Rákérdezés",
"block_mobile": "Blokkolás"
},
"placeholder_warning": {
"label": "Figyelmeztetés helykitöltők esetén",
"description": "Megerősítés kérése olyan üzenet küldése előtt, amelyben még helykitöltők vannak, például [TIME] vagy [Az Ön neve], üres sablonmezők vagy befejezetlen szöveg, például TODO vagy TBD."
},
"plain_text_mode": {
"description_mobile": "Írás és olvasás csak egyszerű szövegként."
},
+47
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Hai dimenticato un allegato?",
"attachments_too_large_total": "Gli allegati supererebbero i {max} consentiti da questo server per messaggio.",
"editor_unavailable": "Impossibile leggere il contenuto del messaggio. Copia il testo, poi chiudi e riapri l'editor.",
"files_picker": {
"attach": "{count, plural, =0 {Allega} one {Allega 1 file} other {Allega # file}}",
"empty_folder": "Questa cartella è vuota",
"load_failed": "Impossibile caricare i tuoi file"
},
"from_options": "Opzioni mittente",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identità non disponibile",
@@ -292,6 +297,32 @@
"no_sent_folder": "Nessuna cartella Inviati trovata",
"permission_camera": "Serve l'autorizzazione alla fotocamera per scattare una foto.",
"permission_photos": "Serve l'autorizzazione alla galleria foto per allegare immagini.",
"placeholder_warning": {
"title": "Controllare i segnaposto?",
"message": "Il messaggio contiene testo segnaposto che i destinatari vedranno. Vuoi modificarlo prima di inviarlo?",
"found": "Segnaposto trovati",
"more": "e altri {count}",
"dont_ask_again": "Non chiedere più",
"send_anyway": "Invia comunque",
"back": "Torna alla modifica"
},
"placeholder_words": {
"before": [
"il tuo",
"la tua",
"i tuoi",
"le tue",
"il suo",
"la sua",
"inserisci",
"inserire",
"aggiungi",
"aggiungere",
"indica"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Aggiungi ai contatti",
"recipient_copy": "Copia indirizzo",
"recipient_move_to": "Sposta in {field}",
@@ -431,6 +462,14 @@
"remove_color": "Rimuovi etichetta",
"replied": "Risposto",
"reschedule_recipients_refused": "Non è stato cambiato nulla: il server ha rifiutato {recipients}.",
"save_to_files": {
"title": "Salva in File",
"save_here": "Salva qui",
"saved": "Salvato in File",
"saved_to": "In {folder}",
"failed": "Impossibile salvare in File",
"load_failed": "Impossibile caricare le cartelle. Riprova."
},
"sender_actions": "Mittente…",
"set_color": "Imposta etichetta",
"show_all_count": "Mostra tutto ({count})",
@@ -625,6 +664,10 @@
"up_to_date": "Già aggiornato"
}
},
"org_templates": {
"personal_in_account": "I tuoi modelli sono conservati nel tuo account, quindi li hai su ogni dispositivo da cui accedi. Chiunque con cui condividi la Posta in arrivo può leggerli.",
"section_personal": "I miei modelli"
},
"push_prompt": {
"body": "Attiva le notifiche in background per ricevere i nuovi messaggi anche quando l'app è chiusa.",
"dismiss": "Non ora",
@@ -820,6 +863,10 @@
"ask_mobile": "Chiedi",
"block_mobile": "Blocca"
},
"placeholder_warning": {
"label": "Avviso segnaposto",
"description": "Chiedi conferma prima di inviare un messaggio che contiene ancora segnaposto come [TIME] o [Il tuo nome], campi del modello vuoti o testo incompleto come TODO o TBD."
},
"plain_text_mode": {
"description_mobile": "Scrivi e leggi solo in testo normale."
},
+45
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "添付ファイルを忘れていませんか?",
"attachments_too_large_total": "添付ファイルがサーバーの上限(1 通あたり {max})を超えます。",
"editor_unavailable": "メッセージの内容を読み取れませんでした。テキストをコピーしてから、作成画面を閉じて開き直してください。",
"files_picker": {
"attach": "{count, plural, =0 {添付} other {# 件のファイルを添付}}",
"empty_folder": "このフォルダーは空です",
"load_failed": "ファイルを読み込めませんでした"
},
"from_options": "送信者のオプション",
"group_chip": "{name}({count})",
"identity_unavailable": "送信者情報を利用できません",
@@ -292,6 +297,30 @@
"no_sent_folder": "送信済みフォルダーが見つかりません",
"permission_camera": "写真を撮影するには、カメラへのアクセス許可が必要です。",
"permission_photos": "画像を添付するには、写真ライブラリへのアクセス許可が必要です。",
"placeholder_warning": {
"title": "プレースホルダーを確認しますか?",
"message": "メッセージに受信者に表示されるプレースホルダーのテキストが含まれています。送信前に編集しますか?",
"found": "見つかったプレースホルダー",
"more": "ほか {count} 件",
"dont_ask_again": "今後は確認しない",
"send_anyway": "そのまま送信",
"back": "編集に戻る"
},
"placeholder_words": {
"before": [
"あなたの-",
"貴社の-",
"御社の-"
],
"after": [
"-を入力",
"-を記入",
"-を挿入",
"-を追加",
"-を入れる"
],
"unfinished": []
},
"recipient_add_contact": "連絡先に追加",
"recipient_copy": "アドレスをコピー",
"recipient_move_to": "{field}に移動",
@@ -431,6 +460,14 @@
"remove_color": "ラベルを削除",
"replied": "返信済み",
"reschedule_recipients_refused": "何も変更されていません: サーバーが {recipients} を拒否しました。",
"save_to_files": {
"title": "ファイルに保存",
"save_here": "ここに保存",
"saved": "ファイルに保存しました",
"saved_to": "{folder} に保存",
"failed": "ファイルに保存できませんでした",
"load_failed": "フォルダを読み込めませんでした。もう一度お試しください。"
},
"sender_actions": "差出人…",
"set_color": "ラベルを設定",
"show_all_count": "すべて表示({count})",
@@ -625,6 +662,10 @@
"up_to_date": "すでに最新です"
}
},
"org_templates": {
"personal_in_account": "テンプレートはアカウントに保存されるため、サインインするどのデバイスでも使えます。受信トレイを共有している相手は誰でも読むことができます。",
"section_personal": "マイテンプレート"
},
"push_prompt": {
"body": "バックグラウンド通知をオンにすると、アプリを閉じていても新着メッセージを受け取れます。",
"dismiss": "今はしない",
@@ -820,6 +861,10 @@
"ask_mobile": "確認",
"block_mobile": "ブロック"
},
"placeholder_warning": {
"label": "プレースホルダーの警告",
"description": "[TIME] や [あなたの名前] などのプレースホルダー、空のテンプレート項目、TODO や TBD などの未完成のテキストが残っているメッセージを送信する前に確認します。"
},
"plain_text_mode": {
"description_mobile": "プレーンテキストのみで作成・閲覧します。"
},
+43
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "첨부 파일을 잊으셨나요?",
"attachments_too_large_total": "첨부 파일이 이 서버의 메시지당 한도({max})를 넘어요.",
"editor_unavailable": "메시지 내용을 읽을 수 없어요. 텍스트를 복사한 다음 작성 화면을 닫았다가 다시 열어 주세요.",
"files_picker": {
"attach": "{count, plural, =0 {첨부} other {파일 #개 첨부}}",
"empty_folder": "이 폴더는 비어 있습니다",
"load_failed": "파일을 불러올 수 없습니다"
},
"from_options": "보내는 사람 옵션",
"group_chip": "{name} ({count})",
"identity_unavailable": "보내는 사람을 사용할 수 없어요",
@@ -292,6 +297,28 @@
"no_sent_folder": "보낸편지함을 찾을 수 없어요",
"permission_camera": "사진을 찍으려면 카메라 권한이 필요해요.",
"permission_photos": "이미지를 첨부하려면 사진 보관함 권한이 필요해요.",
"placeholder_warning": {
"title": "자리표시자를 확인할까요?",
"message": "메시지에 받는 사람에게 보이는 자리표시자 텍스트가 있습니다. 보내기 전에 수정할까요?",
"found": "찾은 자리표시자",
"more": "외 {count}개",
"dont_ask_again": "다시 묻지 않기",
"send_anyway": "그래도 보내기",
"back": "편집으로 돌아가기"
},
"placeholder_words": {
"before": [
"귀하의",
"당신의",
"고객님의"
],
"after": [
"입력",
"삽입",
"추가"
],
"unfinished": []
},
"recipient_add_contact": "연락처에 추가",
"recipient_copy": "주소 복사",
"recipient_move_to": "{field}(으)로 이동",
@@ -431,6 +458,14 @@
"remove_color": "태그 제거",
"replied": "답장함",
"reschedule_recipients_refused": "변경된 내용이 없습니다: 서버가 {recipients}을(를) 거부했습니다.",
"save_to_files": {
"title": "파일에 저장",
"save_here": "여기에 저장",
"saved": "파일에 저장했어요",
"saved_to": "{folder}에 저장",
"failed": "파일에 저장하지 못했어요",
"load_failed": "폴더를 불러오지 못했어요. 다시 시도하세요."
},
"sender_actions": "보낸 사람…",
"set_color": "태그 설정",
"show_all_count": "전체 보기({count})",
@@ -625,6 +660,10 @@
"up_to_date": "이미 최신 상태예요"
}
},
"org_templates": {
"personal_in_account": "템플릿은 계정에 저장되므로 로그인하는 모든 기기에서 사용할 수 있어요. 받은편지함을 공유한 사람은 누구나 읽을 수 있어요.",
"section_personal": "내 템플릿"
},
"push_prompt": {
"body": "백그라운드 알림을 켜면 앱이 닫혀 있어도 새 메시지를 받을 수 있어요.",
"dismiss": "나중에",
@@ -820,6 +859,10 @@
"ask_mobile": "묻기",
"block_mobile": "차단"
},
"placeholder_warning": {
"label": "자리표시자 경고",
"description": "[TIME]이나 [귀하의 이름] 같은 자리표시자, 비어 있는 템플릿 필드, TODO나 TBD 같은 미완성 텍스트가 남아 있는 메시지를 보내기 전에 확인을 요청합니다."
},
"plain_text_mode": {
"description_mobile": "일반 텍스트로만 작성하고 읽어요."
},
+44
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Aizmirsāt pielikumu?",
"attachments_too_large_total": "Pielikumi pārsniegtu {max}, ko šis serveris atļauj vienam ziņojumam.",
"editor_unavailable": "Neizdevās nolasīt ziņojuma saturu. Nokopējiet savu tekstu, pēc tam aizveriet un atkal atveriet redaktoru.",
"files_picker": {
"attach": "{count, plural, =0 {Pievienot} one {Pievienot # failu} other {Pievienot # failus}}",
"empty_folder": "Šī mape ir tukša",
"load_failed": "Jūsu failus neizdevās ielādēt"
},
"from_options": "Sūtītāja opcijas",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identitāte nav pieejama",
@@ -292,6 +297,29 @@
"no_sent_folder": "Mape Nosūtītie nav atrasta",
"permission_camera": "Lai uzņemtu fotoattēlu, nepieciešama kameras atļauja.",
"permission_photos": "Lai pievienotu attēlus, nepieciešama fotoattēlu bibliotēkas atļauja.",
"placeholder_warning": {
"title": "Pārbaudīt vietturus?",
"message": "Ziņā ir viettura teksts, ko redzēs adresāti. Vai rediģēt to pirms sūtīšanas?",
"found": "Atrastie vietturi",
"more": "un vēl {count}",
"dont_ask_again": "Vairs nejautāt",
"send_anyway": "Sūtīt tik un tā",
"back": "Atpakaļ pie rediģēšanas"
},
"placeholder_words": {
"before": [
"jūsu",
"tavs",
"tava",
"ievadiet",
"ierakstiet",
"ievietojiet",
"pievienojiet",
"norādiet"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Pievienot kontaktpersonām",
"recipient_copy": "Kopēt adresi",
"recipient_move_to": "Pārvietot uz lauku {field}",
@@ -431,6 +459,14 @@
"remove_color": "Noņemt tagu",
"replied": "Atbildēts",
"reschedule_recipients_refused": "Nekas netika mainīts: serveris noraidīja {recipients}.",
"save_to_files": {
"title": "Saglabāt failos",
"save_here": "Saglabāt šeit",
"saved": "Saglabāts failos",
"saved_to": "Mapē {folder}",
"failed": "Neizdevās saglabāt failos",
"load_failed": "Neizdevās ielādēt mapes. Mēģiniet vēlreiz."
},
"sender_actions": "Sūtītājs…",
"set_color": "Iestatīt tagu",
"show_all_count": "Rādīt visus ({count})",
@@ -625,6 +661,10 @@
"up_to_date": "Viss jau ir atjaunināts"
}
},
"org_templates": {
"personal_in_account": "Jūsu veidnes tiek glabātas jūsu kontā, tāpēc tās ir pieejamas katrā ierīcē, kurā pierakstāties. Tās var lasīt ikviens, ar kuru kopīgojat savu Iesūtni.",
"section_personal": "Manas veidnes"
},
"push_prompt": {
"body": "Ieslēdziet fona paziņojumus, lai jaunas vēstules jūs sasniegtu arī tad, kad lietotne ir aizvērta.",
"dismiss": "Ne tagad",
@@ -820,6 +860,10 @@
"ask_mobile": "Jautāt",
"block_mobile": "Bloķēt"
},
"placeholder_warning": {
"label": "Brīdinājums par vietturiem",
"description": "Pieprasīt apstiprinājumu pirms tādas ziņas sūtīšanas, kurā vēl ir vietturi, piemēram, [TIME] vai [Jūsu vārds], tukši veidnes lauki vai nepabeigts teksts, piemēram, TODO vai TBD."
},
"plain_text_mode": {
"description_mobile": "Rakstīt un lasīt tikai vienkāršā tekstā."
},
+40
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Хавсралтаа мартсан уу?",
"attachments_too_large_total": "Хавсралтууд энэ серверийн нэг имэйлд зөвшөөрөх {max} хэмжээнээс хэтэрнэ.",
"editor_unavailable": "Имэйлийн агуулгыг уншиж чадсангүй. Текстээ хуулаад засварлагчийг хааж, дахин нээнэ үү.",
"files_picker": {
"attach": "{count, plural, =0 {Хавсаргах} one {1 файл хавсаргах} other {# файл хавсаргах}}",
"empty_folder": "Энэ фолдер хоосон байна",
"load_failed": "Таны файлуудыг ачаалж чадсангүй"
},
"from_options": "Илгээгчийн сонголтууд",
"group_chip": "{name} ({count})",
"identity_unavailable": "Таниулбар боломжгүй",
@@ -292,6 +297,25 @@
"no_sent_folder": "Илгээсэн хавтас олдсонгүй",
"permission_camera": "Зураг авахын тулд камерын зөвшөөрөл шаардлагатай.",
"permission_photos": "Зураг хавсаргахын тулд зургийн сангийн зөвшөөрөл шаардлагатай.",
"placeholder_warning": {
"title": "Орлуулагч текстийг шалгах уу?",
"message": "Таны зурваст хүлээн авагчид харагдах орлуулагч текст байна. Илгээхээс өмнө засах уу?",
"found": "Олдсон орлуулагч текст",
"more": "болон өөр {count}",
"dont_ask_again": "Дахиж битгий асуу",
"send_anyway": "Ямар ч байсан илгээнэ үү",
"back": "Засварлах руу буцах"
},
"placeholder_words": {
"before": [
"таны",
"чиний"
],
"after": [
"оруулна уу"
],
"unfinished": []
},
"recipient_add_contact": "Харилцагчдад нэмэх",
"recipient_copy": "Хаягийг хуулах",
"recipient_move_to": "\"{field}\" талбар руу зөөх",
@@ -431,6 +455,14 @@
"remove_color": "Тагийг хасах",
"replied": "Хариулсан",
"reschedule_recipients_refused": "Юу ч өөрчлөгдсөнгүй: сервер дараах хүлээн авагчдыг татгалзлаа: {recipients}.",
"save_to_files": {
"title": "Файлд хадгалах",
"save_here": "Энд хадгалах",
"saved": "Файлд хадгаллаа",
"saved_to": "{folder}-д",
"failed": "Файлд хадгалж чадсангүй",
"load_failed": "Хавтсуудыг ачаалж чадсангүй. Дахин оролдоно уу."
},
"sender_actions": "Илгээгч…",
"set_color": "Таг оноох",
"show_all_count": "Бүгдийг харуулах ({count})",
@@ -625,6 +657,10 @@
"up_to_date": "Аль хэдийн шинэчлэгдсэн"
}
},
"org_templates": {
"personal_in_account": "Таны загварууд таны бүртгэлд хадгалагддаг тул нэвтэрсэн төхөөрөмж бүр дээрээ ашиглах боломжтой. Ирсэн имэйлээ хуваалцсан хэн ч тэдгээрийг уншиж чадна.",
"section_personal": "Миний загварууд"
},
"push_prompt": {
"body": "Апп хаалттай үед ч шинэ имэйлийн мэдэгдэл авахын тулд арын мэдэгдлийг асаана уу.",
"dismiss": "Одоо биш",
@@ -820,6 +856,10 @@
"ask_mobile": "Асуух",
"block_mobile": "Блоклох"
},
"placeholder_warning": {
"label": "Орлуулагч текстийн анхааруулга",
"description": "[TIME] эсвэл [Таны нэр] зэрэг орлуулагч текст, хоосон загварын талбар, эсвэл TODO, TBD зэрэг дуусаагүй текст үлдсэн мессеж илгээхээс өмнө баталгаажуулахыг хүс."
},
"plain_text_mode": {
"description_mobile": "Зөвхөн энгийн текстээр бичих, унших."
},
+42
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Glemt et vedlegg?",
"attachments_too_large_total": "Vedleggene ville overskride grensen på {max} per melding som denne serveren tillater.",
"editor_unavailable": "Kunne ikke lese innholdet i meldingen. Kopier teksten din, og lukk og åpne skrivevinduet på nytt.",
"files_picker": {
"attach": "{count, plural, =0 {Legg ved} one {Legg ved 1 fil} other {Legg ved # filer}}",
"empty_folder": "Denne mappen er tom",
"load_failed": "Filene dine kunne ikke lastes inn"
},
"from_options": "Avsenderalternativer",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identiteten er ikke tilgjengelig",
@@ -292,6 +297,27 @@
"no_sent_folder": "Fant ingen Sendt-mappe",
"permission_camera": "Du må gi tilgang til kameraet for å ta et bilde.",
"permission_photos": "Du må gi tilgang til bildebiblioteket for å legge ved bilder.",
"placeholder_warning": {
"title": "Sjekke plassholdere?",
"message": "Meldingen har plassholdertekst som mottakerne vil se. Vil du redigere den før du sender?",
"found": "Plassholdere som ble funnet",
"more": "og {count} til",
"dont_ask_again": "Ikke spør igjen",
"send_anyway": "Send likevel",
"back": "Tilbake til redigering"
},
"placeholder_words": {
"before": [
"din",
"ditt",
"dine",
"sett inn",
"legg til",
"skriv"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Legg til som kontakt",
"recipient_copy": "Kopier adressen",
"recipient_move_to": "Flytt til {field}",
@@ -431,6 +457,14 @@
"remove_color": "Fjern etiketten",
"replied": "Besvart",
"reschedule_recipients_refused": "Ingenting ble endret: serveren avviste {recipients}.",
"save_to_files": {
"title": "Lagre i Filer",
"save_here": "Lagre her",
"saved": "Lagret i Filer",
"saved_to": "I {folder}",
"failed": "Kunne ikke lagre i Filer",
"load_failed": "Kunne ikke laste inn mappene. Prøv igjen."
},
"sender_actions": "Avsender…",
"set_color": "Legg til etikett",
"show_all_count": "Vis alle ({count})",
@@ -625,6 +659,10 @@
"up_to_date": "Allerede oppdatert"
}
},
"org_templates": {
"personal_in_account": "Malene dine lagres i kontoen din, så du har dem på alle enheter du logger på. Alle du deler innboksen din med, kan lese dem.",
"section_personal": "Mine maler"
},
"push_prompt": {
"body": "Slå på bakgrunnsvarsler, slik at nye meldinger når deg mens appen er lukket.",
"dismiss": "Ikke nå",
@@ -820,6 +858,10 @@
"ask_mobile": "Spør",
"block_mobile": "Blokker"
},
"placeholder_warning": {
"label": "Advarsel om plassholdere",
"description": "Be om bekreftelse før du sender en melding som fortsatt har plassholdere som [TIME] eller [Ditt navn], tomme malfelt eller uferdig tekst som TODO eller TBD."
},
"plain_text_mode": {
"description_mobile": "Skriv og les bare ren tekst."
},
+45
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Bijlage vergeten?",
"attachments_too_large_total": "De bijlagen zouden de {max} overschrijden die deze server per bericht toestaat.",
"editor_unavailable": "Kon de inhoud van het bericht niet lezen. Kopieer uw tekst en sluit en open daarna het opstelvenster opnieuw.",
"files_picker": {
"attach": "{count, plural, =0 {Bijvoegen} one {1 bestand bijvoegen} other {# bestanden bijvoegen}}",
"empty_folder": "Deze map is leeg",
"load_failed": "Je bestanden konden niet worden geladen"
},
"from_options": "Afzenderopties",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identiteit niet beschikbaar",
@@ -292,6 +297,30 @@
"no_sent_folder": "Geen map Verzonden gevonden",
"permission_camera": "Toestemming voor de camera is nodig om een foto te maken.",
"permission_photos": "Toestemming voor de fotobibliotheek is nodig om afbeeldingen bij te voegen.",
"placeholder_warning": {
"title": "Tijdelijke aanduidingen controleren?",
"message": "Je bericht bevat tijdelijke tekst die ontvangers zullen zien. Wil je het bewerken voordat je het verzendt?",
"found": "Gevonden tijdelijke aanduidingen",
"more": "en nog {count}",
"dont_ask_again": "Niet meer vragen",
"send_anyway": "Toch verzenden",
"back": "Terug naar bewerken"
},
"placeholder_words": {
"before": [
"je",
"jouw",
"uw",
"voeg",
"vul"
],
"after": [
"invoegen",
"invullen",
"toevoegen"
],
"unfinished": []
},
"recipient_add_contact": "Aan contacten toevoegen",
"recipient_copy": "Adres kopiëren",
"recipient_move_to": "Verplaatsen naar {field}",
@@ -431,6 +460,14 @@
"remove_color": "Label verwijderen",
"replied": "Beantwoord",
"reschedule_recipients_refused": "Er is niets gewijzigd: de server heeft {recipients} geweigerd.",
"save_to_files": {
"title": "Opslaan in Bestanden",
"save_here": "Hier opslaan",
"saved": "Opgeslagen in Bestanden",
"saved_to": "In {folder}",
"failed": "Kon niet opslaan in Bestanden",
"load_failed": "Kon je mappen niet laden. Probeer het opnieuw."
},
"sender_actions": "Afzender…",
"set_color": "Label instellen",
"show_all_count": "Alles tonen ({count})",
@@ -625,6 +662,10 @@
"up_to_date": "Al up-to-date"
}
},
"org_templates": {
"personal_in_account": "Je sjablonen worden in je account bewaard, dus je hebt ze op elk apparaat waarop je je aanmeldt. Iedereen met wie je je Postvak IN deelt, kan ze lezen.",
"section_personal": "Mijn sjablonen"
},
"push_prompt": {
"body": "Schakel achtergrondmeldingen in zodat nieuwe berichten u bereiken terwijl de app gesloten is.",
"dismiss": "Niet nu",
@@ -820,6 +861,10 @@
"ask_mobile": "Vragen",
"block_mobile": "Blokkeren"
},
"placeholder_warning": {
"label": "Waarschuwing bij tijdelijke aanduidingen",
"description": "Vraag om bevestiging voordat een bericht wordt verzonden dat nog tijdelijke aanduidingen bevat zoals [TIME] of [Uw naam], lege sjabloonvelden of onafgemaakte tekst zoals TODO of TBD."
},
"plain_text_mode": {
"description_mobile": "Alleen in platte tekst opstellen en lezen."
},
+45
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Zapomniano o załączniku?",
"attachments_too_large_total": "Załączniki przekroczyłyby limit {max} na wiadomość obowiązujący na tym serwerze.",
"editor_unavailable": "Nie można odczytać treści wiadomości. Skopiuj tekst, a następnie zamknij i ponownie otwórz edytor.",
"files_picker": {
"attach": "{count, plural, =0 {Dołącz} one {Dołącz 1 plik} few {Dołącz # pliki} many {Dołącz # plików} other {Dołącz # pliku}}",
"empty_folder": "Ten folder jest pusty",
"load_failed": "Nie udało się wczytać plików"
},
"from_options": "Opcje nadawcy",
"group_chip": "{name} ({count})",
"identity_unavailable": "Tożsamość niedostępna",
@@ -292,6 +297,30 @@
"no_sent_folder": "Nie znaleziono folderu Wysłane",
"permission_camera": "Aby zrobić zdjęcie, potrzebny jest dostęp do aparatu.",
"permission_photos": "Aby załączać obrazy, potrzebny jest dostęp do galerii zdjęć.",
"placeholder_warning": {
"title": "Sprawdzić symbole zastępcze?",
"message": "Wiadomość zawiera tekst zastępczy, który zobaczą odbiorcy. Czy chcesz go poprawić przed wysłaniem?",
"found": "Znalezione symbole zastępcze",
"more": "i jeszcze {count}",
"dont_ask_again": "Nie pytaj ponownie",
"send_anyway": "Wyślij mimo to",
"back": "Wróć do edycji"
},
"placeholder_words": {
"before": [
"twoje",
"twój",
"twoja",
"pana",
"pani",
"wstaw",
"wpisz",
"dodaj",
"podaj"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Dodaj do kontaktów",
"recipient_copy": "Kopiuj adres",
"recipient_move_to": "Przenieś do pola {field}",
@@ -431,6 +460,14 @@
"remove_color": "Usuń etykietę",
"replied": "Odpowiedziano",
"reschedule_recipients_refused": "Nic nie zostało zmienione: serwer odrzucił {recipients}.",
"save_to_files": {
"title": "Zapisz w Plikach",
"save_here": "Zapisz tutaj",
"saved": "Zapisano w Plikach",
"saved_to": "W folderze {folder}",
"failed": "Nie udało się zapisać w Plikach",
"load_failed": "Nie udało się wczytać folderów. Spróbuj ponownie."
},
"sender_actions": "Nadawca…",
"set_color": "Ustaw etykietę",
"show_all_count": "Pokaż wszystkie ({count})",
@@ -625,6 +662,10 @@
"up_to_date": "Wszystko jest aktualne"
}
},
"org_templates": {
"personal_in_account": "Twoje szablony są przechowywane na Twoim koncie, więc masz je na każdym urządzeniu, na którym się logujesz. Może je przeczytać każdy, komu udostępniasz folder Odebrane.",
"section_personal": "Moje szablony"
},
"push_prompt": {
"body": "Włącz powiadomienia w tle, aby nowe wiadomości docierały do Ciebie, gdy aplikacja jest zamknięta.",
"dismiss": "Nie teraz",
@@ -820,6 +861,10 @@
"ask_mobile": "Pytaj",
"block_mobile": "Blokuj"
},
"placeholder_warning": {
"label": "Ostrzeżenie o symbolach zastępczych",
"description": "Poproś o potwierdzenie przed wysłaniem wiadomości, która wciąż zawiera symbole zastępcze, takie jak [TIME] lub [Twoje imię], puste pola szablonu albo niedokończony tekst, taki jak TODO lub TBD."
},
"plain_text_mode": {
"description_mobile": "Twórz i czytaj wiadomości tylko jako zwykły tekst."
},
+48
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Esqueceu um anexo?",
"attachments_too_large_total": "Os anexos excederiam o limite de {max} por mensagem permitido por este servidor.",
"editor_unavailable": "Não foi possível ler o conteúdo da mensagem. Copie seu texto e depois feche e reabra o editor.",
"files_picker": {
"attach": "{count, plural, =0 {Anexar} one {Anexar 1 arquivo} other {Anexar # arquivos}}",
"empty_folder": "Esta pasta está vazia",
"load_failed": "Não foi possível carregar seus arquivos"
},
"from_options": "Opções de remetente",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identidade indisponível",
@@ -292,6 +297,33 @@
"no_sent_folder": "Nenhuma pasta Enviados encontrada",
"permission_camera": "É necessária permissão da câmera para tirar uma foto.",
"permission_photos": "É necessária permissão da galeria de fotos para anexar imagens.",
"placeholder_warning": {
"title": "Verificar marcadores?",
"message": "A mensagem tem texto de marcador que os destinatários vão ver. Quer editá-la antes de enviar?",
"found": "Marcadores encontrados",
"more": "e mais {count}",
"dont_ask_again": "Não perguntar novamente",
"send_anyway": "Enviar mesmo assim",
"back": "Voltar à edição"
},
"placeholder_words": {
"before": [
"seu",
"sua",
"seus",
"suas",
"teu",
"tua",
"inserir",
"insira",
"adicionar",
"adicione",
"digite",
"informe"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Adicionar aos contatos",
"recipient_copy": "Copiar endereço",
"recipient_move_to": "Mover para {field}",
@@ -431,6 +463,14 @@
"remove_color": "Remover etiqueta",
"replied": "Respondido",
"reschedule_recipients_refused": "Nada foi alterado: o servidor recusou {recipients}.",
"save_to_files": {
"title": "Guardar em Ficheiros",
"save_here": "Guardar aqui",
"saved": "Guardado em Ficheiros",
"saved_to": "Em {folder}",
"failed": "Não foi possível guardar em Ficheiros",
"load_failed": "Não foi possível carregar as pastas. Tente novamente."
},
"sender_actions": "Remetente…",
"set_color": "Definir etiqueta",
"show_all_count": "Mostrar tudo ({count})",
@@ -625,6 +665,10 @@
"up_to_date": "Já está atualizado"
}
},
"org_templates": {
"personal_in_account": "Seus modelos ficam guardados na sua conta, então você os tem em todos os dispositivos em que faz login. Qualquer pessoa com quem você compartilha a Caixa de Entrada pode lê-los.",
"section_personal": "Meus modelos"
},
"push_prompt": {
"body": "Ative as notificações em segundo plano para receber novas mensagens mesmo com o app fechado.",
"dismiss": "Agora não",
@@ -820,6 +864,10 @@
"ask_mobile": "Perguntar",
"block_mobile": "Bloquear"
},
"placeholder_warning": {
"label": "Aviso de marcadores",
"description": "Pedir confirmação antes de enviar uma mensagem que ainda tem marcadores como [TIME] ou [Seu nome], campos de modelo vazios ou texto inacabado como TODO ou TBD."
},
"plain_text_mode": {
"description_mobile": "Escrever e ler apenas em texto simples."
},
+45
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Ați uitat un atașament?",
"attachments_too_large_total": "Atașamentele ar depăși limita de {max} per mesaj permisă de acest server.",
"editor_unavailable": "Conținutul mesajului nu a putut fi citit. Copiați textul, apoi închideți și redeschideți editorul.",
"files_picker": {
"attach": "{count, plural, =0 {Atașează} one {Atașează 1 fișier} few {Atașează # fișiere} other {Atașează # de fișiere}}",
"empty_folder": "Acest dosar este gol",
"load_failed": "Fișierele tale nu au putut fi încărcate"
},
"from_options": "Opțiuni expeditor",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identitate indisponibilă",
@@ -292,6 +297,30 @@
"no_sent_folder": "Nu s-a găsit folderul Trimise",
"permission_camera": "Este necesară permisiunea pentru cameră pentru a face o fotografie.",
"permission_photos": "Este necesară permisiunea pentru galeria foto pentru a atașa imagini.",
"placeholder_warning": {
"title": "Verificați substituenții?",
"message": "Mesajul conține text substituent pe care destinatarii îl vor vedea. Doriți să-l editați înainte de trimitere?",
"found": "Substituenți găsiți",
"more": "și încă {count}",
"dont_ask_again": "Nu mai întreba",
"send_anyway": "Trimite oricum",
"back": "Înapoi la editare"
},
"placeholder_words": {
"before": [
"introduceți",
"inserați",
"adăugați",
"completați"
],
"after": [
"tău",
"ta",
"dvs.",
"dumneavoastră"
],
"unfinished": []
},
"recipient_add_contact": "Adaugă la contacte",
"recipient_copy": "Copiați adresa",
"recipient_move_to": "Mutați la {field}",
@@ -431,6 +460,14 @@
"remove_color": "Eliminați eticheta",
"replied": "Răspuns",
"reschedule_recipients_refused": "Nu s-a schimbat nimic: serverul a respins {recipients}.",
"save_to_files": {
"title": "Salvează în Fișiere",
"save_here": "Salvează aici",
"saved": "Salvat în Fișiere",
"saved_to": "În {folder}",
"failed": "Nu s-a putut salva în Fișiere",
"load_failed": "Dosarele nu au putut fi încărcate. Încearcă din nou."
},
"sender_actions": "Expeditor…",
"set_color": "Setați eticheta",
"show_all_count": "Afișează tot ({count})",
@@ -625,6 +662,10 @@
"up_to_date": "Deja actualizat"
}
},
"org_templates": {
"personal_in_account": "Șabloanele dvs. sunt păstrate în cont, deci le aveți pe orice dispozitiv pe care vă autentificați. Oricine cu care partajați dosarul Primite le poate citi.",
"section_personal": "Șabloanele mele"
},
"push_prompt": {
"body": "Activați notificările în fundal pentru a primi mesajele noi chiar și când aplicația este închisă.",
"dismiss": "Nu acum",
@@ -820,6 +861,10 @@
"ask_mobile": "Întrebați",
"block_mobile": "Blocați"
},
"placeholder_warning": {
"label": "Avertisment pentru substituenți",
"description": "Cere confirmare înainte de a trimite un mesaj care încă are substituenți precum [TIME] sau [Numele tău], câmpuri de șablon goale sau text neterminat precum TODO sau TBD."
},
"plain_text_mode": {
"description_mobile": "Scrieți și citiți numai în text simplu."
},
+48
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Забыли вложение?",
"attachments_too_large_total": "Вложения превысят лимит {max} на одно письмо, установленный этим сервером.",
"editor_unavailable": "Не удалось прочитать содержимое письма. Скопируйте текст, затем закройте и снова откройте редактор.",
"files_picker": {
"attach": "{count, plural, =0 {Прикрепить} one {Прикрепить # файл} few {Прикрепить # файла} many {Прикрепить # файлов} other {Прикрепить # файла}}",
"empty_folder": "Эта папка пуста",
"load_failed": "Не удалось загрузить ваши файлы"
},
"from_options": "Параметры отправителя",
"group_chip": "{name} ({count})",
"identity_unavailable": "Удостоверение недоступно",
@@ -292,6 +297,33 @@
"no_sent_folder": "Папка «Отправленные» не найдена",
"permission_camera": "Чтобы сделать фото, нужен доступ к камере.",
"permission_photos": "Чтобы прикреплять изображения, нужен доступ к фотогалерее.",
"placeholder_warning": {
"title": "Проверить заполнители?",
"message": "В письме есть текст-заполнитель, который увидят получатели. Отредактировать его перед отправкой?",
"found": "Найденные заполнители",
"more": "и ещё {count}",
"dont_ask_again": "Больше не спрашивать",
"send_anyway": "Отправить всё равно",
"back": "Вернуться к редактированию"
},
"placeholder_words": {
"before": [
"ваше",
"ваш",
"ваша",
"ваши",
"твоё",
"твое",
"твой",
"твоя",
"вставьте",
"укажите",
"введите",
"добавьте"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Добавить в контакты",
"recipient_copy": "Копировать адрес",
"recipient_move_to": "Переместить в «{field}»",
@@ -431,6 +463,14 @@
"remove_color": "Убрать тег",
"replied": "Отвечено",
"reschedule_recipients_refused": "Ничего не изменилось: сервер отклонил {recipients}.",
"save_to_files": {
"title": "Сохранить в «Файлы»",
"save_here": "Сохранить сюда",
"saved": "Сохранено в «Файлы»",
"saved_to": "В папку {folder}",
"failed": "Не удалось сохранить в «Файлы»",
"load_failed": "Не удалось загрузить папки. Повторите попытку."
},
"sender_actions": "Отправитель…",
"set_color": "Назначить тег",
"show_all_count": "Показать все ({count})",
@@ -625,6 +665,10 @@
"up_to_date": "Всё уже актуально"
}
},
"org_templates": {
"personal_in_account": "Ваши шаблоны хранятся в вашей учётной записи, поэтому они есть на каждом устройстве, где вы входите. Их может прочитать любой, с кем вы делитесь папкой «Входящие».",
"section_personal": "Мои шаблоны"
},
"push_prompt": {
"body": "Включите фоновые уведомления, чтобы узнавать о новых письмах, даже когда приложение закрыто.",
"dismiss": "Не сейчас",
@@ -820,6 +864,10 @@
"ask_mobile": "Спрашивать",
"block_mobile": "Блокировать"
},
"placeholder_warning": {
"label": "Предупреждение о заполнителях",
"description": "Запрашивать подтверждение перед отправкой письма, в котором остались заполнители вроде [TIME] или [Ваше имя], пустые поля шаблона или незаконченный текст вроде TODO или TBD."
},
"plain_text_mode": {
"description_mobile": "Писать и читать письма только в виде простого текста."
},
+44
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Nezabudli ste prílohu?",
"attachments_too_large_total": "Prílohy by prekročili limit {max}, ktorý tento server povoľuje na jednu správu.",
"editor_unavailable": "Obsah správy sa nepodarilo načítať. Skopírujte si text a potom editor zatvorte a znova otvorte.",
"files_picker": {
"attach": "{count, plural, =0 {Priložiť} one {Priložiť 1 súbor} few {Priložiť # súbory} other {Priložiť # súborov}}",
"empty_folder": "Tento priečinok je prázdny",
"load_failed": "Vaše súbory sa nepodarilo načítať"
},
"from_options": "Možnosti odosielateľa",
"group_chip": "{name} ({count})",
"identity_unavailable": "Identita nie je k dispozícii",
@@ -292,6 +297,29 @@
"no_sent_folder": "Priečinok Odoslané sa nenašiel",
"permission_camera": "Na odfotenie je potrebné povolenie pre fotoaparát.",
"permission_photos": "Na priloženie obrázkov je potrebné povolenie pre knižnicu fotiek.",
"placeholder_warning": {
"title": "Skontrolovať zástupné texty?",
"message": "Vaša správa obsahuje zástupný text, ktorý uvidia príjemcovia. Chcete ju pred odoslaním upraviť?",
"found": "Nájdené zástupné texty",
"more": "a ďalšie ({count})",
"dont_ask_again": "Znova sa nepýtať",
"send_anyway": "Odoslať aj tak",
"back": "Späť na úpravu"
},
"placeholder_words": {
"before": [
"vaše",
"váš",
"vaša",
"tvoje",
"vložte",
"doplňte",
"zadajte",
"uveďte"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Pridať do kontaktov",
"recipient_copy": "Kopírovať adresu",
"recipient_move_to": "Presunúť do poľa {field}",
@@ -431,6 +459,14 @@
"remove_color": "Odstrániť štítok",
"replied": "Zodpovedané",
"reschedule_recipients_refused": "Nič sa nezmenilo: server odmietol {recipients}.",
"save_to_files": {
"title": "Uložiť do Súborov",
"save_here": "Uložiť sem",
"saved": "Uložené do Súborov",
"saved_to": "Do priečinka {folder}",
"failed": "Nepodarilo sa uložiť do Súborov",
"load_failed": "Priečinky sa nepodarilo načítať. Skúste to znova."
},
"sender_actions": "Odosielateľ…",
"set_color": "Nastaviť štítok",
"show_all_count": "Zobraziť všetko ({count})",
@@ -625,6 +661,10 @@
"up_to_date": "Všetko je aktuálne"
}
},
"org_templates": {
"personal_in_account": "Vaše šablóny sa ukladajú vo vašom účte, takže ich máte na každom zariadení, kde sa prihlásite. Môže ich čítať každý, s kým zdieľate priečinok Doručené.",
"section_personal": "Moje šablóny"
},
"push_prompt": {
"body": "Zapnite oznámenia na pozadí, aby vám nové správy chodili, aj keď je aplikácia zatvorená.",
"dismiss": "Teraz nie",
@@ -820,6 +860,10 @@
"ask_mobile": "Pýtať sa",
"block_mobile": "Blokovať"
},
"placeholder_warning": {
"label": "Upozornenie na zástupné texty",
"description": "Pred odoslaním správy, ktorá ešte obsahuje zástupné texty ako [TIME] alebo [Vaše meno], prázdne polia šablóny alebo nedokončený text ako TODO či TBD, požiadať o potvrdenie."
},
"plain_text_mode": {
"description_mobile": "Písať a čítať iba v obyčajnom texte."
},
+46
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Ek eklemeyi mi unuttunuz?",
"attachments_too_large_total": "Ekler, bu sunucunun ileti başına izin verdiği {max} sınırını aşacak.",
"editor_unavailable": "İleti içeriği okunamadı. Metninizi kopyalayın, ardından düzenleyiciyi kapatıp yeniden açın.",
"files_picker": {
"attach": "{count, plural, =0 {Ekle} one {1 dosya ekle} other {# dosya ekle}}",
"empty_folder": "Bu klasör boş",
"load_failed": "Dosyalarınız yüklenemedi"
},
"from_options": "Gönderen seçenekleri",
"group_chip": "{name} ({count})",
"identity_unavailable": "Kimlik kullanılamıyor",
@@ -292,6 +297,31 @@
"no_sent_folder": "Gönderilenler klasörü bulunamadı",
"permission_camera": "Fotoğraf çekmek için kamera izni gerekiyor.",
"permission_photos": "Görsel eklemek için fotoğraf arşivi izni gerekiyor.",
"placeholder_warning": {
"title": "Yer tutucular kontrol edilsin mi?",
"message": "İletinizde alıcıların göreceği yer tutucu metin var. Göndermeden önce düzenlemek ister misiniz?",
"found": "Bulunan yer tutucular",
"more": "ve {count} tane daha",
"dont_ask_again": "Bir daha sorma",
"send_anyway": "Yine de gönder",
"back": "Düzenlemeye dön"
},
"placeholder_words": {
"before": [
"şirketinizin",
"buraya"
],
"after": [
"ekleyin",
"girin",
"yazın",
"-ınız",
"-iniz",
"-unuz",
"-ünüz"
],
"unfinished": []
},
"recipient_add_contact": "Kişilere ekle",
"recipient_copy": "Adresi kopyala",
"recipient_move_to": "{field} alanına taşı",
@@ -431,6 +461,14 @@
"remove_color": "Etiketi kaldır",
"replied": "Yanıtlandı",
"reschedule_recipients_refused": "Hiçbir şey değiştirilmedi: sunucu şu alıcıları reddetti: {recipients}.",
"save_to_files": {
"title": "Dosyalar'a kaydet",
"save_here": "Buraya kaydet",
"saved": "Dosyalar'a kaydedildi",
"saved_to": "{folder} içine",
"failed": "Dosyalar'a kaydedilemedi",
"load_failed": "Klasörler yüklenemedi. Yeniden deneyin."
},
"sender_actions": "Gönderen…",
"set_color": "Etiket ayarla",
"show_all_count": "Tümünü göster ({count})",
@@ -625,6 +663,10 @@
"up_to_date": "Zaten güncel"
}
},
"org_templates": {
"personal_in_account": "Şablonlarınız hesabınızda saklanır, böylece oturum açtığınız her cihazda elinizin altındadır. Gelen Kutunuzu paylaştığınız herkes bunları okuyabilir.",
"section_personal": "Şablonlarım"
},
"push_prompt": {
"body": "Uygulama kapalıyken de yeni iletilerden haberdar olmak için arka plan bildirimlerini açın.",
"dismiss": "Şimdi değil",
@@ -820,6 +862,10 @@
"ask_mobile": "Sor",
"block_mobile": "Engelle"
},
"placeholder_warning": {
"label": "Yer tutucu uyarısı",
"description": "Hâlâ [TIME] veya [Adınız] gibi yer tutucular, boş şablon alanları ya da TODO veya TBD gibi bitmemiş metin içeren bir ileti gönderilmeden önce onay iste."
},
"plain_text_mode": {
"description_mobile": "Yalnızca düz metin olarak yazın ve okuyun."
},
+47
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "Забули вкладення?",
"attachments_too_large_total": "Вкладення перевищать ліміт {max} на один лист, встановлений цим сервером.",
"editor_unavailable": "Не вдалося прочитати вміст листа. Скопіюйте текст, потім закрийте й знову відкрийте редактор.",
"files_picker": {
"attach": "{count, plural, =0 {Прикріпити} one {Прикріпити # файл} few {Прикріпити # файли} many {Прикріпити # файлів} other {Прикріпити # файлу}}",
"empty_folder": "Ця папка порожня",
"load_failed": "Не вдалося завантажити ваші файли"
},
"from_options": "Параметри відправника",
"group_chip": "{name} ({count})",
"identity_unavailable": "Ідентичність недоступна",
@@ -292,6 +297,32 @@
"no_sent_folder": "Папку «Надіслані» не знайдено",
"permission_camera": "Щоб зробити фото, потрібен доступ до камери.",
"permission_photos": "Щоб вкладати зображення, потрібен доступ до фотогалереї.",
"placeholder_warning": {
"title": "Перевірити заповнювачі?",
"message": "У листі є текст-заповнювач, який побачать одержувачі. Відредагувати його перед надсиланням?",
"found": "Знайдені заповнювачі",
"more": "і ще {count}",
"dont_ask_again": "Більше не запитувати",
"send_anyway": "Все одно надішліть",
"back": "Назад до редагування"
},
"placeholder_words": {
"before": [
"ваше",
"ваш",
"ваша",
"ваші",
"твоє",
"твій",
"твоя",
"вставте",
"вкажіть",
"введіть",
"додайте"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "Додати до контактів",
"recipient_copy": "Копіювати адресу",
"recipient_move_to": "Перемістити в «{field}»",
@@ -431,6 +462,14 @@
"remove_color": "Прибрати тег",
"replied": "Відповіли",
"reschedule_recipients_refused": "Нічого не змінено: сервер відхилив {recipients}.",
"save_to_files": {
"title": "Зберегти у «Файли»",
"save_here": "Зберегти сюди",
"saved": "Збережено у «Файли»",
"saved_to": "У теку {folder}",
"failed": "Не вдалося зберегти у «Файли»",
"load_failed": "Не вдалося завантажити теки. Спробуйте ще раз."
},
"sender_actions": "Відправник…",
"set_color": "Призначити тег",
"show_all_count": "Показати всі ({count})",
@@ -625,6 +664,10 @@
"up_to_date": "Усе вже актуально"
}
},
"org_templates": {
"personal_in_account": "Ваші шаблони зберігаються у вашому обліковому записі, тож вони є на кожному пристрої, де ви входите. Їх може прочитати будь-хто, з ким ви ділитеся папкою «Вхідні».",
"section_personal": "Мої шаблони"
},
"push_prompt": {
"body": "Увімкніть фонові сповіщення, щоб дізнаватися про нові листи, навіть коли застосунок закрито.",
"dismiss": "Не зараз",
@@ -820,6 +863,10 @@
"ask_mobile": "Питати",
"block_mobile": "Блокувати"
},
"placeholder_warning": {
"label": "Попередження про заповнювачі",
"description": "Запитувати підтвердження перед надсиланням листа, у якому лишилися заповнювачі на кшталт [TIME] або [Ваше ім’я], порожні поля шаблону чи незавершений текст на кшталт TODO або TBD."
},
"plain_text_mode": {
"description_mobile": "Писати й читати листи лише як простий текст."
},
+44
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "忘記新增附件了嗎?",
"attachments_too_large_total": "附件將超過此伺服器每封郵件 {max} 的上限。",
"editor_unavailable": "無法讀取郵件內容。請先複製您的文字,再關閉並重新開啟撰寫畫面。",
"files_picker": {
"attach": "{count, plural, =0 {附加} other {附加 # 個檔案}}",
"empty_folder": "此資料夾是空的",
"load_failed": "無法載入你的檔案"
},
"from_options": "寄件者選項",
"group_chip": "{name}({count})",
"identity_unavailable": "寄件身分無法使用",
@@ -292,6 +297,29 @@
"no_sent_folder": "找不到寄件備份信件匣",
"permission_camera": "需要相機權限才能拍照。",
"permission_photos": "需要相片圖庫權限才能附加圖片。",
"placeholder_warning": {
"title": "檢查預留位置?",
"message": "你的郵件中有收件者會看到的預留位置文字。要在傳送前編輯嗎?",
"found": "找到的預留位置",
"more": "以及另外 {count} 個",
"dont_ask_again": "不再提示",
"send_anyway": "仍然傳送",
"back": "繼續編輯"
},
"placeholder_words": {
"before": [
"您的-",
"你的-",
"貴公司-",
"插入-",
"輸入-",
"填寫-",
"新增-",
"在此-"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "新增至聯絡人",
"recipient_copy": "複製地址",
"recipient_move_to": "移至{field}",
@@ -431,6 +459,14 @@
"remove_color": "移除標籤",
"replied": "已回覆",
"reschedule_recipients_refused": "未作任何變更:伺服器拒絕了 {recipients}。",
"save_to_files": {
"title": "儲存到檔案",
"save_here": "儲存在這裡",
"saved": "已儲存到檔案",
"saved_to": "在 {folder} 中",
"failed": "無法儲存到檔案",
"load_failed": "無法載入資料夾。請再試一次。"
},
"sender_actions": "寄件者…",
"set_color": "設定標籤",
"show_all_count": "顯示全部({count})",
@@ -625,6 +661,10 @@
"up_to_date": "已是最新狀態"
}
},
"org_templates": {
"personal_in_account": "您的範本保存在您的帳號中,因此您登入的每部裝置上都能使用。與您共用收件匣的任何人都可以閱讀這些範本。",
"section_personal": "我的範本"
},
"push_prompt": {
"body": "開啟背景通知,即使應用程式已關閉也能收到新郵件。",
"dismiss": "稍後再說",
@@ -820,6 +860,10 @@
"ask_mobile": "詢問",
"block_mobile": "封鎖"
},
"placeholder_warning": {
"label": "預留位置提醒",
"description": "傳送仍含有預留位置(如 [TIME] 或 [您的姓名])、空白範本欄位或未完成文字(如 TODO 或 TBD)的郵件前請求確認。"
},
"plain_text_mode": {
"description_mobile": "僅以純文字撰寫與閱讀。"
},
+44
View File
@@ -284,6 +284,11 @@
"attachment_reminder_title": "忘记添加附件了吗?",
"attachments_too_large_total": "附件将超过此服务器每封邮件 {max} 的上限。",
"editor_unavailable": "无法读取邮件内容。请复制您的文本,然后关闭并重新打开撰写窗口。",
"files_picker": {
"attach": "{count, plural, =0 {添加附件} other {添加 # 个文件}}",
"empty_folder": "此文件夹为空",
"load_failed": "无法加载你的文件"
},
"from_options": "发件人选项",
"group_chip": "{name}({count})",
"identity_unavailable": "身份不可用",
@@ -292,6 +297,29 @@
"no_sent_folder": "未找到“已发送”文件夹",
"permission_camera": "需要相机权限才能拍照。",
"permission_photos": "需要相册权限才能附加图片。",
"placeholder_warning": {
"title": "检查占位符?",
"message": "你的邮件中有收件人会看到的占位符文本。要在发送前编辑吗?",
"found": "找到的占位符",
"more": "以及另外 {count} 个",
"dont_ask_again": "不再提示",
"send_anyway": "仍然发送",
"back": "返回编辑"
},
"placeholder_words": {
"before": [
"您的-",
"你的-",
"贵公司-",
"插入-",
"输入-",
"填写-",
"添加-",
"在此-"
],
"after": [],
"unfinished": []
},
"recipient_add_contact": "添加到联系人",
"recipient_copy": "复制地址",
"recipient_move_to": "移至{field}",
@@ -431,6 +459,14 @@
"remove_color": "删除标签",
"replied": "已回复",
"reschedule_recipients_refused": "未作任何更改:服务器拒绝了 {recipients}。",
"save_to_files": {
"title": "保存到文件",
"save_here": "保存到此处",
"saved": "已保存到文件",
"saved_to": "在 {folder} 中",
"failed": "无法保存到文件",
"load_failed": "无法加载文件夹。请重试。"
},
"sender_actions": "发件人…",
"set_color": "设置颜色标签",
"show_all_count": "显示全部({count})",
@@ -625,6 +661,10 @@
"up_to_date": "已是最新"
}
},
"org_templates": {
"personal_in_account": "您的模板保存在您的账户中,因此您登录的每台设备上都能使用。与您共享收件箱的任何人都可以阅读它们。",
"section_personal": "我的模板"
},
"push_prompt": {
"body": "开启后台通知,即使应用已关闭也能收到新邮件。",
"dismiss": "以后再说",
@@ -820,6 +860,10 @@
"ask_mobile": "询问",
"block_mobile": "阻止"
},
"placeholder_warning": {
"label": "占位符提醒",
"description": "发送仍含有占位符(如 [TIME] 或 [您的姓名])、空白模板字段或未完成文本(如 TODO 或 TBD)的邮件前请求确认。"
},
"plain_text_mode": {
"description_mobile": "仅以纯文本撰写和阅读。"
},
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: { accountId: 'me', request: vi.fn() },
}));
import { jmapClient } from '../jmap-client';
import { copyBlobs } from '../email';
const request = vi.mocked(jmapClient.request);
beforeEach(() => vi.clearAllMocks());
describe('copyBlobs', () => {
// Stalwart 0.16.27 and 1.0.0 answer unknownMethod without it (seen live).
it('names the blob capability, without which Stalwart refuses Blob/copy', async () => {
request.mockResolvedValueOnce({ methodResponses: [['Blob/copy', { copied: { b1: 'b9' } }, '0']] });
await expect(copyBlobs('team', ['b1'], 'me')).resolves.toEqual({ b1: 'b9' });
expect(request).toHaveBeenCalledWith(
[['Blob/copy', { fromAccountId: 'team', accountId: 'me', blobIds: ['b1'] }, '0']],
['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:blob'],
);
});
it('sends nothing within one account', async () => {
await expect(copyBlobs('me', ['b1'], 'me')).resolves.toEqual({ b1: 'b1' });
expect(request).not.toHaveBeenCalled();
});
it('fails when the server copied nothing', async () => {
request.mockResolvedValueOnce({
methodResponses: [['Blob/copy', { notCopied: { b1: { type: 'blobNotFound' } } }, '0']],
});
await expect(copyBlobs('team', ['b1'], 'me')).rejects.toThrow('blobNotFound');
});
});
+2 -2
View File
@@ -23,7 +23,7 @@ vi.mock('../jmap-client', () => ({
},
}));
import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata';
import { getBulwarkMetadata, resetBulwarkMetadata, supportsBulwarkMetadata } from '../bulwark-metadata';
const VENDOR = { supportsVendorNamespaces: true, supportsPrivate: true, namespaces: [], maxDepth: 8 };
@@ -52,7 +52,7 @@ function inboxWith(metadata: unknown) {
beforeEach(() => {
state.requests = [];
state.answer = null;
resetBulwarkMetadataPauses();
resetBulwarkMetadata();
});
describe('getBulwarkMetadata', () => {
@@ -40,7 +40,7 @@ vi.mock('../blob', () => ({
import { jmapClient } from '../jmap-client';
import {
cancelScheduledSend, listScheduledEmails, rescheduleScheduledSend, sendEmail, sendReadReceipt,
cancelScheduledSend, listScheduledEmails, RescheduleLostError, rescheduleScheduledSend, sendEmail, sendReadReceipt,
} from '../email';
import { NoLongerScheduledError, SubmissionQuotaError } from '../jmap-result';
import { prunableSubmissionIds, pruneEmailSubmissions, resetSubmissionPrune } from '../submissions';
@@ -456,7 +456,9 @@ describe('rescheduling and "Send now"', () => {
expect(argsOf(server, 'EmailSubmission/set').map((a) => a.destroy).filter(Boolean)).toEqual([[original]]);
});
it('Stalwart 1.0: a full quota makes room without touching the pending original', async () => {
// The original is cancelled and its record removed first (#1308), so the
// replacement takes the room the original had.
it('Stalwart 1.0: a full account has room for the replacement once the original is cancelled', async () => {
const server = setup('1.0', { limit: 3 });
const now = Date.now();
const [original] = server.seed('acc-1', [{ sendAt: now + HOUR, status: 'pending', emailId: 'mail-1' }]);
@@ -464,24 +466,42 @@ describe('rescheduling and "Send now"', () => {
const result = await rescheduleScheduledSend(scheduled(original), 2 * 3600);
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(2);
expect(server.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' }]);
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(server.submissions().filter((s) => s.undoStatus === 'pending')).toEqual([
{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' },
]);
expect(server.submissions().some((s) => s.id === original)).toBe(false);
});
it('Stalwart 1.0: still full after making room, the original stays scheduled', async () => {
const server = setup('1.0', { limit: 1 });
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending', emailId: 'mail-1' }]);
it('Stalwart 1.0: an original that went out before the cancel is not sent a second time', async () => {
const server = setup('1.0');
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + 30_000, status: 'pending', emailId: 'mail-1' }]);
// Its HOLDFOR runs out while the lookup is under way.
server.afterEachRequest(() => server.deliver('acc-1', original));
const err = await rescheduleScheduledSend(scheduled(original), 0).catch((e: unknown) => e);
expect(err).toBeInstanceOf(SubmissionQuotaError);
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'pending' }]);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: true });
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.create)).toBe(false);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'final' }]);
});
/** A full account: the original, due soon, and two delivered records the prune can remove. */
function fullWithOriginal(dueIn: number) {
const server = setup('1.0', { limit: 3 });
it('Stalwart 1.0: an original cancelled on another device meanwhile is left as it is', async () => {
const server = setup('1.0');
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending', emailId: 'mail-1' }]);
server.afterEachRequest(() => server.cancel('acc-1', original));
const err = await rescheduleScheduledSend(scheduled(original), 2 * 3600).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: false });
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.create)).toBe(false);
});
/** An account at its limit once the original is gone: two delivered records the prune can remove. */
function fullAfterCancel(dueIn: number) {
const server = setup('1.0', { limit: 2 });
const now = Date.now();
const [original] = server.seed('acc-1', [{ sendAt: now + dueIn, status: 'pending', emailId: 'mail-1' }]);
server.seed('acc-1', [{ sendAt: now - HOUR, status: 'final' }, { sendAt: now - 2 * HOUR, status: 'final' }]);
@@ -489,59 +509,10 @@ describe('rescheduling and "Send now"', () => {
}
const isQuery = (calls: Array<[string, unknown, string]>) => calls[0][0] === 'EmailSubmission/query';
it('Stalwart 1.0: an original that went out while room was made is not sent a second time', async () => {
const { server, original } = fullWithOriginal(30_000);
// Its HOLDFOR runs out while the prune looks for records to remove.
server.afterEachRequest((calls) => {
if (isQuery(calls)) server.deliver('acc-1', original);
});
const err = await rescheduleScheduledSend(scheduled(original), 0).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: true });
// The original was read again after the prune, and only the refused create was made.
expect(server.requests.at(-1)?.calls).toEqual([
['EmailSubmission/get', { accountId: 'acc-1', ids: [original], properties: ['id', 'undoStatus'] }, '0'],
]);
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'final' }]);
});
it('Stalwart 1.0: an original cancelled on another device meanwhile is left as it is', async () => {
const { server, original } = fullWithOriginal(HOUR);
server.afterEachRequest((calls) => {
if (isQuery(calls)) server.cancel('acc-1', original);
});
const err = await rescheduleScheduledSend(scheduled(original), 2 * 3600).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: false });
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'canceled' }]);
});
it('Stalwart 1.0: when the original cannot be read again, nothing is resubmitted', async () => {
const { server, original } = fullWithOriginal(HOUR);
server.beforeEachRequest((calls) => {
const [name, args] = calls[0];
if (name === 'EmailSubmission/get' && (args as { properties?: string[] }).properties?.join() === 'id,undoStatus') {
throw new Error('Network request failed');
}
});
await expect(rescheduleScheduledSend(scheduled(original), 2 * 3600)).rejects.toThrow('Network request failed');
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'pending' }]);
});
it('Stalwart 1.0: the retry takes the time spent making room off the hold', async () => {
let clock = Date.parse('2026-09-25T12:00:00Z');
vi.spyOn(Date, 'now').mockImplementation(() => clock);
const { server, original } = fullWithOriginal(HOUR);
const { server, original } = fullAfterCancel(HOUR);
const target = clock + 2 * HOUR;
// The prune takes five minutes.
server.afterEachRequest((calls) => {
@@ -561,7 +532,7 @@ describe('rescheduling and "Send now"', () => {
it('Stalwart 1.0: "Send now" that waited for room still goes out at once', async () => {
let clock = Date.parse('2026-09-25T12:00:00Z');
vi.spyOn(Date, 'now').mockImplementation(() => clock);
const { server, original } = fullWithOriginal(HOUR);
const { server, original } = fullAfterCancel(HOUR);
server.afterEachRequest((calls) => {
if (isQuery(calls)) clock += 5 * MINUTE;
});
@@ -573,6 +544,22 @@ describe('rescheduling and "Send now"', () => {
.map((a) => a.create.replacement.envelope.mailFrom.parameters.HOLDFOR);
expect(holds).toEqual(['1', '1']);
});
it('Stalwart 1.0: still full after making room, the original goes back to its old time', async () => {
const server = setup('1.0', { limit: 2 });
const now = Date.now();
const [original] = server.seed('acc-1', [{ sendAt: now + HOUR, status: 'pending', emailId: 'mail-1' }]);
// Two other held messages: nothing the prune may remove.
server.seed('acc-1', [{ sendAt: now + 2 * HOUR, status: 'pending' }, { sendAt: now + 3 * HOUR, status: 'pending' }]);
const err = await rescheduleScheduledSend(scheduled(original), 0).catch((e: unknown) => e);
// Neither the replacement nor the original found room: the message is
// back in Drafts rather than in Sent looking sent.
expect(err).toBeInstanceOf(RescheduleLostError);
expect((err as RescheduleLostError).reason).toBeInstanceOf(SubmissionQuotaError);
expect(server.emails.get('mail-1')).toMatchObject({ mailboxIds: { drafts: true }, keywords: { $draft: true } });
});
});
describe('read receipts', () => {
@@ -36,7 +36,9 @@ vi.mock('../jmap-client', () => ({
}));
import { jmapClient } from '../jmap-client';
import { listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email';
import {
listScheduledEmails, RescheduleLostError, RescheduleRestoredError, rescheduleScheduledSend, sendEmail,
} from '../email';
import { RecipientsRefusedError, jmapErrorType } from '../jmap-result';
import { pruneEmailSubmissions, resetSubmissionPrune } from '../submissions';
import {
@@ -263,20 +265,24 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
const cancelled = (server: FakeSubmissionServer) => argsOf(server, 'EmailSubmission/set')
.filter((a) => a.update)
.flatMap((a) => Object.keys(a.update as object));
/** The replacements the server created, in order. */
const replacements = (server: FakeSubmissionServer) => server.requests
.flatMap(({ responses }) => responses)
.filter(([name, body]) => name === 'EmailSubmission/set' && body.created?.replacement)
.map(([, body]) => body.created.replacement.id as string);
beforeEach(() => {
// "Send now" holds the replacement for a second: the clock stands still.
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-26T10:00:00Z'));
});
// The original is cancelled before the replacement is made (#1308). A
// replacement that takes nobody puts the original back; when the throttle
// refuses that one too, the message goes back to Drafts and says so.
it.each([
['1.0', 0],
['1.0', 7200],
['0.16.23', 0],
['0.16.23', 7200],
] as Array<[StalwartVersion, number]>)(
'Stalwart %s: keeps the original when every recipient of the replacement is refused (hold %is)',
'Stalwart %s: moves the message back to Drafts when every recipient is refused again (hold %is)',
async (version, holdFor) => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const server = setup(version);
const scheduled = scheduledTo(server, 'bob@example.org');
// A throttle used up since the message was scheduled.
@@ -284,13 +290,32 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
const err = await rescheduleScheduledSend(scheduled, holdFor).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RecipientsRefusedError);
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
// Still goes at its old time; no cancel went out, and the dead
// replacement's record is gone.
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false);
expect(err).toBeInstanceOf(RescheduleLostError);
const reason = (err as RescheduleLostError).reason;
expect(reason).toBeInstanceOf(RecipientsRefusedError);
expect((reason as RecipientsRefusedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
expect(server.emails.get('mail-1')).toMatchObject({ mailboxIds: { drafts: true }, keywords: { $draft: true } });
// The dead records are gone, and nothing is pending.
expect(server.submissions()).toEqual([]);
},
);
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: puts the original back at its old time when the replacement took nobody but the original is taken again',
async (version) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'bob@example.org');
// Refused once, for the replacement; the throttle has room again for the original.
let refusals = 1;
server.refuseRecipients(() => (refusals-- > 0 ? RATE_LIMITED : undefined));
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RescheduleRestoredError);
const restored = err as RescheduleRestoredError;
expect(restored.reason).toBeInstanceOf(RecipientsRefusedError);
expect(server.submissions()).toEqual([
{ id: scheduled.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' },
{ id: restored.restoredSubmissionId, sendAt: Date.now() + HOUR, undoStatus: 'pending' },
]);
},
);
@@ -305,7 +330,9 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
expect(create?.calls[1]).toEqual(
['EmailSubmission/get', { accountId: 'acc-1', ids: ['#replacement'], properties: ['id', 'deliveryStatus'] }, '1'],
);
expect(create?.using).toEqual(['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
expect(create?.using).toEqual([
'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission',
]);
});
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
@@ -324,141 +351,51 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
},
);
it('leaves the refused replacement to the prune on a server without urn:stalwart:jmap', async () => {
it.each([
['1.0', 0],
['1.0', 7200],
['0.16.23', 0],
['0.16.6', 7200],
] as Array<[StalwartVersion, number]>)(
'Stalwart %s: goes ahead and names the recipient the replacement was refused while it took the others (hold %is)',
async (version, holdFor) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
const result = await rescheduleScheduledSend(scheduled, holdFor);
// The original is gone before the replacement is made, so the caller
// warns that bob does not get it.
expect(result.refusedRecipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
expect(cancelled(server)).toEqual([scheduled.emailSubmissionId]);
expect(server.submissions()).toEqual([
{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' },
]);
},
);
it('leaves the dead records to the prune on a server without urn:stalwart:jmap', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
fake.stalwart = false;
const server = setup('1.0');
const scheduled = scheduledTo(server, 'bob@example.org');
server.refuseRecipients(() => RATE_LIMITED);
await expect(rescheduleScheduledSend(scheduled, 0)).rejects.toBeInstanceOf(RecipientsRefusedError);
await expect(rescheduleScheduledSend(scheduled, 0)).rejects.toBeInstanceOf(RescheduleLostError);
expect(server.submissions().map((s) => s.undoStatus)).toEqual(['pending', 'final']);
// The replacement's and the put-back original's, both final with nobody taken.
expect(server.submissions().map((s) => s.undoStatus)).toEqual(['final', 'final']);
});
// The replacement takes the others: cancelling the original would lose
// the message for the refused one, whom the original still reaches.
describe('when the replacement is refused a recipient the original was taken for', () => {
beforeEach(() => {
// "Send now" holds the replacement for a second, so the clock stands
// still for the withdraw to find it held.
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-26T10:00:00Z'));
});
it('files the replacement in Sent, for servers that pull a cancelled message back to Drafts', async () => {
const server = setup('1.0');
const scheduled = scheduledTo(server, 'bob@example.org');
server.emails.set('mail-1', { ...server.emails.get('mail-1')!, mailboxIds: { drafts: true }, keywords: { $draft: true } });
it.each([
['1.0', 0],
['1.0', 7200],
['0.16.23', 0],
['0.16.23', 7200],
['0.16.6', 0],
] as Array<[StalwartVersion, number]>)(
'Stalwart %s: keeps the original at its old time and withdraws the replacement (hold %is)',
async (version, holdFor) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
await rescheduleScheduledSend(scheduled, 7200);
const err = await rescheduleScheduledSend(scheduled, holdFor).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RecipientsRefusedError);
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
// Bob still gets it at the old time, and alice only once: the
// replacement she was taken for is cancelled, not just its record.
expect(cancelled(server)).toEqual(replacements(server));
expect(server.submissions()).toEqual([
{ id: scheduled.emailSubmissionId, sendAt: Date.now() + HOUR, undoStatus: 'pending' },
]);
},
);
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: names only whom the replacement would have lost when it took nobody',
async (version) => {
const server = setup(version, { refuseRecipient: relayOff });
const scheduled = scheduledTo(server, 'alice@example.org', 'nobody@example.net');
server.refuseRecipients((email) => relayOff(email) ?? throttledFor('alice@example.org')(email));
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
// nobody@example.net never got it: the relay rule refused it back then.
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'alice@example.org', smtpReply: RATE_LIMITED }]);
// Nothing was queued for the replacement, so only its record goes.
expect(cancelled(server)).toEqual([]);
expect(server.submissions()).toEqual([
{ id: scheduled.emailSubmissionId, sendAt: Date.now() + HOUR, undoStatus: 'pending' },
]);
},
);
it('Stalwart 1.0 shapes: counts every recipient as taken when the server keeps no delivery status for the original', async () => {
const server = setup('1.0', { refuseRecipient: relayOff });
const scheduled = scheduledTo(server, 'alice@example.org', 'nobody@example.net');
vi.mocked(jmapClient.request).mockImplementationOnce(async (calls, using) => {
const res = await server.request(calls, using);
return {
methodResponses: res.methodResponses.map(([name, body, callId]) => (name === 'EmailSubmission/get'
? [name, { ...body, list: body.list.map((s: object) => ({ ...s, deliveryStatus: null })) }, callId]
: [name, body, callId])) as Array<[string, Record<string, any>, string]>,
};
});
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
// Nothing says the relay rule refused nobody@example.net back then.
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]);
expect(cancelled(server)).toEqual(replacements(server));
expect(server.submissions().map((s) => [s.id, s.undoStatus])).toEqual([[scheduled.emailSubmissionId, 'pending']]);
});
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: goes ahead and names the refused recipient when "Send now" went out before it could be withdrawn',
async (version) => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
// Its second of hold is over before the withdraw arrives.
server.afterEachRequest(() => {
for (const id of replacements(server)) server.deliver('acc-1', id);
});
const result = await rescheduleScheduledSend(scheduled, 0);
// Alice has it from the replacement: keeping the original as well
// would send her a second copy.
expect(result.refusedRecipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
expect(cancelled(server)).toEqual([result.emailSubmissionId, scheduled.emailSubmissionId]);
expect(server.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'final' }]);
},
);
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: keeps the original when the withdraw went through and only its answer was lost',
async (version) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
let answered = false;
server.afterEachRequest((calls) => {
const [name, args] = calls[0];
if (name === 'EmailSubmission/set' && (args as { update?: object }).update && !answered) {
answered = true;
throw new Error('Network request failed');
}
});
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
// Going ahead would cancel the original too: nobody would get it.
expect(err).toBeInstanceOf(RecipientsRefusedError);
const [replacement] = replacements(server);
expect(cancelled(server)).toEqual([replacement]);
// The canceled record is left to the prune.
expect(server.submissions().map((s) => [s.id, s.undoStatus])).toEqual([
[scheduled.emailSubmissionId, 'pending'],
[replacement, 'canceled'],
]);
},
);
expect(server.emails.get('mail-1')).toMatchObject({ mailboxIds: { sent: true }, keywords: {} });
});
});
+121 -81
View File
@@ -17,8 +17,11 @@ vi.mock('../jmap-client', () => ({
}));
import { jmapClient } from '../jmap-client';
import { cancelScheduledSend, listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email';
import { ScheduleTooLateError } from '../jmap-result';
import {
cancelScheduledSend, listScheduledEmails, RescheduleLostError, RescheduleRestoredError, rescheduleScheduledSend,
sendEmail, SendUnconfirmedError,
} from '../email';
import { NoLongerScheduledError, ScheduleTooLateError } from '../jmap-result';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -34,12 +37,18 @@ const SCHEDULED = {
to: [{ email: 'to@example.com' }],
};
const ORIGINAL_AT = new Date(Date.now() + 3600_000).toISOString();
const LOOKUP = {
methodResponses: [
['EmailSubmission/get', {
list: [{ id: 'sub-1', envelope: { mailFrom: { email: 'me@example.com' }, rcptTo: [{ email: 'to@example.com' }] } }],
list: [{
id: 'sub-1',
sendAt: ORIGINAL_AT,
envelope: { mailFrom: { email: 'me@example.com' }, rcptTo: [{ email: 'to@example.com' }] },
}],
}, '0'],
['Email/get', { list: [{ id: 'e-1', to: [{ email: 'to@example.com' }] }] }, '1'],
['Mailbox/get', { list: [{ id: 'mb-drafts', role: 'drafts' }, { id: 'mb-sent', role: 'sent' }, { id: 'mb-in', role: 'inbox' }] }, '2'],
],
};
const REPLACED = [['EmailSubmission/set', {
@@ -58,8 +67,17 @@ const setCalls = () => mockRequest.mock.calls
Object.keys(args.update ?? {}),
args.destroy ?? [],
]);
/** The replacement create of the n-th EmailSubmission/set create call. */
const createdArgs = (n = 0) => mockRequest.mock.calls
.map((c) => c[0][0])
.filter(([method, args]: [string, { create?: object }]) => method === 'EmailSubmission/set' && args.create)[n][1];
const REFUSED = (description: string) => set({ notCreated: { replacement: { type: 'forbiddenToSend', description } } });
describe('rescheduleScheduledSend', () => {
afterEach(() => {
mockRequest.mockReset();
});
it('keeps Cc, Bcc and the envelope sender of the held submission (B19)', async () => {
mockRequest
.mockResolvedValueOnce({
@@ -80,20 +98,18 @@ describe('rescheduleScheduledSend', () => {
['Email/get', { list: [{ id: 'e-1', to: [{ email: 'to@example.com' }], cc: [{ email: 'cc@example.com' }] }] }, '1'],
],
})
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
const result = await rescheduleScheduledSend(SCHEDULED, 7200);
expect(result).toEqual({ emailSubmissionId: 'sub-2', sendAt: '2026-09-25T08:00:00Z' });
// What the server answered for each recipient comes with the envelope.
const [lookup] = mockRequest.mock.calls[0][0];
expect(lookup).toEqual([
'EmailSubmission/get', { accountId: 'acc-1', ids: ['sub-1'], properties: ['envelope', 'deliveryStatus'] }, '0',
'EmailSubmission/get', { accountId: 'acc-1', ids: ['sub-1'], properties: ['envelope', 'sendAt'] }, '0',
]);
const args = mockRequest.mock.calls[1][0][0][1];
expect(args.create.replacement.envelope).toEqual({
expect(createdArgs().create.replacement.envelope).toEqual({
mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '7200' } },
rcptTo: [{ email: 'to@example.com' }, { email: 'cc@example.com' }, { email: 'bcc@example.com' }],
});
@@ -114,104 +130,112 @@ describe('rescheduleScheduledSend', () => {
}, '1'],
],
})
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
await rescheduleScheduledSend(SCHEDULED, 60);
expect(mockRequest.mock.calls[1][0][0][1].create.replacement.envelope).toEqual({
expect(createdArgs().create.replacement.envelope).toEqual({
mailFrom: { email: 'news@shop.example', parameters: { HOLDFOR: '60' } },
rcptTo: [{ email: 'to@example.com' }, { email: 'cc@example.com' }, { email: 'bcc@example.com' }],
});
});
it('creates the replacement before cancelling the original, then removes the canceled record', async () => {
// #1308: some servers refuse a second active submission for one Email, and
// some undo onSuccessUpdateEmail on cancel, pulling the message back out of
// Sent once the original went after its replacement.
it('cancels the original and removes its record before it creates the replacement, which files the message in Sent', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
await rescheduleScheduledSend(SCHEDULED, 7200);
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []], [[], [], ['sub-1']]]);
expect(mockRequest.mock.calls[2][0][0][1].update).toEqual({ 'sub-1': { undoStatus: 'canceled' } });
expect(setCalls()).toEqual([[[], ['sub-1'], []], [[], [], ['sub-1']], [['replacement'], [], []]]);
expect(mockRequest.mock.calls[1][0][0][1].update).toEqual({ 'sub-1': { undoStatus: 'canceled' } });
expect(createdArgs().onSuccessUpdateEmail).toEqual({
'#replacement': { mailboxIds: { 'mb-sent': true }, 'keywords/$draft': null },
});
});
it('sends now as a 1-second hold, so the replacement can still be withdrawn', async () => {
it('sends now as a 1-second hold', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
await rescheduleScheduledSend(SCHEDULED, 0);
const replacement = mockRequest.mock.calls[1][0][0][1].create.replacement;
expect(replacement.envelope).toEqual({
expect(createdArgs().create.replacement.envelope).toEqual({
mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '1' } },
rcptTo: [{ email: 'to@example.com' }],
});
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []], [[], [], ['sub-1']]]);
});
it('leaves the original scheduled when the replacement is refused', async () => {
it('changes nothing when the original has already gone out', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(set({ notCreated: { replacement: { type: 'forbiddenToSend', description: 'Quota exceeded' } } }));
await expect(rescheduleScheduledSend(SCHEDULED, 7200)).rejects.toThrow('Quota exceeded');
// No cancel went out.
expect(setCalls()).toEqual([[['replacement'], [], []]]);
});
it('withdraws the replacement when the original has already gone out', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(set({ notUpdated: { 'sub-1': { type: 'cannotUnsend', description: 'Already sent' } } }))
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'final' }] }, '0']] })
.mockResolvedValueOnce(set({ updated: { 'sub-2': null } }))
.mockResolvedValueOnce(DESTROYED('sub-2'));
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'final' }] }, '0']] });
await expect(rescheduleScheduledSend(SCHEDULED, 0)).rejects.toThrow('Failed to cancel the previous schedule: Already sent');
// The original went out, so its record stays for the prune; the
// withdrawn replacement's goes at once.
expect(setCalls()).toEqual([
[['replacement'], [], []], [[], ['sub-1'], []], [[], ['sub-2'], []], [[], [], ['sub-2']],
]);
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
const err = await rescheduleScheduledSend(SCHEDULED, 0).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: true });
// No replacement: it would send the message a second time.
expect(setCalls()).toEqual([[[], ['sub-1'], []]]);
});
it('still reports the failure when the replacement cannot be withdrawn either', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
it('changes nothing and reports the failure when the cancel fails while the original is still due', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(set({ notUpdated: { 'sub-1': { type: 'cannotUnsend' } } }))
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'final' }] }, '0']] })
.mockResolvedValueOnce(set({ notUpdated: { 'sub-2': { type: 'cannotUnsend' } } }));
await expect(rescheduleScheduledSend(SCHEDULED, 0)).rejects.toThrow('Failed to cancel the previous schedule');
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
});
it('keeps the replacement when a cancel whose answer got lost did go through', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockRejectedValueOnce(new Error('Network request failed'))
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'canceled' }] }, '0']] });
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'pending' }] }, '0']] });
const result = await rescheduleScheduledSend(SCHEDULED, 7200);
await expect(rescheduleScheduledSend(SCHEDULED, 7200)).rejects.toThrow('Network request failed');
expect(setCalls()).toEqual([[[], ['sub-1'], []]]);
});
expect(result.emailSubmissionId).toBe('sub-2');
// Unconfirmed, so the original's record is left for the prune.
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []]]);
it('puts the original back at its old time, under a new id, when the replacement is refused', async () => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse(ORIGINAL_AT) - 1800_000);
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce(REFUSED('Quota exceeded'))
.mockResolvedValueOnce(set({ created: { replacement: { id: 'sub-3' } } }));
const err = await rescheduleScheduledSend(SCHEDULED, 7200).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RescheduleRestoredError);
expect(err).toMatchObject({ restoredSubmissionId: 'sub-3', restoredSendAt: ORIGINAL_AT, message: 'Quota exceeded' });
// Half an hour was left of the original hold.
expect(createdArgs(1).create.replacement.envelope.mailFrom.parameters).toEqual({ HOLDFOR: '1800' });
vi.mocked(Date.now).mockRestore();
});
it('moves the message back to Drafts when neither the replacement nor the original can be made', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce(REFUSED('Quota exceeded'))
.mockResolvedValueOnce(REFUSED('Quota exceeded'))
.mockResolvedValueOnce({ methodResponses: [['Email/set', { updated: { 'e-1': null } }, '0']] });
const err = await rescheduleScheduledSend(SCHEDULED, 7200).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RescheduleLostError);
expect(err).toMatchObject({ inDrafts: true });
const [, restore] = mockRequest.mock.calls.at(-1)![0][0];
expect(restore.update).toEqual({
'e-1': { 'mailboxIds/mb-drafts': true, 'keywords/$draft': true, 'keywords/$seen': true, 'mailboxIds/mb-sent': null },
});
});
});
@@ -257,6 +281,22 @@ describe('sendEmail', () => {
.rejects.toThrow('Invalid envelope');
});
// #1019: an answer without the submission confirms nothing. The draft
// stays, and so does the copy, which may be the one that went out.
it('reports a send the server did not confirm as unconfirmed, and keeps the draft', async () => {
mockRequest.mockResolvedValueOnce({
methodResponses: [
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
],
});
await expect(sendEmail(OUTGOING, 'identity-1', 'sent-mb', undefined, {
draftsMailboxId: 'drafts-mb', draftId: 'old-draft',
})).rejects.toBeInstanceOf(SendUnconfirmedError);
// No destroy of the old draft version, nor of the copy.
expect(mockRequest).toHaveBeenCalledTimes(1);
});
it('still fails when the message could not be created', async () => {
mockRequest.mockResolvedValueOnce({
methodResponses: [
@@ -374,24 +414,24 @@ describe('hold-limit rejections', () => {
expect(jmapClient.learnHoldLimit).not.toHaveBeenCalled();
});
it('turns a refused reschedule into ScheduleTooLateError', async () => {
it('turns a refused reschedule into ScheduleTooLateError, with the original put back', async () => {
mockRequest
.mockResolvedValueOnce({
methodResponses: [
['EmailSubmission/get', { list: [{ id: 'sub-1', envelope: { mailFrom: { email: 'me@example.com' }, rcptTo: [{ email: 'to@example.com' }] } }] }, '0'],
['Email/get', { list: [] }, '1'],
],
})
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({
methodResponses: [['EmailSubmission/set', { notCreated: { replacement: HOLD_REJECTED } }, '0']],
});
})
.mockResolvedValueOnce(set({ created: { replacement: { id: 'sub-3' } } }));
const err = await rescheduleScheduledSend(SCHEDULED, 5 * 24 * 3600).catch((e) => e);
expect(err).toBeInstanceOf(ScheduleTooLateError);
expect(err).toBeInstanceOf(RescheduleRestoredError);
expect((err as RescheduleRestoredError).reason).toBeInstanceOf(ScheduleTooLateError);
expect(jmapClient.learnHoldLimit).toHaveBeenCalledWith(172_800);
// The original stays scheduled.
expect(setCalls()).toEqual([[['replacement'], [], []]]);
// The original goes at its old time again, under a new id.
expect(setCalls()).toEqual([[[], ['sub-1'], []], [[], [], ['sub-1']], [['replacement'], [], []], [['replacement'], [], []]]);
mockRequest.mockReset();
});
});
@@ -473,9 +513,9 @@ describe('scheduled sends in shared accounts (webmail #874)', () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(set({ created: { replacement: { id: 's-new', sendAt: FUTURE } } }))
.mockResolvedValueOnce(set({ updated: { 's-grp': null } }))
.mockResolvedValueOnce(DESTROYED('s-grp'));
.mockResolvedValueOnce(DESTROYED('s-grp'))
.mockResolvedValueOnce(set({ created: { replacement: { id: 's-new', sendAt: FUTURE } } }));
await rescheduleScheduledSend({ ...SCHEDULED, emailSubmissionId: 's-grp', accountId: 'grp-1' }, 0);
expect(mockRequest.mock.calls.slice(2).map((c) => c[0][0][1].accountId)).toEqual(['grp-1', 'grp-1', 'grp-1', 'grp-1']);
});
@@ -389,7 +389,27 @@ export function createFakeSubmissionServer(options: {
if (emails.delete(id)) (body.destroyed ??= []).push(id);
else (body.notDestroyed ??= {})[id] = { type: 'notFound' };
}
// Pointer patches of mailboxes and keywords (a message back to Drafts).
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
const email = emails.get(id);
if (!email) {
(body.notUpdated ??= {})[id] = { type: 'notFound' };
continue;
}
for (const [path, value] of Object.entries(patch)) {
const [field, key] = path.split('/');
const target = field === 'mailboxIds' ? email.mailboxIds : field === 'keywords' ? email.keywords : null;
if (!target || !key) continue;
if (value === null) delete target[key];
else target[key] = true;
}
(body.updated ??= {})[id] = null;
}
responses.push(['Email/set', body, callId]);
} else if (name === 'Mailbox/get') {
responses.push(['Mailbox/get', {
accountId, state: 'm1', list: [{ id: 'drafts', role: 'drafts' }, { id: 'sent', role: 'sent' }], notFound: [],
}, callId]);
} else if (name === 'Email/import') {
const body: Record<string, any> = { accountId };
for (const [creationId, item] of Object.entries(args.emails as Record<string, Record<string, any>>)) {
@@ -11,6 +11,10 @@ import path from 'path';
* and the gets whose server default could grow to include metadata ask for an
* explicit property list instead. Port of the webmail's
* lib/__tests__/jmap-metadata-requests.test.ts.
*
* Stalwart 1.0.0 (the release) serves Mailbox metadata, and templates are
* kept there (src/api/bulwark-metadata.ts, in requests of their own, only
* for accounts that advertise it). Everything else still never asks for it.
*/
const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
@@ -239,18 +243,15 @@ describe('JMAP Metadata in the request-building sources', () => {
/(['"`])metadata(\/[^'"`\n]*)?\1/,
];
// Stalwart 1.0.0 serves Metadata. The one module that reads it (a group's
// shared tags) asks only accounts that advertise it, in a request of its
// own; its tests cover that.
const METADATA_READER = 'src/api/bulwark-metadata.ts';
// The one module that may: Bulwark's own entries on the Inbox, asked for
// in requests of their own.
const METADATA_MODULE = 'src/api/bulwark-metadata.ts';
it('never names a JMAP Metadata property, argument or capability in code', () => {
const files = [...sourceFiles('src'), 'App.tsx'];
const files = [...sourceFiles('src'), 'App.tsx'].filter((file) => file !== METADATA_MODULE);
expect(files).toContain('src/api/email.ts');
expect(files).toContain(METADATA_READER);
const hits: string[] = [];
for (const file of files) {
if (file === METADATA_READER) continue;
const code = withoutComments(readFileSync(path.join(root, file), 'utf8'));
for (const pattern of FORBIDDEN) {
const match = code.match(pattern);
@@ -259,4 +260,9 @@ describe('JMAP Metadata in the request-building sources', () => {
}
expect(hits).toEqual([]);
});
it('keeps privateMetadata and ignoreMetadataOnlyChanges out of the metadata module too', () => {
const code = withoutComments(readFileSync(path.join(root, METADATA_MODULE), 'utf8'));
expect(code).not.toMatch(/privateMetadata|ignoreMetadataOnlyChanges/);
});
});
@@ -0,0 +1,207 @@
/**
* Live checks of the composer's sending paths against a real Stalwart, for
* the differences between 0.16 and 1.0: reschedule and "Send now" cancel
* the original first (#1308), Cancel send puts the message back in Drafts
* (native #84), Files and mail share blobs (#1179, Save to Files), Blob/copy
* needs the blob capability, and templates live in the Inbox metadata on
* 1.0.0 only.
*
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
* `BULWARK_LIVE_PASS` are set.
*
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
* BULWARK_LIVE_USER=e2e-phone-dark@example.org BULWARK_LIVE_PASS=… \
* npx vitest run src/api/__tests__/live-stalwart-compose.test.ts
*
* Everything it creates is removed again. Mail goes to the user's own
* address and nothing depends on delivery (SMTP is down on the test VM).
*
* Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0 (2026-10-29).
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { JMAPClient } from '../jmap-client';
import {
destroyEmails, getFullEmails, getMailboxes, listScheduledEmails, rescheduleScheduledSend, saveDraftVersion, sendEmail,
} from '../email';
import { uploadBytes } from '../blob';
import { deleteFileNodes } from '../files';
import { getIdentities, resolveSendAccountId } from '../identity';
import {
getBulwarkMetadata, MetadataTooLargeError, resetBulwarkMetadata, setBulwarkMetadata, supportsBulwarkMetadata,
} from '../bulwark-metadata';
import { CAPABILITIES } from '../types';
import { cancelScheduledToDrafts } from '../../lib/compose-scheduled';
import { attachmentFromFileNode, saveAttachmentToFiles } from '../../lib/compose-files';
import { servedLoginOf } from '../../lib/draft-login';
import { useAccountStore } from '../../stores/account-store';
import { useTemplatesStore } from '../../stores/templates-store';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'e2e-phone-dark@example.org';
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const live = SERVER && PASS ? describe : describe.skip;
const RUN = Date.now().toString(36);
let client: JMAPClient;
let singleton: JMAPClient;
/** Stalwart 1.0 advertises JMAP Metadata, 0.16 does not. */
const is10 = () => !!client.currentSession?.capabilities?.['urn:ietf:params:jmap:metadata'];
live('live Stalwart: composer and sending (0.16 and 1.0)', () => {
let drafts = '';
let sent = '';
const tag = `bulwark-compose-${RUN}`;
const createdEmails: string[] = [];
const createdNodes: string[] = [];
beforeAll(async () => {
const mod = await import('../jmap-client');
singleton = mod.jmapClient as unknown as JMAPClient;
client = new JMAPClient();
await client.connect(SERVER!, USER, PASS);
singleton.restoreSnapshot(client.snapshot());
const boxes = await getMailboxes();
drafts = boxes.find((b) => b.role === 'drafts')?.id ?? '';
sent = boxes.find((b) => b.role === 'sent')?.id ?? '';
}, 60_000);
afterAll(async () => {
const res = await singleton.request([['Email/query', { accountId: singleton.accountId, filter: { subject: tag } }, '0']]);
const leftovers = [...new Set([...(res.methodResponses[0][1].ids as string[] ?? []), ...createdEmails])];
if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined);
if (createdNodes.length) await deleteFileNodes(createdNodes).catch(() => undefined);
}, 60_000);
describe('scheduled sends', () => {
it('cancels the original before it schedules the replacement, then sends a cancelled one back to Drafts', async () => {
const [identity] = await getIdentities();
const result = await sendEmail(
{ from: [{ email: USER }], to: [{ email: USER }], subject: `later ${tag}`, textBody: 'live' },
identity.id, sent, 3600, { draftsMailboxId: drafts },
);
createdEmails.push(result.emailId!);
const first = (await listScheduledEmails()).find((s) => s.emailSubmissionId === result.emailSubmissionId)!;
expect(first).toBeTruthy();
const request = vi.spyOn(singleton, 'request');
const moved = await rescheduleScheduledSend(first, 7200);
// The cancel of the original goes out before the replacement's create.
const sets = request.mock.calls
.map(([calls]) => calls[0])
.filter(([name]) => name === 'EmailSubmission/set')
.map(([, args]) => (args.update ? 'cancel' : args.create ? 'create' : 'destroy'));
expect(sets.slice(0, 3)).toEqual(['cancel', 'destroy', 'create']);
request.mockRestore();
expect(moved.emailSubmissionId).toBeTruthy();
const listed = await listScheduledEmails();
expect(listed.some((s) => s.emailSubmissionId === first.emailSubmissionId)).toBe(false);
const second = listed.find((s) => s.emailSubmissionId === moved.emailSubmissionId)!;
expect(new Date(second.sendAt).getTime()).toBeGreaterThan(new Date(first.sendAt).getTime() + 3_000_000);
// The replacement filed it in Sent.
const [filed] = await getFullEmails([result.emailId!]);
expect(filed.mailboxIds[sent]).toBe(true);
// Cancel send: nothing goes out, and the message is a draft again.
const inDrafts = await cancelScheduledToDrafts(second, await getMailboxes(), singleton.accountId);
expect(inDrafts).toBe(true);
expect((await listScheduledEmails()).some((s) => s.emailId === result.emailId)).toBe(false);
const [restored] = await getFullEmails([result.emailId!]);
expect(restored.mailboxIds[drafts]).toBe(true);
expect(restored.mailboxIds[sent]).toBeUndefined();
expect(restored.keywords.$draft).toBe(true);
}, 90_000);
});
describe('Files and mail share blobs', () => {
it('saves an attachment to Files and attaches a file from Files, by blob', async () => {
const upload = await uploadBytes(new TextEncoder().encode(`hello ${RUN}`), 'text/plain');
// Save to Files: the attachment's blob becomes a file.
const node = await saveAttachmentToFiles({ blobId: upload.blobId, name: `${tag}.txt`, type: 'text/plain' }, null);
createdNodes.push(node.id);
expect(node.id).toBeTruthy();
const nodes = await singleton.request(
[['FileNode/get', { accountId: singleton.accountId, ids: [node.id] }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.FILES],
);
const stored = nodes.methodResponses[0][1].list[0];
// Attach from Files: the file's blob goes into a draft as it is.
const part = await attachmentFromFileNode({ ...stored, nodeType: 'file', type: 'text/plain' });
const saved = await saveDraftVersion({
from: [{ email: USER }], to: [{ email: USER }], subject: `files ${tag}`, textBody: 'see attached',
attachments: [{ blobId: part.blobId, name: 'from-files.txt', type: 'text/plain', disposition: 'attachment' }],
}, drafts);
createdEmails.push(saved.id);
expect(saved.attachments.map((a) => a.name)).toContain('from-files.txt');
}, 60_000);
it('copies blobs with Blob/copy only when the blob capability is in `using`', async () => {
const upload = await uploadBytes(new TextEncoder().encode(`copy ${RUN}`), 'text/plain');
const call = ['Blob/copy', { fromAccountId: singleton.accountId, accountId: singleton.accountId, blobIds: [upload.blobId] }, '0'];
const without = await singleton.request([call as never], [CAPABILITIES.CORE]);
expect(without.methodResponses[0][0]).toBe('error');
const withBlob = await singleton.request([call as never], [CAPABILITIES.CORE, CAPABILITIES.BLOB]);
expect(withBlob.methodResponses[0][0]).toBe('Blob/copy');
}, 30_000);
});
describe('identities', () => {
it('sends the own address from the own account (this login is in no group)', async () => {
await expect(resolveSendAccountId(USER)).resolves.toBe(singleton.accountId);
expect(singleton.getSubmissionAccountIds()).toEqual([singleton.accountId]);
}, 30_000);
});
describe('templates in the Inbox metadata', () => {
it('keeps no metadata on 0.16 and asks for none', async () => {
if (is10()) return;
resetBulwarkMetadata();
const request = vi.spyOn(singleton, 'request');
expect(supportsBulwarkMetadata(singleton.accountId)).toBe(false);
await expect(getBulwarkMetadata(singleton.accountId)).resolves.toBeNull();
expect(request).not.toHaveBeenCalled();
request.mockRestore();
});
it('writes, reads and removes an entry on 1.0, and refuses one over 8 KB', async () => {
if (!is10()) return;
resetBulwarkMetadata();
const key = `template.live-${RUN}`;
await setBulwarkMetadata(singleton.accountId, key, { id: `live-${RUN}`, name: 'Live', updatedAt: new Date().toISOString() });
try {
expect((await getBulwarkMetadata(singleton.accountId))?.[key]).toMatchObject({ name: 'Live' });
await expect(setBulwarkMetadata(singleton.accountId, `template.big-${RUN}`, { body: 'x'.repeat(9000) }))
.rejects.toBeInstanceOf(MetadataTooLargeError);
} finally {
await setBulwarkMetadata(singleton.accountId, key, null);
}
expect((await getBulwarkMetadata(singleton.accountId))?.[key]).toBeUndefined();
}, 30_000);
it('syncs a template made on the phone into the account, and deletes it there', async () => {
if (!is10()) return;
resetBulwarkMetadata();
const before = await getBulwarkMetadata(singleton.accountId);
const hadMarker = !!before && 'templates' in before;
useAccountStore.setState({ activeAccountId: servedLoginOf(singleton) });
useTemplatesStore.setState({ hydrated: true, device: [], accounts: {}, deviceListOwner: null, templates: [] });
try {
await useTemplatesStore.getState().refresh({ force: true });
const made = useTemplatesStore.getState().addTemplate({ name: `Live ${RUN}`, subject: 's', body: 'b' });
await useTemplatesStore.getState().refresh({ force: true });
expect((await getBulwarkMetadata(singleton.accountId))?.[`template.${made.id}`]).toMatchObject({ name: `Live ${RUN}` });
useTemplatesStore.getState().deleteTemplate(made.id);
await useTemplatesStore.getState().refresh({ force: true });
const after = await getBulwarkMetadata(singleton.accountId);
expect(after?.[`template.${made.id}`]).toBeUndefined();
expect((after?.templates as { deleted?: Record<string, string> })?.deleted?.[made.id]).toBeTruthy();
} finally {
if (!hadMarker) await setBulwarkMetadata(singleton.accountId, 'templates', null).catch(() => undefined);
}
}, 60_000);
});
});
+3 -3
View File
@@ -48,7 +48,7 @@ import {
createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript,
} from '../sieve';
import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media';
import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata';
import { getBulwarkMetadata, resetBulwarkMetadata, supportsBulwarkMetadata } from '../bulwark-metadata';
import { parseSharedTags, SHARED_TAGS_KEY } from '../../lib/shared-tags';
import { generateVCard, parseVCard } from '../../lib/vcard';
import type { ContactCard } from '../types';
@@ -358,7 +358,7 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
// is in no group, so this checks the shape on its own Inbox: written the
// way the webmail writes it, read the way the app reads it.
it('reads shared tags from Inbox metadata on 1.0 and asks nothing of 0.16', async () => {
resetBulwarkMetadataPauses();
resetBulwarkMetadata();
const accountId = client.accountId;
if (!is10()) {
expect(supportsBulwarkMetadata(accountId)).toBe(false);
@@ -378,7 +378,7 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
// One tag patched on its own, as the webmail saves a change.
await setTags({ label: 'Second', color: 'blue' }, `/tags/second-${RUN}`);
resetBulwarkMetadataPauses();
resetBulwarkMetadata();
const entries = await getBulwarkMetadata(accountId);
expect(parseSharedTags(entries?.[SHARED_TAGS_KEY])).toEqual([
{ id: `live-${RUN}`, label: 'Live check', color: 'teal' },
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// #1090: a message From a group identity is created, submitted and filed in
// the group's account. Stalwart lists the group's send-as identity among the
// member's own too, so the member's list alone sent it through the member's
// account and filed the Sent copy there.
const { client } = vi.hoisted(() => ({
client: {
accountId: 'me',
serverUrl: 'https://mail.example.com',
username: 'me@example.com',
request: vi.fn(),
getSubmissionAccountIds: vi.fn(() => ['me', 'team']),
currentSession: { accounts: { me: { name: 'me@example.com' }, team: { name: 'Team' } } } as Record<string, unknown>,
getAccountName: vi.fn((id: string) => id),
hasAccountCapability: vi.fn(() => false),
getMaxCallsInRequest: vi.fn(() => 16),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
},
}));
vi.mock('../jmap-client', () => ({ jmapClient: client }));
import { resetSendIdentities, resolveSendAccountId, sendIdentityIn } from '../identity';
import { saveDraftVersion, sendEmail } from '../email';
import { sendTargetFor } from '../../lib/compose-send-account';
import type { Identity, Mailbox } from '../types';
const ME = { id: 'i-me', name: 'Me', email: 'me@example.com', mayDelete: false } as Identity;
// The member's list mirrors the group's identity, with the member's id for it.
const MIRRORED = { id: 'i-me-team', name: 'Team', email: 'team@example.com', mayDelete: false } as Identity;
const TEAM = { id: 'i-team', name: 'Team', email: 'team@example.com', mayDelete: false } as Identity;
function identitiesAnswer(calls: Array<[string, { accountId: string }, string]>) {
return {
methodResponses: calls.map(([name, args, callId]) => {
if (name !== 'Identity/get') throw new Error(`unexpected ${name}`);
const list = args.accountId === 'team' ? [TEAM] : [ME, MIRRORED];
return ['Identity/get', { accountId: args.accountId, list }, callId];
}),
};
}
function mailbox(role: string, id: string, accountId?: string): Mailbox {
return {
id: accountId ? `${accountId}:${id}` : id, originalId: accountId ? id : undefined, role, name: role,
accountId, isShared: !!accountId,
} as Mailbox;
}
const MAILBOXES = [
mailbox('drafts', 'own-drafts'), mailbox('sent', 'own-sent'),
mailbox('drafts', 'team-drafts', 'team'), mailbox('sent', 'team-sent', 'team'),
];
beforeEach(() => {
vi.clearAllMocks();
resetSendIdentities();
client.getSubmissionAccountIds.mockReturnValue(['me', 'team']);
client.request.mockImplementation(async (calls) => identitiesAnswer(calls));
});
describe('resolveSendAccountId', () => {
it("sends a group identity's message from the group account, not the member's mirror", async () => {
await expect(resolveSendAccountId('team@example.com')).resolves.toBe('team');
await expect(resolveSendAccountId('Team@Example.com')).resolves.toBe('team');
});
it('keeps the own address, and a sub-address of it, in the own account', async () => {
await expect(resolveSendAccountId('me@example.com')).resolves.toBe('me');
await expect(resolveSendAccountId('me+news@example.com')).resolves.toBe('me');
});
it('takes a sub-address of the group address for the group', async () => {
await expect(resolveSendAccountId('team+sales@example.com')).resolves.toBe('team');
});
it('asks nothing when there is no group or shared account', async () => {
client.getSubmissionAccountIds.mockReturnValue(['me']);
await expect(resolveSendAccountId('team@example.com')).resolves.toBe('me');
expect(client.request).not.toHaveBeenCalled();
});
it('reads the identities once for a burst of saves', async () => {
await resolveSendAccountId('team@example.com');
await resolveSendAccountId('team@example.com');
expect(client.request).toHaveBeenCalledTimes(1);
});
it("names the group account's own identity for the address", async () => {
await expect(sendIdentityIn('team', 'team@example.com')).resolves.toMatchObject({ id: 'i-team' });
});
});
describe('sendTargetFor', () => {
it("submits a group identity's message through the group's identity and files it in the group's folders", async () => {
await expect(sendTargetFor(MIRRORED, 'team@example.com', MAILBOXES)).resolves.toEqual({
accountId: 'team', identityId: 'i-team', draftsMailboxId: 'team-drafts', sentMailboxId: 'team-sent',
});
});
it("sends from the own account when the group's identities can't be read", async () => {
const accounts = client.currentSession.accounts as Record<string, { name: string }>;
accounts.team.name = 'team@example.com';
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
client.request.mockRejectedValue(new Error('Network request failed'));
try {
await expect(sendTargetFor(MIRRORED, 'team@example.com', MAILBOXES)).resolves.toEqual({
identityId: 'i-me-team', draftsMailboxId: 'own-drafts', sentMailboxId: 'own-sent',
});
} finally {
accounts.team.name = 'Team';
}
});
it('keeps an own message in the own folders', async () => {
await expect(sendTargetFor(ME, 'me@example.com', MAILBOXES)).resolves.toEqual({
identityId: 'i-me', draftsMailboxId: 'own-drafts', sentMailboxId: 'own-sent',
});
});
});
describe('a draft that moves between accounts', () => {
const OUTGOING = { from: [{ email: 'team@example.com' }], to: [{ email: 'you@example.net' }], subject: 'Hi', textBody: 'Hi' };
it("destroys the previous version in the account it was saved in, never one with the same id in the new one", async () => {
client.request.mockReset();
client.request
.mockResolvedValueOnce({
methodResponses: [
['Email/set', { created: { draft: { id: 'd7' } } }, '0'],
['Email/get', { list: [{ id: 'd7', attachments: [] }] }, '1'],
],
})
.mockResolvedValueOnce({ methodResponses: [['Email/set', { destroyed: ['d7'] }, '0']] });
// The own Drafts held version d7; the From is now the group's, whose
// account has a message d7 of its own.
await saveDraftVersion(OUTGOING, 'team-drafts', 'd7', 'team', 'me');
const [create] = client.request.mock.calls[0][0];
expect(create[1].accountId).toBe('team');
const [destroy] = client.request.mock.calls[1][0];
expect(destroy[1]).toMatchObject({ accountId: 'me', destroy: ['d7'] });
});
it('removes the old draft from its own account once the group send went out', async () => {
client.request.mockReset();
client.request
.mockResolvedValueOnce({
methodResponses: [
['Email/set', { created: { draft: { id: 'e1' } } }, '0'],
['EmailSubmission/set', { created: { 'sub-1': { id: 's1' } } }, '1'],
],
})
.mockResolvedValueOnce({ methodResponses: [['Email/set', { destroyed: ['d7'] }, '0']] });
await sendEmail(OUTGOING, 'i-team', 'team-sent', undefined, {
draftsMailboxId: 'team-drafts', draftId: 'd7', draftAccountId: 'me', accountId: 'team',
});
expect(client.request.mock.calls[0][0][0][1].accountId).toBe('team');
expect(client.request.mock.calls[0][0][1][1].accountId).toBe('team');
expect(client.request.mock.calls[1][0][0][1]).toMatchObject({ accountId: 'me', destroy: ['d7'] });
});
});
+146 -48
View File
@@ -1,35 +1,70 @@
// Bulwark's own data in JMAP Metadata (RFC draft, urn:ietf:params:jmap:metadata)
// of an account's Inbox: the shared tag definitions of a group account
// (namespace `org.bulwarkmail.tags`). Port of the reading half of the
// webmail's lib/jmap/bulwark-metadata.ts and client.getBulwarkMetadata.
// of an account's Inbox: personal templates in the user's own account, team
// templates in a group account (webmail lib/jmap/bulwark-metadata.ts).
//
// Stalwart 1.0.0 serves Metadata; 0.16 advertises none, and a pre-release
// 1.0 build advertised it but dropped the connection on a Mailbox/get that
// selects it (42751a6b). So this is the only module that asks for it: only
// for an account whose capabilities offer vendor namespaces on mailboxes,
// in a request of its own, and any failure pauses that account for ten
// minutes. Every other Mailbox/get keeps its explicit property list.
// What Stalwart does: 1.0.0 serves it (seen live 2026-10-29: Mailbox/get and
// Mailbox/set with `metadata/<namespace>` pointers on the Inbox, at most
// 8 KB per namespace, "Metadata value size … exceeds the maximum of 8192
// bytes" above that). Vendor namespaces need a dot, so every item gets one
// of its own: `org.bulwarkmail.<key>`. 0.16 has no metadata and refuses the
// capability in `using` (notRequest); an account that doesn't advertise it
// is never asked. A pre-release 1.0 build advertised it but dropped the
// connection on such a request, so the calls go in requests of their own
// and any failure pauses that account for ten minutes.
//
// This is the only module that names the Metadata capability or property
// (src/api/__tests__/jmap-metadata-requests.test.ts).
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import { CAPABILITIES, type JMAPMethodCall } from './types';
export const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
export const BULWARK_NAMESPACE = 'org.bulwarkmail';
/** Largest value Stalwart's default config takes in one namespace. */
export const MAX_METADATA_ENTRY_BYTES = 8192;
const PAUSE_AFTER_FAILURE_MS = 10 * 60_000;
const KEY_RE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/;
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, METADATA_CAPABILITY];
const PAUSE_MS = 10 * 60_000;
/**
* Whether the account's capabilities offer vendor-namespaced metadata on
* mailboxes. Says nothing about whether the server really implements it.
*/
export function advertisesMailboxMetadata(accountCapabilities: Record<string, unknown> | undefined): boolean {
const cap = accountCapabilities?.[METADATA_CAPABILITY] as
| { dataTypes?: Record<string, { supportsVendorNamespaces?: boolean }> }
| undefined;
return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true;
export class MetadataTooLargeError extends Error {
constructor(message: string) {
super(message);
this.name = 'MetadataTooLargeError';
}
}
/** Bulwark's entries of a `metadata` object, by key (`tags`, `template.<id>`). */
/** The server refused a metadata change; `type` is its SetError type. */
export class MetadataRefusedError extends Error {
constructor(readonly type: string, message: string) {
super(message);
this.name = 'MetadataRefusedError';
}
}
export class MetadataUnsupportedError extends Error {
constructor(message = 'This server keeps no metadata for Bulwark') {
super(message);
this.name = 'MetadataUnsupportedError';
}
}
/** `templates`, `template.<id>`: lowercase, digits, dashes, dot-separated. */
export function isValidMetadataKey(key: string): boolean {
return key.length <= 120 && KEY_RE.test(key);
}
function namespaceFor(key: string): string {
if (!isValidMetadataKey(key)) throw new Error(`Invalid metadata key: ${key}`);
return `${BULWARK_NAMESPACE}.${key}`;
}
/** The size Stalwart counts for a value: its JSON in UTF-8. */
export function metadataValueBytes(value: unknown): number {
return new TextEncoder().encode(JSON.stringify(value)).length;
}
/** Bulwark's entries of a metadata object, by key. */
export function bulwarkEntries(metadata: unknown): Record<string, unknown> {
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {};
const prefix = `${BULWARK_NAMESPACE}.`;
@@ -40,52 +75,115 @@ export function bulwarkEntries(metadata: unknown): Record<string, unknown> {
return out;
}
// Accounts whose last read failed, by server and account, with when.
// Account ids repeat across servers and logins.
const keyOf = (accountId: string) => `${jmapClient.serverUrl ?? ''}\u0000${jmapClient.username ?? ''}\u0000${accountId}`;
const failedAt = new Map<string, number>();
const pauseKey = (accountId: string) => `${jmapClient.serverUrl ?? ''}|${accountId}`;
const inboxIds = new Map<string, string>();
/** Whether the app may ask this account for its Inbox metadata now. */
export function supportsBulwarkMetadata(accountId: string): boolean {
const failed = failedAt.get(pauseKey(accountId));
if (failed !== undefined && Date.now() - failed < PAUSE_AFTER_FAILURE_MS) return false;
const account = jmapClient.currentSession?.accounts?.[accountId] as
| { accountCapabilities?: Record<string, unknown> }
/** Forget paused accounts and Inbox ids (tests). */
export function resetBulwarkMetadata(): void {
failedAt.clear();
inboxIds.clear();
}
/** Whether the account advertises vendor metadata on mailboxes, whatever its last request did. */
export function advertisesBulwarkMetadata(accountId: string): boolean {
const caps = jmapClient.currentSession?.accounts?.[accountId]?.accountCapabilities as Record<string, unknown> | undefined;
const cap = caps?.[METADATA_CAPABILITY] as
| { dataTypes?: Record<string, { supportsVendorNamespaces?: boolean }> }
| undefined;
return advertisesMailboxMetadata(account?.accountCapabilities);
return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true;
}
/** Whether Bulwark may keep data in this account's Inbox metadata now. */
export function supportsBulwarkMetadata(accountId: string): boolean {
const at = failedAt.get(keyOf(accountId));
if (at !== undefined && Date.now() - at < PAUSE_MS) return false;
return advertisesBulwarkMetadata(accountId);
}
/**
* Bulwark's shared metadata entries of the account's Inbox, by key. Null
* when the account has none to offer (0.16) or the read failed; no request
* goes out for an account that doesn't advertise Metadata.
* Bulwark's metadata entries of the account's Inbox, by key. Null when the
* account has no usable metadata; a failure pauses the account.
*/
export async function getBulwarkMetadata(accountId: string): Promise<Record<string, unknown> | null> {
if (!supportsBulwarkMetadata(accountId)) return null;
const property = 'metadata';
try {
const res = await jmapClient.request([
['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q'],
['Mailbox/get', {
accountId,
'#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' },
properties: ['id', property],
accountId, '#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' }, properties: ['id', 'metadata'],
}, 'g'],
], [CAPABILITIES.CORE, CAPABILITIES.MAIL, METADATA_CAPABILITY]);
const get = res.methodResponses?.find((r) => r[0] === 'Mailbox/get');
] as JMAPMethodCall[], USING);
const get = res.methodResponses.find(([name]) => name === 'Mailbox/get');
const inbox = (get?.[1] as { list?: Array<Record<string, unknown>> } | undefined)?.list?.[0];
// An answer without the property is a server that doesn't keep it,
// never "every entry was removed".
if (!inbox || !(property in inbox)) throw new Error('No Inbox metadata in the answer');
failedAt.delete(pauseKey(accountId));
return bulwarkEntries(inbox[property]);
if (!inbox || !('metadata' in inbox)) throw new MetadataUnsupportedError('No Inbox metadata in the answer');
inboxIds.set(keyOf(accountId), inbox.id as string);
failedAt.delete(keyOf(accountId));
return bulwarkEntries(inbox.metadata);
} catch (err) {
console.warn('[bulwark-metadata] unavailable for account', accountId, err);
failedAt.set(pauseKey(accountId), Date.now());
console.warn(`[metadata] unavailable for account ${accountId}:`, err);
failedAt.set(keyOf(accountId), Date.now());
return null;
}
}
/** Forget the failure pauses (sign-out, tests). */
export function resetBulwarkMetadataPauses(): void {
failedAt.clear();
/**
* Write one Bulwark entry of the account's Inbox metadata (null removes it),
* leaving every other entry as it is. Throws MetadataTooLargeError when the
* value is over the size the server takes, MetadataRefusedError for another
* refusal, MetadataUnsupportedError when the account has none.
*/
export async function setBulwarkMetadata(accountId: string, key: string, value: unknown): Promise<void> {
if (!supportsBulwarkMetadata(accountId)) throw new MetadataUnsupportedError();
if (value !== null && metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) {
throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`);
}
let inboxId = inboxIds.get(keyOf(accountId));
if (!inboxId) {
await getBulwarkMetadata(accountId);
inboxId = inboxIds.get(keyOf(accountId));
if (!inboxId) throw new MetadataUnsupportedError();
}
const res = await jmapClient.request(
[['Mailbox/set', { accountId, update: { [inboxId]: { [`metadata/${namespaceFor(key)}`]: value } } }, '0']],
USING,
);
const [name, body] = res.methodResponses[0] ?? [];
if (name !== 'Mailbox/set') {
throw new Error((body as { description?: string } | undefined)?.description || 'Failed to save metadata');
}
const refused = (body as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated?.[inboxId];
if (refused) {
if (/exceeds the maximum/i.test(refused.description ?? '')) throw new MetadataTooLargeError(refused.description!);
throw new MetadataRefusedError(refused.type ?? 'unknown', refused.description || `Failed to save metadata (${refused.type ?? 'unknown'})`);
}
}
/**
* The group accounts of the session: shared accounts whose principal is a
* group (Stalwart uses the account id as the principal id). A mailbox
* another person shared is not a team. Empty when the server won't say.
*/
export async function groupAccounts(): Promise<Array<{ id: string; name: string }>> {
const session = jmapClient.currentSession;
if (!session || !jmapClient.hasCapability(CAPABILITIES.PRINCIPALS)) return [];
const own = jmapClient.accountId;
const shared = Object.entries(session.accounts ?? {})
.filter(([id, account]) => id !== own && account && account.isPersonal === false);
if (shared.length === 0) return [];
try {
const res = await jmapClient.request(
[['Principal/get', { accountId: own, ids: shared.map(([id]) => id), properties: ['id', 'type'] }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS],
);
const [name, body] = res.methodResponses[0] ?? [];
if (name !== 'Principal/get') return [];
const types = new Map(((body as { list?: Array<{ id: string; type?: string }> }).list ?? []).map((p) => [p.id, p.type]));
return shared.filter(([id]) => types.get(id) === 'group').map(([id, account]) => ({ id, name: account.name }));
} catch {
return [];
}
}
+232 -130
View File
@@ -1650,6 +1650,18 @@ function submissionError(err: { description?: string; type?: string }, fallback:
return new ScheduleTooLateError(limit);
}
/**
* The send request came back without an EmailSubmission and without an
* error: nothing confirms the message left, and it may have (webmail
* SendUnconfirmedError, #1019). The draft is kept.
*/
export class SendUnconfirmedError extends Error {
constructor() {
super('Send confirmation was not received. Check Sent before sending again. Your draft has been kept.');
this.name = 'SendUnconfirmedError';
}
}
export interface SendEmailOptions {
/**
* Drafts folder id. When given, the message is created there with `$draft`
@@ -1660,6 +1672,12 @@ export interface SendEmailOptions {
draftsMailboxId?: string;
/** Previous draft version to destroy once the submission succeeded (#849). */
draftId?: string;
/**
* Account `draftId` lives in, when not the submitting one: a draft saved
* in the own Drafts and sent from a group identity, or the other way
* round. Email ids repeat across accounts.
*/
draftAccountId?: string;
/** Submitting account (shared/group account); defaults to the primary. */
accountId?: string;
}
@@ -1804,6 +1822,14 @@ export async function sendEmail(
}
}
// No submission came back and nothing said why: an answer cut short, or a
// server that skipped the call. Nothing confirms the message left, and it
// may have. Reporting it as sent dropped the draft and closed the composer
// (#1019); this keeps both, and the copy made for it too, since it may be
// the one that went out.
if (!failure && !emailSubmissionId) failure = new SendUnconfirmedError();
if (failure instanceof SendUnconfirmedError) throw failure;
if (failure) {
// Nothing went out. A message created before the submission was refused
// must not stay behind: nobody tracked that copy, so a retry left a
@@ -1855,11 +1881,12 @@ export async function sendEmail(
// rather than a failed send (#849). The submission record rides along, and
// nothing about it may turn into a warning.
if (opts?.draftId && emailSubmissionId) {
const draftAccountId = opts.draftAccountId ?? accountId;
try {
if (recordToDestroy) {
const cleanup = await jmapClient.request(
[
['Email/set', { accountId, destroy: [opts.draftId] }, '0'],
['Email/set', { accountId: draftAccountId, destroy: [opts.draftId] }, '0'],
submissionDestroyCall([recordToDestroy], accountId, '1'),
],
SUBMISSION_USING,
@@ -1867,7 +1894,7 @@ export async function sendEmail(
readSubmissionDestroy(cleanup, '1');
assertEmailsDestroyed(requireMethodResult(cleanup, '0', 'Email/set'));
} else {
await destroyEmails([opts.draftId], accountId);
await destroyEmails([opts.draftId], draftAccountId);
}
} catch (err) {
filingWarning = filingWarning ?? `old draft cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
@@ -1916,6 +1943,11 @@ export async function saveDraftVersion(
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
/**
* Account the previous version lives in, when not this one: the draft
* moves between the own Drafts and a group's when its From does (#1090).
*/
previousDraftAccountId?: string,
): Promise<SavedDraftVersion> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const emailCreate = buildEmailCreate(email);
@@ -1948,9 +1980,10 @@ export async function saveDraftVersion(
} catch (err) {
console.warn('[email] could not read back the saved draft parts:', err);
}
if (previousDraftId && previousDraftId !== id) {
const previousAccountId = previousDraftAccountId ?? accountId;
if (previousDraftId && (previousDraftId !== id || previousAccountId !== accountId)) {
try {
await destroyEmails([previousDraftId], accountId);
await destroyEmails([previousDraftId], previousAccountId);
} catch (err) {
console.warn('[email] failed to destroy previous draft version:', err);
}
@@ -2124,21 +2157,53 @@ export async function cancelScheduledSend(emailSubmissionId: string, accountIdOv
}
}
/**
* A reschedule or "Send now" whose replacement the server refused, after the
* original had been cancelled: the original was put back at its old time
* under a new submission id, which the caller now holds instead (webmail
* RescheduleRestoredError). `reason` is why the replacement failed.
*/
export class RescheduleRestoredError extends Error {
constructor(
readonly reason: unknown,
readonly restoredSubmissionId: string,
readonly restoredSendAt?: string,
) {
super(reason instanceof Error ? reason.message : String(reason));
this.name = 'RescheduleRestoredError';
}
}
/**
* A reschedule or "Send now" that failed and could not put the original back
* either: the message is no longer scheduled. It was moved back to Drafts
* (`inDrafts`) so it does not sit in Sent looking sent. `reason` is why the
* replacement failed.
*/
export class RescheduleLostError extends Error {
constructor(readonly reason: unknown, readonly inDrafts: boolean) {
super(`Reschedule failed and the message is no longer scheduled: ${reason instanceof Error ? reason.message : String(reason)}`);
this.name = 'RescheduleLostError';
}
}
/**
* Change when a scheduled message goes out, or send it now with
* `holdForSeconds` = 0. A replacement submission for the same Email, with the
* held envelope and a fresh HOLDFOR, is created first, and the original is
* cancelled only once the replacement exists (webmail parity). Both used to
* go in one /set, whose create and update the server applies independently:
* a refused replacement still cancelled the original, so the message never
* went out, and a refused cancel (the original had just gone out) still let
* the replacement go, so it was sent twice. Now a refused replacement leaves
* the original alone, and a replacement whose original can't be cancelled is
* withdrawn again. Returns the new submission id and send time, and the
* recipients the server refused for the replacement when it took the others.
* When it refused the replacement a recipient the original had been taken
* for, the original is kept at its old time, and RecipientsRefusedError
* names whom the replacement would have lost.
* `holdForSeconds` = 0. The original is cancelled first and a replacement
* for the same Email, with the held envelope and a fresh HOLDFOR, created
* after it (#1308, webmail parity). The other order failed on servers that
* allow one active submission per Email and refuse the second create, and on
* servers that undo onSuccessUpdateEmail on cancel, which pulled the message
* back to Drafts once the original was cancelled after the replacement
* existed; the replacement now files it in Sent itself.
*
* A cancel that fails changes nothing: the original still goes out, or went
* out already (NoLongerScheduledError). A replacement the server refuses, or
* takes for no recipient, puts the original back at its old time under a new
* id (RescheduleRestoredError); when that fails too the message goes back to
* Drafts (RescheduleLostError). Returns the new submission id and send time,
* and the recipients the server refused for the replacement when it took
* the others.
*/
export async function rescheduleScheduledSend(
scheduled: {
@@ -2154,151 +2219,163 @@ export async function rescheduleScheduledSend(
recipients?: EmailAddress[],
): Promise<{ emailSubmissionId?: string; sendAt?: string; refusedRecipients?: RefusedRecipient[] }> {
const accountId = scheduled.accountId ?? jmapClient.accountId;
// "Send now" is a 1-second hold, so the replacement can still be withdrawn.
// "Send now" is a 1-second hold, as in the webmail.
const holdFor = Math.max(1, Math.ceil(holdForSeconds));
// Reuse the held submission's envelope: it names every recipient,
// Cc and Bcc included, and the envelope sender a catch-all From went
// out through. Rebuilding it from the Email's To dropped Cc and Bcc.
// The Email's To/Cc/Bcc are the fallback when the server has none. What
// the server answered for each recipient when it took the original comes
// along: whom a refusal of the replacement costs the message depends on it.
// The Email's To/Cc/Bcc are the fallback when the server has none. The
// account's Drafts and Sent come along for filing the replacement and for
// a message that ends up not scheduled at all.
const lookup = await jmapClient.request(
[
['EmailSubmission/get', {
accountId, ids: [scheduled.emailSubmissionId], properties: ['envelope', 'deliveryStatus'],
accountId, ids: [scheduled.emailSubmissionId], properties: ['envelope', 'sendAt'],
}, '0'],
['Email/get', { accountId, ids: [scheduled.emailId], properties: ['to', 'cc', 'bcc'] }, '1'],
['Mailbox/get', { accountId, ids: null, properties: ['id', 'role'] }, '2'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
);
const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{
envelope?: { mailFrom?: { email?: string }; rcptTo?: Array<{ email?: string }> } | null;
deliveryStatus?: Record<string, { delivered?: string } | null> | null;
sendAt?: string;
}> | undefined)?.[0];
const envelope = original?.envelope;
const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined;
const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])];
const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients);
const rcpt = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email);
const create = {
emailId: scheduled.emailId,
identityId: scheduled.identityId,
envelope: {
mailFrom: {
email: envelope?.mailFrom?.email || scheduled.from?.[0]?.email,
parameters: { HOLDFOR: String(holdFor) },
},
rcptTo: rcpt,
},
const rcptTo = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email);
const mailFrom = envelope?.mailFrom?.email || scheduled.from?.[0]?.email;
// Without an answer the replacement is made without filing, and a message
// that can't be put back stays where it is.
const [mailboxAnswer, mailboxBody] = lookup.methodResponses.find(([, , callId]) => callId === '2') ?? [];
const roles = mailboxAnswer === 'Mailbox/get'
? ((mailboxBody?.list ?? []) as Array<{ id: string; role?: string | null }>)
: [];
const draftsId = roles.find((m) => m.role === 'drafts')?.id;
const sentId = roles.find((m) => m.role === 'sent')?.id;
type Created = { id: string; sendAt?: string; delivery: { recipients: RefusedRecipient[]; all: boolean } };
// `until`: when the submission goes out (ms); undefined, at once.
const createSubmission = async (until: number | undefined): Promise<Created> => {
// HOLDFOR counts from when the server takes the request, so a retry
// after making room takes the time spent off it.
const holdSeconds = () => (until === undefined ? undefined : Math.max(1, Math.ceil((until - Date.now()) / 1000)));
const send = () => {
const seconds = holdSeconds();
return jmapClient.request(
[
['EmailSubmission/set', {
accountId,
create: {
replacement: {
emailId: scheduled.emailId,
identityId: scheduled.identityId,
envelope: {
mailFrom: seconds === undefined
? { email: mailFrom }
: { email: mailFrom, parameters: { HOLDFOR: String(seconds) } },
rcptTo,
},
},
},
...(draftsId && sentId ? {
onSuccessUpdateEmail: {
'#replacement': { mailboxIds: { [sentId]: true }, [keywordPointer('$draft')]: null },
},
} : {}),
}, '0'],
deliveryStatusCall(accountId, 'replacement', '1'),
],
SUBMISSION_USING,
);
};
let res = await send();
// A full submission quota (Stalwart 1.0): make room and try once more.
if (submissionCreateError(res, 'replacement')?.type === 'overQuota') {
await makeRoomForSubmission(accountId);
res = await send();
}
// Only the submission call counts: the implied Email/set of
// onSuccessUpdateEmail can fail while the submission was created.
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined;
if (notCreated) throw submissionError(notCreated, 'Failed to reschedule', accountId);
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
if (!created?.id) throw new Error('Failed to reschedule: the server returned no submission');
return {
id: created.id,
sendAt: created.sendAt,
delivery: refusedRecipients(res.methodResponses.find(([, , callId]) => callId === '1')),
};
};
// A submission that took no recipient goes nowhere; its record only counts
// against the limit. The recipients it refused, or null when it took some.
const withdrawIfDead = async (created: Created): Promise<RefusedRecipient[] | null> => {
if (!created.delivery.all) return null;
if (isStalwartAccount(accountId)) await destroyEmailSubmissions([created.id], accountId);
return created.delivery.recipients;
};
// HOLDFOR counts from when the server takes the request.
const sendAt = Date.now() + holdFor * 1000;
const createReplacement = (seconds: number) => jmapClient.request(
[
['EmailSubmission/set', {
accountId,
create: {
replacement: {
...create,
envelope: { ...create.envelope, mailFrom: { ...create.envelope.mailFrom, parameters: { HOLDFOR: String(seconds) } } },
},
},
}, '0'],
deliveryStatusCall(accountId, 'replacement', '1'),
],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
let res = await createReplacement(holdFor);
// A full submission quota (Stalwart 1.0): make room and try once more. The
// original is still pending, so making room never touches it.
if (submissionCreateError(res, 'replacement')?.type === 'overQuota') {
await makeRoomForSubmission(accountId);
// Making room can take a while - a prune, a walk through the change log -
// and meanwhile the original may have gone out: its time came, or it was
// "Send now" of a message due any moment. A replacement would then send
// it a second time. A read that fails throws, and nothing changes.
const originalStatus = await readSubmissionUndoStatus(scheduled.emailSubmissionId, accountId);
if (originalStatus !== 'pending') throw new NoLongerScheduledError(originalStatus === 'final');
// The time spent is taken off the hold; a moment already past goes out
// at once.
res = await createReplacement(Math.max(1, Math.ceil((sendAt - Date.now()) / 1000)));
}
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined;
if (notCreated) throw submissionError(notCreated, 'Failed to reschedule', accountId);
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
if (!created?.id) throw new Error('Failed to reschedule: the server returned no submission');
const holdUntil = Date.now() + holdFor * 1000;
// The recipients are checked again for the replacement, and a throttle
// used up since the original was taken ("452 4.4.5 Rate limit exceeded")
// can refuse some or all of them. Cancelling the original would lose the
// message for a recipient it took and the replacement didn't, so the
// original is kept then - it still goes at its old time to everyone it
// took - and the replacement is withdrawn. A recipient the original was
// refused for as well loses nothing, so the reschedule goes ahead; without
// a delivery status of the original, every recipient counts as taken.
// Should the replacement not be withdrawn, it goes ahead too, and the
// caller names the refused recipients: keeping both would send the others
// two copies (webmail aa99f038).
const delivery = refusedRecipients(res.methodResponses.find(([, , callId]) => callId === '1'));
const refusedBefore = new Set(Object.entries(original?.deliveryStatus ?? {})
.filter(([, status]) => status?.delivered === 'no')
.map(([address]) => address.toLowerCase()));
const lost = delivery.recipients.filter((recipient) => !refusedBefore.has(recipient.email.toLowerCase()));
if (lost.length > 0 && await withdrawReplacement(created.id, accountId, delivery.all)) {
throw new RecipientsRefusedError(lost);
}
// Either cancel below also removes the record it canceled, so a reschedule
// leaves only the replacement behind.
// Cancel first. Should it fail, nothing has changed and the original still
// goes out - unless it has gone out already ("Send now" of a message due
// any moment), or was cancelled on another device. Either cancel also
// removes the record it canceled.
try {
await cancelScheduledSend(scheduled.emailSubmissionId, accountId);
} catch (err) {
// A cancel whose answer got lost may still have gone through. Otherwise
// the original is still due or has just gone out, and the replacement
// must not go as well.
if ((await submissionUndoStatus(scheduled.emailSubmissionId, accountId)) !== 'canceled') {
try {
await cancelScheduledSend(created.id, accountId);
} catch (withdrawErr) {
console.warn('[email] could not withdraw the replacement scheduled send:', withdrawErr);
}
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to cancel the previous schedule: ${reason}`);
const status = await submissionUndoStatus(scheduled.emailSubmissionId, accountId);
if (status === 'final' || status === 'canceled') throw new NoLongerScheduledError(status === 'final');
throw err;
}
let replacement: Created;
try {
replacement = await createSubmission(holdUntil);
// The recipients are checked again for the replacement, and a throttle
// used up since the original was taken can refuse some or all of them.
// When it took nobody the reschedule failed; when it took some, the
// caller names the refused ones.
const refused = await withdrawIfDead(replacement);
if (refused) throw new RecipientsRefusedError(refused);
} catch (err) {
// Put the original back at its old time: HOLDFOR with the seconds left,
// at once when that time has passed meanwhile.
const originalTime = original?.sendAt ? Date.parse(original.sendAt) : NaN;
let restored: Created | undefined;
try {
restored = await createSubmission(Number.isFinite(originalTime) && originalTime > Date.now() ? originalTime : undefined);
} catch (restoreErr) {
console.warn('[email] could not put the original scheduled send back:', restoreErr);
}
if (restored && !(await withdrawIfDead(restored).catch(() => null))) {
throw new RescheduleRestoredError(err, restored.id, restored.sendAt ?? original?.sendAt);
}
// Neither went through: the message is not scheduled any more. It goes
// back to Drafts rather than sit in Sent looking sent.
let inDrafts = false;
if (draftsId) {
try {
await restoreEmailToDraft(scheduled.emailId, draftsId, sentId, accountId);
inDrafts = true;
} catch (draftErr) {
console.warn('[email] could not move the unscheduled message back to Drafts:', draftErr);
}
}
throw new RescheduleLostError(err, inDrafts);
}
return {
emailSubmissionId: created.id,
sendAt: created.sendAt,
...(delivery.recipients.length > 0 ? { refusedRecipients: delivery.recipients } : {}),
emailSubmissionId: replacement.id,
sendAt: replacement.sendAt,
...(replacement.delivery.recipients.length > 0 ? { refusedRecipients: replacement.delivery.recipients } : {}),
};
}
/**
* Withdraw the replacement of a reschedule that keeps its original instead;
* true once it can no longer go out. A replacement the server took for
* nobody (`tookNobody`) holds no mail, so only its record goes, on Stalwart
* (elsewhere the prune takes it once it is final). Any other is cancelled,
* which "Send now" may be too late for: its replacement is held for a second
* only. A cancel that failed counts when a read shows it went through with
* only its answer lost.
*/
async function withdrawReplacement(replacementId: string, accountId: string, tookNobody: boolean): Promise<boolean> {
if (tookNobody) {
if (isStalwartAccount(accountId)) await destroyEmailSubmissions([replacementId], accountId);
return true;
}
try {
await cancelScheduledSend(replacementId, accountId);
return true;
} catch (err) {
if ((await submissionUndoStatus(replacementId, accountId)) === 'canceled') return true;
console.warn('[email] could not withdraw a replacement some recipients were refused for:', err);
return false;
}
}
/**
* A submission's `undoStatus`; undefined when the server no longer has the
* record. Throws when it can't be read.
@@ -2413,3 +2490,28 @@ export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise<str
}
return emailId;
}
/**
* Copy blobs into another account of the login (RFC 8620 §6.3): the blob of
* an attachment in a shared or group mailbox, filed in the user's Files, or
* a file attached to a message of another account. Resolves with the new
* blobId for each. Stalwart answers Blob/copy only with the blob capability
* in `using`. Same account: the ids as they are, nothing is sent.
*/
export async function copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise<Record<string, string>> {
if (blobIds.length === 0 || fromAccountId === toAccountId) {
return Object.fromEntries(blobIds.map((id) => [id, id]));
}
const res = await jmapClient.request(
[['Blob/copy', { fromAccountId, accountId: toAccountId, blobIds }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.BLOB],
);
const body = requireMethodResult(res, '0', 'Blob/copy');
const copied = (body.copied ?? {}) as Record<string, string>;
const missing = blobIds.find((id) => !copied[id]);
if (missing) {
const error = (body.notCopied ?? {})[missing] as { type?: string; description?: string } | undefined;
throw new Error(error?.description || error?.type || 'Blob/copy did not copy the blob');
}
return copied;
}
+111 -1
View File
@@ -1,7 +1,7 @@
import { jmapClient } from './jmap-client';
import { assertSetResult, requireMethodResult } from './jmap-result';
import { CAPABILITIES } from './types';
import type { Identity } from './types';
import type { Identity, JMAPMethodCall } from './types';
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
const USING = [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION];
@@ -60,3 +60,113 @@ export async function deleteIdentity(id: string): Promise<void> {
);
assertSetResult(requireMethodResult(res, '0', 'Identity/set'), [id], 'identity');
}
/** An address the identity matches exactly. */
function identityEmailMatchesExactly(identityEmail: string | undefined, from: string): boolean {
return !!identityEmail && identityEmail.toLowerCase() === from;
}
/** An identity address `from` is a sub-address of (team+tag@ for team@, with the common "+" / "-" delimiters). */
function identityEmailIsSubAddressOf(identityEmail: string | undefined, from: string): boolean {
if (!identityEmail) return false;
const [idLocal, idDomain] = identityEmail.toLowerCase().split('@');
const [fromLocal, fromDomain] = from.split('@');
if (!idDomain || !fromDomain || idDomain !== fromDomain || idLocal === '*') return false;
return fromLocal.startsWith(`${idLocal}+`) || fromLocal.startsWith(`${idLocal}-`);
}
/** A wildcard identity for the domain of `from` (RFC 8621 §6, "*@example.com"). */
function identityEmailIsWildcardFor(identityEmail: string | undefined, from: string): boolean {
if (!identityEmail) return false;
const [idLocal, idDomain] = identityEmail.toLowerCase().split('@');
return idLocal === '*' && !!idDomain && idDomain === from.split('@')[1];
}
const IDENTITY_MATCHERS = [identityEmailMatchesExactly, identityEmailIsSubAddressOf, identityEmailIsWildcardFor];
// Draft autosave resolves on every save; identities rarely change.
let sendIdentitiesCache: { key: string; byAccount: Array<[string, Identity[]]>; expires: number } | null = null;
/** Forget the identities resolveSendAccountId read (tests, a switch of logins). */
export function resetSendIdentities(): void {
sendIdentitiesCache = null;
}
/**
* The identities of every account that can send, the delegated (group or
* shared) ones first and the login's own last. Null when there is no
* delegated account, or the identities can't be read.
*/
async function identitiesOfSendAccounts(): Promise<Array<[string, Identity[]]> | null> {
const own = jmapClient.accountId;
const delegated = jmapClient.getSubmissionAccountIds().filter((id) => id !== own);
if (delegated.length === 0) return null;
const candidates = [...delegated, own];
// The server and login are part of the key: account ids repeat across them.
const key = [jmapClient.serverUrl, jmapClient.username, ...candidates].join('\u0000');
if (sendIdentitiesCache && sendIdentitiesCache.key === key && sendIdentitiesCache.expires > Date.now()) {
return sendIdentitiesCache.byAccount;
}
try {
const res = await jmapClient.request(
candidates.map((accountId, i) => ['Identity/get', { accountId }, String(i)] as JMAPMethodCall),
USING,
);
const byAccount = candidates.map((accountId, i): [string, Identity[]] => {
const [name, body] = res.methodResponses.find(([, , callId]) => callId === String(i)) ?? [];
return [accountId, name === 'Identity/get' ? ((body?.list ?? []) as Identity[]) : []];
});
sendIdentitiesCache = { key, byAccount, expires: Date.now() + 60_000 };
return byAccount;
} catch (err) {
console.warn('[identity] could not read the identities to pick the sending account', err);
return null;
}
}
/**
* The account a message sent From `fromEmail` belongs to: where it is
* created, submitted and filed in Sent (webmail resolveSendAccountId,
* #1090). Stalwart lists a group's send-as identity among the member's own
* identities too, so going by the identity list alone sent the message
* through the member's account and filed its Sent copy there.
*
* Order: an account named exactly `fromEmail`; then an account that owns an
* identity for the address - exact, then sub-address, then wildcard, and a
* delegated (group/shared) account before the own one, since the own one
* may merely mirror the group's identity; else the own account. A
* sub-address of the member's own address stays theirs even when a shared
* catch-all account has a wildcard identity for the domain.
*/
export async function resolveSendAccountId(fromEmail?: string): Promise<string> {
const own = jmapClient.accountId;
const from = fromEmail?.trim().toLowerCase();
if (!from) return own;
const accounts = jmapClient.currentSession?.accounts ?? {};
const named = Object.keys(accounts).find((id) => accounts[id]?.name?.toLowerCase() === from);
if (named) return named;
const byAccount = await identitiesOfSendAccounts();
if (!byAccount) return own;
for (const matches of IDENTITY_MATCHERS) {
const hit = byAccount.find(([, list]) => list.some((identity) => matches(identity.email, from)));
if (hit) return hit[0];
}
return own;
}
/**
* The identity of `accountId` a message From `fromEmail` is submitted
* through: identity ids are per account, so the one the composer picked
* from the login's list names nothing (or another identity) in a group
* account. Undefined when the account has none for the address.
*/
export async function sendIdentityIn(accountId: string, fromEmail: string): Promise<Identity | undefined> {
const from = fromEmail.trim().toLowerCase();
const byAccount = await identitiesOfSendAccounts();
const list = byAccount?.find(([id]) => id === accountId)?.[1] ?? await getIdentities(accountId);
for (const matches of IDENTITY_MATCHERS) {
const hit = list.find((identity) => matches(identity.email, from));
if (hit) return hit;
}
return undefined;
}
+69 -29
View File
@@ -3,7 +3,7 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Star, FileText } from './icons';
import { X, Star, FileText, Users } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
@@ -22,7 +22,8 @@ interface TemplateSheetProps {
/**
* Bottom sheet listing the user's templates for insertion into the composer
* (webmail template picker): favourites first, then the rest, with a search
* box over name/subject/category.
* box over name/subject/category. The templates of the user's group
* mailboxes follow, each team under its name (Stalwart 1.0 and later).
*/
export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) {
const c = useColors();
@@ -32,6 +33,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
const templates = useTemplatesStore((s) => s.templates);
const hydrated = useTemplatesStore((s) => s.hydrated);
const hydrate = useTemplatesStore((s) => s.hydrate);
const teams = useTemplatesStore((s) => s.teams);
const refresh = useTemplatesStore((s) => s.refresh);
const [query, setQuery] = React.useState('');
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
@@ -46,6 +49,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
React.useEffect(() => {
if (visible) {
setQuery('');
// Templates changed in the webmail or on another device, and the team ones.
void refresh();
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
@@ -56,6 +61,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, slideY, overlayOpacity]);
const visibleTemplates = React.useMemo(() => {
@@ -65,6 +71,42 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
return a.name.localeCompare(b.name);
});
}, [templates, query]);
const visibleTeams = React.useMemo(() => teams
.map((team) => ({
...team,
templates: (query.trim() ? filterTemplates(team.templates, query.trim()) : team.templates)
.slice()
.sort((a, b) => a.name.localeCompare(b.name)),
}))
.filter((team) => team.templates.length > 0), [teams, query]);
const total = templates.length + teams.reduce((n, team) => n + team.templates.length, 0);
const renderRow = (template: EmailTemplate, key: string, team?: boolean) => (
<Pressable
key={key}
onPress={() => {
onPick(template);
onClose();
}}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={styles.iconWrap}>
{team
? <Users size={16} color={c.textSecondary} />
: template.isFavorite
? <Star size={16} color={c.primary} />
: <FileText size={16} color={c.textSecondary} />}
</View>
<View style={styles.details}>
<Text style={styles.name} numberOfLines={1}>{template.name}</Text>
{!!(template.subject || template.category) && (
<Text style={styles.subject} numberOfLines={1}>
{[template.category, template.subject].filter(Boolean).join(' · ')}
</Text>
)}
</View>
</Pressable>
);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
@@ -91,7 +133,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
</Pressable>
</View>
</View>
{templates.length > 4 && (
{total > 4 && (
<TextInput
value={query}
onChangeText={setQuery}
@@ -102,36 +144,26 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
/>
)}
<ScrollView style={styles.scrollList} keyboardShouldPersistTaps="handled">
{visibleTemplates.length === 0 ? (
{visibleTemplates.length === 0 && visibleTeams.length === 0 ? (
<Text style={styles.empty}>
{templates.length === 0
{total === 0
? t('templates.no_templates', 'No templates yet')
: t('templates.no_results', 'No templates found')}
</Text>
) : visibleTemplates.map((template) => (
<Pressable
key={template.id}
onPress={() => {
onPick(template);
onClose();
}}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={styles.iconWrap}>
{template.isFavorite
? <Star size={16} color={c.primary} />
: <FileText size={16} color={c.textSecondary} />}
</View>
<View style={styles.details}>
<Text style={styles.name} numberOfLines={1}>{template.name}</Text>
{!!(template.subject || template.category) && (
<Text style={styles.subject} numberOfLines={1}>
{[template.category, template.subject].filter(Boolean).join(' · ')}
</Text>
)}
</View>
</Pressable>
))}
) : (
<>
{visibleTeams.length > 0 && visibleTemplates.length > 0 && (
<Text style={styles.section}>{t('org_templates.section_personal', 'My templates')}</Text>
)}
{visibleTemplates.map((template) => renderRow(template, template.id))}
{visibleTeams.map((team) => (
<React.Fragment key={team.accountId}>
<Text style={styles.section} numberOfLines={1}>{team.name}</Text>
{team.templates.map((template) => renderRow(template, `${team.accountId}|${template.id}`, true))}
</React.Fragment>
))}
</>
)}
</ScrollView>
</Animated.View>
</Modal>
@@ -182,6 +214,14 @@ function makeStyles(c: ThemePalette) {
},
scrollList: { paddingVertical: spacing.xs },
empty: { ...typography.body, color: c.textMuted, textAlign: 'center', padding: spacing.xl },
section: {
...typography.caption,
color: c.textMuted,
fontWeight: '600',
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
row: {
flexDirection: 'row',
alignItems: 'center',
+224
View File
@@ -0,0 +1,224 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, FlatList, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ArrowLeft, X, Folder, FileText, Check, ChevronRight } from '../icons';
import { getAllFileNodes, fileNodeKind } from '../../api/files';
import type { FileNode } from '../../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { folderPath, pickerEntries } from '../../lib/compose-files';
import { formatSize } from '../../lib/attachment-display';
type Props =
| {
visible: boolean;
/** Pick files to attach (#1179). */
mode: 'files';
onClose: () => void;
onPickFiles: (nodes: FileNode[]) => void;
}
| {
visible: boolean;
/** Pick the folder an attachment is saved to. */
mode: 'folder';
onClose: () => void;
onPickFolder: (parentId: string | null, folderName: string) => void;
};
/**
* The user's Files, folder by folder: pick files to attach, or the folder
* to save an attachment in (webmail file-picker-dialog and the Save to
* Files folder picker). Own files only, without the hidden ones.
*/
export function FilesPickerSheet(props: Props) {
const { visible, mode, onClose } = props;
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [nodes, setNodes] = React.useState<FileNode[] | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [here, setHere] = React.useState<string | null>(null);
const [selected, setSelected] = React.useState<FileNode[]>([]);
const load = React.useCallback(() => {
setNodes(null);
setError(null);
getAllFileNodes().then(
(list) => setNodes(list),
(e: unknown) => {
console.warn('[files-picker] listing failed', e);
setError(t('email_composer.files_picker.load_failed', 'Your files could not be loaded'));
},
);
}, [t]);
React.useEffect(() => {
if (!visible) return;
setHere(null);
setSelected([]);
load();
}, [visible, load]);
const entries = React.useMemo(
() => (nodes ? pickerEntries(nodes, here, mode === 'folder') : []),
[nodes, here, mode],
);
const path = React.useMemo(() => (nodes ? folderPath(nodes, here) : []), [nodes, here]);
const myFiles = t('files.my_files', 'My files');
const title = mode === 'folder'
? t('email_viewer.save_to_files.title', 'Save to Files')
: t('email_composer.attach_from_files', 'Attach from Files');
const toggle = (node: FileNode) => setSelected((prev) => (prev.some((n) => n.id === node.id)
? prev.filter((n) => n.id !== node.id)
: [...prev, node]));
const confirm = () => {
if (props.mode === 'folder') {
props.onPickFolder(here, path.at(-1)?.name ?? myFiles);
} else if (selected.length > 0) {
props.onPickFiles(selected);
}
};
const renderItem = ({ item }: { item: FileNode }) => {
const folder = fileNodeKind(item) === 'directory';
const isSelected = selected.some((n) => n.id === item.id);
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => (folder ? setHere(item.id) : toggle(item))}
accessibilityRole="button"
accessibilityState={folder ? undefined : { selected: isSelected }}
>
{folder ? <Folder size={18} color={c.primary} /> : <FileText size={18} color={c.textSecondary} />}
<View style={styles.rowMain}>
<Text style={styles.rowName} numberOfLines={1}>{item.name}</Text>
{!folder && item.size != null && <Text style={styles.rowMeta}>{formatSize(item.size)}</Text>}
</View>
{folder
? <ChevronRight size={16} color={c.textMuted} />
: isSelected && <Check size={18} color={c.primary} />}
</Pressable>
);
};
const confirmLabel = mode === 'folder'
? t('email_viewer.save_to_files.save_here', 'Save here')
: t('email_composer.files_picker.attach', '{count, plural, =0 {Attach} one {Attach 1 file} other {Attach # files}}', { count: selected.length });
return (
<SafeAreaModal
supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="slide"
onRequestClose={here !== null ? () => setHere(path.at(-2)?.id ?? null) : onClose}
>
<SafeAreaView style={styles.container} edges={['top', 'bottom', 'left', 'right']}>
<View style={styles.header}>
<Pressable
onPress={here !== null ? () => setHere(path.at(-2)?.id ?? null) : onClose}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={here !== null ? t('common.back', 'Back') : t('common.close', 'Close')}
>
{here !== null ? <ArrowLeft size={22} color={c.text} /> : <X size={22} color={c.text} />}
</Pressable>
<View style={styles.headerText}>
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
<Text style={styles.headerPath} numberOfLines={1}>
{[myFiles, ...path.map((n) => n.name)].join(' / ')}
</Text>
</View>
</View>
{error ? (
<View style={styles.center}>
<Text style={styles.error}>{error}</Text>
<Pressable onPress={load}>
<Text style={styles.retry}>{t('common.retry', 'Retry')}</Text>
</Pressable>
</View>
) : !nodes ? (
<View style={styles.center}><ActivityIndicator color={c.primary} /></View>
) : (
<FlatList
data={entries}
keyExtractor={(n) => n.id}
renderItem={renderItem}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={(
<View style={styles.center}>
<Text style={styles.empty}>{t('email_composer.files_picker.empty_folder', 'This folder is empty')}</Text>
</View>
)}
/>
)}
<View style={styles.footer}>
<Pressable
onPress={confirm}
disabled={!nodes || (mode === 'files' && selected.length === 0)}
style={({ pressed }) => [
styles.confirm,
(!nodes || (mode === 'files' && selected.length === 0)) && styles.confirmDisabled,
pressed && styles.rowPressed,
]}
accessibilityRole="button"
>
<Text style={styles.confirmText}>{confirmLabel}</Text>
</Pressable>
</View>
</SafeAreaView>
</SafeAreaModal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
minHeight: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: c.border,
gap: spacing.sm,
},
headerBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md },
headerText: { flex: 1, minWidth: 0 },
headerTitle: { ...typography.h3, color: c.text },
headerPath: { ...typography.caption, color: c.textMuted },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
minHeight: 52,
},
rowPressed: { opacity: 0.7 },
rowMain: { flex: 1, minWidth: 0 },
rowName: { ...typography.body, color: c.text },
rowMeta: { ...typography.caption, color: c.textMuted },
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg },
empty: { ...typography.body, color: c.textSecondary },
error: { ...typography.body, color: c.error, textAlign: 'center' },
retry: { ...typography.bodyMedium, color: c.primary, marginTop: spacing.sm },
footer: { padding: spacing.md, borderTopWidth: 1, borderTopColor: c.border },
confirm: {
alignItems: 'center',
justifyContent: 'center',
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: c.primary,
},
confirmDisabled: { opacity: 0.5 },
confirmText: { ...typography.bodyMedium, color: c.primaryForeground },
});
}
@@ -0,0 +1,168 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ScrollView, ActivityIndicator } from 'react-native';
import { ArrowLeft, Clock, Send, Pencil, XCircle } from '../icons';
import type { Email } from '../../api/types';
import type { ScheduledEmail } from '../../api/email';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingsStore } from '../../stores/settings-store';
import { MessageContent } from '../email/MessageContent';
interface Props {
item: ScheduledEmail;
/** The full message; null while it loads. */
email: Email | null;
error: string | null;
/** The account to read the message's blobs from: undefined for the own one. */
jmapAccountId?: string;
busy: boolean;
formatWhen: (iso: string) => string;
onBack: () => void;
onRetry: () => void;
onSendNow: () => void;
onReschedule: () => void;
onEdit: () => void;
onCancelSend: () => void;
}
/**
* A scheduled message, read-only, with the banner the webmail shows above
* it: when it goes out, and Send now / Reschedule / Edit / Cancel send
* (webmail email-viewer.tsx, native #84).
*/
export function ScheduledMessageView({
item, email, error, jmapAccountId, busy, formatWhen,
onBack, onRetry, onSendNow, onReschedule, onEdit, onCancelSend,
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const identities = useSettingsStore((s) => s.identities);
const actions = [
{ key: 'send', label: t('email_viewer.send_now', 'Send now'), icon: Send, onPress: onSendNow },
{ key: 'reschedule', label: t('email_viewer.reschedule_send', 'Reschedule'), icon: Clock, onPress: onReschedule },
{ key: 'edit', label: t('email_viewer.cancel_and_edit', 'Cancel and edit'), icon: Pencil, onPress: onEdit },
{ key: 'cancel', label: t('email_viewer.cancel_scheduled_send', 'Cancel send'), icon: XCircle, onPress: onCancelSend, destructive: true },
];
return (
<View style={styles.container}>
<View style={styles.header}>
<Pressable
onPress={onBack}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>
{item.subject || t('email_composer.no_subject', '(No Subject)')}
</Text>
</View>
<View style={styles.banner} accessibilityRole="summary">
<View style={styles.bannerRow}>
<Clock size={16} color={c.primary} />
<Text style={styles.bannerText}>
{t('email_viewer.scheduled_banner', 'Scheduled to send at {date}', { date: formatWhen(item.sendAt) })}
</Text>
{busy && <ActivityIndicator size="small" color={c.primary} />}
</View>
<View style={styles.bannerActions}>
{actions.map(({ key, label, icon: Icon, onPress, destructive }) => (
<Pressable
key={key}
onPress={onPress}
disabled={busy}
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
style={({ pressed }) => [styles.bannerBtn, pressed && styles.bannerBtnPressed, busy && styles.bannerBtnDisabled]}
>
<Icon size={14} color={destructive ? c.error : c.text} />
<Text style={[styles.bannerBtnText, destructive && { color: c.error }]} numberOfLines={1}>{label}</Text>
</Pressable>
))}
</View>
</View>
{error ? (
<View style={styles.center}>
<Text style={styles.error}>{error}</Text>
<Pressable onPress={onRetry}>
<Text style={styles.retry}>{t('common.retry', 'Retry')}</Text>
</Pressable>
</View>
) : !email ? (
<View style={styles.center}>
<ActivityIndicator color={c.primary} />
</View>
) : (
<ScrollView contentContainerStyle={styles.content}>
<MessageContent
email={email}
jmapAccountId={jmapAccountId}
identities={identities}
// As in Sent: no read receipt banner for the user's own message.
currentMailboxRole="sent"
active={false}
onAddressPress={() => undefined}
onEmailPatched={() => undefined}
/>
</ScrollView>
)}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: c.border,
gap: spacing.sm,
},
headerBtn: {
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
banner: {
margin: spacing.md,
padding: spacing.md,
gap: spacing.sm,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
bannerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
bannerText: { ...typography.bodyMedium, color: c.text, flex: 1 },
bannerActions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
bannerBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
minHeight: 36,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
bannerBtnPressed: { opacity: 0.7 },
bannerBtnDisabled: { opacity: 0.5 },
bannerBtnText: { ...typography.caption, color: c.text, fontWeight: '600' },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg },
error: { ...typography.body, color: c.error, textAlign: 'center' },
retry: { ...typography.bodyMedium, color: c.primary, marginTop: spacing.sm },
content: { paddingBottom: spacing.xl },
});
}
+47 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from '../icons';
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive, FolderOpen } from '../icons';
import type { Attachment, Email } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -17,6 +17,10 @@ import { ActionSheet, type ActionSheetItem } from './ActionSheet';
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
import type { ExtractedAttachment } from './use-body-override';
import { previewKindOf, useFilePreview, type PreviewSource } from './use-file-preview';
import { supportsFiles } from '../../api/files';
import { saveAttachmentToFiles } from '../../lib/compose-files';
import { toast } from '../../stores/toast-store';
import { FilesPickerSheet } from '../compose/FilesPickerSheet';
interface Props {
email: Email;
@@ -64,6 +68,8 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
const [expanded, setExpanded] = React.useState(false);
const [busyKey, setBusyKey] = React.useState<string | null>(null);
const [sheetItem, setSheetItem] = React.useState<Item | null>(null);
// The attachment Save to Files picks a folder for.
const [filesTarget, setFilesTarget] = React.useState<Extract<Item, { kind: 'blob' }> | null>(null);
const preview = useFilePreview();
const items = React.useMemo<Item[]>(() => {
@@ -116,6 +122,25 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
else await shareBytes(item.part.bytes, item.name, item.type, { forceSheet: true });
}, t('email_viewer.attachment_failed', 'Could not share attachment'));
// File the attachment in the user's Files by its blob: nothing is
// downloaded. A shared or group mailbox's blob is copied over first
// (webmail save-to-files-dialog).
const saveToFiles = (item: Extract<Item, { kind: 'blob' }>, parentId: string | null, folderName: string) => run(item.key, async () => {
try {
await saveAttachmentToFiles({ blobId: item.att.blobId, name: item.name, type: item.type }, parentId, jmapAccountId);
} catch (e) {
toast.error(
t('email_viewer.save_to_files.failed', "Couldn't save to Files"),
e instanceof Error ? e.message : undefined,
);
return;
}
toast.success(
t('email_viewer.save_to_files.saved', 'Saved to Files'),
{ message: t('email_viewer.save_to_files.saved_to', 'In {folder}', { folder: folderName }) },
);
}, t('email_viewer.save_to_files.failed', "Couldn't save to Files"));
const sourceOf = (item: Item): PreviewSource => (item.kind === 'blob'
? {
from: 'blob',
@@ -190,6 +215,16 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
icon: <Share2 size={18} color={c.textSecondary} />,
onPress: () => { const it = sheetItem; setSheetItem(null); void share(it); },
},
...(sheetItem.kind === 'blob' && supportsFiles() ? [{
key: 'save_to_files',
label: t('email_viewer.save_to_files.title', 'Save to Files'),
icon: <FolderOpen size={18} color={c.textSecondary} />,
onPress: () => {
const it = sheetItem;
setSheetItem(null);
if (it.kind === 'blob') setFilesTarget(it);
},
}] : []),
] : [];
return (
@@ -241,6 +276,17 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
/>
<AttachmentPreviewModal {...preview.modalProps} />
<FilesPickerSheet
visible={!!filesTarget}
mode="folder"
onClose={() => setFilesTarget(null)}
onPickFolder={(parentId, folderName) => {
const target = filesTarget;
setFilesTarget(null);
if (target) void saveToFiles(target, parentId, folderName);
}}
/>
</View>
);
}
+35 -4
View File
@@ -8,8 +8,10 @@ import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useEmailStore } from '../../stores/email-store';
import { toast } from '../../stores/toast-store';
import { useSendUndoStore } from '../../stores/send-undo-store';
import { sendEmail, patchKeywordsForEmails } from '../../api/email';
import { currentSendLogin, useSendUndoStore } from '../../stores/send-undo-store';
import { useAccountStore } from '../../stores/account-store';
import { loginMovedFrom } from '../../lib/compose-login';
import { sendEmail, patchKeywordsForEmails, SendUnconfirmedError } from '../../api/email';
import { jmapClient } from '../../api/jmap-client';
import { pruneEmailSubmissions } from '../../api/submissions';
import { buildReplyRecipients } from '../../lib/reply-recipients';
@@ -55,6 +57,11 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
const [sending, setSending] = React.useState(false);
React.useEffect(() => { setText(''); }, [email.id]);
// The login the message on screen belongs to: the one active when it was
// shown. A switch while it stays on screen must not send the reply, or
// flag the original, through the other login (lib/compose-login).
const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId);
React.useEffect(() => { ownerLoginRef.current = useAccountStore.getState().activeAccountId; }, [email.id]);
const from = email.from?.[0];
if (!from?.email || email.keywords?.$draft) return null;
@@ -62,6 +69,13 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
const send = async () => {
const body = text.trim();
if (!body || sending) return;
if (loginMovedFrom(ownerLoginRef.current)) {
Alert.alert(
t('email_composer.login_switched_title', 'Account switched'),
t('email_viewer.quick_reply_login_switched', 'This message is in another account. Switch back to it to reply.'),
);
return;
}
const ownEmails = identities.map((i) => i.email).filter(Boolean);
// The own identity the message was delivered to (or, for our own message,
// the one that sent it). Never the catch-all From rewrite: this box has no
@@ -103,6 +117,9 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
});
const threading = computeReplyThreadingHeaders(email);
const holdFor = jmapClient.undoSendHold(sendDelaySeconds, jmapAccountId);
// Where the reply goes, read before it goes: Undo must reach that login
// and account whatever the app switches to meanwhile.
const sentThrough = { login: currentSendLogin(), accountId: jmapAccountId ?? jmapClient.connectedAccountId ?? undefined };
const result = await sendEmail(
{
from: [{ name: identity.name, email: identity.email }],
@@ -127,12 +144,15 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
// Recorded before the flag below so its round trip doesn't eat the window.
useSendUndoStore.getState().recordHeldSend(result, holdFor, {
identityId: identity.id,
accountId: jmapAccountId,
...sentThrough,
from: [{ name: identity.name, email: identity.email }],
});
void pruneEmailSubmissions();
try {
await patchKeywordsForEmails([email.id], { $answered: true }, jmapAccountId);
// After a switch during the send the id names another message.
if (!loginMovedFrom(ownerLoginRef.current)) {
await patchKeywordsForEmails([email.id], { $answered: true }, jmapAccountId);
}
} catch { /* the reply is out; the flag is cosmetic */ }
onSent?.({ ...email, keywords: { ...email.keywords, $answered: true } });
setText('');
@@ -147,6 +167,17 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
toast.success(t('notifications.email_sent', 'Email sent successfully'));
}
} catch (err) {
if (err instanceof SendUnconfirmedError) {
// It may have gone out: the text stays, and Sent tells (#1019).
Alert.alert(
t('email_composer.send_unconfirmed_title', 'Send not confirmed'),
t(
'email_viewer.quick_reply_unconfirmed',
'The server did not confirm the send. Check your Sent folder before sending again.',
),
);
return;
}
Alert.alert(t('email_composer.send_failed', 'Failed to send'), sendFailureMessage(err, t));
} finally {
setSending(false);
@@ -26,6 +26,7 @@ export function ComposingSettings() {
const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled);
const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault);
const emptySubjectWarningEnabled = useSettingsStore((s) => s.emptySubjectWarningEnabled);
const placeholderWarningEnabled = useSettingsStore((s) => s.placeholderWarningEnabled);
const autoSaveDraftInterval = useSettingsStore((s) => s.autoSaveDraftInterval);
const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter);
const updateSetting = useSettingsStore((s) => s.updateSetting);
@@ -141,6 +142,13 @@ export function ComposingSettings() {
<ToggleSwitch checked={emptySubjectWarningEnabled} onChange={(v) => updateSetting('emptySubjectWarningEnabled', v)} />
</SettingItem>
<SettingItem
label={t('settings.email_behavior.placeholder_warning.label', 'Placeholder warning')}
description={t('settings.email_behavior.placeholder_warning.description', 'Ask for confirmation before sending a message that still has placeholders like [TIME] or [Your Name], template fields left empty, or unfinished text like TODO or TBD.')}
>
<ToggleSwitch checked={placeholderWarningEnabled} onChange={(v) => updateSetting('placeholderWarningEnabled', v)} />
</SettingItem>
<View style={styles.subBlock}>
<Text style={styles.subLabel}>{t('settings.email_behavior.signature_position.label', "Signature Position")}</Text>
<Text style={styles.subDescription}>
@@ -34,6 +34,8 @@ export function TemplateSettings() {
const deleteTemplate = useTemplatesStore((s) => s.deleteTemplate);
const exportAll = useTemplatesStore((s) => s.exportAll);
const importTemplates = useTemplatesStore((s) => s.importTemplates);
const storedInAccount = useTemplatesStore((s) => s.storedInAccount);
const refresh = useTemplatesStore((s) => s.refresh);
const [editing, setEditing] = useState<EmailTemplate | null>(null);
const [importVisible, setImportVisible] = useState(false);
@@ -48,6 +50,10 @@ export function TemplateSettings() {
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
// Kept in the account on Stalwart 1.0: changes from the webmail come in.
useEffect(() => {
void refresh();
}, [refresh]);
const openCreate = () => {
setEditing({
@@ -136,6 +142,11 @@ export function TemplateSettings() {
return (
<View style={styles.container}>
<SettingsSection title={tr('settings.templates.title', 'Email Templates')} description={tr('settings.templates.description_mobile', 'Reusable snippets for common replies. Tap one to edit; long-press to delete.')}>
{storedInAccount && (
<Text style={styles.count}>
{tr('org_templates.personal_in_account', 'Your templates are kept in your account, so you have them on every device you sign in on. Anyone you share your Inbox with can read them.')}
</Text>
)}
<View style={styles.headerRow}>
<View style={styles.countRow}>
<FileText size={16} color={c.mutedForeground} />
+7
View File
@@ -50,6 +50,13 @@ describe('RN overlay translations', () => {
const problems: string[] = [];
for (const [key, value] of readOverlay(code)) {
const english = en.get(key);
// Word lists (email_composer.placeholder_words) are lists in every language.
if (Array.isArray(english)) {
if (!Array.isArray(value) || value.some((word) => typeof word !== 'string')) {
problems.push(`${key}: not a list of strings`);
}
continue;
}
if (typeof english !== 'string') {
problems.push(`${key}: not in locales/rn/en.json`);
continue;
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { files, email } = vi.hoisted(() => ({
files: {
filesAccountId: vi.fn(() => 'me'),
copyFileNode: vi.fn(async (node: { blobId: string }, parentId: string | null, name: string) => ({
id: 'new-node', name, parentId, blobId: node.blobId, type: 'application/pdf',
})),
fileNodeKind: vi.fn((node: { nodeType?: string; blobId?: string | null }) =>
node.nodeType ?? (node.blobId == null ? 'directory' : 'file')),
},
email: {
// As the real one: the ids as they are within one account.
copyBlobs: vi.fn(async (from: string, ids: string[], to: string) =>
Object.fromEntries(ids.map((id) => [id, from === to ? id : `copy-of-${id}`]))),
},
}));
vi.mock('../../api/files', () => files);
vi.mock('../../api/email', () => email);
vi.mock('../../api/jmap-client', () => ({ jmapClient: { accountId: 'me' } }));
import type { FileNode } from '../../api/types';
import {
attachmentFromFileNode, folderPath, pickerEntries, saveAttachmentToFiles,
} from '../compose-files';
const node = (id: string, name: string, parentId: string | null, blobId?: string, extra: Partial<FileNode> = {}): FileNode =>
({ id, name, parentId, blobId: blobId ?? null, type: blobId ? 'application/pdf' : '', ...extra });
const NODES = [
node('d1', 'Projects', null),
node('f1', 'b-report.pdf', null, 'blob-1', { size: 10 }),
node('f0', 'a-notes.pdf', null, 'blob-0'),
node('trash', '.Trash', null),
node('hidden', '.cache', null, 'blob-h'),
node('f2', 'plan.pdf', 'd1', 'blob-2'),
node('d2', 'Archive', 'd1'),
node('s1', 'theirs.pdf', null, 'blob-s', { isShared: true, accountId: 'team' }),
];
beforeEach(() => vi.clearAllMocks());
describe('the Files pickers', () => {
it('lists folders first, then files by name, without hidden and shared nodes', () => {
expect(pickerEntries(NODES, null).map((n) => n.id)).toEqual(['d1', 'f0', 'f1']);
expect(pickerEntries(NODES, 'd1').map((n) => n.id)).toEqual(['d2', 'f2']);
});
it('lists only folders to save into', () => {
expect(pickerEntries(NODES, null, true).map((n) => n.id)).toEqual(['d1']);
});
it('names the path down to a folder', () => {
expect(folderPath(NODES, 'd2').map((n) => n.name)).toEqual(['Projects', 'Archive']);
expect(folderPath(NODES, null)).toEqual([]);
});
});
describe('Attach from Files (#1179)', () => {
it("attaches the file's blob as it is when Files and the message share the account", async () => {
await expect(attachmentFromFileNode(NODES[1])).resolves.toEqual({
blobId: 'blob-1', name: 'b-report.pdf', type: 'application/pdf', size: 10,
});
expect(email.copyBlobs).toHaveBeenCalledWith('me', ['blob-1'], 'me');
});
it('copies the blob into the account of a message sent from elsewhere', async () => {
const part = await attachmentFromFileNode(NODES[1], 'team');
expect(email.copyBlobs).toHaveBeenCalledWith('me', ['blob-1'], 'team');
expect(part.blobId).toBe('copy-of-blob-1');
});
it('refuses a folder', async () => {
await expect(attachmentFromFileNode(NODES[0])).rejects.toThrow();
});
});
describe('Save to Files', () => {
it('files an own attachment by its blob, in the folder picked', async () => {
const saved = await saveAttachmentToFiles({ blobId: 'att-1', name: 'invoice.pdf', type: 'application/pdf' }, 'd1');
expect(email.copyBlobs).toHaveBeenCalledWith('me', ['att-1'], 'me');
expect(files.copyFileNode).toHaveBeenCalledWith(
expect.objectContaining({ blobId: 'att-1', nodeType: 'file' }), 'd1', 'invoice.pdf',
);
expect(saved.id).toBe('new-node');
});
it("copies a group mailbox's attachment into the Files account first", async () => {
await saveAttachmentToFiles({ blobId: 'att-1', name: 'invoice.pdf', type: 'application/pdf' }, null, 'team');
expect(email.copyBlobs).toHaveBeenCalledWith('team', ['att-1'], 'me');
expect(files.copyFileNode).toHaveBeenCalledWith(
expect.objectContaining({ blobId: 'copy-of-att-1' }), null, 'invoice.pdf',
);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { client, blob } = vi.hoisted(() => ({
client: {
isConnected: true,
username: 'me@example.com' as string | null,
serverUrl: 'https://mail.example.com' as string | null,
},
blob: {
uploadBlob: vi.fn(async () => ({ blobId: 'b1', size: 3, type: 'text/plain' })),
uploadBytes: vi.fn(async () => ({ blobId: 'b2', size: 3, type: 'text/plain' })),
},
}));
vi.mock('../../api/jmap-client', () => ({ jmapClient: client }));
vi.mock('../../api/blob', () => blob);
import { useAccountStore } from '../../stores/account-store';
import {
ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
} from '../compose-login';
const LOGIN = 'me@example.com@mail.example.com';
beforeEach(() => {
vi.clearAllMocks();
client.serverUrl = 'https://mail.example.com';
useAccountStore.setState({ activeAccountId: LOGIN });
});
describe('composer writes after an account switch', () => {
it('uploads into the login the message was started in', async () => {
await expect(uploadFileForLogin(LOGIN, 'file:///a.txt', 'text/plain')).resolves.toMatchObject({ blobId: 'b1' });
await expect(uploadBytesForLogin(LOGIN, new Uint8Array([1]), 'text/plain')).resolves.toMatchObject({ blobId: 'b2' });
expect(blob.uploadBlob).toHaveBeenCalledWith('file:///a.txt', 'text/plain', undefined);
});
it("uploads nothing into another login's blob store", async () => {
useAccountStore.setState({ activeAccountId: 'me@example.com@other.example.org' });
client.serverUrl = 'https://other.example.org';
await expect(uploadFileForLogin(LOGIN, 'file:///a.txt', 'text/plain')).rejects.toBeInstanceOf(ComposerLoginMovedError);
await expect(uploadBytesForLogin(LOGIN, new Uint8Array([1]), 'text/plain')).rejects.toBeInstanceOf(ComposerLoginMovedError);
expect(blob.uploadBlob).not.toHaveBeenCalled();
expect(blob.uploadBytes).not.toHaveBeenCalled();
});
it('counts a switch the client already serves before the registry says so', () => {
client.serverUrl = 'https://other.example.org';
expect(loginMovedFrom(LOGIN)).toBe(true);
});
it('writes while the client is still connecting the same login', () => {
client.isConnected = false;
expect(loginMovedFrom(LOGIN)).toBe(false);
client.isConnected = true;
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { api } = vi.hoisted(() => ({
api: {
cancelScheduledSend: vi.fn(async () => undefined),
restoreEmailToDraft: vi.fn(async () => undefined),
},
}));
vi.mock('../../api/email', () => api);
import type { Mailbox } from '../../api/types';
import { cancelScheduledToDrafts, scheduledAccountOverride } from '../compose-scheduled';
function mailbox(role: string, id: string, shared?: string): Mailbox {
return {
id: shared ? `${shared}:${id}` : id,
originalId: shared ? id : undefined,
name: role,
role,
accountId: shared,
isShared: !!shared,
} as Mailbox;
}
const MAILBOXES = [
mailbox('drafts', 'own-drafts'),
mailbox('sent', 'own-sent'),
mailbox('drafts', 'team-drafts', 'team'),
mailbox('sent', 'team-sent', 'team'),
];
beforeEach(() => vi.clearAllMocks());
// native #84: "Cancel send" left the message in Sent, where it passed for a
// sent one; like Edit, it now goes back to Drafts.
describe('Cancel send of a scheduled message', () => {
it('cancels the submission and moves an own message back into the own Drafts', async () => {
const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
await expect(cancelScheduledToDrafts(item, MAILBOXES, 'own')).resolves.toBe(true);
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'own');
expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'own-drafts', 'own-sent', 'own');
});
it("moves a group account's message back into that account's Drafts", async () => {
const item = { accountId: 'team', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
await cancelScheduledToDrafts(item, MAILBOXES, 'own');
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'team');
expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'team-drafts', 'team-sent', 'team');
});
it('moves nothing when the cancel fails', async () => {
api.cancelScheduledSend.mockRejectedValueOnce(new Error('already sent'));
const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
await expect(cancelScheduledToDrafts(item, MAILBOXES, 'own')).rejects.toThrow('already sent');
expect(api.restoreEmailToDraft).not.toHaveBeenCalled();
});
it('says so when the account has no Drafts folder', async () => {
const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
await expect(cancelScheduledToDrafts(item, [mailbox('sent', 'own-sent')], 'own')).resolves.toBe(false);
expect(api.restoreEmailToDraft).not.toHaveBeenCalled();
});
it('names the own account as no override', () => {
expect(scheduledAccountOverride({ accountId: 'own' }, 'own')).toBeUndefined();
expect(scheduledAccountOverride({ accountId: 'team' }, 'own')).toBe('team');
});
});
+184
View File
@@ -0,0 +1,184 @@
import { describe, it, expect } from 'vitest';
import { SUPPORTED_LOCALES } from '../../i18n';
import {
findUnfilledPlaceholders, placeholderWordsFor, unfilledPlaceholdersInMessage, type PlaceholderWords,
} from '../placeholder-check';
import { SIGNATURE_RANGE_MARKER } from '../signature-utils';
// Ported from the webmail's lib/__tests__/placeholder-check.test.ts. The words
// come from the RN overlays (locales/rn/<lang>.json) until the vendored
// catalogs carry them.
const locales = SUPPORTED_LOCALES.map((l) => l.code as string);
function wordsOf(locale: string): PlaceholderWords {
return placeholderWordsFor(locale).at(-1)!;
}
// What the composer passes: English and the UI language.
const withUi = (locale: string) => placeholderWordsFor(locale);
const EN = withUi('en');
const ALL = locales.map(wordsOf);
describe('findUnfilledPlaceholders', () => {
it('finds template placeholders in the subject and the body', () => {
expect(findUnfilledPlaceholders('Follow-up: {{subject}}', 'Hi {{ recipient_name }},')).toEqual([
'{{subject}}',
'{{ recipient_name }}',
]);
});
it('finds upper-case fill-ins in brackets', () => {
expect(findUnfilledPlaceholders('', 'See you at [TIME]. It costs [PRICE] per [UNIT_NAME].'))
.toEqual(['[TIME]', '[PRICE]', '[UNIT_NAME]']);
expect(findUnfilledPlaceholders('', 'Best regards, [YOUR NAME]')).toEqual(['[YOUR NAME]']);
expect(findUnfilledPlaceholders('', 'Treffpunkt: [ÜBERGABEORT]')).toEqual(['[ÜBERGABEORT]']);
});
it('finds fill-ins that ask the sender to write something', () => {
expect(findUnfilledPlaceholders('', 'Best regards,\n[Your Name]\n[Insert date]', EN)).toEqual([
'[Your Name]',
'[Insert date]',
]);
expect(findUnfilledPlaceholders('', 'Viele Grüße\n[Ihr Name], [Datum einfügen]', withUi('de'))).toEqual([
'[Ihr Name]',
'[Datum einfügen]',
]);
});
it.each([
['ar', '[أدخل الاسم]'],
['ca', '[El teu nom]'],
['cs', '[Vaše jméno]'],
['da', '[Dit navn]'],
['de', '[Unterschrift eintragen]'],
['es', '[Tu nombre]'],
['es', '[Inserte la fecha]'],
['fa', '[نام شما]'],
['fr', '[Votre nom]'],
['fr', '[Insérer la date]'],
['he', '[השם שלך]'],
['hu', '[Az Ön neve]'],
['it', '[Il tuo nome]'],
['it', '[Inserisci la data]'],
['ja', '[あなたの名前]'],
['ja', '[会社名を入力]'],
['ko', '[귀하의 이름]'],
['ko', '[날짜 입력]'],
['lv', '[Jūsu vārds]'],
['mn', '[Таны нэр]'],
['nb', '[Ditt navn]'],
['nl', '[Uw naam]'],
['nl', '[Datum invullen]'],
['pl', '[Twoje imię]'],
['pt', '[Seu nome]'],
['ro', '[Numele tău]'],
['ru', '[Ваше имя]'],
['ru', '[Укажите дату]'],
['sk', '[Vaše meno]'],
['tr', '[Adınız]'],
['tr', '[Tarih ekleyin]'],
['uk', '[Ваше ім’я]'],
['zh', '[您的姓名]'],
['zh', '【插入日期】'],
['zh-TW', '[填寫公司名稱]'],
])('finds a %s fill-in with that UI language: %s', (locale, fillIn) => {
expect(findUnfilledPlaceholders('', `Text ${fillIn} text`, withUi(locale))).toEqual([fillIn]);
});
it('checks only the languages it is given', () => {
expect(findUnfilledPlaceholders('', 'Cordialement, [Votre nom]', withUi('de'))).toEqual([]);
expect(findUnfilledPlaceholders('', 'Cordialement, [Votre nom]', withUi('fr'))).toEqual(['[Votre nom]']);
});
it('finds English fill-ins whatever the UI language', () => {
expect(findUnfilledPlaceholders('', 'Thanks, [Your Name]. TODO', withUi('ja'))).toEqual(['[Your Name]', 'TODO']);
});
it('skips word lists that are not lists of strings', () => {
const broken = [{ before: 'your' }, { before: [1, '', '-', null] }, null, { after: {} }] as unknown as PlaceholderWords[];
expect(findUnfilledPlaceholders('', '[Your Name] [TIME]', broken)).toEqual(['[TIME]']);
});
it('finds the circles that stand in for a name in Japanese and Korean', () => {
expect(findUnfilledPlaceholders('', '〇〇様\nお世話になっております。○○○ 님')).toEqual(['〇〇', '○○○']);
});
it('leaves bracketed words and headings in other languages alone', () => {
const text = [
'【重要】お知らせ 【通知】',
'[Votre] [Ваше] [您的]',
'Le [ton] juste. Vote [tu].',
'Siehe [Anlage 2] und [Kapitel 3].',
'Termin: ○ und ◯.',
].join('\n');
expect(findUnfilledPlaceholders('', text, ALL)).toEqual([]);
});
it('finds unfinished-text markers', () => {
expect(findUnfilledPlaceholders('', 'The venue is TBD. TODO: add the agenda. Room XXX', EN)).toEqual([
'TBD',
'TODO',
'XXX',
]);
});
it('reports a marker in brackets once', () => {
expect(findUnfilledPlaceholders('', 'Agenda: [TODO]', EN)).toEqual(['[TODO]']);
});
it('lists each placeholder once', () => {
expect(findUnfilledPlaceholders('{{name}}', 'Hi {{name}}, at [TIME] or [TIME]?')).toEqual([
'{{name}}',
'[TIME]',
]);
});
it('leaves ordinary text alone', () => {
const text = [
'See footnote [1] and tick [x].',
'Option [A] is fine, so is [3D] and [2026].',
'I still have a todo list and the date is tba.',
'Read [the docs](https://example.com) and [Your account](https://example.com/me).',
'The mastodon handle is TODOS and the size is XXXL.',
'Signed, [Yours truly].',
'A {single} brace and {{}} are not placeholders.',
].join('\n');
expect(findUnfilledPlaceholders('', text, ALL)).toEqual([]);
});
it('ignores bracket tags and markers in the subject', () => {
expect(findUnfilledPlaceholders('Re: [EXTERNAL] Venue TBD', 'Thanks!', ALL)).toEqual([]);
});
});
describe('placeholder words in the locale files', () => {
it.each(locales)('%s has lists of strings', (locale) => {
const words = wordsOf(locale) as Record<string, unknown>;
for (const key of ['before', 'after', 'unfinished']) {
expect(Array.isArray(words[key]), `${locale}: ${key} is not a list`).toBe(true);
for (const word of words[key] as unknown[]) {
expect(typeof word === 'string' && word.replace(/-/g, '').trim() !== '', `${locale}: ${JSON.stringify(word)}`).toBe(true);
}
}
});
});
describe('unfilledPlaceholdersInMessage', () => {
const signature = { name: 'Me', email: 'me@example.com', textSignature: 'Me\n[YOUR TITLE]', htmlSignature: '' };
it('leaves the embedded signature and the quoted original out', () => {
const body = '<p>See you at [TIME].</p>'
+ `<p ${SIGNATURE_RANGE_MARKER}="separator">-- </p><p>Me, [YOUR TITLE]</p><p ${SIGNATURE_RANGE_MARKER}="end"></p>`
+ '<blockquote><p>Please confirm [DATE]</p></blockquote>';
expect(unfilledPlaceholdersInMessage({
subject: 'Re: hello', body, plainTextMode: false, signature, words: EN,
})).toEqual(['[TIME]']);
});
it('leaves the plain text signature and quoted lines out', () => {
const body = 'See you at [TIME].\n\n-- \nMe\n[YOUR TITLE]\n\n> Please confirm [DATE]';
expect(unfilledPlaceholdersInMessage({
subject: '{{subject}}', body, plainTextMode: true, signature, words: EN,
})).toEqual(['{{subject}}', '[TIME]']);
});
});
+20 -1
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import type { Email } from '../../api/types';
import type { Email, Identity } from '../../api/types';
import { replyComposeParams } from '../reply-compose';
import { resolveReplyIdentity } from '../reply-identity';
const source = {
id: 'm1',
@@ -26,6 +27,24 @@ describe('replyComposeParams', () => {
expect(replyComposeParams('reply', source)?.prefillBody).toBeUndefined();
expect(replyComposeParams('reply', source, undefined, ' ')?.prefillBody).toBeUndefined();
});
it('lets the reply From match an address the message names only in Bcc (native #87)', () => {
const viaBcc = {
...source,
to: [{ name: 'owner/repo', email: 'repo@noreply.github.com' }],
bcc: [{ email: 'github@example.com' }],
} as Email;
const params = replyComposeParams('reply', viaBcc);
expect(params?.replyTo?.bcc).toEqual([{ email: 'github@example.com' }]);
const identities = [
{ id: 'main', name: 'Me', email: 'me@example.com', mayDelete: false },
] as Identity[];
const resolved = resolveReplyIdentity(identities, params!.replyTo!, {
ownEmails: ['me@example.com'], catchAll: true, matchMode: 'domain',
});
expect(resolved?.identityId).toBe('main');
expect(resolved?.overrideEmail).toBe('github@example.com');
});
});
describe('replyComposeParams: quoted inline images', () => {
+88
View File
@@ -0,0 +1,88 @@
// Mail and Files meet here: a file from Files attached to a message (#1179)
// and an attachment filed in Files (webmail save-to-files-dialog). Neither
// downloads or uploads anything: both refer to a blob the server already
// has. Stalwart 0.16.27 and 1.0.0 take a FileNode's blob as an attachment
// and an attachment's blob for a FileNode within one account; across
// accounts the blob is copied first (Blob/copy), as the webmail does.
import { copyBlobs } from '../api/email';
import { copyFileNode, fileNodeKind, filesAccountId } from '../api/files';
import { jmapClient } from '../api/jmap-client';
import type { FileNode } from '../api/types';
/** The Files trash the webmail keeps (lib/files-trash): hidden like any dot folder. */
const TRASH_FOLDER_NAME = '.Trash';
/** Whether a node is one Files hides: a dot file or folder, the trash among them. */
export function isHiddenFileNode(node: Pick<FileNode, 'name'>): boolean {
return node.name.startsWith('.') || node.name === TRASH_FOLDER_NAME;
}
/**
* The nodes the pickers offer in `parentId` (null: My files): the user's
* own, without hidden ones, folders first, then by name. `foldersOnly` for
* the Save to Files folder picker.
*/
export function pickerEntries(nodes: FileNode[], parentId: string | null, foldersOnly = false): FileNode[] {
return nodes
.filter((n) => !n.isShared && !n.discoverableOnly && !isHiddenFileNode(n))
.filter((n) => (n.parentId ?? null) === parentId)
.filter((n) => {
const kind = fileNodeKind(n);
return foldersOnly ? kind === 'directory' : kind === 'directory' || kind === 'file';
})
.sort((a, b) => {
const af = fileNodeKind(a) === 'directory' ? 0 : 1;
const bf = fileNodeKind(b) === 'directory' ? 0 : 1;
return af - bf || a.name.localeCompare(b.name);
});
}
/** The folders from My files down to `folderId`, for the picker's path. */
export function folderPath(nodes: FileNode[], folderId: string | null): FileNode[] {
const path: FileNode[] = [];
const seen = new Set<string>();
let id = folderId;
while (id && !seen.has(id)) {
seen.add(id);
const node = nodes.find((n) => n.id === id);
if (!node) break;
path.unshift(node);
id = node.parentId ?? null;
}
return path;
}
/**
* A file from Files as an attachment of a message in `mailAccountId`
* (undefined: the login's own): its blob, copied into that account when
* Files lives in another.
*/
export async function attachmentFromFileNode(
node: FileNode,
mailAccountId?: string,
): Promise<{ blobId: string; name: string; type: string; size: number }> {
if (!node.blobId || fileNodeKind(node) !== 'file') throw new Error('Only files can be attached');
const from = node.accountId ?? filesAccountId();
const to = mailAccountId ?? jmapClient.accountId;
const blobId = (await copyBlobs(from, [node.blobId], to))[node.blobId];
return { blobId, name: node.name, type: node.type || 'application/octet-stream', size: node.size ?? 0 };
}
/**
* File an attachment in the user's Files, in `parentId` (null: My files).
* `blobAccountId` is the account the message is in when it is not the
* login's own: a shared or group mailbox's blob is copied into the Files
* account first.
*/
export async function saveAttachmentToFiles(
attachment: { blobId: string; name?: string | null; type?: string | null },
parentId: string | null,
blobAccountId?: string,
): Promise<FileNode> {
const from = blobAccountId ?? jmapClient.accountId;
const blobId = (await copyBlobs(from, [attachment.blobId], filesAccountId()))[attachment.blobId];
const name = attachment.name || 'attachment';
const node: FileNode = { id: '', name, type: attachment.type || 'application/octet-stream', blobId, nodeType: 'file' };
return copyFileNode(node, parentId, name);
}
+55
View File
@@ -0,0 +1,55 @@
// Writes the composer and the quick reply make on behalf of the login they
// were opened in (see lib/draft-login). Each goes through the JMAP client
// of the active login, so a switch while one is on screen (a notification
// tap, a deep link) would send it to the other server: an attachment
// uploaded into the other login's blob store, a reply flagged `$answered`
// on whatever message has the original's id there. These check the login
// right before the write, after every await that could let a switch in.
import { uploadBlob, uploadBytes, type UploadBlobOptions } from '../api/blob';
import { jmapClient } from '../api/jmap-client';
import { useAccountStore } from '../stores/account-store';
import { composerLoginMoved, servedLoginOf } from './draft-login';
/** A write refused because another login is active than the one it belongs to. */
export class ComposerLoginMovedError extends Error {
constructor() {
super('The account was switched');
this.name = 'ComposerLoginMovedError';
}
}
/** Whether a write for `owner` would now reach another login. */
export function loginMovedFrom(owner: string | null | undefined): boolean {
return composerLoginMoved(owner, {
activeAccountId: useAccountStore.getState().activeAccountId,
servedLogin: servedLoginOf(jmapClient),
});
}
/** Throws ComposerLoginMovedError when a write for `owner` would reach another login. */
export function assertComposerLogin(owner: string | null | undefined): void {
if (loginMovedFrom(owner)) throw new ComposerLoginMovedError();
}
/** Upload a picked file for a message of `owner`, or refuse once another login is active. */
export async function uploadFileForLogin(
owner: string | null | undefined,
uri: string,
type: string,
options?: UploadBlobOptions,
): Promise<Awaited<ReturnType<typeof uploadBlob>>> {
assertComposerLogin(owner);
return uploadBlob(uri, type, options);
}
/** Upload bytes (a re-uploaded forwarded part) for a message of `owner`, or refuse. */
export async function uploadBytesForLogin(
owner: string | null | undefined,
bytes: Uint8Array,
type: string,
accountId?: string,
): Promise<Awaited<ReturnType<typeof uploadBytes>>> {
assertComposerLogin(owner);
return uploadBytes(bytes, type, accountId);
}
+57
View File
@@ -0,0 +1,57 @@
// What a failed "Send now" or reschedule leaves behind, and how to say so.
// The original is cancelled before its replacement is made (#1308), so a
// failure can end three ways: nothing changed (the cancel failed), the
// original was put back under a new submission id (RescheduleRestoredError),
// or the message is no longer scheduled and back in Drafts
// (RescheduleLostError). The undo bar and the Scheduled list both follow
// the message to its new id, or drop it.
import { RescheduleLostError, RescheduleRestoredError } from '../api/email';
import { ScheduleTooLateError } from '../api/jmap-result';
import type { TranslateFn } from '../stores/locale-store';
import { rescheduleFailureNotice } from './send-failure-message';
/** The submission a failed reschedule put back, which now stands for the message; null when none did. */
export function restoredSubmission(err: unknown): { emailSubmissionId: string; sendAt?: string } | null {
return err instanceof RescheduleRestoredError
? { emailSubmissionId: err.restoredSubmissionId, sendAt: err.restoredSendAt }
: null;
}
/** Whether the message is not scheduled any more after the failure. */
export function rescheduleLostMessage(err: unknown): boolean {
return err instanceof RescheduleLostError;
}
/**
* The alert or toast for a failed reschedule. `formatWhen` formats an ISO
* time for "still scheduled for …"; `scheduledAt` is the time it was
* scheduled for before.
*/
export function rescheduleErrorNotice(
err: unknown,
t: TranslateFn,
failTitle: string,
formatWhen?: (iso: string) => string,
scheduledAt?: string,
): { title: string; message: string } {
if (err instanceof RescheduleLostError) {
return {
title: failTitle,
message: err.inDrafts
? t('scheduled.reschedule_lost_drafts', 'The message is no longer scheduled. It is back in Drafts, where you can send it again.')
: t('scheduled.reschedule_lost', 'The message is no longer scheduled. Check Drafts and Sent before sending it again.'),
};
}
if (err instanceof RescheduleRestoredError) {
const when = err.restoredSendAt ?? scheduledAt;
if (err.reason instanceof ScheduleTooLateError) {
return {
title: t('email_composer.schedule_too_late_title', 'Too far ahead'),
message: t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.'),
};
}
return rescheduleFailureNotice(err.reason, t, failTitle, when && formatWhen ? formatWhen(when) : undefined);
}
return rescheduleFailureNotice(err, t, failTitle, scheduledAt && formatWhen ? formatWhen(scheduledAt) : undefined);
}
+37
View File
@@ -0,0 +1,37 @@
// "Cancel send" and "Edit" of a scheduled message: the submission is
// cancelled and the message goes back to Drafts, so nothing sits in Sent
// looking sent (webmail cancelScheduledEmailForEdit, 780b64461). A message
// held in a shared or group account goes back to that account's Drafts.
import { cancelScheduledSend, restoreEmailToDraft, type ScheduledEmail } from '../api/email';
import type { Mailbox } from '../api/types';
import { mailboxesOfAccount } from './mailbox-tree';
/** The account to pass to the mail calls for `item`: undefined for the own one. */
export function scheduledAccountOverride(item: Pick<ScheduledEmail, 'accountId'>, ownAccountId: string): string | undefined {
return item.accountId !== ownAccountId ? item.accountId : undefined;
}
/**
* Cancel the scheduled send and move the message back into Drafts. Resolves
* with whether it is in Drafts now: false when the account has no Drafts
* folder, where it stays in Sent. A failed cancel throws and moves nothing.
*/
export async function cancelScheduledToDrafts(
item: Pick<ScheduledEmail, 'accountId' | 'emailSubmissionId' | 'emailId'>,
mailboxes: Mailbox[],
ownAccountId: string,
): Promise<boolean> {
await cancelScheduledSend(item.emailSubmissionId, item.accountId);
const scope = mailboxesOfAccount(mailboxes, scheduledAccountOverride(item, ownAccountId));
const drafts = scope.find((m) => m.role === 'drafts');
const sent = scope.find((m) => m.role === 'sent');
if (!drafts) return false;
await restoreEmailToDraft(
item.emailId,
drafts.originalId ?? drafts.id,
sent ? (sent.originalId ?? sent.id) : undefined,
item.accountId,
);
return true;
}
+61
View File
@@ -0,0 +1,61 @@
// Where the composer creates, submits and files a message: the account its
// From belongs to (#1090). A group or shared identity sends from the group's
// account, with that account's identity id, and its draft and Sent copy live
// in the group's Drafts and Sent; everything else uses the login's own.
import { getMailboxes } from '../api/email';
import { jmapClient } from '../api/jmap-client';
import { resolveSendAccountId, sendIdentityIn } from '../api/identity';
import type { Identity, Mailbox } from '../api/types';
import { mailboxesOfAccount } from './mailbox-tree';
export interface SendTarget {
/** The JMAP account; undefined for the login's own. */
accountId?: string;
/** The identity to submit through, as that account names it. */
identityId: string;
draftsMailboxId?: string;
sentMailboxId?: string;
}
const roleIn = (list: Mailbox[], role: string) => {
const box = list.find((m) => m.role === role);
return box ? (box.originalId ?? box.id) : undefined;
};
/**
* The account, identity and folders for a message From `fromEmail` that the
* composer picked `identity` (one of the login's own) for. `mailboxes` are
* the folders the app has loaded, the shared ones among them; a group's are
* read from the server when they are not.
*/
export async function sendTargetFor(identity: Identity, fromEmail: string, mailboxes: Mailbox[]): Promise<SendTarget> {
const ownTarget: SendTarget = {
identityId: identity.id,
draftsMailboxId: roleIn(mailboxesOfAccount(mailboxes, undefined), 'drafts'),
sentMailboxId: roleIn(mailboxesOfAccount(mailboxes, undefined), 'sent'),
};
const accountId = await resolveSendAccountId(fromEmail);
if (accountId === jmapClient.accountId) return ownTarget;
// Submitting needs the account's own identity for the address; without
// one, the message goes out of the login's account as before.
let accountIdentity: Identity | undefined;
try {
accountIdentity = await sendIdentityIn(accountId, fromEmail);
} catch (err) {
console.warn('[compose] could not read the identities of the sending account', err);
}
if (!accountIdentity) return ownTarget;
let scope = mailboxesOfAccount(mailboxes, accountId);
if (!scope.some((m) => m.role === 'sent')) {
try {
scope = await getMailboxes(accountId);
} catch (err) {
console.warn('[compose] could not read the folders of the sending account', err);
return ownTarget;
}
}
const sentMailboxId = roleIn(scope, 'sent');
if (!sentMailboxId) return ownTarget;
return { accountId, identityId: accountIdentity.id, draftsMailboxId: roleIn(scope, 'drafts'), sentMailboxId };
}
+224
View File
@@ -0,0 +1,224 @@
// Templates kept in an account's Inbox metadata (Stalwart 1.0.0 and later),
// as the webmail keeps them (lib/templates/metadata-templates.ts): one
// namespace per template, `org.bulwarkmail.template.<id>`, because Stalwart
// takes at most 8 KB per namespace. Personal templates go in the user's own
// Inbox, team templates in a group account's.
//
// Deletions of personal templates are tombstones in the marker entry
// `org.bulwarkmail.templates` ({ version, deleted: { id: deletedAt } }), and
// only tombstones delete: an entry that is simply missing (a request that
// failed half way, another client) is uploaded again, never taken as a
// deletion.
//
// Pure: reading the entries and planning how the copy on this device and the
// server copy come together. stores/templates-store does the requests.
import type { EmailTemplate } from '../stores/templates-store';
import { isValidMetadataKey, MAX_METADATA_ENTRY_BYTES, metadataValueBytes } from '../api/bulwark-metadata';
const KEY_PREFIX = 'template.';
/** The marker entry: tombstones of deleted personal templates. */
export const MARKER_KEY = 'templates';
export const MARKER_VERSION = 1;
/**
* Tombstones older than this are dropped. A device offline for longer may
* bring a deleted template back: the accepted trade-off for a bounded marker.
*/
export const TEMPLATE_TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1000;
/** The metadata key of a template (`template.<id>`). */
export function templateKey(id: string): string {
return `${KEY_PREFIX}${id}`;
}
/** 53-bit string hash (cyrb53), as 14 hex digits. */
function hash53(text: string, seed: number): string {
let h1 = 0xdeadbeef ^ seed;
let h2 = 0x41c6ce57 ^ seed;
for (let i = 0; i < text.length; i++) {
const ch = text.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0');
}
/**
* An id that fits a metadata key, derived from the old one: the same old id
* gives the same new id on every device and in the webmail, so the copies
* still meet by id.
*/
export function storableId(id: string): string {
return isValidMetadataKey(templateKey(id)) ? id : `${hash53(id, 1)}${hash53(id, 2)}`;
}
/** The same template with an id that fits a metadata key. */
export function withStorableId(template: EmailTemplate): EmailTemplate {
const id = storableId(template.id);
return id === template.id ? template : { ...template, id };
}
/** Whether a template is larger than one metadata entry may be. */
export function isTooLargeForMetadata(template: EmailTemplate): boolean {
return metadataValueBytes(template) > MAX_METADATA_ENTRY_BYTES;
}
/** A template as another client stored it, or null when it is not one. */
function parseTemplate(value: unknown, id: string): EmailTemplate | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const t = value as Record<string, unknown>;
if (typeof t.name !== 'string' || !t.name.trim()) return null;
const str = (v: unknown) => (typeof v === 'string' ? v : '');
const list = (v: unknown) => (Array.isArray(v) ? v.map(String) : undefined);
const recipients = t.defaultRecipients as Record<string, unknown> | undefined;
// A fixed time, not now: a copy without a date must never win a merge.
const unknownTime = new Date(0).toISOString();
return {
id,
name: t.name,
subject: str(t.subject),
body: str(t.body),
isHTML: Boolean(t.isHTML),
category: str(t.category),
defaultRecipients: recipients && typeof recipients === 'object'
? { to: list(recipients.to), cc: list(recipients.cc), bcc: list(recipients.bcc) }
: undefined,
identityId: typeof t.identityId === 'string' ? t.identityId : undefined,
isFavorite: Boolean(t.isFavorite),
createdAt: typeof t.createdAt === 'string' ? t.createdAt : unknownTime,
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : unknownTime,
};
}
/** The templates among an Inbox's Bulwark entries (`template.<id>`), by id. The key decides the id. */
export function templatesFromMetadata(entries: Record<string, unknown> | null | undefined): Record<string, EmailTemplate> {
const out: Record<string, EmailTemplate> = {};
for (const [key, value] of Object.entries(entries ?? {})) {
if (!key.startsWith(KEY_PREFIX)) continue;
const id = key.slice(KEY_PREFIX.length);
const template = parseTemplate(value, id);
if (template) out[id] = template;
}
return out;
}
function parseTombstones(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const out: Record<string, string> = {};
for (const [id, at] of Object.entries(value as Record<string, unknown>)) {
if (typeof at === 'string' && !Number.isNaN(Date.parse(at))) out[id] = at;
}
return out;
}
/** The tombstones of the marker entry, or null when there is none. */
export function markerFromMetadata(entries: Record<string, unknown> | null | undefined): Record<string, string> | null {
const value = entries?.[MARKER_KEY];
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return parseTombstones((value as Record<string, unknown>).deleted);
}
/** Tombstones of the last 90 days, newest first as long as the marker stays within 8 KB. */
export function pruneTombstones(deleted: Record<string, string>, now: Date = new Date()): Record<string, string> {
const cutoff = now.getTime() - TEMPLATE_TOMBSTONE_TTL_MS;
const fresh = Object.entries(deleted)
.filter(([, at]) => Date.parse(at) >= cutoff)
.sort((a, b) => Date.parse(b[1]) - Date.parse(a[1]));
const kept: Record<string, string> = {};
for (const [id, at] of fresh) {
kept[id] = at;
if (metadataValueBytes({ version: MARKER_VERSION, deleted: kept }) > MAX_METADATA_ENTRY_BYTES) {
delete kept[id];
break;
}
}
return kept;
}
/** The newest tombstone per id of both sets. */
export function unionTombstones(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
const out = { ...a };
for (const [id, at] of Object.entries(b)) {
if (!out[id] || Date.parse(at) > Date.parse(out[id])) out[id] = at;
}
return out;
}
export function sameTombstones(a: Record<string, string>, b: Record<string, string>): boolean {
const ka = Object.keys(a);
return ka.length === Object.keys(b).length && ka.every((id) => a[id] === b[id]);
}
export interface TemplateList {
templates: EmailTemplate[];
/** Template id -> ISO time it was deleted. */
deletedTemplateIds: Record<string, string>;
}
/**
* Merge two template lists: per id the newer `updatedAt` wins (ties keep
* `local`); a tombstone beats a template unless the template was edited
* after it, which brings it back and clears the tombstone (webmail
* mergeSyncedTemplates).
*/
export function mergeTemplateLists(local: TemplateList, incoming: TemplateList, now: Date = new Date()): TemplateList {
const cutoff = now.getTime() - TEMPLATE_TOMBSTONE_TTL_MS;
const tombstones: Record<string, string> = {};
for (const source of [local.deletedTemplateIds, incoming.deletedTemplateIds]) {
for (const [id, at] of Object.entries(source)) {
if (Date.parse(at) < cutoff) continue;
if (!tombstones[id] || Date.parse(at) > Date.parse(tombstones[id])) tombstones[id] = at;
}
}
const byId = new Map<string, EmailTemplate>();
for (const t of local.templates) byId.set(t.id, t);
for (const t of incoming.templates) {
const existing = byId.get(t.id);
if (!existing || Date.parse(t.updatedAt) > Date.parse(existing.updatedAt)) byId.set(t.id, t);
}
const templates: EmailTemplate[] = [];
for (const t of byId.values()) {
const deletedAt = tombstones[t.id];
if (deletedAt !== undefined) {
if (Date.parse(t.updatedAt) <= Date.parse(deletedAt)) continue;
delete tombstones[t.id];
}
templates.push(t);
}
return { templates, deletedTemplateIds: tombstones };
}
export interface PersonalSyncPlan extends TemplateList {
/** Templates the server lacks or holds an older version of. */
uploads: EmailTemplate[];
/** Ids to remove from the server, each covered by a tombstone. */
deletes: string[];
}
/**
* Brings this device's copy and the server copy together, per id with the
* newer `updatedAt` winning, with the tombstones of both (webmail
* planPersonalSync).
*/
export function planPersonalSync(
local: TemplateList,
server: Record<string, EmailTemplate>,
serverTombstones: Record<string, string>,
now: Date = new Date(),
): PersonalSyncPlan {
const tombstones = unionTombstones(local.deletedTemplateIds, serverTombstones);
const merged = mergeTemplateLists(
{ templates: local.templates, deletedTemplateIds: tombstones },
{ templates: Object.values(server), deletedTemplateIds: {} },
now,
);
const keptIds = new Set(merged.templates.map((t) => t.id));
const uploads = merged.templates.filter((template) => {
const there = server[template.id];
return !there || Date.parse(template.updatedAt) > Date.parse(there.updatedAt);
});
const deletes = Object.keys(server).filter((id) => !keptIds.has(id) && merged.deletedTemplateIds[id] !== undefined);
return { templates: merged.templates, deletedTemplateIds: pruneTombstones(merged.deletedTemplateIds, now), uploads, deletes };
}
+171
View File
@@ -0,0 +1,171 @@
// Finds text the sender meant to replace before sending: template
// placeholders, bracketed fill-ins that AI drafts and copied templates leave
// behind, and markers like TODO. Port of the webmail's lib/placeholder-check.ts.
import { getDictionary, isSupportedLocale } from '../i18n';
import { extractUserAuthoredText } from './compose-html';
import { getPlainTextSignature, stripEmbeddedSignature, type SignatureSource } from './signature-utils';
// Template syntax, as in template-utils. Spaces inside the braces are allowed
// because templates pasted from other tools often have them.
const TEMPLATE_PLACEHOLDER = /\{\{\s*[\w.-]+\s*\}\}/g;
// [TIME], [PRICE], [YOUR NAME]. Not followed by "(", which would make it a
// Markdown link.
const CAPS_BRACKET = /\[[\p{Lu}\d][\p{Lu}\d _./-]{0,28}[\p{Lu}\d]\](?!\()/gu;
/**
* The words that mark a fill-in in one language. They live in each locale
* file under email_composer.placeholder_words, so a new translation brings
* its own.
*
* - `before` opens a fill-in: "your" in [Your name], "insert" in [Insert date].
* - `after` closes one, for languages that put the verb or "your" last:
* "einfügen" in [Datum einfügen].
* - A "-" on the joining side means the word is written onto the next one
* without a space: "您的-" for [您的姓名], "-ınız" for [Adınız].
* - `unfinished` are words in capitals that mark unfinished text, like TODO.
* They match as written, so "todo" in a sentence does not count.
*/
export interface PlaceholderWords {
before?: unknown;
after?: unknown;
unfinished?: unknown;
}
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const alternation = (words: string[]) => words.map(escapeRegExp).join('|');
// The lists come from locale files that contributors edit, so anything that
// is not a list of strings is skipped.
function wordsOf(lists: PlaceholderWords[], key: keyof PlaceholderWords): string[] {
const words = new Set<string>();
for (const list of lists) {
const value = list?.[key];
if (!Array.isArray(value)) continue;
for (const word of value) {
if (typeof word === 'string' && word.replace(/-/g, '').trim()) words.add(word.trim());
}
}
return Array.from(words);
}
// Plain brackets, and the full-width and lenticular ones of CJK text.
const OPEN = '[\\[[【]';
const CLOSE = '[\\]]】]';
const INNER = '[^\\[\\][]【】\\n]';
function promptBracketPattern(lists: PlaceholderWords[]): RegExp | null {
const before = wordsOf(lists, 'before');
const after = wordsOf(lists, 'after');
const branches: string[] = [];
const add = (words: string[], branch: (alt: string) => string) => {
if (words.length > 0) branches.push(branch(alternation(words)));
};
add(before.filter((w) => !w.endsWith('-')), (alt) => `(?:${alt})\\s${INNER}{1,40}`);
add(before.filter((w) => w.endsWith('-')).map((w) => w.slice(0, -1)), (alt) => `(?:${alt})${INNER}{1,40}`);
add(after.filter((w) => !w.startsWith('-')), (alt) => `${INNER}{1,40}\\s(?:${alt})`);
add(after.filter((w) => w.startsWith('-')).map((w) => w.slice(1)), (alt) => `${INNER}{1,40}(?:${alt})`);
if (branches.length === 0) return null;
return new RegExp(`${OPEN}(?:${branches.join('|')})${CLOSE}(?!\\()`, 'giu');
}
function unfinishedWordPattern(lists: PlaceholderWords[]): RegExp | null {
const words = wordsOf(lists, 'unfinished');
if (words.length === 0) return null;
return new RegExp(`(?<![\\p{L}\\d_])(?:${alternation(words)})(?![\\p{L}\\d_])`, 'gu');
}
// Japanese and Korean stand-ins for a name or number: 〇〇様, ○○○ 님. Signs
// rather than words, so they are not in the locale files.
const CIRCLE_MARKER = /[〇○◯]{2,}/gu;
interface Match {
start: number;
end: number;
text: string;
}
function collect(text: string, patterns: RegExp[]): Match[] {
const matches: Match[] = [];
for (const pattern of patterns) {
for (const m of text.matchAll(pattern)) {
if (pattern === CAPS_BRACKET && (m[0].match(/\p{Lu}/gu)?.length ?? 0) < 2) continue;
const start = m.index ?? 0;
matches.push({ start, end: start + m[0].length, text: m[0] });
}
}
// "[TODO]" is a bracket and a word; keep the outer match only.
matches.sort((a, b) => a.start - b.start || b.end - a.end);
const kept: Match[] = [];
for (const match of matches) {
const last = kept[kept.length - 1];
if (last && match.start < last.end) continue;
kept.push(match);
}
return kept;
}
/**
* The unfilled placeholders in a message, each once, subject first. The
* subject is only checked for template placeholders: it often carries tags
* like [EXTERNAL] or words from the original message, which the sender did
* not write. `bodyText` should be the text the sender wrote, without quoted
* replies and the signature. `words` are the fill-in words of the languages
* to check (see PlaceholderWords).
*/
export function findUnfilledPlaceholders(subject: string, bodyText: string, words: PlaceholderWords[] = []): string[] {
const bodyPatterns = [TEMPLATE_PLACEHOLDER, CAPS_BRACKET, CIRCLE_MARKER];
const prompt = promptBracketPattern(words);
const unfinished = unfinishedWordPattern(words);
if (prompt) bodyPatterns.push(prompt);
if (unfinished) bodyPatterns.push(unfinished);
const found = [
...collect(subject, [TEMPLATE_PLACEHOLDER]),
...collect(bodyText, bodyPatterns),
].map((m) => m.text);
return Array.from(new Set(found));
}
function placeholderWordsOf(dictionary: Record<string, unknown>): PlaceholderWords {
const composer = dictionary.email_composer as Record<string, unknown> | undefined;
const words = composer?.placeholder_words;
return words && typeof words === 'object' ? (words as PlaceholderWords) : {};
}
/**
* The fill-in words to check: the UI language's and English, since AI drafts
* and templates are often English whatever the UI language is.
*/
export function placeholderWordsFor(locale: string): PlaceholderWords[] {
const lists = [placeholderWordsOf(getDictionary('en'))];
if (locale !== 'en' && isSupportedLocale(locale)) lists.push(placeholderWordsOf(getDictionary(locale)));
return lists;
}
/**
* The unfilled placeholders of a message as the composer holds it: the body
* without its signature (a placeholder there would stop every message) and
* without the quoted original.
*/
export function unfilledPlaceholdersInMessage(message: {
subject: string;
body: string;
plainTextMode: boolean;
signature?: SignatureSource | null;
forwardedSeparator?: string;
words: PlaceholderWords[];
}): string[] {
let authored: string;
if (message.plainTextMode) {
const signature = getPlainTextSignature(message.signature ?? undefined);
authored = signature ? message.body.split(signature).join('') : message.body;
} else {
authored = stripEmbeddedSignature(message.body);
}
const bodyText = extractUserAuthoredText(authored, {
plainTextMode: message.plainTextMode,
forwardedSeparator: message.forwardedSeparator,
});
return findUnfilledPlaceholders(message.subject, bodyText, message.words);
}
+4
View File
@@ -49,6 +49,10 @@ export function replyComposeParams(
from: from ?? { email: '' },
to: source.to,
cc: source.cc,
// Never a recipient of the reply, but the composer picks the identity
// (and the catch-all From) by the address the message was delivered
// to, and a Sieve rule or a list may name that one only in Bcc.
bcc: source.bcc,
// RFC 5322: a reply goes to Reply-To when the sender set one.
replyToAddresses: source.replyTo,
subject: source.subject ?? '',
+1
View File
@@ -99,6 +99,7 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
'settings.email_behavior.plain_text_mode',
'settings.email_behavior.request_read_receipt',
'settings.email_behavior.empty_subject_warning',
'settings.email_behavior.placeholder_warning',
'settings.email_behavior.signature_separator',
'settings.email_behavior.signature_position',
'settings.email_behavior.send_delay',
+219 -27
View File
@@ -43,11 +43,11 @@ import { toast } from '../stores/toast-store';
import { type EmailTemplate } from '../stores/templates-store';
import { getIdentities } from '../api/identity';
import {
sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail,
sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, SendUnconfirmedError,
type OutgoingAttachment, type OutgoingEmail,
} from '../api/email';
import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client';
import { pruneEmailSubmissions } from '../api/submissions';
import { uploadBlob, uploadBytes } from '../api/blob';
import { partlyRefusedWarning, sendFailureMessage } from '../lib/send-failure-message';
import { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients';
import { buildReplySubject, buildForwardSubject } from '../lib/subject-prefix';
@@ -66,7 +66,14 @@ import {
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
} from '../lib/reply-identity';
import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
import { composerLoginMoved, servedLoginOf } from '../lib/draft-login';
import { placeholderWordsFor, unfilledPlaceholdersInMessage } from '../lib/placeholder-check';
import { sendTargetFor } from '../lib/compose-send-account';
import { attachmentFromFileNode } from '../lib/compose-files';
import { supportsFiles } from '../api/files';
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
import {
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
} from '../lib/compose-login';
import {
initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody,
} from '../lib/draft-part-blobs';
@@ -82,7 +89,7 @@ import {
generateSubAddress, extractDomain, suggestTagsForDomain, getTagValidationError, MAX_TAG_LENGTH,
} from '../lib/sub-addressing';
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
import type { EmailAddress, Identity } from '../api/types';
import type { EmailAddress, FileNode, Identity } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import ToggleSwitch from '../components/ToggleSwitch';
@@ -480,6 +487,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled);
const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault);
const emptySubjectWarningEnabled = useSettingsStore((s) => s.emptySubjectWarningEnabled);
const placeholderWarningEnabled = useSettingsStore((s) => s.placeholderWarningEnabled);
const autoSaveDraftInterval = useSettingsStore((s) => s.autoSaveDraftInterval);
const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter);
const preferredIdentityIds = useSettingsStore((s) => s.preferredIdentityIds);
@@ -824,7 +832,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const localId = `seed-${a.blobId}`;
try {
const buf = await jmapClient.fetchBlobArrayBuffer(a.blobId, a.name, a.type, seedOwnerAccountId);
const up = await uploadBytes(new Uint8Array(buf), a.type || 'application/octet-stream');
const up = await uploadBytesForLogin(ownerLoginRef.current, new Uint8Array(buf), a.type || 'application/octet-stream');
if (!cancelled) updateAttachment(localId, { blobId: up.blobId, size: up.size, uploading: false });
} catch (e) {
if (!cancelled) {
@@ -857,7 +865,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const mime = att.type?.toLowerCase().startsWith('image/') ? att.type : (sniffImageMime(bytes) ?? 'image/png');
map.set(cid, `data:${mime};base64,${bytesToBase64(bytes)}`);
let blobId = att.blobId;
if (seedOwnerAccountId) blobId = (await uploadBytes(bytes, mime)).blobId;
if (seedOwnerAccountId) blobId = (await uploadBytesForLogin(ownerLoginRef.current, bytes, mime)).blobId;
inlineRegistryRef.current.set(cid, {
localId: `cid-${cid}`,
name: att.name || 'image',
@@ -1117,6 +1125,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
// Snapshot at the last successful draft save (null = never saved).
const lastSavedRef = React.useRef<string | null>(draft ? baselineRef.current : null);
const draftIdRef = React.useRef<string | null>(draft?.id ?? null);
// The account `draftIdRef` lives in, undefined for the login's own: a
// group identity's draft lives in the group's Drafts (#1090), and a send
// undone or a scheduled one cancelled for editing comes back in the
// account it was sent from. Email ids repeat across accounts, so the old
// version is destroyed there and nowhere else.
const draftAccountRef = React.useRef<string | undefined>(draft?.jmapAccountId);
const draftAccount = () => draftAccountRef.current ?? jmapClient.accountId;
const messageIdRef = React.useRef<string | null>(draft?.messageId?.[0] ?? null);
const inflightSaveRef = React.useRef<Promise<string | null> | null>(null);
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -1125,10 +1140,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
const sendingRef = React.useRef(false);
// The login this message was started in (see lib/draft-login).
const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId);
const loginMoved = () => composerLoginMoved(ownerLoginRef.current, {
activeAccountId: useAccountStore.getState().activeAccountId,
servedLogin: servedLoginOf(jmapClient),
});
const loginMoved = () => loginMovedFrom(ownerLoginRef.current);
/** The address of the login this message was started in, for the "switch back" alerts. */
const ownerAccountLabel = () => {
const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined;
return owner?.email || owner?.displayName || '';
};
const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle');
const [savingDraft, setSavingDraft] = React.useState(false);
@@ -1351,6 +1368,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
html = (await editorRef.current?.getHtml()) ?? html;
} catch { /* the last change message is the best we have */ }
if (html !== latestRef.current.bodyHtml) setBodyHtml(html);
// Reading the editor gave a switch time to land.
if (loginMoved()) return draftIdRef.current;
}
const snapshot = snapshotOf({
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
@@ -1361,9 +1380,26 @@ export default function ComposeScreen({ route, navigation }: Props) {
setDraftStatus('saving');
try {
const outgoing = buildOutgoing(identity, html, { forDraft: true });
const saved = await saveDraftVersion(outgoing, draftsMailbox.id, draftIdRef.current ?? undefined);
// A draft From a group identity is saved in the group's Drafts, like
// its Sent copy (#1090); one without a Drafts folder there in the own.
const target = await sendTargetFor(identity, outgoing.from[0]?.email ?? identity.email, mailboxes);
if (loginMoved()) {
setDraftStatus('idle');
return draftIdRef.current;
}
const saveIn = target.accountId && target.draftsMailboxId
? { accountId: target.accountId, draftsMailboxId: target.draftsMailboxId }
: { accountId: undefined, draftsMailboxId: draftsMailbox.id };
const saved = await saveDraftVersion(
outgoing,
saveIn.draftsMailboxId,
draftIdRef.current ?? undefined,
saveIn.accountId,
draftIdRef.current ? draftAccount() : undefined,
);
const id = saved.id;
draftIdRef.current = id;
draftAccountRef.current = saveIn.accountId;
// The previous version and its part blobs are gone: point every
// attachment at the parts of the version just created.
draftPartBlobsRef.current = rebaseDraftPartBlobs(
@@ -1446,13 +1482,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
// Another login is active: saving writes nothing (saveDraftOnce), so
// closing now would drop the text as if it had been saved.
if (loginMoved()) {
const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined;
Alert.alert(
t('email_composer.login_switched_title', 'Account switched'),
t(
'email_composer.login_switched_save_body',
'You started this message in {account}. Switch back to that account to save it.',
{ account: owner?.email || owner?.displayName || '' },
{ account: ownerAccountLabel() },
),
);
return;
@@ -1488,7 +1523,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
void (async () => {
try { await inflightSaveRef.current; } catch { /* ignore */ }
const id = draftIdRef.current ?? autosaved;
destroyEmails([id]).catch((err) => console.warn('[compose] discard failed', err));
destroyEmails([id], draftAccountRef.current).catch((err) => console.warn('[compose] discard failed', err));
})();
}
allowLeaveRef.current = true;
@@ -1622,9 +1657,24 @@ export default function ComposeScreen({ route, navigation }: Props) {
return entry;
};
// Attachments go into the blob store of the login the message belongs to;
// once another login is active they would land in that one's.
const refuseAttachAfterSwitch = (): boolean => {
if (!loginMoved()) return false;
Alert.alert(
t('email_composer.login_switched_title', 'Account switched'),
t(
'email_composer.login_switched_attach_body',
'You started this message in {account}. Switch back to that account to attach files.',
{ account: ownerAccountLabel() },
),
);
return true;
};
const startUpload = async (entry: AttachmentEntry) => {
try {
const { blobId, size, type } = await uploadBlob(entry.uri, entry.type, {
const { blobId, size, type } = await uploadFileForLogin(ownerLoginRef.current, entry.uri, entry.type, {
signal: entry.abort?.signal,
onProgress: (sent, total) => {
if (total > 0) updateAttachment(entry.localId, { progress: Math.min(1, sent / total) });
@@ -1640,7 +1690,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
});
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') return; // removed by the user
const message = e instanceof Error ? e.message : t('email_composer.upload_failed_short', 'Upload failed');
const message = e instanceof ComposerLoginMovedError
? t('email_composer.upload_login_switched', 'Not uploaded: the account was switched')
: e instanceof Error ? e.message : t('email_composer.upload_failed_short', 'Upload failed');
updateAttachment(entry.localId, { uploading: false, progress: undefined, error: message });
Alert.alert(
t('email_composer.upload_failed', 'Failed to upload {filename}', { filename: entry.name }),
@@ -1650,6 +1702,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
};
const addFileAsset = (asset: { name: string; type: string; size: number; uri: string }) => {
if (refuseAttachAfterSwitch()) return;
if (!checkAttachmentSize(asset.size, false)) return;
void startUpload(addUploadEntry({ ...asset, inline: false }));
};
@@ -1665,6 +1718,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}, []);
const pickPhotoAttachments = async () => {
if (refuseAttachAfterSwitch()) return;
// The system photo picker needs no media permission (and Play rejects
// READ_MEDIA_IMAGES for picking a photo now and then).
const result = await ImagePicker.launchImageLibraryAsync({
@@ -1687,6 +1741,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
};
const takePhotoAttachment = async () => {
if (refuseAttachAfterSwitch()) return;
const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm.granted) {
Alert.alert(
@@ -1708,6 +1763,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
};
const pickFileAttachments = async () => {
if (refuseAttachAfterSwitch()) return;
let result: DocumentPicker.DocumentPickerResult;
try {
result = await DocumentPicker.getDocumentAsync({ multiple: true, copyToCacheDirectory: true });
@@ -1729,13 +1785,59 @@ export default function ComposeScreen({ route, navigation }: Props) {
};
const [attachMenuOpen, setAttachMenuOpen] = React.useState(false);
// Files in the user's Files on the server, when it keeps them (#1179).
const [filesPickerOpen, setFilesPickerOpen] = React.useState(false);
const hasServerFiles = supportsFiles();
const attachOptions: SheetOption[] = [
{ label: t('email_composer.attach_photos', 'Photos & Videos'), onPress: () => { void pickPhotoAttachments(); } },
{ label: t('email_composer.attach_camera', 'Camera'), onPress: () => { void takePhotoAttachment(); } },
{ label: t('email_composer.attach_files', 'Files'), onPress: () => { void pickFileAttachments(); } },
{
// Next to "Attach from Files" a plain "Files" would read as the same.
label: hasServerFiles
? t('email_composer.attach_from_device', 'Upload from this device')
: t('email_composer.attach_files', 'Files'),
onPress: () => { void pickFileAttachments(); },
},
...(hasServerFiles ? [{
label: t('email_composer.attach_from_files', 'Attach from Files'),
onPress: () => { if (!refuseAttachAfterSwitch()) setFilesPickerOpen(true); },
}] : []),
];
// A file from Files is attached by its blob: nothing is downloaded or
// uploaded, except a copy when Files lives in another account.
const attachFileNodes = (nodes: FileNode[]) => {
setFilesPickerOpen(false);
for (const node of nodes) {
if (refuseAttachAfterSwitch()) return;
if (!checkAttachmentSize(node.size ?? 0, false)) continue;
const entry: AttachmentEntry = {
localId: genLocalId(),
name: node.name,
type: node.type || 'application/octet-stream',
size: node.size ?? 0,
uri: '',
inline: false,
uploading: true,
};
setAttachments((prev) => [...prev, entry]);
void (async () => {
try {
assertComposerLogin(ownerLoginRef.current);
const part = await attachmentFromFileNode(node);
updateAttachment(entry.localId, { blobId: part.blobId, type: part.type, size: part.size || entry.size, uploading: false });
} catch (e) {
const message = e instanceof ComposerLoginMovedError
? t('email_composer.upload_login_switched', 'Not uploaded: the account was switched')
: e instanceof Error ? e.message : t('email_composer.upload_failed_short', 'Upload failed');
updateAttachment(entry.localId, { uploading: false, error: message });
}
})();
}
};
const insertInlineImages = async (assets: Array<{ uri: string; mimeType?: string | null; fileName?: string | null; fileSize?: number | null }>) => {
if (refuseAttachAfterSwitch()) return;
for (const asset of assets) {
const mime = asset.mimeType ?? 'image/jpeg';
const fallbackName = asset.fileName || `image-${Date.now()}.${mime.split('/')[1] ?? 'jpg'}`;
@@ -1763,6 +1865,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
};
const insertInlineImage = async () => {
if (refuseAttachAfterSwitch()) return;
// The system photo picker needs no media permission (and Play rejects
// READ_MEDIA_IMAGES for picking a photo now and then).
const result = await ImagePicker.launchImageLibraryAsync({
@@ -2021,6 +2124,54 @@ export default function ComposeScreen({ route, navigation }: Props) {
});
};
// Unfilled placeholders: template fields left as {{name}}, fill-ins like
// [TIME] from AI drafts, and markers like TODO (webmail
// lib/placeholder-check). The signature is left out, or a placeholder in
// it would stop every message. Asked once per placeholder: after "Send
// anyway" and a trip back to add an attachment, the same ones pass.
const confirmedPlaceholdersRef = React.useRef(new Set<string>());
const passesPlaceholderCheck = (body: string): Promise<boolean> => {
if (!placeholderWarningEnabled) return Promise.resolve(true);
const found = unfilledPlaceholdersInMessage({
subject,
body,
plainTextMode,
signature: signatureIdentity,
forwardedSeparator: quoteLabels.forwardedSeparator,
words: placeholderWordsFor(locale),
});
if (!found.some((p) => !confirmedPlaceholdersRef.current.has(p))) return Promise.resolve(true);
const SHOWN = 5;
const more = found.length > SHOWN
? ` ${t('email_composer.placeholder_warning.more', 'and {count} more', { count: found.length - SHOWN })}`
: '';
const message = t(
'email_composer.placeholder_warning.message',
'Your message has placeholder text that recipients will see. Do you want to edit it before sending?',
);
const label = t('email_composer.placeholder_warning.found', 'Placeholders found');
return new Promise<boolean>((resolve) => {
Alert.alert(
t('email_composer.placeholder_warning.title', 'Check placeholders?'),
`${message}\n\n${label}: ${found.slice(0, SHOWN).join(', ')}${more}`,
[
{ text: t('email_composer.placeholder_warning.back', 'Back to editing'), style: 'cancel', onPress: () => resolve(false) },
{
text: t('email_composer.placeholder_warning.dont_ask_again', "Don't ask again"),
onPress: () => { updateSetting('placeholderWarningEnabled', false); resolve(true); },
},
{
text: t('email_composer.placeholder_warning.send_anyway', 'Send anyway'),
onPress: () => {
for (const p of found) confirmedPlaceholdersRef.current.add(p);
resolve(true);
},
},
],
);
});
};
// Translate an absolute "send at" time into the HOLDFOR seconds the server
// expects, clamping to what it actually supports. Returns null (and alerts)
// when scheduling isn't possible so the caller can abort.
@@ -2133,18 +2284,19 @@ export default function ComposeScreen({ route, navigation }: Props) {
const performSendInner = async (holdForSeconds?: number, scheduledAt?: Date) => {
if (!primaryIdentity || !sentMailbox) return;
if (loginMoved()) {
const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined;
const refuseSendAfterSwitch = (): boolean => {
if (!loginMoved()) return false;
Alert.alert(
t('email_composer.login_switched_title', 'Account switched'),
t(
'email_composer.login_switched_body',
'You started this message in {account}. Switch back to that account to send it.',
{ account: owner?.email || owner?.displayName || '' },
{ account: ownerAccountLabel() },
),
);
return;
}
return true;
};
if (refuseSendAfterSwitch()) return;
// Read the body straight from the editor DOM at send time. The `change`
// messages that feed `bodyHtml` are async and best-effort — trusting them
@@ -2169,6 +2321,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}
if (!(await passesEmptySubjectCheck())) return;
if (!(await passesPlaceholderCheck(plainTextMode ? plainBody : liveBodyHtml))) return;
if (!(await passesAttachmentReminder(plainTextMode ? plainBody : liveBodyHtml))) return;
// Autosave must not race the send: cancel a scheduled save and let an
@@ -2180,16 +2333,31 @@ export default function ComposeScreen({ route, navigation }: Props) {
if (inflightSaveRef.current) {
try { await inflightSaveRef.current; } catch { /* stale version stays; cleaned up below */ }
}
// The confirmations above wait for the user, long enough for a
// notification tap to switch logins.
if (refuseSendAfterSwitch()) return;
setSending(true);
try {
const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false });
// The account the submission is held in, for Undo (read before the
// request: a switch may land while it is out).
// A group or shared identity sends from its own account, and its Sent
// copy goes there (#1090).
const target = await sendTargetFor(primaryIdentity, outgoing.from[0]?.email ?? primaryIdentity.email, mailboxes);
if (refuseSendAfterSwitch()) return;
const submissionAccountId = target.accountId ?? jmapClient.connectedAccountId ?? undefined;
const result = await sendEmail(
outgoing,
primaryIdentity.id,
sentMailbox.id,
target.identityId,
target.sentMailboxId ?? sentMailbox.id,
holdForSeconds,
{ draftsMailboxId: draftsMailbox?.id, draftId: draftIdRef.current ?? undefined },
{
draftsMailboxId: target.accountId ? target.draftsMailboxId : draftsMailbox?.id,
draftId: draftIdRef.current ?? undefined,
draftAccountId: draftIdRef.current ? draftAccount() : undefined,
accountId: target.accountId,
},
);
draftIdRef.current = null;
lastSavedRef.current = null;
@@ -2205,9 +2373,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
const { title, message } = partlyRefusedWarning(refused, t);
toast.warning(title, { message, duration: 10_000 });
}
// A switch during the send: the original's id and the trusted senders
// book below are the other login's now.
const sameLogin = !loginMoved();
// Flag the original so the list shows the reply/forward arrow; best
// effort - the message already left.
if (replyTo?.originalEmailId) {
if (replyTo?.originalEmailId && sameLogin) {
void patchKeywordsForEmails(
[replyTo.originalEmailId],
{ [mode === 'forward' ? '$forwarded' : '$answered']: true },
@@ -2221,7 +2392,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const contacts = useContactsStore.getState();
for (const r of [...outgoing.to, ...(outgoing.cc ?? [])]) {
settings.addTrustedSender(r.email);
if (isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts)) {
if (sameLogin && isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts)) {
contacts.addToTrustedSendersBook(r.name ? `${r.name} <${r.email}>` : r.email).catch(() => undefined);
}
}
@@ -2237,6 +2408,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
// Undo-send window: the undo bar offers Undo / Send now.
useSendUndoStore.getState().recordHeldSend(result, holdForSeconds, {
identityId: primaryIdentity.id,
accountId: submissionAccountId,
login: ownerLoginRef.current,
from: outgoing.from,
to: [...outgoing.to, ...(outgoing.cc ?? []), ...(outgoing.bcc ?? [])],
});
@@ -2260,6 +2433,18 @@ export default function ComposeScreen({ route, navigation }: Props) {
);
return;
}
if (e instanceof SendUnconfirmedError) {
// The message may have gone out: the composer and the draft stay,
// and the user checks Sent rather than sending it twice (#1019).
Alert.alert(
t('email_composer.send_unconfirmed_title', 'Send not confirmed'),
t(
'email_composer.send_unconfirmed_body',
'The server did not confirm the send. Check your Sent folder before sending again. Your draft has been kept.',
),
);
return;
}
if (e instanceof ScheduleTooLateError) {
// The server refused the hold; the pickers now only offer times
// within the limit it named.
@@ -2997,6 +3182,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
</Modal>
<FilesPickerSheet
visible={filesPickerOpen}
mode="files"
onClose={() => setFilesPickerOpen(false)}
onPickFiles={attachFileNodes}
/>
<TemplateSheet
visible={templateSheetOpen}
onClose={() => setTemplateSheetOpen(false)}
+100 -31
View File
@@ -9,9 +9,7 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
import {
listScheduledEmails,
cancelScheduledSend,
rescheduleScheduledSend,
restoreEmailToDraft,
getFullEmail,
type ScheduledEmail,
} from '../api/email';
@@ -23,13 +21,17 @@ import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { useSendUndoStore } from '../stores/send-undo-store';
import { toast } from '../stores/toast-store';
import { ownMailboxes } from '../lib/mailbox-tree';
import { draftContextFromEmail } from '../lib/draft-context';
import { formatQuoteDate } from '../lib/quote-header';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/scheduled-send-key';
import { rescheduleErrorNotice, rescheduleLostMessage, restoredSubmission } from '../lib/compose-reschedule';
import { cancelScheduledToDrafts, scheduledAccountOverride } from '../lib/compose-scheduled';
import { useBackWhileFocused } from '../lib/use-back-while-focused';
import { ScheduledMessageView } from '../components/compose/ScheduledMessageView';
import type { Email } from '../api/types';
type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
@@ -49,6 +51,28 @@ export default function ScheduledScreen({ navigation }: Props) {
const [rescheduleFor, setRescheduleFor] = React.useState<ScheduledEmail | null>(null);
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
const customDraftRef = React.useRef<Date>(new Date());
// The scheduled message open in the read-only view (native #84).
const [viewing, setViewing] = React.useState<ScheduledEmail | null>(null);
const [viewEmail, setViewEmail] = React.useState<Email | null>(null);
const [viewError, setViewError] = React.useState<string | null>(null);
const viewSeq = React.useRef(0);
const openView = React.useCallback((item: ScheduledEmail) => {
const seq = ++viewSeq.current;
setViewing(item);
setViewEmail(null);
setViewError(null);
getFullEmail(item.emailId, scheduledAccountOverride(item, jmapClient.accountId)).then(
(email) => { if (viewSeq.current === seq) setViewEmail(email); },
(e: unknown) => { if (viewSeq.current === seq) setViewError(e instanceof Error ? e.message : String(e)); },
);
}, []);
const closeView = React.useCallback(() => {
viewSeq.current += 1;
setViewing(null);
setViewEmail(null);
setViewError(null);
}, []);
useBackWhileFocused(!!viewing, closeView);
const formatWhen = React.useCallback(
(iso: string) => formatQuoteDate(iso, timeFormat, locale),
@@ -84,6 +108,24 @@ export default function ScheduledScreen({ navigation }: Props) {
try {
await action();
} catch (e) {
// A failed reschedule or "Send now" put the original back under a new
// id, or could not, and the message is back in Drafts (#1308).
const restored = restoredSubmission(e);
if (restored || rescheduleLostMessage(e)) {
if (restored) {
updateItem(item, { emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? item.sendAt });
const pending = useSendUndoStore.getState().pending;
if (isPendingSend(pending, item, jmapClient.accountId)) {
useSendUndoStore.getState().setPending({ ...pending!, emailSubmissionId: restored.emailSubmissionId });
}
} else {
clearUndoFor(item);
removeItem(item);
}
const { title, message } = rescheduleErrorNotice(e, t, failTitle, formatWhen, item.sendAt);
Alert.alert(title, message);
return;
}
if (e instanceof ScheduleTooLateError) {
Alert.alert(
t('email_composer.schedule_too_late_title', 'Too far ahead'),
@@ -126,7 +168,17 @@ export default function ScheduledScreen({ navigation }: Props) {
};
// Submission ids repeat across accounts: a row is its account and id.
const removeItem = (item: ScheduledEmail) => setItems((prev) => prev.filter((i) => !sameScheduledSend(i, item)));
const removeItem = (item: ScheduledEmail) => {
setItems((prev) => prev.filter((i) => !sameScheduledSend(i, item)));
// It is gone from Scheduled: the view of it closes too.
setViewing((v) => (v && sameScheduledSend(v, item) ? null : v));
};
const updateItem = (item: ScheduledEmail, patch: Pick<ScheduledEmail, 'emailSubmissionId' | 'sendAt'>) => {
setItems((prev) => prev
.map((i) => (sameScheduledSend(i, item) ? { ...i, ...patch } : i))
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()));
setViewing((v) => (v && sameScheduledSend(v, item) ? { ...v, ...patch } : v));
};
const clearUndoFor = (item: ScheduledEmail) => {
if (isPendingSend(useSendUndoStore.getState().pending, item, jmapClient.accountId)) useSendUndoStore.getState().clear();
};
@@ -134,7 +186,7 @@ export default function ScheduledScreen({ navigation }: Props) {
const onCancel = (item: ScheduledEmail) => {
Alert.alert(
t('email_list.cancel_scheduled_send', 'Cancel send'),
t('scheduled.cancel_body', 'The message will not be delivered. A copy stays in your Sent folder.'),
t('scheduled.cancel_to_drafts_body', 'The message will not be sent. It goes back to your Drafts.'),
[
{ text: t('scheduled.keep_scheduled', 'Keep scheduled'), style: 'cancel' },
{
@@ -142,9 +194,15 @@ export default function ScheduledScreen({ navigation }: Props) {
style: 'destructive',
onPress: () => {
void runAction(item, async () => {
await cancelScheduledSend(item.emailSubmissionId, item.accountId);
const inDrafts = await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
clearUndoFor(item);
removeItem(item);
toast.success(
t('scheduled.send_cancelled', 'Send cancelled'),
inDrafts
? { message: t('notifications.moved_to_mailbox', 'Moved to {mailbox}', { mailbox: t('sidebar.mailboxes.drafts', 'Drafts') }) }
: undefined,
);
}, t('scheduled.cancel_failed', 'Cancel failed'));
},
},
@@ -181,11 +239,10 @@ export default function ScheduledScreen({ navigation }: Props) {
void runAction(item, async () => {
const result = await rescheduleScheduledSend(item, seconds);
clearUndoFor(item);
setItems((prev) => prev
.map((i) => (sameScheduledSend(i, item)
? { ...i, emailSubmissionId: result.emailSubmissionId ?? i.emailSubmissionId, sendAt: result.sendAt ?? date.toISOString() }
: i))
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()));
updateItem(item, {
emailSubmissionId: result.emailSubmissionId ?? item.emailSubmissionId,
sendAt: result.sendAt ?? date.toISOString(),
});
warnRefused(result.refusedRecipients);
}, t('scheduled.reschedule_failed', 'Reschedule failed'));
};
@@ -194,26 +251,12 @@ export default function ScheduledScreen({ navigation }: Props) {
// the composer (webmail cancelScheduledEmailForEdit).
const onEdit = (item: ScheduledEmail) => {
void runAction(item, async () => {
await cancelScheduledSend(item.emailSubmissionId, item.accountId);
clearUndoFor(item);
// A message held in a shared account goes back to that account's Drafts.
const shared = item.accountId !== jmapClient.accountId;
const scope = shared
? mailboxes.filter((m) => m.isShared && m.accountId === item.accountId)
: ownMailboxes(mailboxes);
const drafts = scope.find((m) => m.role === 'drafts');
const sent = scope.find((m) => m.role === 'sent');
if (drafts) {
await restoreEmailToDraft(
item.emailId,
drafts.originalId ?? drafts.id,
sent ? (sent.originalId ?? sent.id) : undefined,
item.accountId,
);
}
await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
clearUndoFor(item);
const email = await getFullEmail(item.emailId, item.accountId);
removeItem(item);
navigation.replace('Compose', { draft: draftContextFromEmail(email, shared ? item.accountId : undefined) });
navigation.replace('Compose', { draft: draftContextFromEmail(email, scheduledAccountOverride(item, jmapClient.accountId)) });
}, t('scheduled.edit_failed', 'Could not open the message for editing'));
};
@@ -273,7 +316,7 @@ export default function ScheduledScreen({ navigation }: Props) {
const renderItem = ({ item }: { item: ScheduledEmail }) => {
const busy = busyId === scheduledSendKey(item);
return (
<Pressable style={styles.row} onPress={() => setActionsFor(item)} disabled={busy}>
<Pressable style={styles.row} onPress={() => openView(item)} disabled={busy}>
<View style={styles.rowMain}>
<View style={styles.sendAtRow}>
<Clock size={13} color={c.primary} />
@@ -289,13 +332,20 @@ export default function ScheduledScreen({ navigation }: Props) {
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
) : null}
</View>
<View style={styles.actionBtn}>
<Pressable
style={styles.actionBtn}
onPress={() => setActionsFor(item)}
disabled={busy}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('scheduled.actions', 'Actions')}
>
{busy ? (
<ActivityIndicator size="small" color={c.primary} />
) : (
<MoreHorizontal size={18} color={c.textSecondary} />
)}
</View>
</Pressable>
</Pressable>
);
};
@@ -309,6 +359,23 @@ export default function ScheduledScreen({ navigation }: Props) {
return (
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
{viewing ? (
<ScheduledMessageView
item={viewing}
email={viewEmail}
error={viewError}
jmapAccountId={scheduledAccountOverride(viewing, jmapClient.accountId)}
busy={busyId === scheduledSendKey(viewing)}
formatWhen={formatWhen}
onBack={closeView}
onRetry={() => openView(viewing)}
onSendNow={() => onSendNow(viewing)}
onReschedule={() => setRescheduleFor(viewing)}
onEdit={() => onEdit(viewing)}
onCancelSend={() => onCancel(viewing)}
/>
) : (
<>
<View style={styles.header}>
<Pressable
onPress={() => navigation.goBack()}
@@ -354,6 +421,8 @@ export default function ScheduledScreen({ navigation }: Props) {
onRefresh={() => { void load(); }}
/>
)}
</>
)}
{/* Per-message actions */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
+120 -21
View File
@@ -1,28 +1,56 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../../api/email', () => ({
cancelScheduledSend: vi.fn(async () => undefined),
rescheduleScheduledSend: vi.fn(async () => ({ emailSubmissionId: 'sub-2' })),
restoreEmailToDraft: vi.fn(async () => undefined),
getFullEmail: vi.fn(),
}));
// The error classes are the real ones: the store tells them apart.
vi.mock('../../api/email', async (importOriginal) => {
const { RescheduleLostError, RescheduleRestoredError } = await importOriginal<typeof import('../../api/email')>();
return {
RescheduleLostError,
RescheduleRestoredError,
cancelScheduledSend: vi.fn(async () => undefined),
rescheduleScheduledSend: vi.fn(async () => ({ emailSubmissionId: 'sub-2' })),
restoreEmailToDraft: vi.fn(async () => undefined),
getFullEmail: vi.fn(),
};
});
// The login the client serves: me@example.com on mail.example.com, whose
// own JMAP account is 'primary'. Tests switch it to another server.
const { client } = vi.hoisted(() => ({
client: {
accountId: 'primary',
connectedAccountId: 'primary' as string | null,
isConnected: true,
username: 'me@example.com' as string | null,
serverUrl: 'https://mail.example.com' as string | null,
},
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'primary' },
jmapClient: client,
RequestTimeoutError: class RequestTimeoutError extends Error {},
}));
const LOGIN = 'me@example.com@mail.example.com';
const OTHER_LOGIN = 'me@example.com@other.example.org';
/** Switch the app to the same user on another server, whose own account is also 'primary'. */
function switchToOtherServer() {
useAccountStore.setState({ activeAccountId: OTHER_LOGIN });
client.serverUrl = 'https://other.example.org';
}
// Background work nobody waits for: it never settles here.
vi.mock('../../api/submissions', () => ({
pruneEmailSubmissions: vi.fn(() => new Promise<number>(() => undefined)),
}));
import * as emailApi from '../../api/email';
import { RescheduleLostError, RescheduleRestoredError } from '../../api/email';
import * as submissions from '../../api/submissions';
import { NoLongerScheduledError, RecipientsRefusedError, SubmissionQuotaError } from '../../api/jmap-result';
import type { Email, Mailbox } from '../../api/types';
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../send-undo-store';
import { useToastStore } from '../toast-store';
import { useAccountStore } from '../account-store';
const api = vi.mocked(emailApi);
const prune = vi.mocked(submissions.pruneEmailSubmissions);
@@ -70,6 +98,8 @@ beforeEach(() => {
useSendUndoStore.setState({ pending: null, busy: false, restoredEmailId: null });
useToastStore.getState().clearToasts();
api.getFullEmail.mockResolvedValue({ id: 'mail-1', subject: 'Re: hi' } as Email);
client.serverUrl = 'https://mail.example.com';
useAccountStore.setState({ activeAccountId: LOGIN });
});
afterEach(() => {
@@ -93,9 +123,21 @@ describe('send-undo-store', () => {
from: [SENDER],
sendAt: HELD.sendAt,
delaySeconds: 10,
login: LOGIN,
});
});
it('names the own account and the login of an own-account send', () => {
useSendUndoStore.getState().recordHeldSend(HELD, 10, { identityId: 'id-1', from: [SENDER] });
expect(useSendUndoStore.getState().pending).toMatchObject({ accountId: 'primary', login: LOGIN });
});
it('keeps the login the sender sent through when a switch landed during the send', () => {
client.serverUrl = 'https://other.example.org';
useSendUndoStore.getState().recordHeldSend(HELD, 10, { identityId: 'id-1', login: LOGIN, accountId: 'primary' });
expect(useSendUndoStore.getState().pending?.login).toBe(LOGIN);
});
it('records nothing for a send that went out at once', () => {
const store = useSendUndoStore.getState();
expect(store.recordHeldSend({ scheduled: false, emailSubmissionId: 'sub-1', emailId: 'mail-1' }, undefined, { identityId: 'id-1' })).toBe(false);
@@ -121,9 +163,47 @@ describe('send-undo-store', () => {
});
it('cancels an own-account send in the primary account', async () => {
useSendUndoStore.getState().setPending(entry());
useSendUndoStore.getState().recordHeldSend(HELD, 10, { identityId: 'id-1', from: [SENDER] });
await useSendUndoStore.getState().undo();
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', undefined);
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'primary');
});
// GHSA-8x23: the same user on another server has an account 'primary'
// too, with its own submission 'sub-1'.
it('cancels nothing in another login, and says where the message was sent from', async () => {
useAccountStore.setState({
accounts: [{ id: LOGIN, email: 'me@example.com', displayName: 'Me' } as never],
});
useSendUndoStore.getState().recordHeldSend(HELD, 10, { identityId: 'id-1', from: [SENDER] });
const pending = useSendUndoStore.getState().pending;
// The switch is still under way: the bar is up and the client serves the other server.
client.serverUrl = 'https://other.example.org';
await expect(useSendUndoStore.getState().undo()).resolves.toBe(false);
await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(false);
expect(api.cancelScheduledSend).not.toHaveBeenCalled();
expect(api.rescheduleScheduledSend).not.toHaveBeenCalled();
expect(useToastStore.getState().toasts[0]).toMatchObject({
type: 'error',
message: expect.stringContaining('me@example.com'),
});
expect(useSendUndoStore.getState().pending).toBe(pending);
await expect(restoreUndoneSend(pending!, MAILBOXES)).rejects.toThrow();
expect(api.restoreEmailToDraft).not.toHaveBeenCalled();
useAccountStore.setState({ accounts: [] });
});
it('takes the undo bar away once another login is active', () => {
useSendUndoStore.getState().recordHeldSend(HELD, 10, { identityId: 'id-1', from: [SENDER] });
switchToOtherServer();
expect(useSendUndoStore.getState().pending).toBeNull();
});
it('keeps the undo bar while the same login stays active', () => {
useSendUndoStore.getState().recordHeldSend(HELD, 10, { identityId: 'id-1', from: [SENDER] });
useAccountStore.setState({ defaultAccountId: LOGIN });
expect(useSendUndoStore.getState().pending).not.toBeNull();
});
it('keeps the entry when the cancel fails, and says so', async () => {
@@ -148,12 +228,15 @@ describe('send-undo-store', () => {
expect(prune).toHaveBeenCalledTimes(2);
});
it('explains a send now refused for a full submission quota (Stalwart 1.0)', async () => {
// The original is cancelled before the release is made (#1308): when the
// release is refused, the original is put back under a new id, which
// Undo cancels from then on.
it('explains a send now refused for a full submission quota (Stalwart 1.0), and follows the put-back original', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
api.rescheduleScheduledSend.mockRejectedValueOnce(new SubmissionQuotaError(
api.rescheduleScheduledSend.mockRejectedValueOnce(new RescheduleRestoredError(new SubmissionQuotaError(
'There are too many email submissions, please delete some before adding a new one.',
true,
));
), 'sub-9', '2026-09-24T10:00:10Z'));
useSendUndoStore.getState().setPending(entry());
await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(false);
@@ -163,11 +246,13 @@ describe('send-undo-store', () => {
title: 'Could not send now',
message: expect.stringContaining('ask your administrator to raise the email submission limit'),
}]);
// Still held: it goes out when the delay ends.
expect(useSendUndoStore.getState().pending).not.toBeNull();
// Still held: it goes out when the delay ends, and Undo cancels the put-back one.
expect(useSendUndoStore.getState().pending).toMatchObject({ emailSubmissionId: 'sub-9', emailId: 'mail-1' });
await useSendUndoStore.getState().undo();
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-9', undefined);
});
it('drops the entry when the message went out while send now made room (Stalwart 1.0)', async () => {
it('drops the entry when the message went out before send now could cancel it', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
api.rescheduleScheduledSend.mockRejectedValueOnce(new NoLongerScheduledError(true));
useSendUndoStore.getState().setPending(entry());
@@ -184,15 +269,14 @@ describe('send-undo-store', () => {
});
// A throttle used up since the send was taken (Stalwart 0.16 and 1.0 check
// the recipients again for the release): the original was kept, and a
// the recipients again for the release): the original was put back, and a
// "not sent" would invite sending it a second time.
it('says the held message is still scheduled when the server refused a recipient of the release', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
api.rescheduleScheduledSend.mockRejectedValueOnce(new RecipientsRefusedError([
api.rescheduleScheduledSend.mockRejectedValueOnce(new RescheduleRestoredError(new RecipientsRefusedError([
{ email: 'you@example.com', smtpReply: '452 4.4.5 Rate limit exceeded, try again later.' },
]));
const pending = entry();
useSendUndoStore.getState().setPending(pending);
]), 'sub-9'));
useSendUndoStore.getState().setPending(entry());
await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(false);
@@ -202,7 +286,22 @@ describe('send-undo-store', () => {
message: 'Nothing was changed: the server refused you@example.com (452 4.4.5 Rate limit exceeded, try again later.).',
}]);
// It still goes out when the delay ends, and can still be undone.
expect(useSendUndoStore.getState()).toMatchObject({ pending, busy: false });
expect(useSendUndoStore.getState()).toMatchObject({ pending: { emailSubmissionId: 'sub-9' }, busy: false });
});
it('drops the entry and points to Drafts when neither the release nor the original could be made', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
api.rescheduleScheduledSend.mockRejectedValueOnce(new RescheduleLostError(new Error('Quota exceeded'), true));
useSendUndoStore.getState().setPending(entry());
await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(false);
expect(useToastStore.getState().toasts).toMatchObject([{
type: 'error',
title: 'Could not send now',
message: expect.stringContaining('back in Drafts'),
}]);
expect(useSendUndoStore.getState()).toMatchObject({ pending: null, busy: false });
});
it('names the recipients the server refused for the release while the others get it', async () => {
@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// The templates of a login kept in its Inbox metadata (Stalwart 1.0.0),
// against an in-memory account; 0.16 keeps them on the device.
const { client, server } = vi.hoisted(() => ({
client: {
accountId: 'm',
isConnected: true,
username: 'me@example.com' as string | null,
serverUrl: 'https://mail.example.com' as string | null,
},
server: {
/** Metadata entries per account, by key (without the org.bulwarkmail. prefix). */
entries: {} as Record<string, Record<string, unknown>>,
supports: new Set<string>(),
groups: [] as Array<{ id: string; name: string }>,
writes: [] as Array<[string, string, unknown]>,
/** Runs before each write; throwing fails it. */
beforeWrite: null as null | ((key: string) => void),
},
}));
vi.mock('../../api/jmap-client', () => ({ jmapClient: client }));
vi.mock('../../api/bulwark-metadata', () => {
class MetadataTooLargeError extends Error {}
return {
MetadataTooLargeError,
supportsBulwarkMetadata: (accountId: string) => server.supports.has(accountId),
getBulwarkMetadata: async (accountId: string) =>
(server.supports.has(accountId) ? structuredClone(server.entries[accountId] ?? {}) : null),
setBulwarkMetadata: async (accountId: string, key: string, value: unknown) => {
server.beforeWrite?.(key);
server.writes.push([accountId, key, value]);
const entries = (server.entries[accountId] ??= {});
if (value === null) delete entries[key];
else entries[key] = structuredClone(value);
},
groupAccounts: async () => server.groups,
isValidMetadataKey: (key: string) => /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/.test(key),
MAX_METADATA_ENTRY_BYTES: 8192,
metadataValueBytes: (value: unknown) => JSON.stringify(value).length,
};
});
import { useAccountStore } from '../account-store';
import { useTemplatesStore, type EmailTemplate } from '../templates-store';
const LOGIN = 'me@example.com@mail.example.com';
const OTHER = 'me@example.com@other.example.org';
const template = (id: string, name: string, updatedAt = '2026-10-01T10:00:00.000Z'): EmailTemplate => ({
id, name, subject: '', body: `${name} body`, category: '', isFavorite: false, createdAt: updatedAt, updatedAt,
});
beforeEach(() => {
server.entries = {};
server.supports = new Set();
server.groups = [];
server.writes = [];
server.beforeWrite = null;
client.serverUrl = 'https://mail.example.com';
useAccountStore.setState({ activeAccountId: LOGIN });
useTemplatesStore.setState({
templates: [], storedInAccount: false, teams: [], hydrated: true, device: [], accounts: {}, deviceListOwner: null,
});
});
const refresh = () => useTemplatesStore.getState().refresh({ force: true });
describe('templates on a server without Metadata (Stalwart 0.16)', () => {
it('keeps them on the device and asks the server nothing', async () => {
useTemplatesStore.getState().addTemplate({ name: 'Thanks', subject: '', body: 'Thanks!' });
await refresh();
const state = useTemplatesStore.getState();
expect(state.storedInAccount).toBe(false);
expect(state.templates.map((t) => t.name)).toEqual(['Thanks']);
expect(state.device.map((t) => t.name)).toEqual(['Thanks']);
expect(server.writes).toEqual([]);
});
});
describe('templates kept in the account (Stalwart 1.0)', () => {
beforeEach(() => server.supports.add('m'));
it('moves the device list into the first login that keeps templates, with a marker', async () => {
useTemplatesStore.setState({ device: [template('tpl-a', 'Thanks')], templates: [template('tpl-a', 'Thanks')] });
await refresh();
expect(server.entries.m['template.tpl-a']).toMatchObject({ id: 'tpl-a', name: 'Thanks' });
expect(server.entries.m.templates).toEqual({ version: 1, deleted: {} });
const state = useTemplatesStore.getState();
expect(state.storedInAccount).toBe(true);
expect(state.deviceListOwner).toBe(LOGIN);
expect(state.templates.map((t) => t.id)).toEqual(['tpl-a']);
});
it("brings in the account's templates from the webmail", async () => {
server.entries.m = { 'template.web-1': template('web-1', 'From the web'), templates: { version: 1, deleted: {} } };
await refresh();
expect(useTemplatesStore.getState().templates.map((t) => t.name)).toEqual(['From the web']);
expect(server.writes).toEqual([]);
});
it('deletes everywhere through a tombstone, written before the entry goes', async () => {
server.entries.m = { 'template.web-1': template('web-1', 'Old'), templates: { version: 1, deleted: {} } };
await refresh();
useTemplatesStore.getState().deleteTemplate('web-1');
await refresh();
expect(server.entries.m['template.web-1']).toBeUndefined();
expect(Object.keys((server.entries.m.templates as { deleted: object }).deleted)).toEqual(['web-1']);
expect(server.writes.map(([, key, value]) => [key, value === null])).toEqual([
['templates', false],
['template.web-1', true],
]);
expect(useTemplatesStore.getState().templates).toEqual([]);
});
it('uploads a template missing from the server again instead of taking it for deleted', async () => {
server.entries.m = { 'template.web-1': template('web-1', 'Kept'), templates: { version: 1, deleted: {} } };
await refresh();
// Another client lost the entry without a tombstone.
delete server.entries.m['template.web-1'];
await refresh();
expect(server.entries.m['template.web-1']).toMatchObject({ name: 'Kept' });
expect(useTemplatesStore.getState().templates.map((t) => t.name)).toEqual(['Kept']);
});
it('takes the newer version of a template edited on both sides', async () => {
server.entries.m = { 'template.web-1': template('web-1', 'First'), templates: { version: 1, deleted: {} } };
await refresh();
server.entries.m['template.web-1'] = template('web-1', 'Edited in the webmail', '2026-10-20T10:00:00.000Z');
await refresh();
expect(useTemplatesStore.getState().templates.map((t) => t.name)).toEqual(['Edited in the webmail']);
});
it('gives the device list to one login only', async () => {
useTemplatesStore.setState({ device: [template('tpl-a', 'Mine')] });
await refresh();
useAccountStore.setState({ activeAccountId: OTHER });
client.serverUrl = 'https://other.example.org';
server.entries.m = {};
await refresh();
expect(useTemplatesStore.getState().templates).toEqual([]);
expect(server.entries.m['template.tpl-a']).toBeUndefined();
});
it('writes nothing more once another login is active', async () => {
useTemplatesStore.setState({ device: [template('tpl-a', 'A'), template('tpl-b', 'B')] });
server.beforeWrite = () => {
// The first write lands, then a notification tap switches logins.
server.beforeWrite = null;
useAccountStore.setState({ activeAccountId: OTHER });
client.serverUrl = 'https://other.example.org';
};
await refresh();
expect(server.writes).toHaveLength(1);
});
it('reads the team templates of group accounts for the picker', async () => {
server.supports.add('team');
server.groups = [{ id: 'team', name: 'team@example.com' }];
server.entries.team = { 'template.t1': template('t1', 'Team reply') };
await refresh();
expect(useTemplatesStore.getState().teams).toEqual([
{ accountId: 'team', name: 'team@example.com', templates: [expect.objectContaining({ name: 'Team reply' })] },
]);
});
});
+80 -7
View File
@@ -14,8 +14,11 @@ import { NoLongerScheduledError } from '../api/jmap-result';
import { pruneEmailSubmissions } from '../api/submissions';
import type { EmailAddress, Mailbox } from '../api/types';
import { draftContextFromEmail } from '../lib/draft-context';
import { composerLoginMoved, servedLoginOf } from '../lib/draft-login';
import { mailboxesOfAccount } from '../lib/mailbox-tree';
import { partlyRefusedWarning, rescheduleFailureNotice } from '../lib/send-failure-message';
import { useAccountStore } from './account-store';
import { rescheduleErrorNotice, rescheduleLostMessage, restoredSubmission } from '../lib/compose-reschedule';
import { partlyRefusedWarning } from '../lib/send-failure-message';
import type { ComposeDraftContext } from '../navigation/types';
import { t } from './locale-store';
import { toast } from './toast-store';
@@ -24,8 +27,19 @@ export interface PendingUndoSend {
emailSubmissionId: string;
emailId: string;
identityId: string;
/** Account holding the submission: a shared account for a send from its identity. */
/**
* JMAP account holding the submission: a shared account for a send from
* its identity, else the login's own. Recorded either way: submission ids
* repeat across accounts and servers.
*/
accountId?: string;
/**
* The login (registry id, lib/draft-login) the message was sent through.
* Undo and Send now act through that login only: after a switch the same
* submission id names another message on the other server (webmail
* GHSA-8x23).
*/
login?: string | null;
from?: EmailAddress[];
to?: EmailAddress[];
/** ISO time the server will release the message. */
@@ -36,7 +50,32 @@ export interface PendingUndoSend {
}
/** What the sender knows about a held send beyond the server's answer. */
export type HeldSendDetails = Pick<PendingUndoSend, 'identityId' | 'accountId' | 'from' | 'to'>;
export type HeldSendDetails = Pick<PendingUndoSend, 'identityId' | 'accountId' | 'from' | 'to' | 'login'>;
/** The login the JMAP client works for now, or the registry's active one while it connects. */
export function currentSendLogin(): string | null {
return servedLoginOf(jmapClient) ?? useAccountStore.getState().activeAccountId;
}
/** Whether Undo or Send now of `entry` would now go through another login. */
export function pendingSendLoginMoved(entry: Pick<PendingUndoSend, 'login'>): boolean {
return composerLoginMoved(entry.login, {
activeAccountId: useAccountStore.getState().activeAccountId,
servedLogin: servedLoginOf(jmapClient),
});
}
function refuseOtherLogin(entry: PendingUndoSend): void {
const owner = entry.login ? useAccountStore.getState().getAccountById(entry.login) : undefined;
toast.error(
t('email_composer.undo_send_other_login', 'This message was sent from another account'),
t(
'email_composer.undo_send_other_login_body',
'Switch back to {account} to undo it or send it now. It goes out when the delay ends.',
{ account: owner?.email || owner?.displayName || '' },
),
);
}
interface SendUndoState {
pending: PendingUndoSend | null;
@@ -71,6 +110,10 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
if (!holdForSeconds || !result.scheduled || !result.emailSubmissionId || !result.emailId) return false;
get().setPending({
...details,
accountId: details.accountId ?? jmapClient.connectedAccountId ?? undefined,
// The sender reads the login before it sends; a switch may land while
// the request is out.
login: details.login !== undefined ? details.login : currentSendLogin(),
emailSubmissionId: result.emailSubmissionId,
emailId: result.emailId,
sendAt: result.sendAt,
@@ -84,6 +127,10 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
undo: async () => {
const entry = get().pending;
if (!entry || get().busy) return false;
if (pendingSendLoginMoved(entry)) {
refuseOtherLogin(entry);
return false;
}
set({ busy: true });
try {
await cancelScheduledSend(entry.emailSubmissionId, entry.accountId);
@@ -105,6 +152,10 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
sendNow: async () => {
const entry = get().pending;
if (!entry || get().busy) return false;
if (pendingSendLoginMoved(entry)) {
refuseOtherLogin(entry);
return false;
}
set({ busy: true });
try {
const result = await rescheduleScheduledSend(
@@ -132,11 +183,22 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
// The message stays held and goes out when the delay ends; say why it
// did not go now: a full submission quota, say, or recipients refused
// for the release that the held send had been taken for.
const { title, message } = rescheduleFailureNotice(err, t, t('scheduled.send_now_failed', 'Could not send now'));
const { title, message } = rescheduleErrorNotice(err, t, t('scheduled.send_now_failed', 'Could not send now'));
toast.error(title, message);
// Making room for the release took long enough for the message to go
// out, or be cancelled on another device: nothing is left to undo.
set(err instanceof NoLongerScheduledError ? { pending: null, busy: false } : { busy: false });
// The original was cancelled first and put back under a new id: Undo
// cancels that one now.
const restored = restoredSubmission(err);
if (restored) {
set({
pending: { ...entry, emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? entry.sendAt },
busy: false,
});
return false;
}
// It went out, was cancelled on another device, or is no longer
// scheduled at all: nothing is left to undo.
const gone = err instanceof NoLongerScheduledError || rescheduleLostMessage(err);
set(gone ? { pending: null, busy: false } : { busy: false });
return false;
}
},
@@ -151,6 +213,8 @@ export async function restoreUndoneSend(
entry: PendingUndoSend,
mailboxes: Mailbox[],
): Promise<ComposeDraftContext> {
// The ids name other messages in another login (see pendingSendLoginMoved).
if (pendingSendLoginMoved(entry)) throw new Error('The account was switched');
const shared = !!entry.accountId && entry.accountId !== jmapClient.accountId;
const accountId = shared ? entry.accountId : undefined;
const scope = mailboxesOfAccount(mailboxes, accountId);
@@ -166,3 +230,12 @@ export async function restoreUndoneSend(
}
return draftContextFromEmail(await getFullEmail(entry.emailId, accountId), accountId);
}
// A switch to another login takes the undo bar away, since its actions
// would reach the other server. The held message still goes out when the
// delay ends; back in its account, Scheduled lists it until then.
useAccountStore.subscribe((state, prev) => {
if (state.activeAccountId === prev.activeAccountId) return;
const { pending } = useSendUndoStore.getState();
if (pending?.login && pending.login !== state.activeAccountId) useSendUndoStore.setState({ pending: null });
});
+3
View File
@@ -173,6 +173,8 @@ interface PersistedSettings {
requestReadReceiptDefault: boolean;
// Confirm before sending a message without a subject (#684).
emptySubjectWarningEnabled: boolean;
// Confirm before sending a message with unfilled placeholders like [TIME] or TODO.
placeholderWarningEnabled: boolean;
// Draft autosave debounce, milliseconds.
autoSaveDraftInterval: number;
// Character separating user from tag (e.g. "user+tag@"), RFC 5233.
@@ -365,6 +367,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
emptySubjectWarningEnabled: true,
placeholderWarningEnabled: true,
autoSaveDraftInterval: 60000,
subAddressDelimiter: '+',
preferredIdentityIds: {},
+313 -67
View File
@@ -3,10 +3,31 @@
// favourite/recent tracking are not yet exposed in the mobile UI. We persist
// the same shape so an export from one platform imports cleanly into the
// other.
//
// Where the templates live (webmail lib/templates/personal-templates.ts):
// on a server that keeps JMAP Metadata (Stalwart 1.0.0 and later) in the
// login's own Inbox metadata, shared with the webmail and every other
// device, one list per login; elsewhere (Stalwart 0.16) on this device, as
// before. The device list goes into the first login whose server keeps them,
// and only that one, so one person's templates don't turn up in every
// account signed in here. Team templates, kept in a group account's Inbox,
// are read for the picker; they are changed in the webmail. The webmail's
// organisation library and its locks need the organisation policy, which
// the app can't read, so they are not here.
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { exportTemplates, importTemplates as parseTemplateImport } from '../lib/template-utils';
import {
getBulwarkMetadata, groupAccounts, MetadataTooLargeError, setBulwarkMetadata, supportsBulwarkMetadata,
} from '../api/bulwark-metadata';
import { jmapClient } from '../api/jmap-client';
import {
isTooLargeForMetadata, MARKER_KEY, MARKER_VERSION, markerFromMetadata, mergeTemplateLists, planPersonalSync,
sameTombstones, templateKey, templatesFromMetadata, withStorableId, type TemplateList,
} from '../lib/compose-templates';
import { composerLoginMoved, servedLoginOf } from '../lib/draft-login';
import { useAccountStore } from './account-store';
export interface EmailTemplate {
id: string;
@@ -27,11 +48,29 @@ export interface EmailTemplate {
updatedAt: string;
}
/** The templates of a group mailbox the user is a member of. */
export interface TeamTemplates {
accountId: string;
name: string;
templates: EmailTemplate[];
}
const STORAGE_KEY = 'webmail:templates:v1';
interface TemplatesState {
/** The templates of the active login: its account's, or this device's. */
templates: EmailTemplate[];
/** Whether `templates` live in the account (Metadata) rather than on this device. */
storedInAccount: boolean;
/** Team templates of the active login's group accounts, read-only here. */
teams: TeamTemplates[];
hydrated: boolean;
/** This device's list: every login on a server without Metadata uses it. */
device: EmailTemplate[];
/** Per login (registry id): its account's templates and tombstones, as last synced. */
accounts: Record<string, TemplateList>;
/** The login the device list went into; null while none took it. */
deviceListOwner: string | null;
hydrate: () => Promise<void>;
addTemplate: (
@@ -42,10 +81,26 @@ interface TemplatesState {
deleteTemplate: (id: string) => void;
exportAll: () => string;
importTemplates: (json: string) => { count: number; error?: string };
/** Sync the active login's templates with its account and read its team templates. */
refresh: (options?: { force?: boolean }) => Promise<void>;
}
function persist(state: EmailTemplate[]): void {
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ templates: state })).catch((err) => {
/** Whether the client serves another login than `login` now (lib/draft-login). */
function loginMovedFrom(login: string): boolean {
return composerLoginMoved(login, {
activeAccountId: useAccountStore.getState().activeAccountId,
servedLogin: servedLoginOf(jmapClient),
});
}
/** The login the JMAP client serves, or the registry's active one while it connects. */
function activeLogin(): string | null {
return servedLoginOf(jmapClient) ?? useAccountStore.getState().activeAccountId;
}
function persist(state: Pick<TemplatesState, 'device' | 'accounts' | 'deviceListOwner'>): void {
const value = { templates: state.device, accounts: state.accounts, deviceListOwner: state.deviceListOwner };
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(value)).catch((err) => {
console.warn('[templates-store] persist failed', err);
});
}
@@ -54,75 +109,266 @@ function genId(): string {
return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
export const useTemplatesStore = create<TemplatesState>((set, get) => ({
templates: [],
hydrated: false,
/** What the UI shows for the active login. */
function visibleFor(state: Pick<TemplatesState, 'device' | 'accounts'>, login: string | null): Pick<TemplatesState, 'templates' | 'storedInAccount'> {
const account = login ? state.accounts[login] : undefined;
return account ? { templates: account.templates, storedInAccount: true } : { templates: state.device, storedInAccount: false };
}
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as { templates?: EmailTemplate[] };
if (Array.isArray(parsed.templates)) {
set({ templates: parsed.templates, hydrated: true });
// Bumped by every local change of an account's list, so a sync round that
// ran meanwhile plans once more instead of overwriting it.
const editVersions = new Map<string, number>();
const bump = (login: string) => editVersions.set(login, (editVersions.get(login) ?? 0) + 1);
export const useTemplatesStore = create<TemplatesState>((set, get) => {
/** Change the active login's list, wherever it lives. */
const change = (fn: (list: TemplateList) => TemplateList) => {
const login = activeLogin();
const state = get();
if (login && state.accounts[login]) {
const accounts = { ...state.accounts, [login]: fn(state.accounts[login]) };
bump(login);
set({ accounts, ...visibleFor({ device: state.device, accounts }, login) });
persist(get());
scheduleSync();
return;
}
const device = fn({ templates: state.device, deletedTemplateIds: {} }).templates;
set({ device, ...visibleFor({ device, accounts: state.accounts }, login) });
persist(get());
// The first save may be what makes a Metadata server take the list.
scheduleSync();
};
return {
templates: [],
storedInAccount: false,
teams: [],
hydrated: false,
device: [],
accounts: {},
deviceListOwner: null,
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as {
templates?: EmailTemplate[];
accounts?: Record<string, TemplateList>;
deviceListOwner?: string | null;
};
const device = Array.isArray(parsed.templates) ? parsed.templates : [];
const accounts = parsed.accounts && typeof parsed.accounts === 'object' ? parsed.accounts : {};
set({
device,
accounts,
deviceListOwner: typeof parsed.deviceListOwner === 'string' ? parsed.deviceListOwner : null,
...visibleFor({ device, accounts }, activeLogin()),
hydrated: true,
});
return;
}
} catch (err) {
console.warn('[templates-store] hydrate failed', err);
}
set({ hydrated: true });
},
addTemplate: (data) => {
const now = new Date().toISOString();
const template: EmailTemplate = {
id: genId(),
name: data.name,
subject: data.subject,
body: data.body,
category: data.category ?? '',
isHTML: data.isHTML,
defaultRecipients: data.defaultRecipients,
identityId: data.identityId,
isFavorite: data.isFavorite ?? false,
createdAt: now,
updatedAt: now,
};
change((list) => ({ ...list, templates: [...list.templates, template] }));
return template;
},
updateTemplate: (id, updates) => {
change((list) => ({
...list,
templates: list.templates.map((t) =>
t.id === id ? { ...t, ...updates, updatedAt: new Date().toISOString() } : t,
),
}));
},
deleteTemplate: (id) => {
// In the account a tombstone deletes it everywhere; only tombstones
// delete (lib/compose-templates).
change((list) => ({
templates: list.templates.filter((t) => t.id !== id),
deletedTemplateIds: { ...list.deletedTemplateIds, [id]: new Date().toISOString() },
}));
},
// Webmail export envelope (type: 'webmail-templates', exportedAt) so a
// file exported here imports on the web and vice versa.
exportAll: () => exportTemplates(get().templates),
importTemplates: (json) => {
const result = parseTemplateImport(json);
if (result.errors.length && result.templates.length === 0) {
return { count: 0, error: result.errors[0] };
}
if (result.templates.length === 0) return { count: 0 };
change((list) => ({ ...list, templates: [...list.templates, ...result.templates] }));
return { count: result.templates.length };
},
refresh: async (options) => {
if (!get().hydrated) await get().hydrate();
const login = activeLogin();
if (!login) return;
const last = lastRefresh.get(login) ?? 0;
if (!options?.force && Date.now() - last < 15_000) return;
lastRefresh.set(login, Date.now());
await Promise.all([syncPersonal(login), loadTeams(login)]);
},
};
});
const lastRefresh = new Map<string, number>();
let syncTimer: ReturnType<typeof setTimeout> | null = null;
/** Sync shortly after a change, once for a burst of changes. */
function scheduleSync(): void {
if (syncTimer) clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
syncTimer = null;
const login = activeLogin();
if (login) void syncPersonal(login);
}, 800);
}
const running = new Map<string, Promise<void>>();
/** One sync round for `login` at a time; one asked for meanwhile runs after it. */
export function syncPersonal(login: string): Promise<void> {
const current = running.get(login);
if (current) return current.then(() => syncPersonal(login));
const round = syncRound(login).catch((err) => {
console.warn('[templates-store] sync failed', err);
}).finally(() => running.delete(login));
running.set(login, round);
return round;
}
/**
* One round for the login the client serves: read the account's templates
* and tombstones, merge them with this device's copy, upload what the server
* lacks, write the tombstones, then remove what they cover (webmail
* account-template-sync). A login whose server keeps no metadata keeps the
* device list. A switch of logins ends the round without writing more.
*/
async function syncRound(login: string): Promise<void> {
if (loginMovedFrom(login) || !jmapClient.isConnected) return;
const accountId = jmapClient.accountId;
if (!supportsBulwarkMetadata(accountId)) return;
const entries = await getBulwarkMetadata(accountId);
if (!entries || loginMovedFrom(login)) return;
const server = templatesFromMetadata(entries);
const marker = markerFromMetadata(entries);
const serverTombstones = marker ?? {};
const store = useTemplatesStore.getState();
const version = editVersions.get(login) ?? 0;
let start: TemplateList = store.accounts[login] ?? { templates: [], deletedTemplateIds: {} };
let claimed = false;
// The device list goes into the first login that keeps templates, once.
if (!store.accounts[login] && store.deviceListOwner === null && store.device.length > 0) {
start = mergeTemplateLists(start, { templates: store.device.map(withStorableId), deletedTemplateIds: {} });
claimed = true;
}
start = { ...start, templates: start.templates.map(withStorableId) };
const plan = planPersonalSync(start, server, serverTombstones);
const serverAfter: Record<string, EmailTemplate> = { ...server };
let reachable = true;
for (const template of plan.uploads) {
// Too large for one entry: it stays on this device only.
if (isTooLargeForMetadata(template) || !reachable) continue;
if (loginMovedFrom(login)) return;
try {
await setBulwarkMetadata(accountId, templateKey(template.id), template);
serverAfter[template.id] = template;
} catch (err) {
console.warn('[templates-store] hydrate failed', err);
if (!(err instanceof MetadataTooLargeError)) {
console.warn('[templates-store] could not save a template in the account', err);
reachable = false;
}
}
set({ hydrated: true });
},
addTemplate: (data) => {
const now = new Date().toISOString();
const template: EmailTemplate = {
id: genId(),
name: data.name,
subject: data.subject,
body: data.body,
category: data.category ?? '',
isHTML: data.isHTML,
defaultRecipients: data.defaultRecipients,
identityId: data.identityId,
isFavorite: data.isFavorite ?? false,
createdAt: now,
updatedAt: now,
};
const next = [...get().templates, template];
set({ templates: next });
persist(next);
return template;
},
updateTemplate: (id, updates) => {
const next = get().templates.map((t) =>
t.id === id ? { ...t, ...updates, updatedAt: new Date().toISOString() } : t,
);
set({ templates: next });
persist(next);
},
deleteTemplate: (id) => {
const next = get().templates.filter((t) => t.id !== id);
set({ templates: next });
persist(next);
},
// Webmail export envelope (type: 'webmail-templates', exportedAt) so a
// file exported here imports on the web and vice versa.
exportAll: () => exportTemplates(get().templates),
importTemplates: (json) => {
const result = parseTemplateImport(json);
if (result.errors.length && result.templates.length === 0) {
return { count: 0, error: result.errors[0] };
}
// The tombstones go up before the entries they delete come off.
let serverDeleted = serverTombstones;
if (reachable && (marker === null || !sameTombstones(marker, plan.deletedTemplateIds))) {
if (loginMovedFrom(login)) return;
try {
await setBulwarkMetadata(accountId, MARKER_KEY, { version: MARKER_VERSION, deleted: plan.deletedTemplateIds });
serverDeleted = plan.deletedTemplateIds;
} catch (err) {
console.warn('[templates-store] could not save the deleted templates in the account', err);
reachable = false;
}
if (result.templates.length === 0) return { count: 0 };
const merged = [...get().templates, ...result.templates];
set({ templates: merged });
persist(merged);
return { count: result.templates.length };
},
}));
}
for (const id of plan.deletes) {
if (!reachable || serverDeleted[id] === undefined) continue;
if (loginMovedFrom(login)) return;
try {
await setBulwarkMetadata(accountId, templateKey(id), null);
delete serverAfter[id];
} catch (err) {
console.warn('[templates-store] could not remove a template from the account', err);
reachable = false;
}
}
// Changes made here while the round ran come after it: plan them again
// against the server as it is now, and go round once more for them.
const latestState = useTemplatesStore.getState();
const edited = (editVersions.get(login) ?? 0) !== version;
const latest = edited ? (latestState.accounts[login] ?? start) : start;
const final = planPersonalSync(latest, serverAfter, serverDeleted);
const accounts = { ...latestState.accounts, [login]: { templates: final.templates, deletedTemplateIds: final.deletedTemplateIds } };
const deviceListOwner = claimed ? login : latestState.deviceListOwner;
useTemplatesStore.setState({
accounts,
deviceListOwner,
...(activeLogin() === login ? visibleFor({ device: latestState.device, accounts }, login) : {}),
});
persist({ device: latestState.device, accounts, deviceListOwner });
if (edited) scheduleSync();
}
/** Read the team templates of the login's group accounts. */
async function loadTeams(login: string): Promise<void> {
if (loginMovedFrom(login) || !jmapClient.isConnected) return;
const teams: TeamTemplates[] = [];
for (const group of await groupAccounts()) {
if (!supportsBulwarkMetadata(group.id)) continue;
const entries = await getBulwarkMetadata(group.id);
if (!entries) continue;
const templates = Object.values(templatesFromMetadata(entries));
if (templates.length > 0) teams.push({ accountId: group.id, name: group.name, templates });
}
if (!loginMovedFrom(login)) useTemplatesStore.setState({ teams });
}
// Another login shows its own list; its teams are read again.
useAccountStore.subscribe((state, prev) => {
if (state.activeAccountId === prev.activeAccountId) return;
const store = useTemplatesStore.getState();
useTemplatesStore.setState({ teams: [], ...visibleFor(store, state.activeAccountId) });
});