From 4517d154a27b452f11a09bf85d8f312c37216d77 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 15 Sep 2026 22:49:14 -0700 Subject: [PATCH] Split the extractable parts out of the mail store store/mail.ts was 1,463 lines. It is now a directory, so `@/store/mail` resolves to index.ts and none of the 36 modules importing `useMail` changes a line: mail/props.ts 72 MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS mail/types.ts 125 ListQuery, ListState, MailState, DEFAULT_SORT mail/mailboxes.ts 28 mailboxIcon, ROLE_ORDER mail/index.ts 1,266 the store, and everything bound to it Everything exported before is still exported from index.ts, so this is file layout and nothing else. No behavior change, no call-site change. WHAT THIS DOES NOT DO, and why. index.ts is still 1,266 lines because 947 of them are one `create((set, get) => ({ ... }))`. Cutting that up means Zustand slices -- splitting the state object itself and recombining it -- which is a change to how the store is built rather than to where its text lives, in the part of the app that every screen leans on. That deserves its own PR and its own argument, not a quiet ride along with a file move. Three things had to stay behind and are worth knowing about, because the obvious boundary is wrong in each case: - `listKey` sits among the type declarations but is a function the store calls, not a type. - `ensureFolderPath`, `folderRefs` and `followFolders` read like folder helpers and look like they belong beside mailboxIcon, but they close over `useMail`. Moving them makes mailboxes.ts import index.ts, which imports mailboxes.ts. - the sieve import inside index.ts is `await import(...)`, not a static one, so rewriting import paths by their `from` clause misses it. --- web/src/store/{mail.ts => mail/index.ts} | 223 ++--------------------- web/src/store/mail/mailboxes.ts | 28 +++ web/src/store/mail/props.ts | 72 ++++++++ web/src/store/mail/types.ts | 125 +++++++++++++ 4 files changed, 238 insertions(+), 210 deletions(-) rename web/src/store/{mail.ts => mail/index.ts} (88%) create mode 100644 web/src/store/mail/mailboxes.ts create mode 100644 web/src/store/mail/props.ts create mode 100644 web/src/store/mail/types.ts diff --git a/web/src/store/mail.ts b/web/src/store/mail/index.ts similarity index 88% rename from web/src/store/mail.ts rename to web/src/store/mail/index.ts index e13eeb4..5dbe583 100644 --- a/web/src/store/mail.ts +++ b/web/src/store/mail/index.ts @@ -1,7 +1,6 @@ import { create } from "zustand"; import type { FolderRef } from "@/lib/sieveFolders"; -import { SPAM_HEADER_PROPS } from "@/lib/spamScore"; -import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate"; +import { groupByArchivePath, archivePath } from "@/lib/archiveDate"; import { isOptionalSort, withoutOptionalSorts } from "@/lib/listSort"; import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client"; import type { @@ -12,7 +11,6 @@ import type { Id, Identity, Mailbox, - MailboxRole, QueryResponse, Quota, SetError, @@ -23,198 +21,28 @@ import type { Invocation, } from "@/jmap/types"; import { toast } from "@/ui/toast"; -import { settings, useSettings } from "./settings"; -import { useSession } from "./session"; +import { settings, useSettings } from "../settings"; +import { useSession } from "../session"; import { mailboxDisplayName } from "@/lib/mailboxName"; import { plural, t } from "@/lib/i18n"; import { withBase } from "@/lib/basePath"; +import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props"; +import { type ListQuery, type MailState } from "./types"; /* - * Named explicitly so `shareWith` comes back, which it does not otherwise -- - * see the note on CALENDAR_PROPS and the KNOWN-ISSUES entry. Mailboxes were the - * third and last store fetching everything by asking for nothing. - * - * It matters here for one narrow but real case. Sharing a mail folder is - * withdrawn because Stalwart stores the share and never delivers it, and the - * only way left to clear one already made is the "Stop sharing" entry, which - * appears only when a folder looks shared. Without this it never looked shared, - * so the escape hatch for the exact situation it was built for was invisible. + * `@/store/mail` stays the one public entry. The split below is about file + * size -- 1,463 lines in a single module -- not about asking the 36 call sites + * that import `useMail` to learn which half of the store a symbol moved to. + * Anything exported before is still exported from here. */ -export const MAILBOX_PROPS = [ - "id", - "name", - "parentId", - "role", - "sortOrder", - "totalEmails", - "unreadEmails", - "totalThreads", - "unreadThreads", - "myRights", - "isSubscribed", - "shareWith", -]; - -export const LIST_PROPS = [ - "id", - "blobId", - "threadId", - "mailboxIds", - "keywords", - "hasAttachment", - "from", - "to", - "subject", - "receivedAt", - "sentAt", - "size", - "preview", -]; - -export const FULL_PROPS = [ - ...LIST_PROPS, - "messageId", - "inReplyTo", - "references", - "sender", - "cc", - "bcc", - "replyTo", - "bodyStructure", - "bodyValues", - "textBody", - "htmlBody", - "attachments", - "header:List-Unsubscribe:asText", - "header:List-Unsubscribe-Post:asText", - "header:List-Id:asText", - "header:Disposition-Notification-To:asAddresses", - "header:X-Priority:asText", - "header:Importance:asText", - "header:Auto-Submitted:asText", - "header:Precedence:asText", - "header:Authentication-Results:asText", - ...SPAM_HEADER_PROPS, -]; - -export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"]; - -export interface ListQuery { - key: string; - filter: EmailFilter; - sort: Comparator[]; - collapseThreads: boolean; - mailboxId: string | null; - label?: string; -} - -export interface ListState extends ListQuery { - ids: Id[]; - total: number; - queryState: string | null; - loading: boolean; - loadingMore: boolean; - error: string | null; - exhausted: boolean; -} - -export interface MailState { - accountId: Id | null; - mailboxes: Record; - mailboxState: string | null; - mailboxesLoaded: boolean; - emails: Record; - fullIds: Record; - emailState: string | null; - threads: Record; - identities: Identity[]; - quotas: Quota[]; - vacation: VacationResponse | null; - list: ListState | null; - selected: Record; - /** Unread messages per label keyword, for the sidebar. */ - labelCounts: Record; - /** - * The selection means "everything the current query matches", not the rows - * that happen to be loaded. Ticking the header box selects the loaded page; - * this is the deliberate second step past it. - */ - selectedAll: boolean; - anchorId: Id | null; - loadingThreads: Record; - lastSeenInboxEmailIds: Id[] | null; - openThreadId: Id | null; - setOpenThread(id: Id | null): void; - - setAccount(accountId: Id | null): void; - loadMailboxes(): Promise; - roleId(role: MailboxRole): Id | null; - mailboxPath(id: Id): string; - childrenOf(parentId: Id | null): Mailbox[]; - - query(q: ListQuery, opts?: { reset?: boolean }): Promise; - loadMore(): Promise; - refreshList(): Promise; - - getEmails(ids: Id[], full?: boolean): Promise; - loadThread(threadId: Id): Promise; - threadEmails(threadId: Id): Email[]; - threadIdsIn(threadId: Id, mailboxId: Id | null): Id[]; - - setKeyword(ids: Id[], keyword: string, value: boolean): Promise; - markRead(ids: Id[], read: boolean): Promise; - star(ids: Id[], on: boolean): Promise; - move(ids: Id[], toMailboxId: Id, opts?: { fromMailboxId?: Id | null; silent?: boolean; label?: string }): Promise; - addToMailbox(ids: Id[], mailboxId: Id, add: boolean): Promise; - trash(ids: Id[]): Promise; - destroy(ids: Id[]): Promise; - archive(ids: Id[]): Promise; - /** Archive into a dated subfolder of Archive, creating the folders as needed. */ - archiveByDate(ids: Id[], granularity: ArchiveGranularity): Promise; - spam(ids: Id[], isSpam: boolean): Promise; - emptyMailbox(mailboxId: Id): Promise; - /** Mark every unread message in a mailbox read; optionally its subfolders too. */ - markMailboxRead(mailboxId: Id, includeChildren?: boolean): Promise; - /** The mailbox plus all of its descendants. */ - descendantMailboxIds(mailboxId: Id): Id[]; - - createMailbox(name: string, parentId: Id | null, role?: MailboxRole): Promise; - /** Give something the Archive role -- adopting a folder already named for it, or making one. */ - ensureArchiveFolder(): Promise; - updateMailbox(id: Id, patch: Partial): Promise; - destroyMailbox(id: Id, removeEmails?: boolean): Promise; - - loadIdentities(): Promise; - /** The user's preferred identity (falls back to the first one). */ - defaultIdentity(): Identity | undefined; - setDefaultIdentity(id: Id): void; - saveIdentity(id: Id | null, patch: Partial): Promise; - destroyIdentity(id: Id): Promise; - loadVacation(): Promise; - saveVacation(patch: Partial): Promise; - loadQuota(): Promise; - - select(ids: Id[], on: boolean): void; - clearSelection(): void; - /** Refresh the per-label unread counts, in one request. */ - loadLabelCounts(): Promise; - selectAll(): void; - /** Extend the selection from the loaded rows to everything the query matches. */ - selectAllMatching(): void; - /** Every id the current query matches, walked a page at a time. */ - queryAllIds(): Promise; - setAnchor(id: Id | null): void; - - applyChanges(types: Set): Promise; - importEml(blobId: Id, mailboxId: Id, keywords?: Record): Promise; -} +export { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props"; +export { DEFAULT_SORT, type ListQuery, type ListState, type MailState } from "./types"; +export { mailboxIcon, ROLE_ORDER } from "./mailboxes"; function listKey(q: { filter: EmailFilter; sort: Comparator[]; collapseThreads: boolean }): string { return JSON.stringify([q.filter, q.sort, q.collapseThreads]); } -export const DEFAULT_SORT: Comparator[] = [{ property: "receivedAt", isAscending: false }]; - /** * Nothing carries the Archive role, so offer to fix it rather than explain it. * @@ -1346,32 +1174,7 @@ useSession.subscribe((s) => { useMail.getState().setAccount(s.status === "authenticated" ? s.accountId : null); }); -export function mailboxIcon(role: MailboxRole): string { - switch (role) { - case "inbox": - return "inbox"; - case "drafts": - return "file"; - case "sent": - return "send"; - case "trash": - return "trash"; - case "junk": - return "alert"; - case "archive": - return "archive"; - case "all": - return "mail"; - case "flagged": - return "star"; - case "important": - return "tag"; - default: - return "folder"; - } -} -export const ROLE_ORDER: Record = { inbox: 0, flagged: 1, important: 2, drafts: 3, sent: 4, archive: 5, all: 6, junk: 7, trash: 8 }; /** * Resolve `parentId/segments...` to a mailbox id, creating what is missing. @@ -1426,7 +1229,7 @@ function folderRefs(state: MailState, id: Id): FolderRef[] { */ async function followFolders(before: FolderRef[]): Promise { try { - const { useSieve } = await import("./sieve"); + const { useSieve } = await import("../sieve"); const sieve = useSieve.getState(); if (!sieve.available) return; if (!sieve.scripts.length) await sieve.load(); diff --git a/web/src/store/mail/mailboxes.ts b/web/src/store/mail/mailboxes.ts new file mode 100644 index 0000000..c4d7873 --- /dev/null +++ b/web/src/store/mail/mailboxes.ts @@ -0,0 +1,28 @@ +import type { MailboxRole } from "@/jmap/types"; + +export function mailboxIcon(role: MailboxRole): string { + switch (role) { + case "inbox": + return "inbox"; + case "drafts": + return "file"; + case "sent": + return "send"; + case "trash": + return "trash"; + case "junk": + return "alert"; + case "archive": + return "archive"; + case "all": + return "mail"; + case "flagged": + return "star"; + case "important": + return "tag"; + default: + return "folder"; + } +} + +export const ROLE_ORDER: Record = { inbox: 0, flagged: 1, important: 2, drafts: 3, sent: 4, archive: 5, all: 6, junk: 7, trash: 8 }; diff --git a/web/src/store/mail/props.ts b/web/src/store/mail/props.ts new file mode 100644 index 0000000..48f3c81 --- /dev/null +++ b/web/src/store/mail/props.ts @@ -0,0 +1,72 @@ +import { SPAM_HEADER_PROPS } from "@/lib/spamScore"; + + +/* + * Named explicitly so `shareWith` comes back, which it does not otherwise -- + * see the note on CALENDAR_PROPS and the KNOWN-ISSUES entry. Mailboxes were the + * third and last store fetching everything by asking for nothing. + * + * It matters here for one narrow but real case. Sharing a mail folder is + * withdrawn because Stalwart stores the share and never delivers it, and the + * only way left to clear one already made is the "Stop sharing" entry, which + * appears only when a folder looks shared. Without this it never looked shared, + * so the escape hatch for the exact situation it was built for was invisible. + */ +export const MAILBOX_PROPS = [ + "id", + "name", + "parentId", + "role", + "sortOrder", + "totalEmails", + "unreadEmails", + "totalThreads", + "unreadThreads", + "myRights", + "isSubscribed", + "shareWith", +]; + +export const LIST_PROPS = [ + "id", + "blobId", + "threadId", + "mailboxIds", + "keywords", + "hasAttachment", + "from", + "to", + "subject", + "receivedAt", + "sentAt", + "size", + "preview", +]; + +export const FULL_PROPS = [ + ...LIST_PROPS, + "messageId", + "inReplyTo", + "references", + "sender", + "cc", + "bcc", + "replyTo", + "bodyStructure", + "bodyValues", + "textBody", + "htmlBody", + "attachments", + "header:List-Unsubscribe:asText", + "header:List-Unsubscribe-Post:asText", + "header:List-Id:asText", + "header:Disposition-Notification-To:asAddresses", + "header:X-Priority:asText", + "header:Importance:asText", + "header:Auto-Submitted:asText", + "header:Precedence:asText", + "header:Authentication-Results:asText", + ...SPAM_HEADER_PROPS, +]; + +export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"]; diff --git a/web/src/store/mail/types.ts b/web/src/store/mail/types.ts new file mode 100644 index 0000000..afb3d11 --- /dev/null +++ b/web/src/store/mail/types.ts @@ -0,0 +1,125 @@ +import type { ArchiveGranularity } from "@/lib/archiveDate"; +import type { + Comparator, + Email, + EmailFilter, + Id, + Identity, + Mailbox, + MailboxRole, + Quota, + Thread, + VacationResponse, +} from "@/jmap/types"; + +export interface ListQuery { + key: string; + filter: EmailFilter; + sort: Comparator[]; + collapseThreads: boolean; + mailboxId: string | null; + label?: string; +} + +export interface ListState extends ListQuery { + ids: Id[]; + total: number; + queryState: string | null; + loading: boolean; + loadingMore: boolean; + error: string | null; + exhausted: boolean; +} + +export interface MailState { + accountId: Id | null; + mailboxes: Record; + mailboxState: string | null; + mailboxesLoaded: boolean; + emails: Record; + fullIds: Record; + emailState: string | null; + threads: Record; + identities: Identity[]; + quotas: Quota[]; + vacation: VacationResponse | null; + list: ListState | null; + selected: Record; + /** Unread messages per label keyword, for the sidebar. */ + labelCounts: Record; + /** + * The selection means "everything the current query matches", not the rows + * that happen to be loaded. Ticking the header box selects the loaded page; + * this is the deliberate second step past it. + */ + selectedAll: boolean; + anchorId: Id | null; + loadingThreads: Record; + lastSeenInboxEmailIds: Id[] | null; + openThreadId: Id | null; + setOpenThread(id: Id | null): void; + + setAccount(accountId: Id | null): void; + loadMailboxes(): Promise; + roleId(role: MailboxRole): Id | null; + mailboxPath(id: Id): string; + childrenOf(parentId: Id | null): Mailbox[]; + + query(q: ListQuery, opts?: { reset?: boolean }): Promise; + loadMore(): Promise; + refreshList(): Promise; + + getEmails(ids: Id[], full?: boolean): Promise; + loadThread(threadId: Id): Promise; + threadEmails(threadId: Id): Email[]; + threadIdsIn(threadId: Id, mailboxId: Id | null): Id[]; + + setKeyword(ids: Id[], keyword: string, value: boolean): Promise; + markRead(ids: Id[], read: boolean): Promise; + star(ids: Id[], on: boolean): Promise; + move(ids: Id[], toMailboxId: Id, opts?: { fromMailboxId?: Id | null; silent?: boolean; label?: string }): Promise; + addToMailbox(ids: Id[], mailboxId: Id, add: boolean): Promise; + trash(ids: Id[]): Promise; + destroy(ids: Id[]): Promise; + archive(ids: Id[]): Promise; + /** Archive into a dated subfolder of Archive, creating the folders as needed. */ + archiveByDate(ids: Id[], granularity: ArchiveGranularity): Promise; + spam(ids: Id[], isSpam: boolean): Promise; + emptyMailbox(mailboxId: Id): Promise; + /** Mark every unread message in a mailbox read; optionally its subfolders too. */ + markMailboxRead(mailboxId: Id, includeChildren?: boolean): Promise; + /** The mailbox plus all of its descendants. */ + descendantMailboxIds(mailboxId: Id): Id[]; + + createMailbox(name: string, parentId: Id | null, role?: MailboxRole): Promise; + /** Give something the Archive role -- adopting a folder already named for it, or making one. */ + ensureArchiveFolder(): Promise; + updateMailbox(id: Id, patch: Partial): Promise; + destroyMailbox(id: Id, removeEmails?: boolean): Promise; + + loadIdentities(): Promise; + /** The user's preferred identity (falls back to the first one). */ + defaultIdentity(): Identity | undefined; + setDefaultIdentity(id: Id): void; + saveIdentity(id: Id | null, patch: Partial): Promise; + destroyIdentity(id: Id): Promise; + loadVacation(): Promise; + saveVacation(patch: Partial): Promise; + loadQuota(): Promise; + + select(ids: Id[], on: boolean): void; + clearSelection(): void; + /** Refresh the per-label unread counts, in one request. */ + loadLabelCounts(): Promise; + selectAll(): void; + /** Extend the selection from the loaded rows to everything the query matches. */ + selectAllMatching(): void; + /** Every id the current query matches, walked a page at a time. */ + queryAllIds(): Promise; + setAnchor(id: Id | null): void; + + applyChanges(types: Set): Promise; + importEml(blobId: Id, mailboxId: Id, keywords?: Record): Promise; +} + +export const DEFAULT_SORT: Comparator[] = [{ property: "receivedAt", isAscending: false }];