use-inboxes.ts29.4 KBView on GitHub
import { useCallback, useMemo, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSession } from '@/modules/auth/utils/auth-client';
import { useTRPC } from '@/providers/query-provider';
// ── Types (mirror of apps/server/src/lib/schemas.ts inboxConfigSchema) ──────────

export type InboxClause =
  | { type: 'aop'; aopId: string }
  | { type: 'gmail'; field: 'from' | 'to' | 'subject'; op: 'contains' | 'equals'; value: string }
  | { type: 'gmailLabel'; labelId: string }
  | { type: 'ai'; prompt: string };

export type InboxRule =
  | { kind: 'all' }
  | { kind: 'all_of'; clauses: InboxClause[] }
  | { kind: 'any_of'; clauses: InboxClause[] };

/**
 * The CRM rule attached to an inbox: restrict to threads whose linked conversation
 * matches. `filters` is the wire shape of the server's
 * `conversationFilterParamsSchema` (AOP scope included) — the same object
 * `crm.listConversations` takes — and `uiConfig` is the builder's per-column state,
 * stored only so reopening the editor round-trips what the user picked.
 * See apps/mail/docs/pipeline-inbox-crm-stage-filter.md.
 */
export interface InboxConversationFilter {
  filters: Record<string, unknown>;
  uiConfig?: Record<string, unknown>;
}

export interface InboxConfig {
  // ─── Identity / display ─────────────────────────────────────────────────
  id: string;
  accountId?: string;
  name: string;
  position: number;
  system?: boolean;
  rule: InboxRule;
  query?: string;
  lookbackDays?: number | 'all';

  // ─── Query / split state (merged in from the old inboxSplits array) ─────
  enabled?: boolean;
  hideWhenEmpty?: boolean;
  alsoShowInImportant?: boolean;
  excludeQuery?: string;
  /** Optional CRM rule — see {@link InboxConversationFilter}. */
  conversationFilter?: InboxConversationFilter;
  compiledQuery?: string;
  queryHash?: string;
  createdAt?: string | Date;
  updatedAt?: string | Date;
}

export type InboxLayout = 'inbox' | 'important_other' | 'stacked';
export type ImportantSignal = 'category_personal' | 'gmail_important';

const ALL_SYSTEM_INBOXES: InboxConfig[] = [
  { id: 'default', name: 'Inbox', position: 0, system: true, rule: { kind: 'all' } },
  { id: 'important', name: 'Important', position: 1, system: true, rule: { kind: 'all' } },
  { id: 'other', name: 'Other', position: 2, system: true, rule: { kind: 'all' } },
];

const SYSTEM_INBOXES_BY_LAYOUT: Record<InboxLayout, InboxConfig[]> = {
  inbox: [{ id: 'default', name: 'Inbox', position: 0, system: true, rule: { kind: 'all' } }],
  important_other: [
    { id: 'important', name: 'Important', position: 0, system: true, rule: { kind: 'all' } },
    { id: 'other', name: 'Other', position: 1, system: true, rule: { kind: 'all' } },
  ],
  // Stacked layout shares the same system inbox set as `inbox` so toggling
  // Display mode (Tabs ↔ Containers) only changes presentation, not which
  // inboxes exist. StackedInboxView renders one collapsible section per
  // inbox; the tab strip collapses to a single synthetic "Inbox" tab routed
  // to /mail/inbox (an ALLOWED_FOLDERS standard slug, not an inbox slug).
  stacked: [{ id: 'default', name: 'Inbox', position: 0, system: true, rule: { kind: 'all' } }],
};

export const SYSTEM_INBOX_IDS = new Set(ALL_SYSTEM_INBOXES.map((i) => i.id));

export const DEFAULT_INBOX: InboxConfig = ALL_SYSTEM_INBOXES[0]!;

// Slugs that the `/mail/:folder` route already owns (Gmail folders + dedicated
// sibling routes). A custom inbox whose name slugifies to one of these would
// shadow that route, so we forbid it at create/rename time.
export const RESERVED_INBOX_SLUGS = new Set([
  'tasks',
  'organised',
  'draft',
  'sent',
  'spam',
  'bin',
  'archive',
  'all',
  'done',
  'compose',
  'conversation-inbox',
  'smart-inbox',
]);

