diff --git a/locales/rn/ar.json b/locales/rn/ar.json index bfbf4bfb..6c39d136 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -657,6 +657,10 @@ "up_to_date": "محدّث بالفعل" } }, + "org_templates": { + "personal_in_account": "تُحفظ قوالبك في حسابك، لذا تجدها على كل جهاز تسجّل الدخول منه. يمكن لأي شخص تشاركه بريدك الوارد قراءتها.", + "section_personal": "قوالبي" + }, "push_prompt": { "body": "فعّل الإشعارات في الخلفية لتصلك الرسائل الجديدة حتى عندما يكون التطبيق مغلقًا.", "dismiss": "ليس الآن", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index ea0450ba..ddfac208 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -662,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", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index e429a32b..a6c3b3b1 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -662,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", diff --git a/locales/rn/da.json b/locales/rn/da.json index f774591d..638327f0 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -659,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", diff --git a/locales/rn/de.json b/locales/rn/de.json index 7e31a4dc..b4b34689 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -670,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", diff --git a/locales/rn/en.json b/locales/rn/en.json index bc777dc4..58a9dab4 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -292,6 +292,11 @@ "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", @@ -486,6 +491,7 @@ "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}", @@ -710,6 +716,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", diff --git a/locales/rn/es.json b/locales/rn/es.json index c3116a2f..b19d3f7e 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -668,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", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 0199e8a0..e4f439f0 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -656,6 +656,10 @@ "up_to_date": "از قبل به‌روز است" } }, + "org_templates": { + "personal_in_account": "قالب‌های شما در حسابتان نگه داشته می‌شوند، بنابراین در هر دستگاهی که وارد شوید آن‌ها را دارید. هر کسی که صندوق ورودی خود را با او به اشتراک گذاشته‌اید می‌تواند آن‌ها را بخواند.", + "section_personal": "قالب‌های من" + }, "push_prompt": { "body": "اعلان‌های پس‌زمینه را روشن کنید تا پیام‌های جدید حتی وقتی برنامه بسته است به دستتان برسد.", "dismiss": "اکنون نه", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 99b47bd0..2b5e5fd3 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -666,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", diff --git a/locales/rn/he.json b/locales/rn/he.json index 90c456cc..bbc2a1c6 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -660,6 +660,10 @@ "up_to_date": "כבר מעודכן" } }, + "org_templates": { + "personal_in_account": "התבניות שלך נשמרות בחשבון שלך, כך שהן זמינות בכל מכשיר שאתה מתחבר ממנו. כל מי ששיתפת איתו את הדואר הנכנס יכול לקרוא אותן.", + "section_personal": "התבניות שלי" + }, "push_prompt": { "body": "הפעל התראות ברקע כדי שהודעות חדשות יגיעו אליך גם כשהאפליקציה סגורה.", "dismiss": "לא עכשיו", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index da5ec2f7..e56605cf 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -658,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", diff --git a/locales/rn/it.json b/locales/rn/it.json index 3a7a1834..614cbfdf 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -664,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", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 777d80e7..a1f1989b 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -662,6 +662,10 @@ "up_to_date": "すでに最新です" } }, + "org_templates": { + "personal_in_account": "テンプレートはアカウントに保存されるため、サインインするどのデバイスでも使えます。受信トレイを共有している相手は誰でも読むことができます。", + "section_personal": "マイテンプレート" + }, "push_prompt": { "body": "バックグラウンド通知をオンにすると、アプリを閉じていても新着メッセージを受け取れます。", "dismiss": "今はしない", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index 37ef409a..55841b87 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -660,6 +660,10 @@ "up_to_date": "이미 최신 상태예요" } }, + "org_templates": { + "personal_in_account": "템플릿은 계정에 저장되므로 로그인하는 모든 기기에서 사용할 수 있어요. 받은편지함을 공유한 사람은 누구나 읽을 수 있어요.", + "section_personal": "내 템플릿" + }, "push_prompt": { "body": "백그라운드 알림을 켜면 앱이 닫혀 있어도 새 메시지를 받을 수 있어요.", "dismiss": "나중에", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 830970af..0c875d44 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -661,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", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 805de501..a0b76dbf 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -657,6 +657,10 @@ "up_to_date": "Аль хэдийн шинэчлэгдсэн" } }, + "org_templates": { + "personal_in_account": "Таны загварууд таны бүртгэлд хадгалагддаг тул нэвтэрсэн төхөөрөмж бүр дээрээ ашиглах боломжтой. Ирсэн имэйлээ хуваалцсан хэн ч тэдгээрийг уншиж чадна.", + "section_personal": "Миний загварууд" + }, "push_prompt": { "body": "Апп хаалттай үед ч шинэ имэйлийн мэдэгдэл авахын тулд арын мэдэгдлийг асаана уу.", "dismiss": "Одоо биш", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index 4bb5a948..bb6985fe 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -659,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å", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index 7c1afbe9..2ba8c433 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -662,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", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 8b35dfaf..85891e26 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -662,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", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index 2f6cc9e3..ceb25262 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -665,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", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 15beeed3..bb60824d 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -662,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", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index 5332f49a..c3ae0a87 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -665,6 +665,10 @@ "up_to_date": "Всё уже актуально" } }, + "org_templates": { + "personal_in_account": "Ваши шаблоны хранятся в вашей учётной записи, поэтому они есть на каждом устройстве, где вы входите. Их может прочитать любой, с кем вы делитесь папкой «Входящие».", + "section_personal": "Мои шаблоны" + }, "push_prompt": { "body": "Включите фоновые уведомления, чтобы узнавать о новых письмах, даже когда приложение закрыто.", "dismiss": "Не сейчас", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index c7969f07..8f5de259 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -661,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", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index b7d53793..653f9ade 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -663,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", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index dd7c950b..ee5efa76 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -664,6 +664,10 @@ "up_to_date": "Усе вже актуально" } }, + "org_templates": { + "personal_in_account": "Ваші шаблони зберігаються у вашому обліковому записі, тож вони є на кожному пристрої, де ви входите. Їх може прочитати будь-хто, з ким ви ділитеся папкою «Вхідні».", + "section_personal": "Мої шаблони" + }, "push_prompt": { "body": "Увімкніть фонові сповіщення, щоб дізнаватися про нові листи, навіть коли застосунок закрито.", "dismiss": "Не зараз", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 9cdca666..2ea8a4c5 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -661,6 +661,10 @@ "up_to_date": "已是最新狀態" } }, + "org_templates": { + "personal_in_account": "您的範本保存在您的帳號中,因此您登入的每部裝置上都能使用。與您共用收件匣的任何人都可以閱讀這些範本。", + "section_personal": "我的範本" + }, "push_prompt": { "body": "開啟背景通知,即使應用程式已關閉也能收到新郵件。", "dismiss": "稍後再說", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 9a33879d..7130cf2c 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -661,6 +661,10 @@ "up_to_date": "已是最新" } }, + "org_templates": { + "personal_in_account": "您的模板保存在您的账户中,因此您登录的每台设备上都能使用。与您共享收件箱的任何人都可以阅读它们。", + "section_personal": "我的模板" + }, "push_prompt": { "body": "开启后台通知,即使应用已关闭也能收到新邮件。", "dismiss": "以后再说", diff --git a/src/api/__tests__/jmap-metadata-requests.test.ts b/src/api/__tests__/jmap-metadata-requests.test.ts index b8d119c7..9cb2ff82 100644 --- a/src/api/__tests__/jmap-metadata-requests.test.ts +++ b/src/api/__tests__/jmap-metadata-requests.test.ts @@ -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,8 +243,12 @@ describe('JMAP Metadata in the request-building sources', () => { /(['"`])metadata(\/[^'"`\n]*)?\1/, ]; + // 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'); const hits: string[] = []; for (const file of files) { @@ -252,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/); + }); }); diff --git a/src/api/bulwark-metadata.ts b/src/api/bulwark-metadata.ts new file mode 100644 index 00000000..fad64b19 --- /dev/null +++ b/src/api/bulwark-metadata.ts @@ -0,0 +1,189 @@ +// Bulwark's own data in JMAP Metadata (RFC draft, urn:ietf:params:jmap:metadata) +// 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). +// +// What Stalwart does: 1.0.0 serves it (seen live 2026-10-29: Mailbox/get and +// Mailbox/set with `metadata/` 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.`. 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, type JMAPMethodCall } from './types'; + +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 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; + +export class MetadataTooLargeError extends Error { + constructor(message: string) { + super(message); + this.name = 'MetadataTooLargeError'; + } +} + +/** 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.`: 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 { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {}; + const prefix = `${BULWARK_NAMESPACE}.`; + const out: Record = {}; + for (const [namespace, value] of Object.entries(metadata as Record)) { + if (namespace.startsWith(prefix)) out[namespace.slice(prefix.length)] = value; + } + return out; +} + +// Account ids repeat across servers and logins. +const keyOf = (accountId: string) => `${jmapClient.serverUrl ?? ''}\u0000${jmapClient.username ?? ''}\u0000${accountId}`; +const failedAt = new Map(); +const inboxIds = new Map(); + +/** 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 | undefined; + const cap = caps?.[METADATA_CAPABILITY] as + | { dataTypes?: Record } + | undefined; + 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 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 | null> { + if (!supportsBulwarkMetadata(accountId)) return null; + 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', 'metadata'], + }, 'g'], + ] as JMAPMethodCall[], USING); + const get = res.methodResponses.find(([name]) => name === 'Mailbox/get'); + const inbox = (get?.[1] as { list?: Array> } | undefined)?.list?.[0]; + // An answer without the property is a server that doesn't keep it, + // never "every entry was removed". + 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(`[metadata] unavailable for account ${accountId}:`, err); + failedAt.set(keyOf(accountId), Date.now()); + return null; + } +} + +/** + * 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 { + 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 }).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> { + 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 []; + } +} diff --git a/src/components/TemplateSheet.tsx b/src/components/TemplateSheet.tsx index ae0980bf..5864475e 100644 --- a/src/components/TemplateSheet.tsx +++ b/src/components/TemplateSheet.tsx @@ -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) => ( + { + onPick(template); + onClose(); + }} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + > + + {team + ? + : template.isFavorite + ? + : } + + + {template.name} + {!!(template.subject || template.category) && ( + + {[template.category, template.subject].filter(Boolean).join(' · ')} + + )} + + + ); return ( @@ -91,7 +133,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) - {templates.length > 4 && ( + {total > 4 && ( )} - {visibleTemplates.length === 0 ? ( + {visibleTemplates.length === 0 && visibleTeams.length === 0 ? ( - {templates.length === 0 + {total === 0 ? t('templates.no_templates', 'No templates yet') : t('templates.no_results', 'No templates found')} - ) : visibleTemplates.map((template) => ( - { - onPick(template); - onClose(); - }} - style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} - > - - {template.isFavorite - ? - : } - - - {template.name} - {!!(template.subject || template.category) && ( - - {[template.category, template.subject].filter(Boolean).join(' · ')} - - )} - - - ))} + ) : ( + <> + {visibleTeams.length > 0 && visibleTemplates.length > 0 && ( + {t('org_templates.section_personal', 'My templates')} + )} + {visibleTemplates.map((template) => renderRow(template, template.id))} + {visibleTeams.map((team) => ( + + {team.name} + {team.templates.map((template) => renderRow(template, `${team.accountId}|${template.id}`, true))} + + ))} + + )} @@ -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', diff --git a/src/components/settings/TemplateSettings.tsx b/src/components/settings/TemplateSettings.tsx index 3fc0fd9b..f4249fe7 100644 --- a/src/components/settings/TemplateSettings.tsx +++ b/src/components/settings/TemplateSettings.tsx @@ -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(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 ( + {storedInAccount && ( + + {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.')} + + )} diff --git a/src/i18n/__tests__/rn-overlays.test.ts b/src/i18n/__tests__/rn-overlays.test.ts index 53b1596d..6f3029c7 100644 --- a/src/i18n/__tests__/rn-overlays.test.ts +++ b/src/i18n/__tests__/rn-overlays.test.ts @@ -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; diff --git a/src/lib/compose-templates.ts b/src/lib/compose-templates.ts new file mode 100644 index 00000000..b82884e4 --- /dev/null +++ b/src/lib/compose-templates.ts @@ -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.`, 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.`). */ +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; + 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 | 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.`), by id. The key decides the id. */ +export function templatesFromMetadata(entries: Record | null | undefined): Record { + const out: Record = {}; + 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const out: Record = {}; + for (const [id, at] of Object.entries(value as Record)) { + 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 | null | undefined): Record | null { + const value = entries?.[MARKER_KEY]; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return parseTombstones((value as Record).deleted); +} + +/** Tombstones of the last 90 days, newest first as long as the marker stays within 8 KB. */ +export function pruneTombstones(deleted: Record, now: Date = new Date()): Record { + 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 = {}; + 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, b: Record): Record { + 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, b: Record): 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; +} + +/** + * 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 = {}; + 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(); + 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, + serverTombstones: Record, + 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 }; +} diff --git a/src/stores/__tests__/templates-store.test.ts b/src/stores/__tests__/templates-store.test.ts new file mode 100644 index 00000000..371fc7d8 --- /dev/null +++ b/src/stores/__tests__/templates-store.test.ts @@ -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>, + supports: new Set(), + 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' })] }, + ]); + }); +}); diff --git a/src/stores/templates-store.ts b/src/stores/templates-store.ts index 07d6c9b9..d3df3a44 100644 --- a/src/stores/templates-store.ts +++ b/src/stores/templates-store.ts @@ -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; + /** The login the device list went into; null while none took it. */ + deviceListOwner: string | null; hydrate: () => Promise; 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; } -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): 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((set, get) => ({ - templates: [], - hydrated: false, +/** What the UI shows for the active login. */ +function visibleFor(state: Pick, login: string | null): Pick { + 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(); +const bump = (login: string) => editVersions.set(login, (editVersions.get(login) ?? 0) + 1); + +export const useTemplatesStore = create((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; + 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(); +let syncTimer: ReturnType | 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>(); + +/** One sync round for `login` at a time; one asked for meanwhile runs after it. */ +export function syncPersonal(login: string): Promise { + 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 { + 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 = { ...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 { + 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) }); +});