mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: keep templates in the account on Stalwart 1.0 and offer team templates in the picker
This commit is contained in:
@@ -657,6 +657,10 @@
|
|||||||
"up_to_date": "محدّث بالفعل"
|
"up_to_date": "محدّث بالفعل"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"org_templates": {
|
||||||
|
"personal_in_account": "تُحفظ قوالبك في حسابك، لذا تجدها على كل جهاز تسجّل الدخول منه. يمكن لأي شخص تشاركه بريدك الوارد قراءتها.",
|
||||||
|
"section_personal": "قوالبي"
|
||||||
|
},
|
||||||
"push_prompt": {
|
"push_prompt": {
|
||||||
"body": "فعّل الإشعارات في الخلفية لتصلك الرسائل الجديدة حتى عندما يكون التطبيق مغلقًا.",
|
"body": "فعّل الإشعارات في الخلفية لتصلك الرسائل الجديدة حتى عندما يكون التطبيق مغلقًا.",
|
||||||
"dismiss": "ليس الآن",
|
"dismiss": "ليس الآن",
|
||||||
|
|||||||
@@ -662,6 +662,10 @@
|
|||||||
"up_to_date": "Ja està al dia"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Activeu les notificacions en segon pla perquè els missatges nous us arribin amb l'aplicació tancada.",
|
"body": "Activeu les notificacions en segon pla perquè els missatges nous us arribin amb l'aplicació tancada.",
|
||||||
"dismiss": "Ara no",
|
"dismiss": "Ara no",
|
||||||
|
|||||||
@@ -662,6 +662,10 @@
|
|||||||
"up_to_date": "Vše je aktuální"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Zapněte oznámení na pozadí, aby vám nové zprávy chodily, i když je aplikace zavřená.",
|
"body": "Zapněte oznámení na pozadí, aby vám nové zprávy chodily, i když je aplikace zavřená.",
|
||||||
"dismiss": "Teď ne",
|
"dismiss": "Teď ne",
|
||||||
|
|||||||
@@ -659,6 +659,10 @@
|
|||||||
"up_to_date": "Allerede opdateret"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Slå baggrundsnotifikationer til, så nye beskeder når frem til dig, mens appen er lukket.",
|
"body": "Slå baggrundsnotifikationer til, så nye beskeder når frem til dig, mens appen er lukket.",
|
||||||
"dismiss": "Ikke nu",
|
"dismiss": "Ikke nu",
|
||||||
|
|||||||
@@ -670,6 +670,10 @@
|
|||||||
"up_to_date": "Bereits aktuell"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Aktivieren Sie Hintergrundbenachrichtigungen, damit neue Nachrichten Sie auch bei geschlossener App erreichen.",
|
"body": "Aktivieren Sie Hintergrundbenachrichtigungen, damit neue Nachrichten Sie auch bei geschlossener App erreichen.",
|
||||||
"dismiss": "Nicht jetzt",
|
"dismiss": "Nicht jetzt",
|
||||||
|
|||||||
@@ -292,6 +292,11 @@
|
|||||||
"attachment_reminder_title": "Forgot an attachment?",
|
"attachment_reminder_title": "Forgot an attachment?",
|
||||||
"attachments_too_large_total": "The attachments would exceed the {max} this server allows per message.",
|
"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.",
|
"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_plain_text": "Switch to plain text",
|
||||||
"format_rich_text": "Switch to rich text (HTML)",
|
"format_rich_text": "Switch to rich text (HTML)",
|
||||||
"from_options": "Sender options",
|
"from_options": "Sender options",
|
||||||
@@ -486,6 +491,7 @@
|
|||||||
"reschedule_recipients_refused": "Nothing was changed: the server refused {recipients}.",
|
"reschedule_recipients_refused": "Nothing was changed: the server refused {recipients}.",
|
||||||
"save_to_files": {
|
"save_to_files": {
|
||||||
"failed": "Couldn't save to Files",
|
"failed": "Couldn't save to Files",
|
||||||
|
"load_failed": "Couldn't load your folders. Try again.",
|
||||||
"save_here": "Save here",
|
"save_here": "Save here",
|
||||||
"saved": "Saved to Files",
|
"saved": "Saved to Files",
|
||||||
"saved_to": "In {folder}",
|
"saved_to": "In {folder}",
|
||||||
@@ -710,6 +716,10 @@
|
|||||||
"up_to_date": "Already up to date"
|
"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": {
|
"push": {
|
||||||
"ios": {
|
"ios": {
|
||||||
"more_messages": "and {count} more",
|
"more_messages": "and {count} more",
|
||||||
|
|||||||
@@ -668,6 +668,10 @@
|
|||||||
"up_to_date": "Ya está al día"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Activa las notificaciones en segundo plano para recibir los mensajes nuevos aunque la app esté cerrada.",
|
"body": "Activa las notificaciones en segundo plano para recibir los mensajes nuevos aunque la app esté cerrada.",
|
||||||
"dismiss": "Ahora no",
|
"dismiss": "Ahora no",
|
||||||
|
|||||||
@@ -656,6 +656,10 @@
|
|||||||
"up_to_date": "از قبل بهروز است"
|
"up_to_date": "از قبل بهروز است"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"org_templates": {
|
||||||
|
"personal_in_account": "قالبهای شما در حسابتان نگه داشته میشوند، بنابراین در هر دستگاهی که وارد شوید آنها را دارید. هر کسی که صندوق ورودی خود را با او به اشتراک گذاشتهاید میتواند آنها را بخواند.",
|
||||||
|
"section_personal": "قالبهای من"
|
||||||
|
},
|
||||||
"push_prompt": {
|
"push_prompt": {
|
||||||
"body": "اعلانهای پسزمینه را روشن کنید تا پیامهای جدید حتی وقتی برنامه بسته است به دستتان برسد.",
|
"body": "اعلانهای پسزمینه را روشن کنید تا پیامهای جدید حتی وقتی برنامه بسته است به دستتان برسد.",
|
||||||
"dismiss": "اکنون نه",
|
"dismiss": "اکنون نه",
|
||||||
|
|||||||
@@ -666,6 +666,10 @@
|
|||||||
"up_to_date": "Déjà à jour"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Activez les notifications en arrière-plan pour recevoir les nouveaux messages même lorsque l'application est fermée.",
|
"body": "Activez les notifications en arrière-plan pour recevoir les nouveaux messages même lorsque l'application est fermée.",
|
||||||
"dismiss": "Pas maintenant",
|
"dismiss": "Pas maintenant",
|
||||||
|
|||||||
@@ -660,6 +660,10 @@
|
|||||||
"up_to_date": "כבר מעודכן"
|
"up_to_date": "כבר מעודכן"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"org_templates": {
|
||||||
|
"personal_in_account": "התבניות שלך נשמרות בחשבון שלך, כך שהן זמינות בכל מכשיר שאתה מתחבר ממנו. כל מי ששיתפת איתו את הדואר הנכנס יכול לקרוא אותן.",
|
||||||
|
"section_personal": "התבניות שלי"
|
||||||
|
},
|
||||||
"push_prompt": {
|
"push_prompt": {
|
||||||
"body": "הפעל התראות ברקע כדי שהודעות חדשות יגיעו אליך גם כשהאפליקציה סגורה.",
|
"body": "הפעל התראות ברקע כדי שהודעות חדשות יגיעו אליך גם כשהאפליקציה סגורה.",
|
||||||
"dismiss": "לא עכשיו",
|
"dismiss": "לא עכשיו",
|
||||||
|
|||||||
@@ -658,6 +658,10 @@
|
|||||||
"up_to_date": "Már minden naprakész"
|
"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": {
|
"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.",
|
"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",
|
"dismiss": "Később",
|
||||||
|
|||||||
@@ -664,6 +664,10 @@
|
|||||||
"up_to_date": "Già aggiornato"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Attiva le notifiche in background per ricevere i nuovi messaggi anche quando l'app è chiusa.",
|
"body": "Attiva le notifiche in background per ricevere i nuovi messaggi anche quando l'app è chiusa.",
|
||||||
"dismiss": "Non ora",
|
"dismiss": "Non ora",
|
||||||
|
|||||||
@@ -662,6 +662,10 @@
|
|||||||
"up_to_date": "すでに最新です"
|
"up_to_date": "すでに最新です"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"org_templates": {
|
||||||
|
"personal_in_account": "テンプレートはアカウントに保存されるため、サインインするどのデバイスでも使えます。受信トレイを共有している相手は誰でも読むことができます。",
|
||||||
|
"section_personal": "マイテンプレート"
|
||||||
|
},
|
||||||
"push_prompt": {
|
"push_prompt": {
|
||||||
"body": "バックグラウンド通知をオンにすると、アプリを閉じていても新着メッセージを受け取れます。",
|
"body": "バックグラウンド通知をオンにすると、アプリを閉じていても新着メッセージを受け取れます。",
|
||||||
"dismiss": "今はしない",
|
"dismiss": "今はしない",
|
||||||
|
|||||||
@@ -660,6 +660,10 @@
|
|||||||
"up_to_date": "이미 최신 상태예요"
|
"up_to_date": "이미 최신 상태예요"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"org_templates": {
|
||||||
|
"personal_in_account": "템플릿은 계정에 저장되므로 로그인하는 모든 기기에서 사용할 수 있어요. 받은편지함을 공유한 사람은 누구나 읽을 수 있어요.",
|
||||||
|
"section_personal": "내 템플릿"
|
||||||
|
},
|
||||||
"push_prompt": {
|
"push_prompt": {
|
||||||
"body": "백그라운드 알림을 켜면 앱이 닫혀 있어도 새 메시지를 받을 수 있어요.",
|
"body": "백그라운드 알림을 켜면 앱이 닫혀 있어도 새 메시지를 받을 수 있어요.",
|
||||||
"dismiss": "나중에",
|
"dismiss": "나중에",
|
||||||
|
|||||||
@@ -661,6 +661,10 @@
|
|||||||
"up_to_date": "Viss jau ir atjaunināts"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Ieslēdziet fona paziņojumus, lai jaunas vēstules jūs sasniegtu arī tad, kad lietotne ir aizvērta.",
|
"body": "Ieslēdziet fona paziņojumus, lai jaunas vēstules jūs sasniegtu arī tad, kad lietotne ir aizvērta.",
|
||||||
"dismiss": "Ne tagad",
|
"dismiss": "Ne tagad",
|
||||||
|
|||||||
@@ -657,6 +657,10 @@
|
|||||||
"up_to_date": "Аль хэдийн шинэчлэгдсэн"
|
"up_to_date": "Аль хэдийн шинэчлэгдсэн"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"org_templates": {
|
||||||
|
"personal_in_account": "Таны загварууд таны бүртгэлд хадгалагддаг тул нэвтэрсэн төхөөрөмж бүр дээрээ ашиглах боломжтой. Ирсэн имэйлээ хуваалцсан хэн ч тэдгээрийг уншиж чадна.",
|
||||||
|
"section_personal": "Миний загварууд"
|
||||||
|
},
|
||||||
"push_prompt": {
|
"push_prompt": {
|
||||||
"body": "Апп хаалттай үед ч шинэ имэйлийн мэдэгдэл авахын тулд арын мэдэгдлийг асаана уу.",
|
"body": "Апп хаалттай үед ч шинэ имэйлийн мэдэгдэл авахын тулд арын мэдэгдлийг асаана уу.",
|
||||||
"dismiss": "Одоо биш",
|
"dismiss": "Одоо биш",
|
||||||
|
|||||||
@@ -659,6 +659,10 @@
|
|||||||
"up_to_date": "Allerede oppdatert"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Slå på bakgrunnsvarsler, slik at nye meldinger når deg mens appen er lukket.",
|
"body": "Slå på bakgrunnsvarsler, slik at nye meldinger når deg mens appen er lukket.",
|
||||||
"dismiss": "Ikke nå",
|
"dismiss": "Ikke nå",
|
||||||
|
|||||||
@@ -662,6 +662,10 @@
|
|||||||
"up_to_date": "Al up-to-date"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Schakel achtergrondmeldingen in zodat nieuwe berichten u bereiken terwijl de app gesloten is.",
|
"body": "Schakel achtergrondmeldingen in zodat nieuwe berichten u bereiken terwijl de app gesloten is.",
|
||||||
"dismiss": "Niet nu",
|
"dismiss": "Niet nu",
|
||||||
|
|||||||
@@ -662,6 +662,10 @@
|
|||||||
"up_to_date": "Wszystko jest aktualne"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Włącz powiadomienia w tle, aby nowe wiadomości docierały do Ciebie, gdy aplikacja jest zamknięta.",
|
"body": "Włącz powiadomienia w tle, aby nowe wiadomości docierały do Ciebie, gdy aplikacja jest zamknięta.",
|
||||||
"dismiss": "Nie teraz",
|
"dismiss": "Nie teraz",
|
||||||
|
|||||||
@@ -665,6 +665,10 @@
|
|||||||
"up_to_date": "Já está atualizado"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Ative as notificações em segundo plano para receber novas mensagens mesmo com o app fechado.",
|
"body": "Ative as notificações em segundo plano para receber novas mensagens mesmo com o app fechado.",
|
||||||
"dismiss": "Agora não",
|
"dismiss": "Agora não",
|
||||||
|
|||||||
@@ -662,6 +662,10 @@
|
|||||||
"up_to_date": "Deja actualizat"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Activați notificările în fundal pentru a primi mesajele noi chiar și când aplicația este închisă.",
|
"body": "Activați notificările în fundal pentru a primi mesajele noi chiar și când aplicația este închisă.",
|
||||||
"dismiss": "Nu acum",
|
"dismiss": "Nu acum",
|
||||||
|
|||||||
@@ -665,6 +665,10 @@
|
|||||||
"up_to_date": "Всё уже актуально"
|
"up_to_date": "Всё уже актуально"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"org_templates": {
|
||||||
|
"personal_in_account": "Ваши шаблоны хранятся в вашей учётной записи, поэтому они есть на каждом устройстве, где вы входите. Их может прочитать любой, с кем вы делитесь папкой «Входящие».",
|
||||||
|
"section_personal": "Мои шаблоны"
|
||||||
|
},
|
||||||
"push_prompt": {
|
"push_prompt": {
|
||||||
"body": "Включите фоновые уведомления, чтобы узнавать о новых письмах, даже когда приложение закрыто.",
|
"body": "Включите фоновые уведомления, чтобы узнавать о новых письмах, даже когда приложение закрыто.",
|
||||||
"dismiss": "Не сейчас",
|
"dismiss": "Не сейчас",
|
||||||
|
|||||||
@@ -661,6 +661,10 @@
|
|||||||
"up_to_date": "Všetko je aktuálne"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Zapnite oznámenia na pozadí, aby vám nové správy chodili, aj keď je aplikácia zatvorená.",
|
"body": "Zapnite oznámenia na pozadí, aby vám nové správy chodili, aj keď je aplikácia zatvorená.",
|
||||||
"dismiss": "Teraz nie",
|
"dismiss": "Teraz nie",
|
||||||
|
|||||||
@@ -663,6 +663,10 @@
|
|||||||
"up_to_date": "Zaten güncel"
|
"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": {
|
"push_prompt": {
|
||||||
"body": "Uygulama kapalıyken de yeni iletilerden haberdar olmak için arka plan bildirimlerini açın.",
|
"body": "Uygulama kapalıyken de yeni iletilerden haberdar olmak için arka plan bildirimlerini açın.",
|
||||||
"dismiss": "Şimdi değil",
|
"dismiss": "Şimdi değil",
|
||||||
|
|||||||
@@ -664,6 +664,10 @@
|
|||||||
"up_to_date": "Усе вже актуально"
|
"up_to_date": "Усе вже актуально"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"org_templates": {
|
||||||
|
"personal_in_account": "Ваші шаблони зберігаються у вашому обліковому записі, тож вони є на кожному пристрої, де ви входите. Їх може прочитати будь-хто, з ким ви ділитеся папкою «Вхідні».",
|
||||||
|
"section_personal": "Мої шаблони"
|
||||||
|
},
|
||||||
"push_prompt": {
|
"push_prompt": {
|
||||||
"body": "Увімкніть фонові сповіщення, щоб дізнаватися про нові листи, навіть коли застосунок закрито.",
|
"body": "Увімкніть фонові сповіщення, щоб дізнаватися про нові листи, навіть коли застосунок закрито.",
|
||||||
"dismiss": "Не зараз",
|
"dismiss": "Не зараз",
|
||||||
|
|||||||
@@ -661,6 +661,10 @@
|
|||||||
"up_to_date": "已是最新狀態"
|
"up_to_date": "已是最新狀態"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"org_templates": {
|
||||||
|
"personal_in_account": "您的範本保存在您的帳號中,因此您登入的每部裝置上都能使用。與您共用收件匣的任何人都可以閱讀這些範本。",
|
||||||
|
"section_personal": "我的範本"
|
||||||
|
},
|
||||||
"push_prompt": {
|
"push_prompt": {
|
||||||
"body": "開啟背景通知,即使應用程式已關閉也能收到新郵件。",
|
"body": "開啟背景通知,即使應用程式已關閉也能收到新郵件。",
|
||||||
"dismiss": "稍後再說",
|
"dismiss": "稍後再說",
|
||||||
|
|||||||
@@ -661,6 +661,10 @@
|
|||||||
"up_to_date": "已是最新"
|
"up_to_date": "已是最新"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"org_templates": {
|
||||||
|
"personal_in_account": "您的模板保存在您的账户中,因此您登录的每台设备上都能使用。与您共享收件箱的任何人都可以阅读它们。",
|
||||||
|
"section_personal": "我的模板"
|
||||||
|
},
|
||||||
"push_prompt": {
|
"push_prompt": {
|
||||||
"body": "开启后台通知,即使应用已关闭也能收到新邮件。",
|
"body": "开启后台通知,即使应用已关闭也能收到新邮件。",
|
||||||
"dismiss": "以后再说",
|
"dismiss": "以后再说",
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import path from 'path';
|
|||||||
* and the gets whose server default could grow to include metadata ask for an
|
* and the gets whose server default could grow to include metadata ask for an
|
||||||
* explicit property list instead. Port of the webmail's
|
* explicit property list instead. Port of the webmail's
|
||||||
* lib/__tests__/jmap-metadata-requests.test.ts.
|
* 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';
|
const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
|
||||||
@@ -239,8 +243,12 @@ describe('JMAP Metadata in the request-building sources', () => {
|
|||||||
/(['"`])metadata(\/[^'"`\n]*)?\1/,
|
/(['"`])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', () => {
|
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('src/api/email.ts');
|
||||||
const hits: string[] = [];
|
const hits: string[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -252,4 +260,9 @@ describe('JMAP Metadata in the request-building sources', () => {
|
|||||||
}
|
}
|
||||||
expect(hits).toEqual([]);
|
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,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/<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, 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.<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}.`;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [namespace, value] of Object.entries(metadata as Record<string, unknown>)) {
|
||||||
|
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<string, number>();
|
||||||
|
const inboxIds = new Map<string, string>();
|
||||||
|
|
||||||
|
/** 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 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<Record<string, unknown> | 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<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 || !('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<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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
|
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
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 { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
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
|
* Bottom sheet listing the user's templates for insertion into the composer
|
||||||
* (webmail template picker): favourites first, then the rest, with a search
|
* (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) {
|
export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
@@ -32,6 +33,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
|
|||||||
const templates = useTemplatesStore((s) => s.templates);
|
const templates = useTemplatesStore((s) => s.templates);
|
||||||
const hydrated = useTemplatesStore((s) => s.hydrated);
|
const hydrated = useTemplatesStore((s) => s.hydrated);
|
||||||
const hydrate = useTemplatesStore((s) => s.hydrate);
|
const hydrate = useTemplatesStore((s) => s.hydrate);
|
||||||
|
const teams = useTemplatesStore((s) => s.teams);
|
||||||
|
const refresh = useTemplatesStore((s) => s.refresh);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||||
// Centred on a wide window, clear of side insets in landscape.
|
// Centred on a wide window, clear of side insets in landscape.
|
||||||
@@ -46,6 +49,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
|
// Templates changed in the webmail or on another device, and the team ones.
|
||||||
|
void refresh();
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, 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 }),
|
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||||
]).start();
|
]).start();
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [visible, slideY, overlayOpacity]);
|
}, [visible, slideY, overlayOpacity]);
|
||||||
|
|
||||||
const visibleTemplates = React.useMemo(() => {
|
const visibleTemplates = React.useMemo(() => {
|
||||||
@@ -65,6 +71,42 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
|
|||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
}, [templates, query]);
|
}, [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 (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||||
@@ -91,7 +133,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{templates.length > 4 && (
|
{total > 4 && (
|
||||||
<TextInput
|
<TextInput
|
||||||
value={query}
|
value={query}
|
||||||
onChangeText={setQuery}
|
onChangeText={setQuery}
|
||||||
@@ -102,36 +144,26 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ScrollView style={styles.scrollList} keyboardShouldPersistTaps="handled">
|
<ScrollView style={styles.scrollList} keyboardShouldPersistTaps="handled">
|
||||||
{visibleTemplates.length === 0 ? (
|
{visibleTemplates.length === 0 && visibleTeams.length === 0 ? (
|
||||||
<Text style={styles.empty}>
|
<Text style={styles.empty}>
|
||||||
{templates.length === 0
|
{total === 0
|
||||||
? t('templates.no_templates', 'No templates yet')
|
? t('templates.no_templates', 'No templates yet')
|
||||||
: t('templates.no_results', 'No templates found')}
|
: t('templates.no_results', 'No templates found')}
|
||||||
</Text>
|
</Text>
|
||||||
) : visibleTemplates.map((template) => (
|
) : (
|
||||||
<Pressable
|
<>
|
||||||
key={template.id}
|
{visibleTeams.length > 0 && visibleTemplates.length > 0 && (
|
||||||
onPress={() => {
|
<Text style={styles.section}>{t('org_templates.section_personal', 'My templates')}</Text>
|
||||||
onPick(template);
|
)}
|
||||||
onClose();
|
{visibleTemplates.map((template) => renderRow(template, template.id))}
|
||||||
}}
|
{visibleTeams.map((team) => (
|
||||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
<React.Fragment key={team.accountId}>
|
||||||
>
|
<Text style={styles.section} numberOfLines={1}>{team.name}</Text>
|
||||||
<View style={styles.iconWrap}>
|
{team.templates.map((template) => renderRow(template, `${team.accountId}|${template.id}`, true))}
|
||||||
{template.isFavorite
|
</React.Fragment>
|
||||||
? <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>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -182,6 +214,14 @@ function makeStyles(c: ThemePalette) {
|
|||||||
},
|
},
|
||||||
scrollList: { paddingVertical: spacing.xs },
|
scrollList: { paddingVertical: spacing.xs },
|
||||||
empty: { ...typography.body, color: c.textMuted, textAlign: 'center', padding: spacing.xl },
|
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: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export function TemplateSettings() {
|
|||||||
const deleteTemplate = useTemplatesStore((s) => s.deleteTemplate);
|
const deleteTemplate = useTemplatesStore((s) => s.deleteTemplate);
|
||||||
const exportAll = useTemplatesStore((s) => s.exportAll);
|
const exportAll = useTemplatesStore((s) => s.exportAll);
|
||||||
const importTemplates = useTemplatesStore((s) => s.importTemplates);
|
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 [editing, setEditing] = useState<EmailTemplate | null>(null);
|
||||||
const [importVisible, setImportVisible] = useState(false);
|
const [importVisible, setImportVisible] = useState(false);
|
||||||
@@ -48,6 +50,10 @@ export function TemplateSettings() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydrated) void hydrate();
|
if (!hydrated) void hydrate();
|
||||||
}, [hydrated, hydrate]);
|
}, [hydrated, hydrate]);
|
||||||
|
// Kept in the account on Stalwart 1.0: changes from the webmail come in.
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing({
|
setEditing({
|
||||||
@@ -136,6 +142,11 @@ export function TemplateSettings() {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<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.')}>
|
<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.headerRow}>
|
||||||
<View style={styles.countRow}>
|
<View style={styles.countRow}>
|
||||||
<FileText size={16} color={c.mutedForeground} />
|
<FileText size={16} color={c.mutedForeground} />
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ describe('RN overlay translations', () => {
|
|||||||
const problems: string[] = [];
|
const problems: string[] = [];
|
||||||
for (const [key, value] of readOverlay(code)) {
|
for (const [key, value] of readOverlay(code)) {
|
||||||
const english = en.get(key);
|
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') {
|
if (typeof english !== 'string') {
|
||||||
problems.push(`${key}: not in locales/rn/en.json`);
|
problems.push(`${key}: not in locales/rn/en.json`);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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' })] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+313
-67
@@ -3,10 +3,31 @@
|
|||||||
// favourite/recent tracking are not yet exposed in the mobile UI. We persist
|
// 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
|
// the same shape so an export from one platform imports cleanly into the
|
||||||
// other.
|
// 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 { create } from 'zustand';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { exportTemplates, importTemplates as parseTemplateImport } from '../lib/template-utils';
|
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 {
|
export interface EmailTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,11 +48,29 @@ export interface EmailTemplate {
|
|||||||
updatedAt: string;
|
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';
|
const STORAGE_KEY = 'webmail:templates:v1';
|
||||||
|
|
||||||
interface TemplatesState {
|
interface TemplatesState {
|
||||||
|
/** The templates of the active login: its account's, or this device's. */
|
||||||
templates: EmailTemplate[];
|
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;
|
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>;
|
hydrate: () => Promise<void>;
|
||||||
addTemplate: (
|
addTemplate: (
|
||||||
@@ -42,10 +81,26 @@ interface TemplatesState {
|
|||||||
deleteTemplate: (id: string) => void;
|
deleteTemplate: (id: string) => void;
|
||||||
exportAll: () => string;
|
exportAll: () => string;
|
||||||
importTemplates: (json: string) => { count: number; error?: 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 {
|
/** Whether the client serves another login than `login` now (lib/draft-login). */
|
||||||
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ templates: state })).catch((err) => {
|
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);
|
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)}`;
|
return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTemplatesStore = create<TemplatesState>((set, get) => ({
|
/** What the UI shows for the active login. */
|
||||||
templates: [],
|
function visibleFor(state: Pick<TemplatesState, 'device' | 'accounts'>, login: string | null): Pick<TemplatesState, 'templates' | 'storedInAccount'> {
|
||||||
hydrated: false,
|
const account = login ? state.accounts[login] : undefined;
|
||||||
|
return account ? { templates: account.templates, storedInAccount: true } : { templates: state.device, storedInAccount: false };
|
||||||
|
}
|
||||||
|
|
||||||
hydrate: async () => {
|
// Bumped by every local change of an account's list, so a sync round that
|
||||||
if (get().hydrated) return;
|
// ran meanwhile plans once more instead of overwriting it.
|
||||||
try {
|
const editVersions = new Map<string, number>();
|
||||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
const bump = (login: string) => editVersions.set(login, (editVersions.get(login) ?? 0) + 1);
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw) as { templates?: EmailTemplate[] };
|
export const useTemplatesStore = create<TemplatesState>((set, get) => {
|
||||||
if (Array.isArray(parsed.templates)) {
|
/** Change the active login's list, wherever it lives. */
|
||||||
set({ templates: parsed.templates, hydrated: true });
|
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;
|
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) {
|
} 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 });
|
}
|
||||||
},
|
// The tombstones go up before the entries they delete come off.
|
||||||
|
let serverDeleted = serverTombstones;
|
||||||
addTemplate: (data) => {
|
if (reachable && (marker === null || !sameTombstones(marker, plan.deletedTemplateIds))) {
|
||||||
const now = new Date().toISOString();
|
if (loginMovedFrom(login)) return;
|
||||||
const template: EmailTemplate = {
|
try {
|
||||||
id: genId(),
|
await setBulwarkMetadata(accountId, MARKER_KEY, { version: MARKER_VERSION, deleted: plan.deletedTemplateIds });
|
||||||
name: data.name,
|
serverDeleted = plan.deletedTemplateIds;
|
||||||
subject: data.subject,
|
} catch (err) {
|
||||||
body: data.body,
|
console.warn('[templates-store] could not save the deleted templates in the account', err);
|
||||||
category: data.category ?? '',
|
reachable = false;
|
||||||
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] };
|
|
||||||
}
|
}
|
||||||
if (result.templates.length === 0) return { count: 0 };
|
}
|
||||||
const merged = [...get().templates, ...result.templates];
|
for (const id of plan.deletes) {
|
||||||
set({ templates: merged });
|
if (!reachable || serverDeleted[id] === undefined) continue;
|
||||||
persist(merged);
|
if (loginMovedFrom(login)) return;
|
||||||
return { count: result.templates.length };
|
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) });
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user