// System inbox slugs are also reserved against custom inboxes (a custom inbox
// can't be named "Important" or "Other" — those names belong to the system
// inboxes when the important/other layout is active).
const SYSTEM_INBOX_SLUGS = new Set(['inbox', 'important', 'other']);

export function slugifyInboxName(name: string): string {
  return name
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '');
}

export class InboxNameError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'InboxNameError';
  }
}

const MAX_SPLIT_INBOXES = 7;

export function newInboxId(): string {
  return `inbox-${Math.random().toString(36).slice(2, 10)}`;
}

// ── Hook ────────────────────────────────────────────────────────────────────────

export function getInboxSlug(inbox: InboxConfig): string {
  // System inboxes always slug to their canonical name regardless of any
  // accidental name override — keeps the URL stable.
  if (inbox.id === 'default') return 'inbox';
  if (inbox.id === 'important') return 'important';
  if (inbox.id === 'other') return 'other';
  return slugifyInboxName(inbox.name);
}

export function findInboxBySlug(
  inboxes: InboxConfig[],
  slug: string,
): InboxConfig | undefined {
  return inboxes.find((i) => getInboxSlug(i) === slug);
}

/**
 * Canonical tab-strip / stacked-section ordering. When `hasCustomOrder` is true
 * the user has explicitly reordered via the modal, so we trust the array order
 * as-is. Otherwise we apply the default pinning: Important first, Other last,
 * customs in saved position order in the middle.
 */
export function getRowInboxOrder(
  inboxes: InboxConfig[],
  hasCustomOrder = false,
): InboxConfig[] {
  if (hasCustomOrder) return inboxes;
  const important = inboxes.find((inbox) => inbox.id === 'important');
  const other = inboxes.find((inbox) => inbox.id === 'other');
  const middle = inboxes.filter((inbox) => inbox.id !== 'important' && inbox.id !== 'other');
  if (important && other) return [important, ...middle, other];
  if (important) return [important, ...middle];
  if (other) return [...middle, other];
  return middle;
}

function validateInboxName(
  name: string,
  inboxes: InboxConfig[],
  selfId?: string,
): void {
  const trimmed = name.trim();
  if (!trimmed) throw new InboxNameError('Inbox name cannot be empty.');
  const slug = slugifyInboxName(trimmed);
  if (!slug) throw new InboxNameError('Inbox name must contain letters or numbers.');
  if (RESERVED_INBOX_SLUGS.has(slug)) {
    throw new InboxNameError(
      `"${trimmed}" is reserved for a built-in folder. Pick a different name.`,
    );
  }
  if (SYSTEM_INBOX_SLUGS.has(slug)) {
    throw new InboxNameError(
      `"${trimmed}" is reserved for a system inbox. Pick a different name.`,
    );
  }
  const collision = inboxes.find(
    (i) => i.id !== selfId && !SYSTEM_INBOX_IDS.has(i.id) && getInboxSlug(i) === slug,
  );
  if (collision) {
    throw new InboxNameError(
      `Another inbox already uses the name "${collision.name}". Pick a different name.`,
    );
  }
}

function normalize(
  inboxes: InboxConfig[] | undefined,
  inboxLayout: InboxLayout,
  inboxOrder?: string[],
): InboxConfig[] {
  const persisted = (inboxes ?? []).filter((i) => !SYSTEM_INBOX_IDS.has(i.id));
  const customs = persisted.slice().sort((a, b) => a.position - b.position);
  const systemInboxes = SYSTEM_INBOXES_BY_LAYOUT[inboxLayout];
  const assembled = [...systemInboxes.map((s) => ({ ...s })), ...customs];
  // If the user has saved an explicit order that covers every assembled inbox,
  // apply it. New inboxes not yet in `inboxOrder` are appended in their
  // existing order so they're still discoverable.
  if (inboxOrder && inboxOrder.length > 0) {
    const byId = new Map(assembled.map((i) => [i.id, i]));
    const ordered: InboxConfig[] = [];
    const seen = new Set<string>();
    for (const id of inboxOrder) {
      const inbox = byId.get(id);
      if (inbox && !seen.has(id)) {
        ordered.push(inbox);
        seen.add(id);
      }
    }
    for (const inbox of assembled) {
      if (!seen.has(inbox.id)) ordered.push(inbox);
    }
    return ordered.map((i, idx) => ({ ...i, position: idx }));
  }
  return assembled.map((i, idx) => ({ ...i, position: idx }));
}

