From 0b39e43a4df3f97b8372e13d2eb6935579375a45 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:59:04 +0200 Subject: [PATCH] feat: surface ShareNotification changes as toasts --- .../layout/share-notification-toaster.tsx | 73 +++++++++++++++++++ components/mail/mail-app.tsx | 2 + lib/jmap/client-interface.ts | 7 +- lib/jmap/client.ts | 45 +++++++++++- lib/jmap/types.ts | 18 +++++ locales/ar/common.json | 12 +++ locales/ca/common.json | 12 +++ locales/cs/common.json | 12 +++ locales/da/common.json | 12 +++ locales/de/common.json | 12 +++ locales/en/common.json | 12 +++ locales/es/common.json | 12 +++ locales/fa/common.json | 12 +++ locales/fr/common.json | 12 +++ locales/he/common.json | 12 +++ locales/hu/common.json | 12 +++ locales/it/common.json | 12 +++ locales/ja/common.json | 12 +++ locales/ko/common.json | 12 +++ locales/lv/common.json | 12 +++ locales/mn/common.json | 12 +++ locales/nb/common.json | 12 +++ locales/nl/common.json | 12 +++ locales/pl/common.json | 12 +++ locales/pt/common.json | 12 +++ locales/ro/common.json | 12 +++ locales/ru/common.json | 12 +++ locales/sk/common.json | 12 +++ locales/tr/common.json | 12 +++ locales/uk/common.json | 12 +++ locales/zh-TW/common.json | 12 +++ locales/zh/common.json | 12 +++ .../share-notification-store.test.ts | 60 +++++++++++++++ stores/email-store.ts | 8 ++ stores/share-notification-store.ts | 64 ++++++++++++++++ 35 files changed, 599 insertions(+), 2 deletions(-) create mode 100644 components/layout/share-notification-toaster.tsx create mode 100644 stores/__tests__/share-notification-store.test.ts create mode 100644 stores/share-notification-store.ts diff --git a/components/layout/share-notification-toaster.tsx b/components/layout/share-notification-toaster.tsx new file mode 100644 index 000000000..d4ff15868 --- /dev/null +++ b/components/layout/share-notification-toaster.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useTranslations } from "next-intl"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { ShareNotification } from "@/lib/jmap/types"; +import { useShareNotificationStore } from "@/stores/share-notification-store"; +import { useEmailStore } from "@/stores/email-store"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { useContactStore } from "@/stores/contact-store"; +import { useFileStore } from "@/stores/file-store"; +import { toast } from "@/stores/toast-store"; + +/** What happened to the user's rights, for the message wording. */ +export function shareNotificationKind(n: ShareNotification): "shared" | "changed" | "revoked" { + if (!n.oldRights || Object.keys(n.oldRights).length === 0) return "shared"; + if (!n.newRights || Object.keys(n.newRights).length === 0) return "revoked"; + return "changed"; +} + +const OBJECT_KEYS: Record = { + Mailbox: "folder", + Calendar: "calendar", + AddressBook: "address_book", + FileNode: "files", +}; + +/** + * Shows pending ShareNotifications as toasts, refreshes the collection list + * the change belongs to (so a newly shared folder / calendar / address book + * appears without a reload) and acknowledges them on the server. + */ +export function ShareNotificationToaster({ client }: { client: IJMAPClient | null }) { + const t = useTranslations("share_notifications"); + const pending = useShareNotificationStore((s) => s.pending); + const acknowledge = useShareNotificationStore((s) => s.acknowledge); + const handled = useRef(new Set()); + + // Initial pull; pushes call fetch() from the email store's state handler. + useEffect(() => { + if (!client) return; + void useShareNotificationStore.getState().fetch(client); + }, [client]); + + useEffect(() => { + if (!client || pending.length === 0) return; + const fresh = pending.filter((n) => !handled.current.has(n.id)); + if (fresh.length === 0) return; + const touched = new Set(); + for (const n of fresh) { + handled.current.add(n.id); + touched.add(n.objectType); + const kind = shareNotificationKind(n); + const objectKey = OBJECT_KEYS[n.objectType]; + const message = t(kind, { + name: n.changedBy?.name || n.changedBy?.email || t("someone"), + object: n.name || n.objectId, + kind: objectKey ? t(`object.${objectKey}`) : n.objectType, + }); + if (kind === "revoked") toast.warning(message); + else toast.info(message); + } + if (touched.has("Mailbox")) void useEmailStore.getState().fetchMailboxes(client); + if (touched.has("Calendar") && useCalendarStore.getState().supportsCalendar) { + void useCalendarStore.getState().fetchCalendars(client); + } + if (touched.has("AddressBook")) void useContactStore.getState().fetchAddressBooks(client); + if (touched.has("FileNode")) void useFileStore.getState().refresh(); + void acknowledge(client, fresh.map((n) => n.id)); + }, [client, pending, acknowledge, t]); + + return null; +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 09ba53d46..4f041682f 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -33,6 +33,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog"; +import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster"; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useSettingsStore } from "@/stores/settings-store"; import { useContactStore } from "@/stores/contact-store"; @@ -4201,6 +4202,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { onClose={() => setSharingMailboxId(null)} /> )} + diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index e236c476d..6370a594f 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, EmailPushConfig, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; +import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, EmailPushConfig, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { SortLevel } from "@/lib/message-list-order"; @@ -126,6 +126,11 @@ export interface IJMAPClient { createMailbox(name: string, parentId?: string, accountId?: string): Promise; updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise; deleteMailbox(mailboxId: string, accountId?: string): Promise; + // ShareNotification (RFC 9670 §3): who shared what with this user. Optional + // (demo client). + supportsShareNotifications?(): boolean; + getShareNotifications?(): Promise; + destroyShareNotifications?(ids: string[]): Promise; // Mailbox sharing (urn:ietf:params:jmap:mail:share). Optional: the demo // client has no principals to share with. supportsMailboxSharing?(accountId?: string): boolean; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 9afcc5310..5c589626c 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,5 +1,5 @@ import { generateUUID } from '@/lib/utils'; -import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; +import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; @@ -5071,6 +5071,49 @@ export class JMAPClient implements IJMAPClient { } } + // ── ShareNotification (RFC 9670 §3) ──────────────────────────── + + /** Whether the server can report who shared what with this user. */ + supportsShareNotifications(): boolean { + return this.supportsPrincipals(); + } + + /** + * Every pending share notification of the user's own account, oldest + * first. The list is small by construction: notifications are destroyed + * once they have been shown (see destroyShareNotifications). + */ + async getShareNotifications(): Promise { + if (!this.supportsShareNotifications()) return []; + const accountId = this.accountId; + const response = await this.request([ + ["ShareNotification/query", { + accountId, + sort: [{ property: "created", isAscending: true }], + }, "0"], + ["ShareNotification/get", { + accountId, + "#ids": { resultOf: "0", name: "ShareNotification/query", path: "/ids" }, + }, "1"], + ], this.principalsUsing()); + const getResp = response.methodResponses?.find((r) => r[0] === "ShareNotification/get"); + if (!getResp) { + const error = response.methodResponses?.find((r) => r[0] === "error")?.[1] as { description?: string } | undefined; + throw new Error(error?.description || "Failed to load share notifications"); + } + return ((getResp[1] as { list?: ShareNotification[] }).list ?? []); + } + + /** Acknowledges (destroys) share notifications that have been shown. */ + async destroyShareNotifications(ids: string[]): Promise { + if (ids.length === 0 || !this.supportsShareNotifications()) return; + for (const batch of batched(ids, this.getMaxObjectsInSet())) { + await this.request([ + ["ShareNotification/set", { accountId: this.accountId, destroy: batch }, "0"], + ], this.principalsUsing()); + } + } + /** * Add, update, or remove a principal's rights on a calendar. * Pass `rights: null` to revoke access. diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 626c81318..04acab665 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -851,10 +851,28 @@ export interface StateChange { Calendar?: string; CalendarEvent?: string; SieveScript?: string; + ShareNotification?: string; }; }; } +/** + * A ShareNotification (RFC 9670 §3): someone changed this user's rights on + * one of their collections. `oldRights` null = newly shared, `newRights` + * null = access revoked. + */ +export interface ShareNotification { + id: string; + created: string; + changedBy: { name: string; email: string | null; principalId: string | null }; + objectType: 'Mailbox' | 'Calendar' | 'AddressBook' | 'FileNode' | string; + objectAccountId: string; + objectId: string; + name: string; + oldRights: Record | null; + newRights: Record | null; +} + // draft-ietf-jmap-emailpush EmailPushConfig: the server evaluates `filter` // against every newly delivered message and only pushes when it matches, so a // client can keep spam (Junk) out of its push channel server-side. Keyed by diff --git a/locales/ar/common.json b/locales/ar/common.json index dcdb0e387..7f3e61e60 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3150,6 +3150,18 @@ "overdue": "متأخرة" } }, + "share_notifications": { + "someone": "شخص ما", + "shared": "شارك {name} معك {kind} \"{object}\"", + "changed": "غيّر {name} صلاحيتك على {kind} \"{object}\"", + "revoked": "أزال {name} صلاحيتك على {kind} \"{object}\"", + "object": { + "folder": "المجلد", + "calendar": "التقويم", + "address_book": "دفتر العناوين", + "files": "مجلد الملفات" + } + }, "sharing": { "title": "مشاركة \"{name}\"", "description": "امنح الوصول لمستخدمين أو مجموعات أخرى على هذا الخادم. تُطبَّق التغييرات فورًا.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 54639592c..29685bfb0 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3150,6 +3150,18 @@ "overdue": "Vençuda" } }, + "share_notifications": { + "someone": "Algú", + "shared": "{name} ha compartit amb tu {kind} \"{object}\"", + "changed": "{name} ha canviat el teu accés a {kind} \"{object}\"", + "revoked": "{name} ha retirat el teu accés a {kind} \"{object}\"", + "object": { + "folder": "la carpeta", + "calendar": "el calendari", + "address_book": "la llibreta d'adreces", + "files": "la carpeta de fitxers" + } + }, "sharing": { "title": "Comparteix «{name}»", "description": "Concediu accés a altres usuaris o grups d'aquest servidor. Els canvis s'apliquen immediatament.", diff --git a/locales/cs/common.json b/locales/cs/common.json index 99fb0e926..10a98eef9 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "Vyhledávání není ve sjednoceném zobrazení k dispozici" }, + "share_notifications": { + "someone": "Někdo", + "shared": "{name} s vámi sdílí {kind} „{object}“", + "changed": "{name} změnil(a) váš přístup k {kind} „{object}“", + "revoked": "{name} odebral(a) váš přístup k {kind} „{object}“", + "object": { + "folder": "složku", + "calendar": "kalendář", + "address_book": "adresář", + "files": "složku souborů" + } + }, "sharing": { "title": "Sdílet „{name}\"", "description": "Udělte přístup dalším uživatelům nebo skupinám na tomto serveru. Změny se projeví okamžitě.", diff --git a/locales/da/common.json b/locales/da/common.json index cf82d6a3a..b3db4be56 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3150,6 +3150,18 @@ }, "nav_open_menu": "Åbn menu" }, + "share_notifications": { + "someone": "Nogen", + "shared": "{name} har delt {kind} \"{object}\" med dig", + "changed": "{name} har ændret din adgang til {kind} \"{object}\"", + "revoked": "{name} har fjernet din adgang til {kind} \"{object}\"", + "object": { + "folder": "mappen", + "calendar": "kalenderen", + "address_book": "adressebogen", + "files": "filmappen" + } + }, "sharing": { "title": "Del \"{name}\"", "description": "Giv adgang til andre brugere eller grupper på denne server. Ændringer træder i kraft øjeblikkeligt.", diff --git a/locales/de/common.json b/locales/de/common.json index d1876a53e..9a3ec9610 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar" }, + "share_notifications": { + "someone": "Jemand", + "shared": "{name} hat {kind} „{object}“ mit dir geteilt", + "changed": "{name} hat deinen Zugriff auf {kind} „{object}“ geändert", + "revoked": "{name} hat deinen Zugriff auf {kind} „{object}“ entfernt", + "object": { + "folder": "den Ordner", + "calendar": "den Kalender", + "address_book": "das Adressbuch", + "files": "den Dateiordner" + } + }, "sharing": { "title": "„{name}\" freigeben", "description": "Anderen Benutzern oder Gruppen auf diesem Server Zugriff gewähren. Änderungen werden sofort wirksam.", diff --git a/locales/en/common.json b/locales/en/common.json index 6ad3c39f1..f1968ce51 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3150,6 +3150,18 @@ "overdue": "Overdue" } }, + "share_notifications": { + "someone": "Someone", + "shared": "{name} shared the {kind} \"{object}\" with you", + "changed": "{name} changed your access to the {kind} \"{object}\"", + "revoked": "{name} removed your access to the {kind} \"{object}\"", + "object": { + "folder": "folder", + "calendar": "calendar", + "address_book": "address book", + "files": "file folder" + } + }, "sharing": { "title": "Share \"{name}\"", "description": "Grant access to other users or groups on this server. Changes take effect immediately.", diff --git a/locales/es/common.json b/locales/es/common.json index 38c87abe9..89ba75c03 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "La búsqueda no está disponible en la vista unificada" }, + "share_notifications": { + "someone": "Alguien", + "shared": "{name} ha compartido contigo {kind} \"{object}\"", + "changed": "{name} ha cambiado tu acceso a {kind} \"{object}\"", + "revoked": "{name} ha retirado tu acceso a {kind} \"{object}\"", + "object": { + "folder": "la carpeta", + "calendar": "el calendario", + "address_book": "la libreta de direcciones", + "files": "la carpeta de archivos" + } + }, "sharing": { "title": "Compartir «{name}»", "description": "Concede acceso a otros usuarios o grupos de este servidor. Los cambios surten efecto inmediatamente.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 148b21dc5..4b70a3ed3 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3150,6 +3150,18 @@ "overdue": "عقب‌افتاده" } }, + "share_notifications": { + "someone": "کسی", + "shared": "{name} {kind} «{object}» را با شما به اشتراک گذاشت", + "changed": "{name} دسترسی شما به {kind} «{object}» را تغییر داد", + "revoked": "{name} دسترسی شما به {kind} «{object}» را حذف کرد", + "object": { + "folder": "پوشه", + "calendar": "تقویم", + "address_book": "دفترچه آدرس", + "files": "پوشه فایل" + } + }, "sharing": { "title": "اشتراک‌گذاری \"{name}\"", "description": "دسترسی به سایر کاربران یا گروه‌های این سرور را فراهم کنید.", diff --git a/locales/fr/common.json b/locales/fr/common.json index e8ca34cda..f9db2f9a8 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "La recherche n'est pas disponible dans la vue unifiée" }, + "share_notifications": { + "someone": "Quelqu'un", + "shared": "{name} a partagé {kind} « {object} » avec vous", + "changed": "{name} a modifié votre accès à {kind} « {object} »", + "revoked": "{name} a retiré votre accès à {kind} « {object} »", + "object": { + "folder": "le dossier", + "calendar": "l'agenda", + "address_book": "le carnet d'adresses", + "files": "le dossier de fichiers" + } + }, "sharing": { "title": "Partager « {name} »", "description": "Accordez l'accès à d'autres utilisateurs ou groupes de ce serveur. Les modifications sont immédiates.", diff --git a/locales/he/common.json b/locales/he/common.json index aebe7c064..c6549a253 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3450,6 +3450,18 @@ "toast_error_delete_has_children": "לתיקייה יש תת־תיקיות. הסר אותן קודם.", "toast_error_delete_has_email": "התיקייה אינה ריקה. תרוקנה קודם." }, + "share_notifications": { + "someone": "מישהו", + "shared": "{name} שיתף/ה איתך את {kind} \"{object}\"", + "changed": "{name} שינה/תה את הגישה שלך ל{kind} \"{object}\"", + "revoked": "{name} הסיר/ה את הגישה שלך ל{kind} \"{object}\"", + "object": { + "folder": "התיקייה", + "calendar": "היומן", + "address_book": "פנקס הכתובות", + "files": "תיקיית הקבצים" + } + }, "sharing": { "title": "שתף \"{name}\"", "description": "הענק גישה למשתמשים או קבוצות אחרות בשרת זה. השינויים יופעלו מיד.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 807190584..7bd288366 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3150,6 +3150,18 @@ "overdue": "Lejárt" } }, + "share_notifications": { + "someone": "Valaki", + "shared": "{name} megosztotta veled a(z) {kind} „{object}” elemet", + "changed": "{name} módosította a hozzáférésedet a(z) {kind} „{object}” elemhez", + "revoked": "{name} visszavonta a hozzáférésedet a(z) {kind} „{object}” elemhez", + "object": { + "folder": "mappa", + "calendar": "naptár", + "address_book": "címjegyzék", + "files": "fájlmappa" + } + }, "sharing": { "title": "\"{name}\" megosztása", "description": "Hozzáférés biztosítása más felhasználók vagy csoportok számára ezen a szerveren. A módosítások azonnal életbe lépnek.", diff --git a/locales/it/common.json b/locales/it/common.json index a709c80db..ce48fac29 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "La ricerca non è disponibile nella vista unificata" }, + "share_notifications": { + "someone": "Qualcuno", + "shared": "{name} ha condiviso con te {kind} \"{object}\"", + "changed": "{name} ha modificato il tuo accesso a {kind} \"{object}\"", + "revoked": "{name} ha rimosso il tuo accesso a {kind} \"{object}\"", + "object": { + "folder": "la cartella", + "calendar": "il calendario", + "address_book": "la rubrica", + "files": "la cartella dei file" + } + }, "sharing": { "title": "Condividi \"{name}\"", "description": "Concedi l'accesso ad altri utenti o gruppi su questo server. Le modifiche hanno effetto immediato.", diff --git a/locales/ja/common.json b/locales/ja/common.json index ca30f3a48..981b9546b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "統合ビューでは検索を利用できません" }, + "share_notifications": { + "someone": "誰か", + "shared": "{name} さんが{kind}「{object}」を共有しました", + "changed": "{name} さんが{kind}「{object}」へのアクセス権を変更しました", + "revoked": "{name} さんが{kind}「{object}」へのアクセス権を削除しました", + "object": { + "folder": "フォルダー", + "calendar": "カレンダー", + "address_book": "アドレス帳", + "files": "ファイルフォルダー" + } + }, "sharing": { "title": "「{name}」を共有", "description": "このサーバー上の他のユーザーまたはグループにアクセス権を付与します。変更はすぐに反映されます。", diff --git a/locales/ko/common.json b/locales/ko/common.json index b923683d8..9d793a7ad 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다" }, + "share_notifications": { + "someone": "누군가", + "shared": "{name}님이 {kind} \"{object}\"을(를) 공유했습니다", + "changed": "{name}님이 {kind} \"{object}\"에 대한 접근 권한을 변경했습니다", + "revoked": "{name}님이 {kind} \"{object}\"에 대한 접근 권한을 제거했습니다", + "object": { + "folder": "폴더", + "calendar": "캘린더", + "address_book": "주소록", + "files": "파일 폴더" + } + }, "sharing": { "title": "\"{name}\" 공유", "description": "이 서버의 다른 사용자나 그룹에 액세스 권한을 부여합니다. 변경 사항은 즉시 적용됩니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index deb99ebd2..5d03dcd50 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "Meklēšana nav pieejama apvienotajā skatā" }, + "share_notifications": { + "someone": "Kāds", + "shared": "{name} kopīgoja ar jums {kind} “{object}”", + "changed": "{name} mainīja jūsu piekļuvi {kind} “{object}”", + "revoked": "{name} noņēma jūsu piekļuvi {kind} “{object}”", + "object": { + "folder": "mapi", + "calendar": "kalendāru", + "address_book": "adrešu grāmatu", + "files": "failu mapi" + } + }, "sharing": { "title": "Kopīgot \"{name}\"", "description": "Piešķiriet piekļuvi citiem lietotājiem vai grupām šajā serverī. Izmaiņas stājas spēkā nekavējoties.", diff --git a/locales/mn/common.json b/locales/mn/common.json index c85d54edf..6ab21436c 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3150,6 +3150,18 @@ "overdue": "Хугацаа хэтэрсэн" } }, + "share_notifications": { + "someone": "Хэн нэгэн", + "shared": "{name} танд {kind} \"{object}\"-г хуваалцлаа", + "changed": "{name} таны {kind} \"{object}\"-д хандах эрхийг өөрчиллөө", + "revoked": "{name} таны {kind} \"{object}\"-д хандах эрхийг хаслаа", + "object": { + "folder": "хавтас", + "calendar": "хуанли", + "address_book": "хаягийн дэвтэр", + "files": "файлын хавтас" + } + }, "sharing": { "title": "\"{name}\"-г хуваалцаарай", "description": "Энэ сервер дээрх бусад хэрэглэгчид эсвэл бүлгүүдэд хандах эрх олгох. Өөрчлөлтүүд шууд хүчин төгөлдөр болно.", diff --git a/locales/nb/common.json b/locales/nb/common.json index a6d0c9b2b..d384bb222 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3148,6 +3148,18 @@ "overdue": "Forfalt" } }, + "share_notifications": { + "someone": "Noen", + "shared": "{name} har delt {kind} «{object}» med deg", + "changed": "{name} har endret tilgangen din til {kind} «{object}»", + "revoked": "{name} har fjernet tilgangen din til {kind} «{object}»", + "object": { + "folder": "mappen", + "calendar": "kalenderen", + "address_book": "adresseboken", + "files": "filmappen" + } + }, "sharing": { "title": "Del «{name}»", "description": "Gi andre brukere eller grupper på denne serveren tilgang. Endringer trer i kraft umiddelbart.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8a9cdb8a8..4b78c4947 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave" }, + "share_notifications": { + "someone": "Iemand", + "shared": "{name} heeft {kind} \"{object}\" met je gedeeld", + "changed": "{name} heeft je toegang tot {kind} \"{object}\" gewijzigd", + "revoked": "{name} heeft je toegang tot {kind} \"{object}\" ingetrokken", + "object": { + "folder": "de map", + "calendar": "de agenda", + "address_book": "het adresboek", + "files": "de bestandsmap" + } + }, "sharing": { "title": "\"{name}\" delen", "description": "Geef andere gebruikers of groepen op deze server toegang. Wijzigingen zijn direct van kracht.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 1574d5fc9..bf8fc3139 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym" }, + "share_notifications": { + "someone": "Ktoś", + "shared": "{name} udostępnił(a) Ci {kind} „{object}”", + "changed": "{name} zmienił(a) Twój dostęp do {kind} „{object}”", + "revoked": "{name} odebrał(a) Ci dostęp do {kind} „{object}”", + "object": { + "folder": "folder", + "calendar": "kalendarz", + "address_book": "książkę adresową", + "files": "folder plików" + } + }, "sharing": { "title": "Udostępnij „{name}\"", "description": "Udziel dostępu innym użytkownikom lub grupom na tym serwerze. Zmiany są natychmiastowe.", diff --git a/locales/pt/common.json b/locales/pt/common.json index afe25e022..18b66c571 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "A pesquisa não está disponível na vista unificada" }, + "share_notifications": { + "someone": "Alguém", + "shared": "{name} partilhou consigo {kind} \"{object}\"", + "changed": "{name} alterou o seu acesso a {kind} \"{object}\"", + "revoked": "{name} removeu o seu acesso a {kind} \"{object}\"", + "object": { + "folder": "a pasta", + "calendar": "o calendário", + "address_book": "o livro de endereços", + "files": "a pasta de ficheiros" + } + }, "sharing": { "title": "Compartilhar \"{name}\"", "description": "Conceda acesso a outros usuários ou grupos neste servidor. As alterações têm efeito imediato.", diff --git a/locales/ro/common.json b/locales/ro/common.json index e3dc0ba35..b759bbcb1 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3150,6 +3150,18 @@ "overdue": "Restant" } }, + "share_notifications": { + "someone": "Cineva", + "shared": "{name} a partajat cu tine {kind} „{object}”", + "changed": "{name} ți-a modificat accesul la {kind} „{object}”", + "revoked": "{name} ți-a retras accesul la {kind} „{object}”", + "object": { + "folder": "dosarul", + "calendar": "calendarul", + "address_book": "agenda de contacte", + "files": "dosarul de fișiere" + } + }, "sharing": { "title": "Distribuie „{name}”", "description": "Acordați acces altor utilizatori sau grupuri de pe acest server. Modificările intră în vigoare imediat.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 6006e2e0c..6821fa3b9 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "Поиск недоступен в объединённом представлении" }, + "share_notifications": { + "someone": "Кто-то", + "shared": "{name} поделился(ась) с вами {kind} «{object}»", + "changed": "{name} изменил(а) ваш доступ к {kind} «{object}»", + "revoked": "{name} отозвал(а) ваш доступ к {kind} «{object}»", + "object": { + "folder": "папкой", + "calendar": "календарём", + "address_book": "адресной книгой", + "files": "папкой файлов" + } + }, "sharing": { "title": "Поделиться «{name}»", "description": "Предоставьте доступ другим пользователям или группам на этом сервере. Изменения вступают в силу немедленно.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 8a010ccf8..5f6b84448 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3150,6 +3150,18 @@ "overdue": "Po termíne" } }, + "share_notifications": { + "someone": "Niekto", + "shared": "{name} s vami zdieľa {kind} „{object}“", + "changed": "{name} zmenil(a) váš prístup k {kind} „{object}“", + "revoked": "{name} odobral(a) váš prístup k {kind} „{object}“", + "object": { + "folder": "priečinok", + "calendar": "kalendár", + "address_book": "adresár", + "files": "priečinok súborov" + } + }, "sharing": { "title": "Zdieľať \"{name}\"", "description": "Udeľte prístup ďalším používateľom alebo skupinám na tomto serveri. Zmeny sa prejavia okamžite.", diff --git a/locales/tr/common.json b/locales/tr/common.json index 6a73258be..9a369db81 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3150,6 +3150,18 @@ }, "nav_open_menu": "Menüyü aç" }, + "share_notifications": { + "someone": "Birisi", + "shared": "{name}, \"{object}\" {kind} sizinle paylaştı", + "changed": "{name}, \"{object}\" {kind} erişiminizi değiştirdi", + "revoked": "{name}, \"{object}\" {kind} erişiminizi kaldırdı", + "object": { + "folder": "klasörünü", + "calendar": "takvimini", + "address_book": "adres defterini", + "files": "dosya klasörünü" + } + }, "sharing": { "title": "\"{name}\" paylaş", "description": "Bu sunucudaki diğer kullanıcılara veya gruplara erişim verin. Değişiklikler hemen geçerli olur.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 43ad872d9..37acd7a98 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "Пошук недоступний в об'єднаному перегляді" }, + "share_notifications": { + "someone": "Хтось", + "shared": "{name} поділився(лася) з вами {kind} «{object}»", + "changed": "{name} змінив(ла) ваш доступ до {kind} «{object}»", + "revoked": "{name} скасував(ла) ваш доступ до {kind} «{object}»", + "object": { + "folder": "текою", + "calendar": "календарем", + "address_book": "адресною книгою", + "files": "текою файлів" + } + }, "sharing": { "title": "Поділитися «{name}»", "description": "Надайте доступ іншим користувачам або групам на цьому сервері. Зміни набувають чинності негайно.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index ece8e243d..2e11718f7 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3143,6 +3143,18 @@ "overdue": "逾期" } }, + "share_notifications": { + "someone": "有人", + "shared": "{name} 與您共用了{kind}「{object}」", + "changed": "{name} 變更了您對{kind}「{object}」的存取權限", + "revoked": "{name} 移除了您對{kind}「{object}」的存取權限", + "object": { + "folder": "資料夾", + "calendar": "行事曆", + "address_book": "通訊錄", + "files": "檔案資料夾" + } + }, "sharing": { "title": "分享「{name}」", "description": "授予此伺服器上的其他使用者或群組存取權。變更會立即生效。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 5a08fcc1f..38986b5cb 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3452,6 +3452,18 @@ "unified_mailbox": { "search_unavailable": "统一视图中无法使用搜索" }, + "share_notifications": { + "someone": "有人", + "shared": "{name} 与您共享了{kind}“{object}”", + "changed": "{name} 更改了您对{kind}“{object}”的访问权限", + "revoked": "{name} 移除了您对{kind}“{object}”的访问权限", + "object": { + "folder": "文件夹", + "calendar": "日历", + "address_book": "通讯录", + "files": "文件目录" + } + }, "sharing": { "title": "共享「{name}」", "description": "向此服务器上的其他用户或群组授予访问权限。更改会立即生效。", diff --git a/stores/__tests__/share-notification-store.test.ts b/stores/__tests__/share-notification-store.test.ts new file mode 100644 index 000000000..fb3aeb20d --- /dev/null +++ b/stores/__tests__/share-notification-store.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useShareNotificationStore } from '../share-notification-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { ShareNotification } from '@/lib/jmap/types'; + +const notification = (id: string): ShareNotification => ({ + id, + created: '2026-08-29T10:00:00Z', + changedBy: { name: 'Alice', email: 'alice@example.com', principalId: 'p1' }, + objectType: 'Mailbox', + objectAccountId: 'owner', + objectId: 'f1', + name: 'Projects', + oldRights: null, + newRights: { mayReadItems: true }, +}); + +function makeClient(list: ShareNotification[]) { + return { + supportsShareNotifications: () => true, + getShareNotifications: vi.fn(async () => list), + destroyShareNotifications: vi.fn(async () => undefined), + } as unknown as IJMAPClient & { getShareNotifications: ReturnType; destroyShareNotifications: ReturnType }; +} + +describe('share notification store', () => { + beforeEach(() => { + useShareNotificationStore.getState().reset(); + }); + + it('queues fetched notifications once and acknowledges them on the server', async () => { + const client = makeClient([notification('n1'), notification('n2')]); + const store = useShareNotificationStore.getState(); + + await store.fetch(client); + expect(useShareNotificationStore.getState().pending.map((n) => n.id)).toEqual(['n1', 'n2']); + + // A second fetch returning the same ids (not yet destroyed) adds nothing. + await store.fetch(client); + expect(useShareNotificationStore.getState().pending).toHaveLength(2); + + await store.acknowledge(client, ['n1']); + expect(useShareNotificationStore.getState().pending.map((n) => n.id)).toEqual(['n2']); + expect(client.destroyShareNotifications).toHaveBeenCalledWith(['n1']); + }); + + it('does nothing on a client without share notifications', async () => { + const client = { supportsShareNotifications: () => false } as unknown as IJMAPClient; + await useShareNotificationStore.getState().fetch(client); + expect(useShareNotificationStore.getState().pending).toEqual([]); + }); + + it('coalesces overlapping fetches', async () => { + const client = makeClient([notification('n1')]); + const store = useShareNotificationStore.getState(); + await Promise.all([store.fetch(client), store.fetch(client)]); + expect(client.getShareNotifications).toHaveBeenCalledTimes(1); + expect(useShareNotificationStore.getState().pending).toHaveLength(1); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index edb6bf1b5..94b5a5150 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -3522,6 +3522,14 @@ export const useEmailStore = create((set, get) => ({ } } + // Someone changed this user's access to one of their collections + // (RFC 9670 ShareNotification): pull the notifications, the toaster + // shows them and refreshes the affected list. + if (Object.values(change.changed).some((c) => c?.ShareNotification)) { + const { useShareNotificationStore } = await import('./share-notification-store'); + void useShareNotificationStore.getState().fetch(client); + } + // Handle SieveScript state changes - refresh filter rules if (accountChanges?.SieveScript) { const { useFilterStore } = await import('./filter-store'); diff --git a/stores/share-notification-store.ts b/stores/share-notification-store.ts new file mode 100644 index 000000000..bec59de1a --- /dev/null +++ b/stores/share-notification-store.ts @@ -0,0 +1,64 @@ +import { create } from "zustand"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { ShareNotification } from "@/lib/jmap/types"; + +/** + * ShareNotification (RFC 9670 §3) inbox: the server records every change to + * this user's rights on someone else's collection. The store pulls those on + * login and on every `ShareNotification` push; the toaster component shows + * them (translated) and acknowledges them, which destroys them server-side. + */ +interface ShareNotificationStore { + /** Notifications fetched but not yet shown / acknowledged. */ + pending: ShareNotification[]; + fetch: (client: IJMAPClient) => Promise; + /** Acknowledge shown notifications: drop them locally and destroy them on the server. */ + acknowledge: (client: IJMAPClient, ids: string[]) => Promise; + reset: () => void; +} + +// Ids already handed to the toaster; a fetch racing an acknowledge must not +// re-queue them. +const seen = new Set(); +let inFlight: Promise | null = null; + +export const useShareNotificationStore = create((set, get) => ({ + pending: [], + + fetch: (client) => { + if (!client.supportsShareNotifications?.() || !client.getShareNotifications) return Promise.resolve(); + // Coalesce: a push arriving while the previous fetch runs re-runs it once. + if (inFlight) return inFlight; + inFlight = (async () => { + try { + const list = await client.getShareNotifications!(); + const fresh = list.filter((n) => !seen.has(n.id)); + if (fresh.length === 0) return; + for (const n of fresh) seen.add(n.id); + set((state) => ({ pending: [...state.pending, ...fresh] })); + } catch (error) { + console.error("Failed to fetch share notifications:", error); + } finally { + inFlight = null; + } + })(); + return inFlight; + }, + + acknowledge: async (client, ids) => { + if (ids.length === 0) return; + const idSet = new Set(ids); + set({ pending: get().pending.filter((n) => !idSet.has(n.id)) }); + try { + await client.destroyShareNotifications?.(ids); + } catch (error) { + // Worst case the notification is shown again after a reload. + console.error("Failed to acknowledge share notifications:", error); + } + }, + + reset: () => { + seen.clear(); + set({ pending: [] }); + }, +}));