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:
@@ -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<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 { 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<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.
|
||||
@@ -1426,7 +1229,7 @@ function folderRefs(state: MailState, id: Id): FolderRef[] {
|
||||
*/
|
||||
async function followFolders(before: FolderRef[]): Promise<void> {
|
||||
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();
|
||||
@@ -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 };
|
||||
@@ -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"];
|
||||
@@ -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 }];
|
||||
Reference in New Issue
Block a user