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 }];