fix: keep iCal subscriptions safe when the mail server was replaced

When the server behind an account is replaced (a fresh Stalwart 1.0
install at the address 0.16 ran on), its ids start over. The app
already dropped its caches then, but kept what the user set up with
server ids inside:

- iCal subscriptions kept the ids of their mirror calendars. The next
  sync deleted the "stale" events of whatever calendar now had the id
  and filled it with the feed, and "Remove subscription" destroyed it.
  Now the account's subscriptions keep their feed, name, colour and
  interval but lose their mirror, and the next sync makes a new one as
  adding a subscription does. Renaming or removing an unlinked one
  touches no calendar. Events are only pruned from, and remove only
  destroys, a calendar that still carries the subscription's name.
  Subscriptions now record the signed-in account, so accounts on two
  servers that got the same JMAP id no longer share them.
- the preferred sender identity kept its id, which a new server can
  give to another address (live: identity "b" is usera@ on 0.16.23 and
  team@ on 1.0). It is forgotten for the account's old and new JMAP
  id, which the app now records with the server fingerprint.
- hidden calendars kept their ids. The list is shared by all accounts,
  so it is cleared.

Offline changes the drop discards are now announced once ("3 offline
changes were discarded because the mail server changed"), in all
languages.

Revoking this device from another one turns push off here again: a
subscription gone before its recorded expiry counts as revoked, as it
did before the server check came in. A replaced server forgets the
recorded expiry, so there the subscription is re-created instead.
This commit is contained in:
Linus Rath
2026-09-25 23:49:36 +02:00
parent a2ca98e0e8
commit f490886a8b
40 changed files with 632 additions and 71 deletions
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "أنت غير متصل بالإنترنت",
"discarded_changes": "{count, plural, zero {تم تجاهل # تغيير دون اتصال لأن خادم البريد تغيّر} one {تم تجاهل تغيير واحد دون اتصال لأن خادم البريد تغيّر} two {تم تجاهل تغييرين دون اتصال لأن خادم البريد تغيّر} few {تم تجاهل # تغييرات دون اتصال لأن خادم البريد تغيّر} many {تم تجاهل # تغييرًا دون اتصال لأن خادم البريد تغيّر} other {تم تجاهل # تغيير دون اتصال لأن خادم البريد تغيّر}}",
"queued_changes": "{count, plural, zero {ستتم مزامنة # تغيير عند إعادة الاتصال} one {ستتم مزامنة تغيير واحد عند إعادة الاتصال} two {ستتم مزامنة تغييرين عند إعادة الاتصال} few {ستتم مزامنة # تغييرات عند إعادة الاتصال} many {ستتم مزامنة # تغييرًا عند إعادة الاتصال} other {ستتم مزامنة # تغيير عند إعادة الاتصال}}",
"sync": {
"cached_count": "{count, plural, zero {تم تخزين # رسالة جديدة مؤقتًا} one {تم تخزين رسالة جديدة واحدة مؤقتًا} two {تم تخزين رسالتين جديدتين مؤقتًا} few {تم تخزين # رسائل جديدة مؤقتًا} many {تم تخزين # رسالة جديدة مؤقتًا} other {تم تخزين # رسالة جديدة مؤقتًا}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "No teniu connexió",
"discarded_changes": "{count, plural, one {S'ha descartat # canvi fet sense connexió} other {S'han descartat # canvis fets sense connexió}} perquè el servidor de correu ha canviat",
"queued_changes": "{count, plural, one {# canvi es sincronitzarà} other {# canvis se sincronitzaran}} quan us torneu a connectar",
"sync": {
"cached_count": "{count, plural, one {# missatge nou a la memòria cau} other {# missatges nous a la memòria cau}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Jste offline",
"discarded_changes": "{count, plural, one {Byla zahozena # změna provedená offline} few {Byly zahozeny # změny provedené offline} many {Bylo zahozeno # změny provedené offline} other {Bylo zahozeno # změn provedených offline}}, protože se změnil poštovní server",
"queued_changes": "{count, plural, one {# změna se synchronizuje} few {# změny se synchronizují} many {# změny se synchronizuje} other {# změn se synchronizuje}} po obnovení připojení",
"sync": {
"cached_count": "{count, plural, one {Stažena # nová zpráva} few {Staženy # nové zprávy} many {Staženo # nové zprávy} other {Staženo # nových zpráv}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Du er offline",
"discarded_changes": "{count, plural, one {# ændring foretaget offline blev kasseret} other {# ændringer foretaget offline blev kasseret}}, fordi mailserveren er skiftet",
"queued_changes": "{count, plural, one {# ændring} other {# ændringer}} synkroniseres, når du har forbindelse igen",
"sync": {
"cached_count": "{count, plural, one {# ny besked gemt i cachen} other {# nye beskeder gemt i cachen}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Sie sind offline",
"discarded_changes": "{count, plural, one {# Offline-Änderung wurde} other {# Offline-Änderungen wurden}} verworfen, weil sich der Mailserver geändert hat",
"queued_changes": "{count, plural, one {# Änderung wird} other {# Änderungen werden}} synchronisiert, sobald Sie wieder verbunden sind",
"sync": {
"cached_count": "{count, plural, one {# neue Nachricht zwischengespeichert} other {# neue Nachrichten zwischengespeichert}} • {size}",
+1
View File
@@ -545,6 +545,7 @@
},
"offline": {
"banner": "You are offline",
"discarded_changes": "{count, plural, one {# offline change was} other {# offline changes were}} discarded because the mail server changed",
"queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect",
"sync": {
"cached_count": "{count, plural, one {# new message cached} other {# new messages cached}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Estás sin conexión",
"discarded_changes": "{count, plural, one {Se descartó # cambio sin conexión} other {Se descartaron # cambios sin conexión}} porque cambió el servidor de correo",
"queued_changes": "{count, plural, one {# cambio se sincronizará} other {# cambios se sincronizarán}} cuando vuelvas a conectarte",
"sync": {
"cached_count": "{count, plural, one {# mensaje nuevo en caché} other {# mensajes nuevos en caché}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "شما آفلاین هستید",
"discarded_changes": "{count, plural, one {# تغییر آفلاین کنار گذاشته شد، چون سرور ایمیل عوض شده است} other {# تغییر آفلاین کنار گذاشته شد، چون سرور ایمیل عوض شده است}}",
"queued_changes": "{count, plural, one {# تغییر پس از اتصال دوباره همگام‌سازی می‌شود} other {# تغییر پس از اتصال دوباره همگام‌سازی می‌شود}}",
"sync": {
"cached_count": "{count, plural, one {# پیام جدید کش شد} other {# پیام جدید کش شد}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Vous êtes hors ligne",
"discarded_changes": "{count, plural, one {# modification hors ligne a été abandonnée} other {# modifications hors ligne ont été abandonnées}} car le serveur de messagerie a changé",
"queued_changes": "{count, plural, one {# modification sera synchronisée} other {# modifications seront synchronisées}} à la reconnexion",
"sync": {
"cached_count": "{count, plural, one {# nouveau message mis en cache} other {# nouveaux messages mis en cache}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "אתה במצב לא מקוון",
"discarded_changes": "{count, plural, one {שינוי אחד שנעשה ללא חיבור בוטל כי שרת הדואר השתנה} two {שני שינויים שנעשו ללא חיבור בוטלו כי שרת הדואר השתנה} other {# שינויים שנעשו ללא חיבור בוטלו כי שרת הדואר השתנה}}",
"queued_changes": "{count, plural, one {שינוי אחד יסונכרן כשתתחבר מחדש} two {שני שינויים יסונכרנו כשתתחבר מחדש} other {# שינויים יסונכרנו כשתתחבר מחדש}}",
"sync": {
"cached_count": "{count, plural, one {הודעה חדשה אחת נשמרה במטמון} two {שתי הודעות חדשות נשמרו במטמון} other {# הודעות חדשות נשמרו במטמון}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Offline vagy",
"discarded_changes": "{count, plural, one {# offline módosítás törlődött} other {# offline módosítás törlődött}}, mert megváltozott a levelezőszerver",
"queued_changes": "{count, plural, one {# módosítás szinkronizálódik} other {# módosítás szinkronizálódik}}, amint újra csatlakozol",
"sync": {
"cached_count": "{count, plural, one {# új üzenet letöltve} other {# új üzenet letöltve}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Sei offline",
"discarded_changes": "{count, plural, one {# modifica offline è stata scartata} other {# modifiche offline sono state scartate}} perché il server di posta è cambiato",
"queued_changes": "{count, plural, one {# modifica verrà sincronizzata} other {# modifiche verranno sincronizzate}} quando ti riconnetti",
"sync": {
"cached_count": "{count, plural, one {# nuovo messaggio in cache} other {# nuovi messaggi in cache}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "オフラインです",
"discarded_changes": "メールサーバーが変わったため、{count, plural, other {オフラインでの変更#件}}を破棄しました",
"queued_changes": "{count, plural, other {#件の変更}}は再接続時に同期されます",
"sync": {
"cached_count": "{count, plural, other {#件の新着メッセージをキャッシュしました}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "오프라인 상태예요",
"discarded_changes": "메일 서버가 바뀌어 {count, plural, other {오프라인 변경 사항 #개가}} 폐기됐어요",
"queued_changes": "{count, plural, other {변경 사항 #개}}는 다시 연결되면 동기화돼요",
"sync": {
"cached_count": "{count, plural, other {새 메시지 #개를 캐시했어요}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Jūs esat bezsaistē",
"discarded_changes": "{count, plural, zero {# bezsaistes izmaiņas tika atmestas} one {# bezsaistes izmaiņa tika atmesta} other {# bezsaistes izmaiņas tika atmestas}}, jo mainījās pasta serveris",
"queued_changes": "{count, plural, zero {# izmaiņas tiks sinhronizētas} one {# izmaiņa tiks sinhronizēta} other {# izmaiņas tiks sinhronizētas}}, kad atjaunosies savienojums",
"sync": {
"cached_count": "{count, plural, zero {Lejupielādēti # jauni ziņojumi} one {Lejupielādēts # jauns ziņojums} other {Lejupielādēti # jauni ziņojumi}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Та офлайн байна",
"discarded_changes": "{count, plural, one {Шуудангийн сервер өөрчлөгдсөн тул офлайн хийсэн # өөрчлөлтийг хаслаа} other {Шуудангийн сервер өөрчлөгдсөн тул офлайн хийсэн # өөрчлөлтийг хаслаа}}",
"queued_changes": "{count, plural, one {# өөрчлөлт дахин холбогдоход синк хийгдэнэ} other {# өөрчлөлт дахин холбогдоход синк хийгдэнэ}}",
"sync": {
"cached_count": "{count, plural, one {# шинэ имэйл кэшлэгдлээ} other {# шинэ имэйл кэшлэгдлээ}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Du er uten nett",
"discarded_changes": "{count, plural, one {# endring gjort uten nett ble forkastet} other {# endringer gjort uten nett ble forkastet}} fordi e-postserveren er byttet",
"queued_changes": "{count, plural, one {# endring} other {# endringer}} synkroniseres når du er på nett igjen",
"sync": {
"cached_count": "{count, plural, one {# ny melding bufret} other {# nye meldinger bufret}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "U bent offline",
"discarded_changes": "{count, plural, one {# offline wijziging is} other {# offline wijzigingen zijn}} verwijderd omdat de mailserver is gewijzigd",
"queued_changes": "{count, plural, one {# wijziging wordt} other {# wijzigingen worden}} gesynchroniseerd zodra u weer verbinding hebt",
"sync": {
"cached_count": "{count, plural, one {# nieuw bericht in de cache opgeslagen} other {# nieuwe berichten in de cache opgeslagen}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Jesteś offline",
"discarded_changes": "{count, plural, one {Odrzucono # zmianę offline} few {Odrzucono # zmiany offline} many {Odrzucono # zmian offline} other {Odrzucono # zmiany offline}}, ponieważ zmienił się serwer poczty",
"queued_changes": "{count, plural, one {# zmiana zostanie zsynchronizowana} few {# zmiany zostaną zsynchronizowane} many {# zmian zostanie zsynchronizowanych} other {# zmiany zostanie zsynchronizowane}} po ponownym połączeniu",
"sync": {
"cached_count": "{count, plural, one {Pobrano # nową wiadomość} few {Pobrano # nowe wiadomości} many {Pobrano # nowych wiadomości} other {Pobrano # nowej wiadomości}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Você está offline",
"discarded_changes": "{count, plural, one {# alteração offline foi descartada} other {# alterações offline foram descartadas}} porque o servidor de e-mail mudou",
"queued_changes": "{count, plural, one {# alteração será sincronizada} other {# alterações serão sincronizadas}} quando você se reconectar",
"sync": {
"cached_count": "{count, plural, one {# nova mensagem em cache} other {# novas mensagens em cache}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Sunteți offline",
"discarded_changes": "{count, plural, one {# modificare offline a fost eliminată} few {# modificări offline au fost eliminate} other {# de modificări offline au fost eliminate}} deoarece serverul de e-mail s-a schimbat",
"queued_changes": "{count, plural, one {# modificare va fi sincronizată} few {# modificări vor fi sincronizate} other {# de modificări vor fi sincronizate}} când vă reconectați",
"sync": {
"cached_count": "{count, plural, one {# mesaj nou salvat în cache} few {# mesaje noi salvate în cache} other {# de mesaje noi salvate în cache}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Вы не в сети",
"discarded_changes": "{count, plural, one {# офлайн-изменение отменено} few {# офлайн-изменения отменены} many {# офлайн-изменений отменено} other {# офлайн-изменения отменено}}, так как сменился почтовый сервер",
"queued_changes": "{count, plural, one {# изменение будет синхронизировано} few {# изменения будут синхронизированы} many {# изменений будет синхронизировано} other {# изменения будет синхронизировано}} после подключения к сети",
"sync": {
"cached_count": "{count, plural, one {В кэш сохранено # новое письмо} few {В кэш сохранено # новых письма} many {В кэш сохранено # новых писем} other {В кэш сохранено # нового письма}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Ste offline",
"discarded_changes": "{count, plural, one {Zahodila sa # zmena urobená offline} few {Zahodili sa # zmeny urobené offline} many {Zahodilo sa # zmeny urobenej offline} other {Zahodilo sa # zmien urobených offline}}, pretože sa zmenil poštový server",
"queued_changes": "{count, plural, one {# zmena sa zosynchronizuje} few {# zmeny sa zosynchronizujú} many {# zmeny sa zosynchronizuje} other {# zmien sa zosynchronizuje}} po obnovení pripojenia",
"sync": {
"cached_count": "{count, plural, one {Stiahnutá # nová správa} few {Stiahnuté # nové správy} many {Stiahnutých # novej správy} other {Stiahnutých # nových správ}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Çevrimdışısınız",
"discarded_changes": "{count, plural, one {Çevrimdışı yapılan # değişiklik} other {Çevrimdışı yapılan # değişiklik}} posta sunucusu değiştiği için silindi",
"queued_changes": "{count, plural, one {# değişiklik} other {# değişiklik}} yeniden bağlandığınızda senkronize edilecek",
"sync": {
"cached_count": "{count, plural, one {# yeni ileti önbelleğe alındı} other {# yeni ileti önbelleğe alındı}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "Ви не в мережі",
"discarded_changes": "{count, plural, one {# офлайн-зміну скасовано} few {# офлайн-зміни скасовано} many {# офлайн-змін скасовано} other {# офлайн-зміни скасовано}}, бо змінився поштовий сервер",
"queued_changes": "{count, plural, one {# зміну буде синхронізовано} few {# зміни буде синхронізовано} many {# змін буде синхронізовано} other {# зміни буде синхронізовано}} після підключення до мережі",
"sync": {
"cached_count": "{count, plural, one {У кеш збережено # новий лист} few {У кеш збережено # нові листи} many {У кеш збережено # нових листів} other {У кеш збережено # нового листа}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "您目前處於離線狀態",
"discarded_changes": "由於郵件伺服器已變更,{count, plural, other {# 項離線變更}}已被捨棄",
"queued_changes": "{count, plural, other {# 項變更}}將在重新連線後同步",
"sync": {
"cached_count": "{count, plural, other {已快取 # 封新郵件}} • {size}",
+1
View File
@@ -538,6 +538,7 @@
},
"offline": {
"banner": "您已离线",
"discarded_changes": "由于邮件服务器已更改,{count, plural, other {# 项离线更改}}已被丢弃",
"queued_changes": "{count, plural, other {# 项更改}}将在重新连接后同步",
"sync": {
"cached_count": "{count, plural, other {已缓存 # 封新邮件}} • {size}",
+14
View File
@@ -1156,6 +1156,20 @@ export async function setDefaultCalendar(
methodResult(res);
}
/**
* The name of the user's calendar `id`, or null when the account has no
* calendar by that id.
*/
export async function getCalendarName(id: string): Promise<string | null> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/get', { accountId, ids: [id], properties: ['id', 'name'] }, '0']],
USING,
);
const list = methodResult<{ list?: Calendar[] }>(res).list ?? [];
return list.find((cal) => cal.id === id)?.name ?? null;
}
// Destroy a calendar. `onDestroyRemoveEvents` removes its events too —
// Stalwart otherwise refuses to delete a non-empty calendar.
export async function deleteCalendar(id: string, targetAccountId?: string): Promise<void> {
@@ -12,6 +12,7 @@ import { CALENDAR_COLOR_PALETTE, calendarColorName } from '../../lib/calendar-ut
import { useCalendarLocale } from '../../lib/calendar-locale';
import {
DEFAULT_REFRESH_INTERVAL_MINUTES,
currentLocalAccountId,
selectAccountSubscriptions,
useCalendarSubscriptionsStore,
type CalendarSubscription,
@@ -40,7 +41,11 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
// Only the signed-in account's feeds: a sub created under another account
// mirrors into a calendar id that doesn't exist (or collides) here.
const subscriptions = React.useMemo(
() => selectAccountSubscriptions(allSubscriptions, jmapClient.isConnected ? jmapClient.accountId : null),
() => selectAccountSubscriptions(
allSubscriptions,
jmapClient.isConnected ? jmapClient.accountId : null,
currentLocalAccountId(),
),
[allSubscriptions],
);
const syncing = useCalendarSubscriptionsStore((s) => s.syncing);
+24 -4
View File
@@ -72,6 +72,7 @@ import {
setupPushNotifications,
deviceClientIdKey,
disablePushForAccount,
forgetPushServerState,
isValidRelayUrl,
mailAccountsWithoutPush,
notificationTapJmapAccountId,
@@ -937,6 +938,8 @@ describe('resyncPushNotifications', () => {
expect(result?.subscriptionId).toBe('existing');
expect(updateMock).toHaveBeenCalledTimes(1);
expect(createMock).not.toHaveBeenCalled();
// The expiry the server reported is what a later resync measures against.
expect(await AsyncStorage.getItem(EXPIRES_KEY)).toBe(expires);
});
it('leaves an account alone after the user turned push off for it', async () => {
@@ -969,10 +972,7 @@ describe('resyncPushNotifications', () => {
expect(createMock).not.toHaveBeenCalled();
});
it('re-creates a subscription the server lost before it was due to expire (reinstalled at the same address)', async () => {
// A server reinstalled for Stalwart 1.0, or an upgrade that doesn't carry
// the subscriptions over, looks exactly like a revocation from elsewhere.
// The expiry record is what older builds took for proof of revocation.
it('does not re-register a subscription another device revoked', async () => {
await AsyncStorage.setItem(SUB_KEY, 'existing');
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
@@ -980,6 +980,25 @@ describe('resyncPushNotifications', () => {
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
expect(result).toBeNull();
expect(createMock).not.toHaveBeenCalled();
expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull();
expect(await readPushAccountIds()).toEqual([]);
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).not.toBeNull();
});
it('re-creates the subscription once the server behind the account was replaced (0.16.23 to 1.0)', async () => {
// A server reinstalled for Stalwart 1.0 holds none of the subscriptions,
// which looks exactly like a revocation from elsewhere; the fingerprint
// check tells them apart and forgets the recorded expiry.
await AsyncStorage.setItem(SUB_KEY, 'existing');
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
listMock.mockResolvedValue([]);
await forgetPushServerState(ACCOUNT_ID);
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
expect(result?.subscriptionId).toBe('new-server-id');
expect(createMock).toHaveBeenCalledTimes(1);
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id');
@@ -1053,6 +1072,7 @@ describe('resyncPushNotifications', () => {
await setupPushNotifications({ relayBaseUrl: RELAY });
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
expect(await AsyncStorage.getItem(EXPIRES_KEY)).not.toBeNull();
vi.clearAllMocks();
listMock.mockResolvedValue([
{ id: 'new-server-id', deviceClientId: OUR_DCID, expires: inDays(7), types: ['EmailDelivery'] },
+54 -18
View File
@@ -47,12 +47,14 @@ const PROMPT_DISMISSED_PREFIX = 'push:promptDismissed:v1:';
// emailPushFallbacks). Left out of the map from then on, so a refusal isn't
// re-provoked on every launch.
const EMAIL_PUSH_REFUSED_PREFIX = 'push:emailPushRefused:v1:';
// The recorded subscription's expiry. Earlier builds took a subscription gone
// before it for revoked; nothing reads it now, and teardown still clears it.
// When the recorded subscription expires, as the server last reported it. Lets
// the launch-time resync tell a subscription that lapsed (re-create it) from
// one that was revoked (leave push off). Forgotten when the server behind the
// account is replaced, which loses every subscription (forgetPushServerState).
const SUBSCRIPTION_EXPIRES_PREFIX = 'push:subscriptionExpires:v1:';
// Set when the user turned push off for the account - the settings toggle, or
// revoking this device here. The launch-time resync leaves such an account
// alone until push is enabled for it again.
// revoking this device here or from elsewhere. The launch-time resync leaves
// such an account alone until push is enabled for it again.
const OPTED_OUT_PREFIX = 'push:optedOut:v1:';
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
@@ -226,6 +228,10 @@ const SUBSCRIPTION_EXPIRES_DAYS = 90;
// When an existing subscription has less than this much lifetime left, push
// expires forward on the next app start.
const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
// A recorded subscription missing from the server was revoked only if it still
// had at least this long to live; closer to its expiry (or with the clock a
// little off) it may simply have lapsed, and is re-created.
const REVOKED_EXPIRY_MARGIN_MS = 24 * 60 * 60 * 1000;
function expiresFromNow(days: number): string {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
@@ -1033,6 +1039,7 @@ async function setupPushNotificationsInner(
: undefined;
if (storedServerId) {
if (match) {
if (match.expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), match.expires);
if (!params.forceRecreate) {
let refreshed: Awaited<ReturnType<typeof refreshSubscriptionExpires>>;
try {
@@ -1057,9 +1064,10 @@ async function setupPushNotificationsInner(
// once it's verified.
replacedServerId = storedServerId;
} else {
// The server no longer has it: it expired, was revoked, or the server
// lost its subscriptions (a reinstall, an upgrade). Either way it is
// re-created below; push is never left off because of it.
// The server no longer has it: it lapsed, or the server behind the
// account was replaced. A revocation from another device is caught
// before this by the launch-time resync (wasRevokedOnServer). Either
// way it is re-created below.
await AsyncStorage.removeItem(subKey);
}
}
@@ -1090,6 +1098,7 @@ async function setupPushNotificationsInner(
}
let serverAssignedId: string;
let serverExpires: string | null;
try {
const created = await writeWithEmailPush(emailPush, (filter) =>
createPushSubscription({
@@ -1101,6 +1110,7 @@ async function setupPushNotificationsInner(
}),
);
serverAssignedId = created.result.id;
serverExpires = created.result.expires;
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
} catch (err) {
throw subscriptionError(err);
@@ -1119,6 +1129,7 @@ async function setupPushNotificationsInner(
}
await AsyncStorage.setItem(subKey, serverAssignedId);
if (serverExpires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), serverExpires);
if (replacedServerId) {
await destroyPushSubscription(replacedServerId).catch(() => undefined);
}
@@ -1209,15 +1220,17 @@ async function clearAccountPushKeys(accountId: string): Promise<void> {
/**
* Forget what push learned from the account's server: the ring of notified
* message ids (a different server hands the same short ids to new mail, which
* would then never notify) and the accounts it refused in the emailPush map.
* The registration is left to the next resync, which re-creates it when the
* server no longer has it (see lib/server-change).
* would then never notify), the accounts it refused in the emailPush map, and
* the recorded subscription's expiry. A replaced server holds none of the old
* subscriptions, which must not count as revoked: without the expiry the next
* resync re-creates it (see lib/server-change).
*/
export async function forgetPushServerState(accountId: string): Promise<void> {
await AsyncStorage.multiRemove([
lastNotifiedKey(accountId),
notifiedIdsKey(accountId),
emailPushRefusedKey(accountId),
subscriptionExpiresKey(accountId),
]);
}
@@ -1272,23 +1285,41 @@ export async function teardownPushNotificationsForAccount(
/**
* Turn push off for one account because the user asked to: the settings
* toggle, or revoking this device here. Tears the registration down and
* remembers the choice, so the launch-time resync doesn't quietly register
* the account again. Enabling push for it again clears the mark.
* toggle, or revoking this device (here, or from another device - see
* resyncPushNotifications). Tears the registration down and remembers the
* choice, so the launch-time resync doesn't quietly register the account
* again. Enabling push for it again clears the mark.
*/
export async function disablePushForAccount(accountId: string): Promise<void> {
await teardownPushNotificationsForAccount(accountId);
await AsyncStorage.setItem(optedOutKey(accountId), String(Date.now()));
}
/**
* True when the server no longer has this account's recorded subscription
* although it wasn't due to expire - someone revoked it. Without a known
* expiry (a registration made by an older build, or one on a server that was
* replaced since) nothing can be told apart, so it counts as not revoked.
* Throws when the server can't be asked.
*/
async function wasRevokedOnServer(accountId: string): Promise<boolean> {
const storedServerId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
const expires = Date.parse((await AsyncStorage.getItem(subscriptionExpiresKey(accountId))) ?? '');
if (!storedServerId || !Number.isFinite(expires)) return false;
if (expires - Date.now() < REVOKED_EXPIRY_MARGIN_MS) return false;
const subs = await listPushSubscriptions();
return !subs.some((s) => s.id === storedServerId);
}
/**
* Bring the loaded account's registration up to date without user action -
* on launch and when the push token or endpoint rotates. Skips an account the
* user turned push off for. A subscription the server no longer has counts
* as expired and is re-created: the server can lose every subscription (a
* reinstall at the same address, an upgrade that doesn't carry them over),
* which looks the same as a revocation from another device. Resolves to null
* when it left push off.
* user turned push off for, and honours a revocation: when the server dropped
* the subscription before it was due to expire, push is turned off for the
* account instead of being silently registered again. A server replaced at
* the account's address loses every subscription too; that is told apart by
* its fingerprint, which forgets the expiry (forgetPushServerState), so the
* subscription is re-created there. Resolves to null when it left push off.
*/
export async function resyncPushNotifications(
params: PushSetupParams,
@@ -1298,6 +1329,11 @@ export async function resyncPushNotifications(
if (!username || !serverUrl) return null;
const accountId = generateAccountId(username, serverUrl);
if (await AsyncStorage.getItem(optedOutKey(accountId))) return null;
if (await wasRevokedOnServer(accountId)) {
logPhase('revoked', 'subscription gone before it was due to expire; leaving push off');
await disablePushForAccount(accountId);
return null;
}
return setupPushNotifications(params);
}
+63 -8
View File
@@ -1,15 +1,25 @@
import type { JMAPSession } from '../api/types';
import { useCalendarStore } from '../stores/calendar-store';
import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store';
import { useContactsStore } from '../stores/contacts-store';
import { useEmailStore } from '../stores/email-store';
import { t } from '../stores/locale-store';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import { useOutboxStore } from '../stores/outbox-store';
import { flushPersistedWrites } from '../stores/persist-storage';
import { useSettingsStore } from '../stores/settings-store';
import { toast } from '../stores/toast-store';
import { clearBodyHeights } from './body-heights';
import { clearBodyDocuments } from './email-body-document';
import { clearEmailDetailCache } from './email-detail-cache';
import { forgetPushServerState } from './push-notifications';
import { readServerFingerprint, sessionFingerprint, writeServerFingerprint } from './server-fingerprint';
import {
primaryAccountId,
readServerFingerprint,
readServerPrimaryAccountId,
sessionFingerprint,
writeServerFingerprint,
} from './server-fingerprint';
// The app keeps a lot across launches that only means something on the
// server it came from: JMAP states and query states, message ids in the
@@ -22,11 +32,26 @@ import { readServerFingerprint, sessionFingerprint, writeServerFingerprint } fro
// derived from the old server is dropped before anything reads it.
/**
* Drop what the app derived from an account's server. Preferences, templates,
* tags and iCal subscriptions stay: they are the user's, not the server's.
* The JMAP ids the replaced server and the new one gave the account's own
* data (null when not known). Preferences kept per JMAP account are found by
* them.
*/
export interface ServerAccountIds {
previous: string | null;
next: string | null;
}
/**
* Drop what the app derived from an account's server. Preferences, templates
* and tags stay: they are the user's, not the server's. So do the iCal
* subscriptions, without their mirror calendars. Preferences that name a
* server object go: the preferred sender identity and the hidden calendars.
* Every step runs; the first failure is thrown afterwards.
*/
export async function forgetServerDerivedState(accountId: string): Promise<void> {
export async function forgetServerDerivedState(
accountId: string,
jmapAccountIds: ServerAccountIds = { previous: null, next: null },
): Promise<void> {
const failures: unknown[] = [];
const step = async (run: () => unknown): Promise<void> => {
try {
@@ -54,11 +79,31 @@ export async function forgetServerDerivedState(accountId: string): Promise<void>
// that one. So the whole queue goes, failed ops too. Offline edits still
// waiting are lost; a message the user wrote never is: the outbox holds no
// sends (sending needs a connection, and drafts are saved to the server).
await step(() => useOutboxStore.getState().clearAccount(accountId));
let droppedOps = 0;
await step(async () => {
droppedOps = await useOutboxStore.getState().clearAccount(accountId);
});
await step(() => forgetPushServerState(accountId));
// Calendar ids start over as well: a subscription's mirror id may name
// another calendar now, which its next sync would fill and "Remove" would
// destroy. Each gets a new mirror instead.
await step(() => useCalendarSubscriptionsStore.getState().unlinkAccount(accountId, jmapAccountIds));
await step(() => useCalendarStore.getState().forgetHiddenCalendars());
// Identity ids too (live: "b" is usera@ on 0.16.23, team@ on 1.0), and the
// preference would send from another address.
await step(() => useSettingsStore.getState().forgetPreferredIdentities(
[jmapAccountIds.previous, jmapAccountIds.next].filter((id): id is string => !!id),
));
// Get the emptied caches onto disk before the new fingerprint is.
await step(() => flushPersistedWrites());
if (droppedOps > 0) {
toast.warning(t(
'offline.discarded_changes',
'{count, plural, one {# offline change was} other {# offline changes were}} discarded because the mail server changed',
{ count: droppedOps },
));
}
if (failures.length > 0) throw failures[0];
}
@@ -72,11 +117,21 @@ export async function forgetServerDerivedState(accountId: string): Promise<void>
export async function reconcileServerFingerprint(accountId: string, session: JMAPSession): Promise<boolean> {
const next = sessionFingerprint(session);
const recorded = await readServerFingerprint(accountId);
if (recorded === next) return false;
const primary = primaryAccountId(session);
if (recorded === next) {
// Recorded by a build that kept no primary account id with it.
if (primary && (await readServerPrimaryAccountId(accountId)) === null) {
await writeServerFingerprint(accountId, next, primary);
}
return false;
}
if (recorded !== null) {
console.warn(`[server-change] ${accountId}: the server changed, dropping what was read from the old one`);
await forgetServerDerivedState(accountId);
await forgetServerDerivedState(accountId, {
previous: await readServerPrimaryAccountId(accountId),
next: primary || null,
});
}
await writeServerFingerprint(accountId, next);
await writeServerFingerprint(accountId, next, primary);
return recorded !== null;
}
+20 -2
View File
@@ -15,13 +15,17 @@ import { sha256Hex } from './sha256';
// only costs a re-download.
const STORAGE_PREFIX = 'server-fingerprint:v1:';
// The JMAP id the recorded server gave the account's own data. Preferences
// kept per JMAP account (the preferred sender identity, iCal subscriptions)
// are found by it once that server is gone.
const PRIMARY_PREFIX = 'server-primary-account:v1:';
function storageKey(accountId: string): string {
return STORAGE_PREFIX + accountId;
}
/** The session's primary account, picked the way JMAPClient picks it. */
function primaryAccountId(session: JMAPSession): string {
export function primaryAccountId(session: JMAPSession): string {
return session.primaryAccounts?.[CAPABILITIES.MAIL]
|| session.primaryAccounts?.[CAPABILITIES.CORE]
|| Object.keys(session.accounts ?? {})[0]
@@ -53,6 +57,20 @@ export async function readServerFingerprint(accountId: string): Promise<string |
}
}
export async function writeServerFingerprint(accountId: string, fingerprint: string): Promise<void> {
/** The primary JMAP account id recorded with the fingerprint, if any. */
export async function readServerPrimaryAccountId(accountId: string): Promise<string | null> {
try {
return await AsyncStorage.getItem(PRIMARY_PREFIX + accountId);
} catch {
return null;
}
}
export async function writeServerFingerprint(
accountId: string,
fingerprint: string,
primaryAccount?: string,
): Promise<void> {
if (primaryAccount) await AsyncStorage.setItem(PRIMARY_PREFIX + accountId, primaryAccount);
await AsyncStorage.setItem(storageKey(accountId), fingerprint);
}
@@ -166,6 +166,8 @@ describe.each<StalwartVersion>(['0.16', '1.0'])('calendar reads through the stor
it(`removes what a subscribed feed dropped and adds only what is new on Stalwart ${version}`, async () => {
serve({
// The mirror calendar, recognised by the subscription's name.
calendars: [...CALENDARS, { id: 'feed', name: 'Feed' }],
objects: {
old: { id: 'old', uid: 'old@feed', title: 'Gone upstream', start: '2026-09-02T09:00:00', calendarIds: { feed: true } },
kept: { id: 'kept', uid: 'kept@feed', title: 'Still there', start: '2026-09-03T09:00:00', calendarIds: { feed: true } },
@@ -1,8 +1,9 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../api/calendar', () => ({
createCalendar: vi.fn(),
deleteCalendar: vi.fn(),
getCalendarName: vi.fn(),
parseCalendarBlob: vi.fn(),
queryEvents: vi.fn(),
getEventIdentities: vi.fn(),
@@ -10,12 +11,17 @@ vi.mock('../../api/calendar', () => ({
updateEvent: vi.fn(),
updateCalendar: vi.fn(),
}));
vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn() }));
vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn(async () => ({ blobId: 'feed-blob' })) }));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', isConnected: true },
jmapClient: { accountId: 'acc-1', isConnected: true, username: 'user', serverUrl: 'https://mail.example.org' },
}));
const calendarStore = vi.hoisted(() => ({
importEvents: vi.fn(async () => ({ created: 1, updated: 0 })),
fetchCalendars: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
}));
vi.mock('../calendar-store', () => ({
useCalendarStore: { getState: () => ({}) },
useCalendarStore: { getState: () => calendarStore },
}));
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
@@ -58,12 +64,22 @@ describe('selectAccountSubscriptions', () => {
expect(selectAccountSubscriptions(subs, 'acc-1').map((s) => s.id)).toEqual(['a', 'legacy']);
expect(selectAccountSubscriptions(subs, 'acc-2').map((s) => s.id)).toEqual(['b', 'legacy']);
});
it('tells apart accounts on two servers that gave them the same JMAP id', () => {
const both: CalendarSubscription[] = [
{ id: 'x', name: 'X', url: 'x', calendarId: 'c1', accountId: 'b', localAccountId: 'ann@one.example', lastSyncAt: null, lastError: null },
{ id: 'y', name: 'Y', url: 'y', calendarId: 'c1', accountId: 'b', localAccountId: 'bob@two.example', lastSyncAt: null, lastError: null },
];
expect(selectAccountSubscriptions(both, 'b', 'ann@one.example').map((s) => s.id)).toEqual(['x']);
expect(selectAccountSubscriptions(both, 'b', 'bob@two.example').map((s) => s.id)).toEqual(['y']);
});
});
describe('updateSubscription', () => {
it('renames the mirror calendar without re-sending a colour Stalwart 1.0 refuses', async () => {
const update = calendarApi.updateCalendar as ReturnType<typeof vi.fn>;
update.mockResolvedValue(undefined);
vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Feed');
useCalendarSubscriptionsStore.setState({
subscriptions: [{ id: 's', name: 'Feed', url: 'https://example.com/f.ics', color: '#FF2968FF', calendarId: 'c9', lastSyncAt: null, lastError: null }],
});
@@ -74,3 +90,147 @@ describe('updateSubscription', () => {
expect(useCalendarSubscriptionsStore.getState().subscriptions[0].name).toBe('Holidays');
});
});
describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => {
const ME = 'user@mail.example.org';
const OTHER = 'user@other.example.org';
const mine = (over: Partial<CalendarSubscription> = {}): CalendarSubscription => ({
id: 'mine', name: 'Holidays', url: 'https://feeds.example/holidays.ics', color: '#ff0000',
calendarId: 'c', accountId: 'acc-1', localAccountId: ME, refreshIntervalMinutes: 360,
lastSyncAt: 5, lastError: null, ...over,
});
const only = () => useCalendarSubscriptionsStore.getState().subscriptions[0];
beforeEach(() => {
vi.clearAllMocks();
useCalendarSubscriptionsStore.setState({ syncing: {} });
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
headers: { get: () => null },
text: async () => 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n',
})) as unknown as typeof fetch;
vi.mocked(calendarApi.parseCalendarBlob).mockResolvedValue([{ uid: 'e1', title: 'New year', start: '2027-01-01T00:00:00' }]);
vi.mocked(calendarApi.queryEvents).mockResolvedValue([]);
vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([]);
vi.mocked(calendarApi.createCalendar).mockResolvedValue({ id: 'n1', name: 'Holidays' } as never);
});
it('keeps the feed, name, colour and interval of the account\'s subscriptions and drops their mirror calendar', async () => {
useCalendarSubscriptionsStore.setState({
subscriptions: [
mine({ lastError: 'Feed moved' }),
// Made before subscriptions recorded their account: found by JMAP id.
{ id: 'legacy', name: 'Moon', url: 'https://feeds.example/moon.ics', calendarId: 'd', accountId: 'acc-1', lastSyncAt: 5, lastError: null },
{ id: 'theirs', name: 'Sports', url: 'https://feeds.example/sports.ics', calendarId: 'c', accountId: 'acc-1', localAccountId: OTHER, lastSyncAt: 5, lastError: null },
],
});
await useCalendarSubscriptionsStore.getState().unlinkAccount(ME, { previous: 'acc-1', next: 'acc-9' });
const [first, legacy, theirs] = useCalendarSubscriptionsStore.getState().subscriptions;
expect(first).toEqual({
id: 'mine', name: 'Holidays', url: 'https://feeds.example/holidays.ics', color: '#ff0000',
calendarId: null, accountId: 'acc-9', localAccountId: ME, refreshIntervalMinutes: 360,
lastSyncAt: null, lastError: null,
});
expect(legacy).toMatchObject({ calendarId: null, accountId: 'acc-9', localAccountId: ME, url: 'https://feeds.example/moon.ics' });
expect(theirs).toMatchObject({ calendarId: 'c', accountId: 'acc-1', localAccountId: OTHER, lastSyncAt: 5 });
});
it('makes an unlinked subscription a new mirror calendar on its next sync, as adding one does', async () => {
vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Holidays');
useCalendarSubscriptionsStore.setState({ subscriptions: [mine({ calendarId: null, lastSyncAt: null })] });
await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000');
expect(calendarStore.importEvents).toHaveBeenCalledWith([expect.objectContaining({ uid: 'e1' })], 'n1');
expect(only()).toMatchObject({ calendarId: 'n1', accountId: 'acc-1', localAccountId: ME, lastError: null });
expect(only().lastSyncAt).not.toBeNull();
expect(calendarStore.refresh).toHaveBeenCalled();
});
it('keeps the new mirror when the feed fails, so the next sync makes no second one', async () => {
globalThis.fetch = vi.fn(async () => ({ ok: false, status: 503, headers: { get: () => null }, text: async () => '' })) as unknown as typeof fetch;
useCalendarSubscriptionsStore.setState({ subscriptions: [mine({ calendarId: null, lastSyncAt: null })] });
await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
expect(calendarApi.createCalendar).toHaveBeenCalledTimes(1);
expect(only().calendarId).toBe('n1');
expect(only().lastError).toContain('503');
});
it('leaves the unlinked subscription of another signed-in account alone', async () => {
useCalendarSubscriptionsStore.setState({ subscriptions: [mine({ calendarId: null, localAccountId: OTHER })] });
await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
expect(calendarApi.createCalendar).not.toHaveBeenCalled();
expect(only().calendarId).toBeNull();
});
it('touches no calendar when an unlinked subscription is renamed or removed', async () => {
useCalendarSubscriptionsStore.setState({ subscriptions: [mine({ calendarId: null })] });
await useCalendarSubscriptionsStore.getState().updateSubscription('mine', { name: 'Public holidays', color: '#00ff00' });
expect(only()).toMatchObject({ name: 'Public holidays', color: '#00ff00', calendarId: null });
await useCalendarSubscriptionsStore.getState().removeSubscription('mine');
expect(calendarApi.getCalendarName).not.toHaveBeenCalled();
expect(calendarApi.updateCalendar).not.toHaveBeenCalled();
expect(calendarApi.deleteCalendar).not.toHaveBeenCalled();
expect(useCalendarSubscriptionsStore.getState().subscriptions).toEqual([]);
});
it('drops no events from a calendar under the recorded id that carries another name', async () => {
// The reinstalled server gave "c" to the user's own calendar.
vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Personal');
vi.mocked(calendarApi.queryEvents).mockResolvedValue(['p1']);
vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([{ id: 'p1', uid: 'dentist', calendarIds: { c: true } }] as never);
useCalendarSubscriptionsStore.setState({ subscriptions: [mine()] });
await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
expect(calendarApi.getCalendarName).toHaveBeenCalledWith('c');
expect(calendarApi.queryEvents).not.toHaveBeenCalled();
expect(calendarApi.deleteEvents).not.toHaveBeenCalled();
expect(calendarApi.updateEvent).not.toHaveBeenCalled();
});
it('still drops events that left the feed from its own mirror calendar', async () => {
vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Holidays');
vi.mocked(calendarApi.queryEvents).mockResolvedValue(['x1', 'x2']);
vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([
{ id: 'x1', uid: 'gone', calendarIds: { c: true } },
{ id: 'x2', uid: 'e1', calendarIds: { c: true } },
] as never);
useCalendarSubscriptionsStore.setState({ subscriptions: [mine()] });
await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1']);
});
it('destroys only a calendar that carries the subscription name on remove', async () => {
vi.mocked(calendarApi.getCalendarName).mockResolvedValueOnce('Personal').mockResolvedValueOnce('Holidays');
useCalendarSubscriptionsStore.setState({ subscriptions: [mine(), mine({ id: 'mine-2' })] });
await useCalendarSubscriptionsStore.getState().removeSubscription('mine');
expect(calendarApi.deleteCalendar).not.toHaveBeenCalled();
await useCalendarSubscriptionsStore.getState().removeSubscription('mine-2');
expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c');
});
it('renames only a calendar that carries the subscription name', async () => {
vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Personal');
useCalendarSubscriptionsStore.setState({ subscriptions: [mine()] });
await useCalendarSubscriptionsStore.getState().updateSubscription('mine', { name: 'Public holidays' });
expect(calendarApi.updateCalendar).not.toHaveBeenCalled();
expect(only().name).toBe('Public holidays');
});
});
+102 -9
View File
@@ -9,12 +9,20 @@ vi.mock('../../api/jmap-client', () => ({
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Email, JMAPSession } from '../../api/types';
import { reconcileServerFingerprint } from '../../lib/server-change';
import { readServerFingerprint, sessionFingerprint, writeServerFingerprint } from '../../lib/server-fingerprint';
import {
readServerFingerprint,
readServerPrimaryAccountId,
sessionFingerprint,
writeServerFingerprint,
} from '../../lib/server-fingerprint';
import { useCalendarStore } from '../calendar-store';
import { useCalendarSubscriptionsStore, type CalendarSubscription } from '../calendar-subscriptions-store';
import { useContactsStore } from '../contacts-store';
import { useEmailStore, type AccountSnapshot } from '../email-store';
import { useOfflineCacheStore } from '../offline-cache-store';
import { useOutboxStore } from '../outbox-store';
import { useSettingsStore } from '../settings-store';
import { useToastStore } from '../toast-store';
// Stalwart 1.0 has no migration from 0.16: the way there is a fresh install
// at the same address, where ids start over ("a", "b", ...) and the account's
@@ -22,7 +30,9 @@ import { useOutboxStore } from '../outbox-store';
const ACCOUNT = 'userc@mail.example.org';
const OTHER = 'usera@other.example.org';
function session(version: '0.16.23' | '1.0'): JMAPSession {
// The server gives the account's own data JMAP id `primary` ("f" on both
// test servers for userc; a reinstall can hand out another).
function session(version: '0.16.23' | '1.0', primary = 'f'): JMAPSession {
const accountCaps: Record<string, unknown> = {
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:emailpush': {},
@@ -35,7 +45,7 @@ function session(version: '0.16.23' | '1.0'): JMAPSession {
};
if (version === '1.0') {
accountCaps['urn:ietf:params:jmap:metadata'] = {};
accountCaps['urn:ietf:params:jmap:principals:owner'] = { accountIdForPrincipal: 'f', principalId: 'f' };
accountCaps['urn:ietf:params:jmap:principals:owner'] = { accountIdForPrincipal: primary, principalId: primary };
capabilities['urn:ietf:params:jmap:metadata'] = {};
}
return {
@@ -43,8 +53,8 @@ function session(version: '0.16.23' | '1.0'): JMAPSession {
downloadUrl: '',
uploadUrl: '',
eventSourceUrl: '',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'f' },
accounts: { f: { name: 'userc@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: accountCaps } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': primary },
accounts: { [primary]: { name: 'userc@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: accountCaps } },
capabilities,
state: 'b3bed0bb',
};
@@ -65,6 +75,20 @@ const INBOX = {
myRights: {} as never, accountId: 'f', isShared: false,
};
// iCal subscriptions whose mirror calendars live on the 0.16.23 server.
const SUBSCRIPTIONS: CalendarSubscription[] = [
{
id: 'sub-mine', name: 'Holidays', url: 'https://feeds.example/holidays.ics', color: '#ff0000',
calendarId: 'c', accountId: 'f', localAccountId: ACCOUNT, refreshIntervalMinutes: 360, lastSyncAt: 1, lastError: null,
},
// Made before subscriptions recorded their account.
{ id: 'sub-legacy', name: 'Moon', url: 'https://feeds.example/moon.ics', calendarId: 'd', accountId: 'f', lastSyncAt: 1, lastError: null },
{
id: 'sub-other', name: 'Sports', url: 'https://feeds.example/sports.ics', calendarId: 'c', accountId: 'b',
localAccountId: OTHER, lastSyncAt: 1, lastError: null,
},
];
const otherSnapshot: AccountSnapshot = {
mailboxes: [{ ...INBOX, accountId: 'b' }],
mailboxState: 'sae',
@@ -88,12 +112,12 @@ async function seed(active: string): Promise<void> {
for (const account of [OTHER, ACCOUNT]) {
await useOfflineCacheStore.getState().setAccount(account);
await useOfflineCacheStore.getState().put({ ...email('b'), bodyValues: {} } as Email, 500);
await useOutboxStore.getState().setAccount(account);
useOutboxStore.getState().enqueue({ kind: 'destroy', emailId: 'b' });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'c', patch: { $seen: true } });
await AsyncStorage.setItem(`webmail:outbox:v1:${account}:failed`, JSON.stringify([
{ id: 'x', op: { kind: 'mailboxes', emailId: 'd', mailboxIds: { e: true } }, createdAt: 1, attempts: 5 },
]));
await useOutboxStore.getState().setAccount(account);
useOutboxStore.getState().enqueue({ kind: 'destroy', emailId: 'b' });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'c', patch: { $seen: true } });
await AsyncStorage.setItem(`push:notifiedIds:v1:${account}`, JSON.stringify(['b', 'a']));
await AsyncStorage.setItem(`push:lastNotifiedEmailId:v2:${account}`, 'b');
await AsyncStorage.setItem(`push:emailPushRefused:v1:${account}`, JSON.stringify(['d']));
@@ -111,10 +135,18 @@ async function seed(active: string): Promise<void> {
addressBooks: [{ id: 'a', name: 'Personal' } as never],
contacts: [{ id: 'b', name: { full: 'Ann' } } as never],
});
useCalendarSubscriptionsStore.setState({ subscriptions: SUBSCRIPTIONS.map((sub) => ({ ...sub })) });
// Identity "b" was userc@ on 0.16.23; on 1.0 it can be a group's address.
useSettingsStore.setState({ preferredIdentityIds: { f: 'b', b: 'c' } });
useCalendarStore.setState({ hiddenCalendarIds: ['c'] });
await AsyncStorage.setItem('webmail:calendar:hidden:v1', JSON.stringify(['c']));
useToastStore.setState({ toasts: [] });
await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('0.16.23')));
await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('0.16.23')), 'f');
}
const subscription = (id: string) => useCalendarSubscriptionsStore.getState().subscriptions.find((s) => s.id === id)!;
const storedKeys = async () => (await AsyncStorage.getAllKeys()) as string[];
beforeEach(() => {
@@ -162,6 +194,63 @@ describe('reconcileServerFingerprint', () => {
expect(useContactsStore.getState().contacts).toEqual([]);
expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('1.0')));
expect(await readServerPrimaryAccountId(ACCOUNT)).toBe('f');
});
it('keeps the iCal subscriptions of the account without their mirror calendars (0.16.23 to 1.0)', async () => {
await seed(ACCOUNT);
await reconcileServerFingerprint(ACCOUNT, session('1.0'));
// "c" and "d" may name other calendars on 1.0: the next sync makes new ones.
expect(subscription('sub-mine')).toEqual({ ...SUBSCRIPTIONS[0], calendarId: null, lastSyncAt: null });
expect(subscription('sub-legacy')).toMatchObject({ calendarId: null, localAccountId: ACCOUNT, accountId: 'f' });
expect(subscription('sub-other')).toEqual(SUBSCRIPTIONS[2]);
});
it('forgets the preferred identity and the hidden calendars (0.16.23 to 1.0)', async () => {
await seed(ACCOUNT);
await reconcileServerFingerprint(ACCOUNT, session('1.0'));
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ b: 'c' });
expect(useCalendarStore.getState().hiddenCalendarIds).toEqual([]);
expect(await AsyncStorage.getItem('webmail:calendar:hidden:v1')).toBe('[]');
});
it('follows the account to the JMAP id the new server gave it', async () => {
await seed(ACCOUNT);
await reconcileServerFingerprint(ACCOUNT, session('1.0', 'h'));
expect(subscription('sub-mine')).toMatchObject({ calendarId: null, accountId: 'h', localAccountId: ACCOUNT });
expect(subscription('sub-legacy')).toMatchObject({ calendarId: null, accountId: 'h', localAccountId: ACCOUNT });
expect(subscription('sub-other').calendarId).toBe('c');
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ b: 'c' });
expect(await readServerPrimaryAccountId(ACCOUNT)).toBe('h');
});
it('tells the user once how many offline changes it discarded', async () => {
await seed(ACCOUNT);
await reconcileServerFingerprint(ACCOUNT, session('1.0'));
// A later check of the same server has nothing left to drop.
await reconcileServerFingerprint(ACCOUNT, session('1.0'));
const toasts = useToastStore.getState().toasts;
expect(toasts).toHaveLength(1);
expect(toasts[0].type).toBe('warning');
// Two queued and one failed op.
expect(toasts[0].title).toBe('3 offline changes were discarded because the mail server changed');
});
it('says nothing about an empty outbox', async () => {
await seed(ACCOUNT);
await useOutboxStore.getState().clearAccount(ACCOUNT);
await reconcileServerFingerprint(ACCOUNT, session('1.0'));
expect(useToastStore.getState().toasts).toEqual([]);
});
it('leaves every other account alone', async () => {
@@ -207,6 +296,10 @@ describe('reconcileServerFingerprint', () => {
expect(useOutboxStore.getState().entries).toHaveLength(2);
expect(useCalendarStore.getState().events).toHaveLength(1);
expect(await AsyncStorage.getItem(`push:notifiedIds:v1:${ACCOUNT}`)).not.toBeNull();
expect(useCalendarSubscriptionsStore.getState().subscriptions).toEqual(SUBSCRIPTIONS);
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ f: 'b', b: 'c' });
expect(useCalendarStore.getState().hiddenCalendarIds).toEqual(['c']);
expect(useToastStore.getState().toasts).toEqual([]);
});
it('only records the first server it sees for an account', async () => {
+13
View File
@@ -281,6 +281,12 @@ export interface CalendarState {
deleteTask: (id: string) => Promise<void>;
toggleCalendarVisibility: (id: string) => void;
setCalendarHidden: (id: string, hidden: boolean) => void;
/**
* Show every calendar again. The hidden ids are kept for all accounts
* together, and a server that was replaced may have given one of them to
* another calendar (see lib/server-change).
*/
forgetHiddenCalendars: () => Promise<void>;
reset: () => void;
}
@@ -1183,6 +1189,13 @@ export const useCalendarStore = create<CalendarState>()(
persistHidden(next);
},
forgetHiddenCalendars: async () => {
// A read of the stored list still under way would bring them back.
await get().hydrate();
set({ hiddenCalendarIds: [] });
persistHidden([]);
},
reset: () => {
// A new session may grant access the old one lacked, or reach an
// upgraded server.
+116 -22
View File
@@ -5,6 +5,7 @@ import type { CalendarEvent } from '../api/types';
import {
createCalendar,
deleteCalendar,
getCalendarName,
parseCalendarBlob,
queryEvents,
getEventIdentities,
@@ -14,6 +15,7 @@ import {
} from '../api/calendar';
import { jmapClient } from '../api/jmap-client';
import { uploadBytes } from '../api/blob';
import { generateAccountId } from '../lib/account-utils';
import { useCalendarStore } from './calendar-store';
import { t } from './locale-store';
@@ -22,10 +24,19 @@ export interface CalendarSubscription {
name: string;
url: string;
color?: string;
/** The local Stalwart calendar that mirrors this remote feed. */
calendarId: string;
/**
* The local Stalwart calendar that mirrors this remote feed. Null once the
* server behind the account was replaced (lib/server-change), whose ids may
* name other calendars; the next sync makes a new mirror.
*/
calendarId: string | null;
/** JMAP account the mirror calendar lives in; subs of other accounts are hidden. */
accountId?: string;
/**
* The signed-in account (username@host) it belongs to: JMAP account ids
* repeat across servers. Unset on subscriptions made before it was kept.
*/
localAccountId?: string;
/** Minutes between automatic refreshes (default 60). */
refreshIntervalMinutes?: number;
lastSyncAt: number | null;
@@ -56,20 +67,55 @@ interface SubscriptionsState {
syncAll: () => Promise<void>;
/** Refresh every subscription of the active account whose interval elapsed. */
syncDue: () => Promise<void>;
/**
* The server behind a signed-in account was replaced (lib/server-change):
* its calendar ids name other calendars now, or none. The account's
* subscriptions keep their feed, name, colour and interval, lose their
* mirror calendar and get a new one on the next sync. Subscriptions made
* before the account was recorded on them are matched by the JMAP account
* ids the old and the new server gave it.
*/
unlinkAccount: (localAccountId: string, jmapAccountIds: { previous: string | null; next: string | null }) => Promise<void>;
}
/** Subscriptions belonging to the signed-in JMAP account (legacy ones without an accountId count as its own). */
/**
* Subscriptions belonging to the signed-in account: by username@host where
* the subscription records it, else by JMAP account id (legacy ones without
* either count as its own).
*/
export function selectAccountSubscriptions(
subscriptions: CalendarSubscription[],
accountId: string | null,
localAccountId?: string | null,
): CalendarSubscription[] {
return subscriptions.filter((s) => !s.accountId || !accountId || s.accountId === accountId);
return subscriptions.filter((s) => {
if (s.localAccountId && localAccountId) return s.localAccountId === localAccountId;
return !s.accountId || !accountId || s.accountId === accountId;
});
}
function currentAccountId(): string | null {
return jmapClient.isConnected ? jmapClient.accountId : null;
}
/** The signed-in account as username@host, when the client serves one. */
export function currentLocalAccountId(): string | null {
const { username, serverUrl } = jmapClient;
if (!jmapClient.isConnected || !username || !serverUrl) return null;
return generateAccountId(username, serverUrl);
}
// The mirror calendar is recognised by the name the subscription gave it.
// A calendar under the recorded id with another name is not the mirror: the
// id may be reused by a replaced server, or the user renamed the calendar.
async function isMirrorCalendar(calendarId: string, name: string): Promise<boolean> {
try {
return (await getCalendarName(calendarId)) === name;
} catch {
return false;
}
}
// webcal:// and webcals:// are just iCalendar over HTTP(S) — clients map
// them to https. Credentials embedded in the URL (#275) are moved into an
// Authorization header because RN's WHATWG fetch rejects userinfo in URLs.
@@ -121,23 +167,26 @@ async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]>
// Reconcile the local calendar with the remote feed: drop events whose UID
// disappeared upstream (unlinking rather than deleting when the event also
// lives in another calendar), then import the current set (importEvents
// dedupes by UID, so unchanged events are left in place).
async function syncFeedIntoCalendar(calendarId: string, url: string): Promise<void> {
// dedupes by UID, so unchanged events are left in place). Nothing is dropped
// from a calendar that doesn't carry the subscription's name.
async function syncFeedIntoCalendar(calendarId: string, url: string, name: string): Promise<void> {
const parsed = await fetchAndParseFeed(url);
const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]);
try {
const ids = await queryEvents([calendarId], '', '');
const existing = await getEventIdentities(ids);
const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid));
const toDelete: string[] = [];
for (const e of stale) {
const others = { ...(e.calendarIds || {}) };
delete others[calendarId];
if (Object.keys(others).length === 0) toDelete.push(e.id);
else await updateEvent(e.id, { calendarIds: others });
if (await isMirrorCalendar(calendarId, name)) {
const ids = await queryEvents([calendarId], '', '');
const existing = await getEventIdentities(ids);
const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid));
const toDelete: string[] = [];
for (const e of stale) {
const others = { ...(e.calendarIds || {}) };
delete others[calendarId];
if (Object.keys(others).length === 0) toDelete.push(e.id);
else await updateEvent(e.id, { calendarIds: others });
}
if (toDelete.length > 0) await deleteEvents(toDelete);
}
if (toDelete.length > 0) await deleteEvents(toDelete);
} catch {
// Non-fatal: if we can't enumerate existing events, still import new ones.
}
@@ -161,6 +210,7 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
color,
calendarId: calendar.id,
accountId: currentAccountId() ?? undefined,
localAccountId: currentLocalAccountId() ?? undefined,
refreshIntervalMinutes: refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES,
lastSyncAt: null,
lastError: null,
@@ -169,7 +219,7 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
await useCalendarStore.getState().fetchCalendars();
try {
set({ syncing: { ...get().syncing, [sub.id]: true } });
await syncFeedIntoCalendar(sub.calendarId, sub.url);
await syncFeedIntoCalendar(calendar.id, sub.url, name);
set({ subscriptions: [...get().subscriptions, { ...sub, lastSyncAt: Date.now() }] });
await useCalendarStore.getState().refresh();
return { ...sub, lastSyncAt: Date.now() };
@@ -199,7 +249,13 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
? { refreshIntervalMinutes: updates.refreshIntervalMinutes }
: {}),
};
if (next.name !== sub.name || next.color !== sub.color) {
// An unlinked subscription has no calendar to rename yet; its next
// sync makes one with the new name and colour.
if (
sub.calendarId
&& (next.name !== sub.name || next.color !== sub.color)
&& (await isMirrorCalendar(sub.calendarId, sub.name))
) {
try {
// Only what changed: Stalwart 1.0 refuses a colour that is not
// #rgb, #rrggbb or a CSS name, which another client may have set.
@@ -219,7 +275,9 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
removeSubscription: async (id) => {
const sub = get().subscriptions.find((s) => s.id === id);
set({ subscriptions: get().subscriptions.filter((s) => s.id !== id) });
if (sub) {
// Only a calendar recognisably the mirror is destroyed, with its
// events: under a replaced server the recorded id can name another.
if (sub?.calendarId && (await isMirrorCalendar(sub.calendarId, sub.name))) {
try {
await deleteCalendar(sub.calendarId);
} catch {
@@ -234,14 +292,27 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
const sub = get().subscriptions.find((s) => s.id === id);
if (!sub) return;
if (get().syncing[id]) return;
// An unlinked subscription gets its new mirror in its own account only.
const localAccountId = currentLocalAccountId();
if (!sub.calendarId && (!localAccountId || (sub.localAccountId && sub.localAccountId !== localAccountId))) return;
set({ syncing: { ...get().syncing, [id]: true } });
try {
await syncFeedIntoCalendar(sub.calendarId, sub.url);
let calendarId = sub.calendarId;
if (!calendarId) {
// As addSubscription does. Linked right away, so a feed that
// fails below doesn't get another calendar on the next sync.
calendarId = (await createCalendar(sub.name, sub.color)).id;
const linked = { calendarId, accountId: currentAccountId() ?? undefined, localAccountId: localAccountId ?? undefined };
set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? { ...s, ...linked } : s)) });
await useCalendarStore.getState().fetchCalendars();
}
await syncFeedIntoCalendar(calendarId, sub.url, sub.name);
set({
subscriptions: get().subscriptions.map((s) =>
s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
),
});
if (!sub.calendarId) await useCalendarStore.getState().refresh();
} catch (err) {
const message = err instanceof Error ? err.message : t('calendar.subscription.refresh_error', 'Failed to refresh subscription');
set({
@@ -255,7 +326,7 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
},
syncAll: async () => {
const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId());
const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId(), currentLocalAccountId());
for (const sub of subs) {
await get().syncSubscription(sub.id);
}
@@ -264,13 +335,36 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
syncDue: async () => {
if (!jmapClient.isConnected) return;
const now = Date.now();
const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId());
const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId(), currentLocalAccountId());
for (const sub of subs) {
const interval = (sub.refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES) * 60_000;
if (sub.lastSyncAt && now - sub.lastSyncAt < interval) continue;
await get().syncSubscription(sub.id);
}
},
unlinkAccount: async (localAccountId, { previous, next }) => {
// Cold start: the subscriptions may not have been read from storage yet.
if (!useCalendarSubscriptionsStore.persist.hasHydrated()) {
await useCalendarSubscriptionsStore.persist.rehydrate();
}
const owned = (s: CalendarSubscription) => (s.localAccountId
? s.localAccountId === localAccountId
: !s.accountId || s.accountId === previous || s.accountId === next);
set({
subscriptions: get().subscriptions.map((s) => (owned(s)
? {
...s,
calendarId: null,
localAccountId,
accountId: next ?? s.accountId,
// Due at once: the calendar is gone until the next sync.
lastSyncAt: null,
lastError: null,
}
: s)),
});
},
}),
{
name: 'calendar-subscriptions',
+11 -3
View File
@@ -116,9 +116,9 @@ interface OutboxState {
/**
* Drop an account's queued and failed ops, loaded or not. Every op names a
* message by its server id, which a different server can have given to
* another message (see lib/server-change).
* another message (see lib/server-change). Resolves to how many went.
*/
clearAccount: (accountId: string) => Promise<void>;
clearAccount: (accountId: string) => Promise<number>;
}
function persist(accountId: string, entries: OutboxEntry[]): void {
@@ -447,14 +447,22 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
},
clearAccount: async (accountId) => {
if (get().activeAccountId === accountId) {
const state = get();
const loaded = state.activeAccountId === accountId && state.hydrated;
let dropped = loaded ? state.entries.length + state.failed.length : 0;
if (state.activeAccountId === accountId) {
clearRetry();
// A read of the bucket still under way must not bring the ops back.
loadEpoch += 1;
set({ entries: [], failed: [], hydrated: true });
}
if (!loaded) {
const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]);
dropped = entries.length + failed.length;
}
await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined);
await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined);
return dropped;
},
}));
+16
View File
@@ -461,6 +461,12 @@ export interface SettingsState extends PersistedSettings {
*/
ensureIdentities: () => Promise<void>;
hydrate: () => Promise<void>;
/**
* Forget the preferred sender identity of these JMAP accounts: the id names
* an identity of a server that was replaced (lib/server-change), which the
* new one may have given to another address.
*/
forgetPreferredIdentities: (jmapAccountIds: string[]) => Promise<void>;
// Generic setter — preferred for new code.
updateSetting: <K extends keyof PersistedSettings>(
@@ -757,6 +763,16 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
persist(snapshot(get()));
},
forgetPreferredIdentities: async (jmapAccountIds) => {
// Written before the settings were read, the defaults would replace them.
await get().hydrate();
const current = get().preferredIdentityIds;
if (!jmapAccountIds.some((id) => id in current)) return;
const next = { ...current };
for (const id of jmapAccountIds) delete next[id];
get().updateSetting('preferredIdentityIds', next);
},
setExternalContentPolicy: (policy) => { set({ externalContentPolicy: policy }); persist(snapshot(get())); },
setSenderFavicons: (enabled) => { set({ senderFavicons: enabled }); persist(snapshot(get())); },
setGroupContactsByLetter: (enabled) => { set({ groupContactsByLetter: enabled }); persist(snapshot(get())); },