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<MailState>((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.
This commit is contained in:
2026-09-15 22:49:14 -07:00
parent f7712b1c1e
commit 4517d154a2
4 changed files with 238 additions and 210 deletions
@@ -1,7 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import type { FolderRef } from "@/lib/sieveFolders"; import type { FolderRef } from "@/lib/sieveFolders";
import { SPAM_HEADER_PROPS } from "@/lib/spamScore"; import { groupByArchivePath, archivePath } from "@/lib/archiveDate";
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
import { isOptionalSort, withoutOptionalSorts } from "@/lib/listSort"; import { isOptionalSort, withoutOptionalSorts } from "@/lib/listSort";
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client"; import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
import type { import type {
@@ -12,7 +11,6 @@ import type {
Id, Id,
Identity, Identity,
Mailbox, Mailbox,
MailboxRole,
QueryResponse, QueryResponse,
Quota, Quota,
SetError, SetError,
@@ -23,198 +21,28 @@ import type {
Invocation, Invocation,
} from "@/jmap/types"; } from "@/jmap/types";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { settings, useSettings } from "./settings"; import { settings, useSettings } from "../settings";
import { useSession } from "./session"; import { useSession } from "../session";
import { mailboxDisplayName } from "@/lib/mailboxName"; import { mailboxDisplayName } from "@/lib/mailboxName";
import { plural, t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
import { withBase } from "@/lib/basePath"; 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 -- * `@/store/mail` stays the one public entry. The split below is about file
* see the note on CALENDAR_PROPS and the KNOWN-ISSUES entry. Mailboxes were the * size -- 1,463 lines in a single module -- not about asking the 36 call sites
* third and last store fetching everything by asking for nothing. * that import `useMail` to learn which half of the store a symbol moved to.
* * Anything exported before is still exported from here.
* 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 = [ export { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
"id", export { DEFAULT_SORT, type ListQuery, type ListState, type MailState } from "./types";
"name", export { mailboxIcon, ROLE_ORDER } from "./mailboxes";
"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<Id, Mailbox>;
mailboxState: string | null;
mailboxesLoaded: boolean;
emails: Record<Id, Email>;
fullIds: Record<Id, true>;
emailState: string | null;
threads: Record<Id, Thread>;
identities: Identity[];
quotas: Quota[];
vacation: VacationResponse | null;
list: ListState | null;
selected: Record<Id, true>;
/** Unread messages per label keyword, for the sidebar. */
labelCounts: Record<string, number>;
/**
* 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<Id, true>;
lastSeenInboxEmailIds: Id[] | null;
openThreadId: Id | null;
setOpenThread(id: Id | null): void;
setAccount(accountId: Id | null): void;
loadMailboxes(): Promise<void>;
roleId(role: MailboxRole): Id | null;
mailboxPath(id: Id): string;
childrenOf(parentId: Id | null): Mailbox[];
query(q: ListQuery, opts?: { reset?: boolean }): Promise<void>;
loadMore(): Promise<void>;
refreshList(): Promise<void>;
getEmails(ids: Id[], full?: boolean): Promise<Email[]>;
loadThread(threadId: Id): Promise<Email[]>;
threadEmails(threadId: Id): Email[];
threadIdsIn(threadId: Id, mailboxId: Id | null): Id[];
setKeyword(ids: Id[], keyword: string, value: boolean): Promise<void>;
markRead(ids: Id[], read: boolean): Promise<void>;
star(ids: Id[], on: boolean): Promise<void>;
move(ids: Id[], toMailboxId: Id, opts?: { fromMailboxId?: Id | null; silent?: boolean; label?: string }): Promise<void>;
addToMailbox(ids: Id[], mailboxId: Id, add: boolean): Promise<void>;
trash(ids: Id[]): Promise<void>;
destroy(ids: Id[]): Promise<void>;
archive(ids: Id[]): Promise<void>;
/** Archive into a dated subfolder of Archive, creating the folders as needed. */
archiveByDate(ids: Id[], granularity: ArchiveGranularity): Promise<void>;
spam(ids: Id[], isSpam: boolean): Promise<void>;
emptyMailbox(mailboxId: Id): Promise<void>;
/** Mark every unread message in a mailbox read; optionally its subfolders too. */
markMailboxRead(mailboxId: Id, includeChildren?: boolean): Promise<void>;
/** The mailbox plus all of its descendants. */
descendantMailboxIds(mailboxId: Id): Id[];
createMailbox(name: string, parentId: Id | null, role?: MailboxRole): Promise<Id>;
/** Give something the Archive role -- adopting a folder already named for it, or making one. */
ensureArchiveFolder(): Promise<Id>;
updateMailbox(id: Id, patch: Partial<Mailbox>): Promise<void>;
destroyMailbox(id: Id, removeEmails?: boolean): Promise<void>;
loadIdentities(): Promise<Identity[]>;
/** The user's preferred identity (falls back to the first one). */
defaultIdentity(): Identity | undefined;
setDefaultIdentity(id: Id): void;
saveIdentity(id: Id | null, patch: Partial<Identity>): Promise<void>;
destroyIdentity(id: Id): Promise<void>;
loadVacation(): Promise<void>;
saveVacation(patch: Partial<VacationResponse>): Promise<void>;
loadQuota(): Promise<void>;
select(ids: Id[], on: boolean): void;
clearSelection(): void;
/** Refresh the per-label unread counts, in one request. */
loadLabelCounts(): Promise<void>;
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<Id[]>;
setAnchor(id: Id | null): void;
applyChanges(types: Set<string>): Promise<void>;
importEml(blobId: Id, mailboxId: Id, keywords?: Record<string, boolean>): Promise<Id | null>;
}
function listKey(q: { filter: EmailFilter; sort: Comparator[]; collapseThreads: boolean }): string { function listKey(q: { filter: EmailFilter; sort: Comparator[]; collapseThreads: boolean }): string {
return JSON.stringify([q.filter, q.sort, q.collapseThreads]); 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. * 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); 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<string, number> = { 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. * 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<void> { async function followFolders(before: FolderRef[]): Promise<void> {
try { try {
const { useSieve } = await import("./sieve"); const { useSieve } = await import("../sieve");
const sieve = useSieve.getState(); const sieve = useSieve.getState();
if (!sieve.available) return; if (!sieve.available) return;
if (!sieve.scripts.length) await sieve.load(); if (!sieve.scripts.length) await sieve.load();
+28
View File
@@ -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<string, number> = { inbox: 0, flagged: 1, important: 2, drafts: 3, sent: 4, archive: 5, all: 6, junk: 7, trash: 8 };
+72
View File
@@ -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"];
+125
View File
@@ -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<Id, Mailbox>;
mailboxState: string | null;
mailboxesLoaded: boolean;
emails: Record<Id, Email>;
fullIds: Record<Id, true>;
emailState: string | null;
threads: Record<Id, Thread>;
identities: Identity[];
quotas: Quota[];
vacation: VacationResponse | null;
list: ListState | null;
selected: Record<Id, true>;
/** Unread messages per label keyword, for the sidebar. */
labelCounts: Record<string, number>;
/**
* 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<Id, true>;
lastSeenInboxEmailIds: Id[] | null;
openThreadId: Id | null;
setOpenThread(id: Id | null): void;
setAccount(accountId: Id | null): void;
loadMailboxes(): Promise<void>;
roleId(role: MailboxRole): Id | null;
mailboxPath(id: Id): string;
childrenOf(parentId: Id | null): Mailbox[];
query(q: ListQuery, opts?: { reset?: boolean }): Promise<void>;
loadMore(): Promise<void>;
refreshList(): Promise<void>;
getEmails(ids: Id[], full?: boolean): Promise<Email[]>;
loadThread(threadId: Id): Promise<Email[]>;
threadEmails(threadId: Id): Email[];
threadIdsIn(threadId: Id, mailboxId: Id | null): Id[];
setKeyword(ids: Id[], keyword: string, value: boolean): Promise<void>;
markRead(ids: Id[], read: boolean): Promise<void>;
star(ids: Id[], on: boolean): Promise<void>;
move(ids: Id[], toMailboxId: Id, opts?: { fromMailboxId?: Id | null; silent?: boolean; label?: string }): Promise<void>;
addToMailbox(ids: Id[], mailboxId: Id, add: boolean): Promise<void>;
trash(ids: Id[]): Promise<void>;
destroy(ids: Id[]): Promise<void>;
archive(ids: Id[]): Promise<void>;
/** Archive into a dated subfolder of Archive, creating the folders as needed. */
archiveByDate(ids: Id[], granularity: ArchiveGranularity): Promise<void>;
spam(ids: Id[], isSpam: boolean): Promise<void>;
emptyMailbox(mailboxId: Id): Promise<void>;
/** Mark every unread message in a mailbox read; optionally its subfolders too. */
markMailboxRead(mailboxId: Id, includeChildren?: boolean): Promise<void>;
/** The mailbox plus all of its descendants. */
descendantMailboxIds(mailboxId: Id): Id[];
createMailbox(name: string, parentId: Id | null, role?: MailboxRole): Promise<Id>;
/** Give something the Archive role -- adopting a folder already named for it, or making one. */
ensureArchiveFolder(): Promise<Id>;
updateMailbox(id: Id, patch: Partial<Mailbox>): Promise<void>;
destroyMailbox(id: Id, removeEmails?: boolean): Promise<void>;
loadIdentities(): Promise<Identity[]>;
/** The user's preferred identity (falls back to the first one). */
defaultIdentity(): Identity | undefined;
setDefaultIdentity(id: Id): void;
saveIdentity(id: Id | null, patch: Partial<Identity>): Promise<void>;
destroyIdentity(id: Id): Promise<void>;
loadVacation(): Promise<void>;
saveVacation(patch: Partial<VacationResponse>): Promise<void>;
loadQuota(): Promise<void>;
select(ids: Id[], on: boolean): void;
clearSelection(): void;
/** Refresh the per-label unread counts, in one request. */
loadLabelCounts(): Promise<void>;
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<Id[]>;
setAnchor(id: Id | null): void;
applyChanges(types: Set<string>): Promise<void>;
importEml(blobId: Id, mailboxId: Id, keywords?: Record<string, boolean>): Promise<Id | null>;
}
export const DEFAULT_SORT: Comparator[] = [{ property: "receivedAt", isAscending: false }];