export function useInboxes() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const { data: session } = useSession();
  const settingsKey=[redacted];
  // After the inboxes/inboxSplits unification, the single source of truth is
  // `mail.listInboxes`. settings.get is still queried for the OTHER settings
  // (inboxLayout, activeInboxId, importantSignal, inboxOrder) but no longer
  // for the inbox array itself.
  const inboxesKey=[redacted];

  const settingsQuery = useQuery(
    trpc.settings.get.queryOptions(void 0, {
      enabled: !!session?.user.id,
      staleTime: Infinity,
    }),
  );

  const inboxesQuery = useQuery(
    trpc.mail.listInboxes.queryOptions(void 0, {
      enabled: !!session?.user.id,
      staleTime: 60 * 1000,
    }),
  );

  const rawInboxes = inboxesQuery.data as InboxConfig[] | undefined;
  const inboxLayout =
    ((settingsQuery.data?.settings as { inboxLayout?: InboxLayout } | undefined)?.inboxLayout ??
      'inbox') as InboxLayout;
  const importantSignal =
    ((settingsQuery.data?.settings as { importantSignal?: ImportantSignal } | undefined)
      ?.importantSignal ?? 'category_personal') as ImportantSignal;
  const inboxOrder = (settingsQuery.data?.settings as { inboxOrder?: string[] } | undefined)
    ?.inboxOrder;
  const hasCustomInboxOrder = Array.isArray(inboxOrder) && inboxOrder.length > 0;
  const inboxes = useMemo(
    () => normalize(rawInboxes, inboxLayout, inboxOrder),
    [rawInboxes, inboxLayout, inboxOrder],
  );
  const activeInboxId =
    (settingsQuery.data?.settings as { activeInboxId?: string } | undefined)?.activeInboxId ??
    inboxes[0]!.id;
  const activeInbox = inboxes.find((i) => i.id === activeInboxId) ?? inboxes[0]!;

  const { mutate: saveSettings } = useMutation(
    trpc.settings.save.mutationOptions({
      onMutate: async (vars) => {
        await queryClient.cancelQueries({ queryKey=[redacted] });
        const previous = queryClient.getQueryData(settingsKey);
        queryClient.setQueryData(settingsKey, (old: typeof settingsQuery.data) =>
          old
            ? // `vars` is the partial save input; only the keys it actually carries are
              // written over the fully-populated cached settings.
              { ...old, settings: { ...old.settings, ...vars } as typeof old.settings }
            : old,
        );
        return { previous };
      },
      onError: (_e, _v, ctx) => {
        if (ctx?.previous) queryClient.setQueryData(settingsKey, ctx.previous);
      },
      onSettled: () => queryClient.invalidateQueries({ queryKey=[redacted] }),
    }),
  );

  // Optimistic writes against the `mail.listInboxes` cache. The client mints the
  // inbox id before calling the server, so an optimistically inserted row carries
  // its REAL id — any follow-up write (rename, alsoShowInImportant toggle,
  // delete) addresses the same record the server is creating, and the refetch on
  // settle reconciles rather than replaces.
  // `inboxesQuery.data` appears below only inside `typeof`, which is erased — listing it as a
  // dependency would re-identify both callbacks on every refetch for no runtime reason.
  const patchInboxCache = useCallback(
    (mutate: (current: InboxConfig[]) => InboxConfig[]) => {
      const previous = queryClient.getQueryData(inboxesKey) as InboxConfig[] | undefined;
      // Cache rows come from `mail.listInboxes` (accountId required). Client InboxConfig keeps
      // accountId optional for local system stubs that never land in this cache.
      queryClient.setQueryData(
        inboxesKey,
        mutate(previous ?? []) as NonNullable<typeof inboxesQuery.data>,
      );
      return previous;
    },
    [queryClient, inboxesKey],
  );

  const restoreInboxCache = useCallback(
    (previous: InboxConfig[] | undefined) => {
      if (previous) {
        queryClient.setQueryData(inboxesKey, previous as NonNullable<typeof inboxesQuery.data>);
      }
    },
    [queryClient, inboxesKey],
  );

  /** In-flight `createInbox` calls, keyed by the client-minted inbox id. */
  const pendingCreatesRef = useRef<Map<string, Promise<unknown>>>(new Map());

  /**
   * How many `createInbox` mutations are between `onMutate` and `onSettled`.
   *
   * Counted separately from `pendingCreatesRef`, which is keyed off `addInbox` and so
   * is only populated once the caller has awaited its way back — too late to gate the
   * settle of the very mutation that is settling.
   */
  const inFlightCreatesRef = useRef(0);

  /**
   * Highest position handed to a create so far.
   *
   * The cache alone cannot answer this. `addInbox` picks the position synchronously,
   * but the optimistic row is written inside `onMutate` — which awaits `cancelQueries`
   * first — so two adds fired back to back BOTH read the cache before either has
   * written to it, and both claim the same index. Carrying the last issued value
   * forward closes that window without waiting on the cache.
   */
  const lastIssuedPositionRef = useRef(-1);

  /** Wait out an in-flight create for `id` so a follow-up write can't overtake it. */
  const awaitPendingCreate = useCallback(async (id: string) => {
    const pending = pendingCreatesRef.current.get(id);
    if (pending) await pending.catch(() => undefined);
  }, []);

  /**
   * Refetch the inbox list — but ONLY once every create has settled.
   *
   * Invalidating per-mutation loses rows. Click two templates in a row and create #1's
   * refetch is already in flight when create #2 writes its optimistic row; the response
   * lands afterwards carrying a server list that predates #2 and overwrites it. The row
   * vanishes, the template card flips back to unselected, and the user clicks again —
   * which is what "adding an inbox doesn't save" actually looks like from the outside.
   *
   * `cancelQueries` in `onMutate` does not cover this: it cancels what is in flight at
   * that instant, and #1's refetch is started by its settle, which comes later.
   *
   * So the LAST create to settle does the single refetch, and every other write defers
   * to it while any create is outstanding.
   */
  const settleInboxes = useCallback(() => {
    if (inFlightCreatesRef.current > 0) return;
    queryClient.invalidateQueries({ queryKey=[redacted] });
  }, [queryClient, inboxesKey]);

  const { mutateAsync: createInboxMutation } = useMutation(
    trpc.mail.createInbox.mutationOptions({
      onMutate: async (vars) => {
        inFlightCreatesRef.current += 1;
        await queryClient.cancelQueries({ queryKey=[redacted] });
        const previous = patchInboxCache((current) => [
          ...current,
          {
            ...vars,
            rule: vars.rule ?? { kind: 'all_of', clauses: [] },
            // The server recomputes both on write; the include query is a good
            // enough stand-in for the one render before the refetch lands.
            compiledQuery: vars.query ?? '',
            queryHash: '',
          } as InboxConfig,
        ]);
        return { previous };
      },
      // A failed create drops only ITS row. Restoring the `previous` snapshot wholesale
      // would also undo any create that landed after this one's `onMutate` — the exact
      // clobber `settleInboxes` exists to prevent, arrived at from the other side.
      onError: (_error, vars) =>
        patchInboxCache((current) => current.filter((inbox) => inbox.id !== vars.id)),
      onSettled: () => {
        inFlightCreatesRef.current = Math.max(0, inFlightCreatesRef.current - 1);
        settleInboxes();
      },
    }),
  );

  const { mutateAsync: updateInboxMutation } = useMutation(
    trpc.mail.updateInbox.mutationOptions({
      onMutate: async (vars) => {
        await queryClient.cancelQueries({ queryKey=[redacted] });
        const previous = patchInboxCache((current) =>
          current.map((inbox) =>
            inbox.id === vars.inboxId ? ({ ...inbox, ...vars.patch } as InboxConfig) : inbox,
          ),
        );
        return { previous };
      },
      onError: (_error, _vars, context) => restoreInboxCache(context?.previous),
      onSettled: settleInboxes,
    }),
  );

  const { mutateAsync: deleteInboxMutation } = useMutation(
    trpc.mail.deleteInbox.mutationOptions({
      onMutate: async (vars) => {
        await queryClient.cancelQueries({ queryKey=[redacted] });
        const previous = patchInboxCache((current) =>
          current.filter((inbox) => inbox.id !== vars.inboxId),
        );
        return { previous };
      },
      onError: (_error, _vars, context) => restoreInboxCache(context?.previous),
      onSettled: settleInboxes,
    }),
  );

  const { mutateAsync: reorderInboxesMutation } = useMutation(
    trpc.mail.reorderInboxes.mutationOptions({
      onSettled: () => {
        settleInboxes();
        queryClient.invalidateQueries({ queryKey=[redacted] });
      },
    }),
  );

  // Legacy migration effects (localStorage → settings.inboxes, settings.inboxes
  // → inboxSplits, and the includeQuery backfill) all became moot after the
  // server-side unification: the single `mail.listInboxes` source already
  // carries every field these migrations were trying to reconcile. The SQL
  // migration in apps/server/src/docs/unified-inbox-storage.md handled
  // existing rows; new client installs never see the legacy shapes.

  const previewSplitQuery = useCallback(
    async (input: { includeQuery: string; excludeQuery?: string }) => {
      return queryClient.fetchQuery(
        trpc.mail.previewSplitQuery.queryOptions({
          includeQuery: input.includeQuery,
          excludeQuery: input.excludeQuery,
        }),
      );
    },
    [queryClient, trpc],
  );

  // activeInboxId is transient UI state — which tab is active. The URL is the
  // canonical source of truth (browser restores it across reloads) and the
  // server never reads this field. Keep it cache-only so tab switches don't
  // round-trip to settings.save.
  const setActiveInboxId = useCallback(
    (id: string) => {
      queryClient.setQueryData(settingsKey, (old: typeof settingsQuery.data) =>
        old ? { ...old, settings: { ...old.settings, activeInboxId: id } } : old,
      );
    },
    [queryClient, settingsKey],
  );

  // AI inboxes are filled in lazily by the per-email orchestrator agent — no
  // upfront backfill (would burn LLM cost on history the agent will see anyway
  // the next time each thread is touched). Only deterministic rules backfill.
  const hasAiClause = (rule: InboxRule) =>
    rule.kind !== 'all' && rule.clauses.some((c) => c.type === 'ai');

  /**
   * Synchronous pre-flight for a new inbox — capacity + name validation. Lets a
   * caller reject bad input while the form is still open, then fire the create
   * without awaiting it (the optimistic cache write already renders the inbox).
   */
  const assertCanAddInbox = useCallback(
    (name: string) => {
      const currentCustomCount = inboxes.filter((inbox) => !SYSTEM_INBOX_IDS.has(inbox.id)).length;
      if (currentCustomCount >= MAX_SPLIT_INBOXES) {
        throw new Error(`You can create up to ${MAX_SPLIT_INBOXES} inboxes.`);
      }
      validateInboxName(name, inboxes);
    },
    [inboxes],
  );

  const addInbox = useCallback(
    async (
      config: Omit<InboxConfig, 'id' | 'position'> & {
        query?: string;
        alsoShowInImportant?: boolean;
        hideWhenEmpty?: boolean;
        /**
         * Called with the client-minted id the moment it exists, before the write is
         * awaited. The optimistic row carries this id, so a caller that wants to SELECT
         * what it just created can do so on the same frame instead of after the
         * round-trip. The returned promise still resolves to the same id.
         */
        onOptimisticId?: (id: string) => void;
      },
    ): Promise<string> => {
      assertCanAddInbox(config.name);

      const id = newInboxId();
      config.onOptimisticId?.(id);
      const query = config.query?.trim();

      // Position off the optimistic cache rather than `rawInboxes`: the server list
      // gives every create fired before the first refetch the SAME position, so a run of
      // template clicks lands as a pile at one index and the tab strip reorders itself
      // once the refetch arrives. `lastIssuedPositionRef` covers the adds that are too
      // close together for even the optimistic cache to have caught up.
      const cachedRows = (queryClient.getQueryData(inboxesKey) as InboxConfig[] | undefined) ?? [];
      const cachedMax = cachedRows.reduce((max, inbox) => Math.max(max, inbox.position ?? 0), -1);
      const position = Math.max(cachedMax, lastIssuedPositionRef.current) + 1;
      lastIssuedPositionRef.current = position;

      // The optimistic row lands in the cache the moment the mutation fires, so a
      // fast follow-up edit (rename, "also show in inbox") can find the inbox by
      // id before the server has actually created it. Park the in-flight create
      // so those writes can wait on it instead of racing to a "not found".
      const pending = createInboxMutation({
        id,
        name: config.name,
        position,
        rule: config.rule,
        lookbackDays: config.lookbackDays,
        enabled: true,
        hideWhenEmpty: config.hideWhenEmpty ?? false,
        // A CRM-filtered inbox defaults to mirroring into Inbox rather than
        // carving out of it: its `query` is a bare scope anchor, so treating it
        // as a partition would subtract all of `label:INBOX` from the default
        // tab. See buildImportantQueryExclusions for the guard that makes this
        // safe even if the user later un-ticks the toggle.
        alsoShowInImportant: config.alsoShowInImportant ?? !!config.conversationFilter,
        query,
        excludeQuery: undefined,
        conversationFilter: config.conversationFilter,
      });
      pendingCreatesRef.current.set(id, pending);
      try {
        await pending;
      } finally {
        pendingCreatesRef.current.delete(id);
      }

      if (!hasAiClause(config.rule)) {
        queryClient.invalidateQueries({ queryKey=[redacted] });
        queryClient.invalidateQueries({ queryKey=[redacted] });
      }
      return id;
    },
    [
      assertCanAddInbox,
      inboxesKey,
      createInboxMutation,
      queryClient,
      trpc.mail.getSplitCounts,
      trpc.mail.listThreads,
    ],
  );

  const updateInbox = useCallback(
    async (
      id: string,
      // `conversationFilter: null` is meaningful (clear the CRM rule) and cannot
      // be expressed by the optional field on InboxConfig, so that key is omitted
      // before widening — an intersection would have re-narrowed it to non-null.
      patch: Partial<Omit<InboxConfig, 'id' | 'conversationFilter'>> & {
        conversationFilter?: InboxConversationFilter | null;
      },
    ) => {
      if (SYSTEM_INBOX_IDS.has(id)) return; // system inboxes are not editable
      if (typeof patch.name === 'string') {
        validateInboxName(patch.name, inboxes, id);
      }
      const ruleChanged = 'rule' in patch || 'lookbackDays' in patch;
      const nextRule = patch.rule ?? inboxes.find((i) => i.id === id)?.rule;
      const shouldReclassify = ruleChanged && nextRule && !hasAiClause(nextRule);

      await awaitPendingCreate(id);
      await updateInboxMutation({
        inboxId: id,
        patch: {
          // Allowed fields only — the server schema rejects unknown ones.
          ...(patch.name !== undefined ? { name: patch.name } : {}),
          ...(patch.position !== undefined ? { position: patch.position } : {}),
          ...(patch.rule !== undefined ? { rule: patch.rule } : {}),
          ...(patch.lookbackDays !== undefined ? { lookbackDays: patch.lookbackDays } : {}),
          ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
          ...(patch.hideWhenEmpty !== undefined ? { hideWhenEmpty: patch.hideWhenEmpty } : {}),
          ...(patch.alsoShowInImportant !== undefined
            ? { alsoShowInImportant: patch.alsoShowInImportant }
            : {}),
          ...(patch.query !== undefined ? { query: patch.query } : {}),
          ...(patch.excludeQuery !== undefined ? { excludeQuery: patch.excludeQuery } : {}),
          // `null` clears the CRM rule server-side; absent leaves it untouched.
          ...(patch.conversationFilter !== undefined
            ? { conversationFilter: patch.conversationFilter }
            : {}),
        },
      });

      if (shouldReclassify) {
        queryClient.invalidateQueries({ queryKey=[redacted] });
        queryClient.invalidateQueries({ queryKey=[redacted] });
      }
    },
    [
      inboxes,
      awaitPendingCreate,
      updateInboxMutation,
      queryClient,
      trpc.mail.getSplitCounts,
      trpc.mail.listThreads,
    ],
  );

  const renameInbox = useCallback(
    (id: string, name: string) => updateInbox(id, { name }),
    [updateInbox],
  );

  const removeInbox = useCallback(
    async (id: string) => {
      const target = inboxes.find((i) => i.id === id);
      if (!target || target.system || target.id === 'default') return;

      await awaitPendingCreate(id);
      // Deliberately NOT caught. The delete's own `onError` puts the row back in the
      // cache, but a caller holding its own selected-state (the template gallery's
      // applied set) has no way to learn it should do the same if the rejection is
      // swallowed here — the row returns while the card stays deselected.
      await deleteInboxMutation({ inboxId: id });

      // If the deleted inbox was active, switch to the first remaining one.
      // activeInboxId is cache-only — no network round-trip needed.
      if (activeInboxId === id) {
        const nextActive = inboxes.find((i) => i.id !== id)?.id;
        if (nextActive) {
          setActiveInboxId(nextActive);
        }
      }
    },
    [inboxes, awaitPendingCreate, deleteInboxMutation, activeInboxId, setActiveInboxId],
  );

  const reorderInboxes = useCallback(
    async (orderedIds: string[]) => {
      // Optimistically reflect the new order in the cached settings so the UI
      // updates immediately. The server persists both the inbox row positions
      // and the `inboxOrder` field in a single write.
      queryClient.setQueryData(settingsKey, (old: typeof settingsQuery.data) =>
        old ? { ...old, settings: { ...old.settings, inboxOrder: orderedIds } } : old,
      );

      await reorderInboxesMutation({ orderedIds }).catch((err) => {
        console.warn('[reorderInboxes] Failed to reorder inboxes:', err);
      });
    },
    [queryClient, settingsKey, settingsQuery.data, reorderInboxesMutation],
  );

  const setInboxLayout = useCallback(
    (nextLayout: InboxLayout) => {
      const firstSystemInbox = SYSTEM_INBOXES_BY_LAYOUT[nextLayout][0];
      // Persist inboxLayout (real setting), but only update activeInboxId in
      // cache — it's transient UI state.
      saveSettings({ inboxLayout: nextLayout } as never);
      queryClient.setQueryData(settingsKey, (old: typeof settingsQuery.data) =>
        old
          ? {
              ...old,
              settings: {
                ...old.settings,
                inboxLayout: nextLayout,
                activeInboxId: firstSystemInbox?.id,
              },
            }
          : old,
      );
    },
    [saveSettings, queryClient, settingsKey, settingsQuery.data],
  );

  const setImportantSignal = useCallback(
    (nextSignal: ImportantSignal) => {
      saveSettings({ importantSignal: nextSignal } as never);
      queryClient.setQueryData(settingsKey, (old: typeof settingsQuery.data) =>
        old
          ? { ...old, settings: { ...old.settings, importantSignal: nextSignal } }
          : old,
      );
      // Both 'important' and 'other' compiled queries depend on the signal,
      // so invalidate to force a refetch with the new query.
      queryClient.invalidateQueries({ queryKey=[redacted] });
      queryClient.invalidateQueries({ queryKey=[redacted] });
    },
    [saveSettings, queryClient, settingsKey, settingsQuery.data, trpc.mail.listThreads, trpc.mail.getSplitCounts],
  );

  return {
    inboxes,
    activeInbox,
    inboxLayout,
    importantSignal,
    activeInboxId,
    setActiveInboxId,
    setInboxLayout,
    setImportantSignal,
    addInbox,
    assertCanAddInbox,
    updateInbox,
    renameInbox,
    removeInbox,
    reorderInboxes,
    hasCustomInboxOrder,
    isLoading: settingsQuery.isLoading || inboxesQuery.isLoading,
    inboxesLoading: inboxesQuery.isLoading,
    /**
     * Whether the tab scope is actually KNOWN, as against merely not in flight.
     *
     * Both queries are gated on the session, and a DISABLED react-query reports
     * `isLoading: false` — pending, but not fetching. So `isLoading` reads as "loaded"
     * for the whole window before the session resolves, with no settings behind it: the
     * exact window in which `inboxLayout` falls back to `inbox`, no stub owns the
     * `important`/`other` slug, and a feed asked now is asked without its inbox half.
     *
     * `isPending` is the honest question. An ERROR settles it too — a failed settings
     * fetch must let the feed through rather than spin forever.
     */
    scopeReady: !settingsQuery.isPending && !inboxesQuery.isPending,
    previewSplitQuery,
  };
}