diff --git a/src/cli/commands/email-log-search.local.test.ts b/src/cli/commands/email-log-search.local.test.ts new file mode 100644 index 00000000..470cd800 --- /dev/null +++ b/src/cli/commands/email-log-search.local.test.ts @@ -0,0 +1,167 @@ +// Task db244cd4 — the top-level `emails search` verb, on the LOCAL SQLite store. +// +// The self-hosted surface carried this defect through `listMailbox("sent")`; +// this surface carried the SAME defect by a different route, calling +// `searchEmails`, which enumerates the outbound ledger. Two implementations, +// one blindness — so both now run the single shared `mailboxSearch`, and both +// are tested, because a fix proven on one surface says nothing about the other. +// +// This file exists rather than extending `email-log.local.test.ts` because that +// suite cannot run: every one of its 10 tests fails in `setupDb` with +// `SQLiteError: FOREIGN KEY constraint failed` on unmodified main (measured +// 2026-08-04 at d3ece11, `0 pass, 10 fail`). That is a pre-existing fixture +// defect, out of scope here, and it would have left this change with no local +// coverage at all. The harness below is the one from `inbox.local.test.ts`, +// which passes. +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { Command } from "commander"; +import { closeDatabase, getDatabase, resetDatabase } from "../../db/database.js"; +import { storeInboundEmail } from "../../db/inbound.local.js"; +import { resetMailDataSource } from "../../lib/mail-data-source.js"; +import { EMAILS_CLIENT_ENV_SECRET_ENV } from "../../lib/client-env.js"; +import { + API_BASE_URL_SETTING, + API_CREDENTIAL_SETTINGS, + DATABASE_PATH_SETTINGS, +} from "../../store-resolution.js"; +import { registerEmailLogCommands } from "./email-log.local.js"; + +// Cleared by SHAPE, not by name, so this file adds no fresh spelling of the +// deployment-word variable the axis ratchet is retiring (same rule as +// inbox.local.test.ts, from which this harness is taken). +const DEPLOYMENT_WORD_ENV = /^(?:HASNA_)?EMAILS_[A-Z_]*MODE$/; +function pinLocalStore(): void { + for (const key of Object.keys(process.env)) { + if (DEPLOYMENT_WORD_ENV.test(key)) delete process.env[key]; + } + delete process.env[EMAILS_CLIENT_ENV_SECRET_ENV]; + delete process.env[API_BASE_URL_SETTING]; + for (const key of API_CREDENTIAL_SETTINGS) delete process.env[key]; + for (const key of DATABASE_PATH_SETTINGS) delete process.env[key]; + process.env.EMAILS_DB_PATH = ":memory:"; +} + +let originalEnv: NodeJS.ProcessEnv; +let sequence = 0; + +beforeEach(() => { + originalEnv = { ...process.env }; + pinLocalStore(); + resetMailDataSource(); + resetDatabase(); + getDatabase(); + sequence = 0; +}); + +afterEach(() => { + resetMailDataSource(); + closeDatabase(); + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(originalEnv, key)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); + process.exitCode = 0; +}); + +// SENT MAIL IS MARKED WITH THE `SENT` LABEL, never with an `is_sent` field. +// `storeInboundEmail` Omit<>s `is_sent` from its input and derives it solely +// from label_ids — so passing `is_sent: 1` is silently ignored and every seeded +// row lands in the inbox. +// +// That is not a footnote: the first version of this file did exactly that, and +// the "received AND sent together" test below PASSED ANYWAY, because both rows +// were inbound and both matched on the inbox side. A fixture that cannot +// express the sent shape produces a green test that proves nothing about it — +// the same coverage-bounded-by-axes failure this whole task is about, one level +// down. Only the --folder test, which asks for sent ALONE, could see it. +function seedMessage(subject: string, opts: { sent?: boolean } = {}) { + sequence += 1; + return storeInboundEmail({ + provider_id: null, + message_id: ``, + in_reply_to_email_id: null, + from_address: `sender-${sequence}@example.com`, + to_addresses: ["me@example.com"], + cc_addresses: [], + subject, + text_body: `body ${sequence}`, + html_body: null, + attachments: [], + attachment_paths: [], + headers: {}, + raw_size: 100, + received_at: `2026-07-${String(sequence).padStart(2, "0")}T00:00:00.000Z`, + label_ids: opts.sent ? ["SENT"] : [], + }, getDatabase()); +} + +async function runSearch(args: string[]) { + const program = new Command(); + program.exitOverride(); + let data: unknown; + const out: string[] = []; + registerEmailLogCommands(program, (payload, formatted) => { + data = payload; + out.push(String(formatted ?? "")); + }); + await program.parseAsync(["node", "emails", ...args]); + return { data, out: out.join("\n") }; +} + +describe("local `emails search` covers received mail (db244cd4)", () => { + it("finds a term that exists ONLY in received mail", async () => { + seedMessage("Your account is past due"); + + const { data } = await runSearch(["search", "past due"]); + const rows = data as Array>; + + // Returned [] before the fix: `searchEmails` reads the outbound ledger. + expect(rows.map((row) => row.subject)).toEqual(["Your account is past due"]); + }); + + it("returns received AND sent matches together", async () => { + seedMessage("Invoice needle received", { sent: false }); + seedMessage("Invoice needle sent", { sent: true }); + + const { data } = await runSearch(["search", "Invoice needle"]); + const rows = data as Array>; + + expect(rows.map((row) => row.subject).sort()).toEqual([ + "Invoice needle received", + "Invoice needle sent", + ]); + // Assert the SIDES, not just the count: two inbound rows would satisfy the + // subject assertion above and prove nothing about sent coverage. This is + // the check that caught the fixture bug described on seedMessage. + // + // Discriminated by FOLDER, deliberately not by the `kind` field: in local + // mode `kind` reports STORAGE ORIGIN, so an imported sent message reads + // "inbound" while correctly living in the Sent folder (data.local.ts labels + // the synced_sent branch 'inbound'). In self-hosted mode `kind` reports + // DIRECTION. Asserting on it would be a mode-specific truth dressed as a + // general one. + const inboxOnly = await runSearch(["search", "Invoice needle", "--folder", "inbox"]); + expect((inboxOnly.data as Array>).map((row) => row.subject)) + .toEqual(["Invoice needle received"]); + }); + + it("narrows to one folder on --folder", async () => { + seedMessage("Reconciliation needle received", { sent: false }); + seedMessage("Reconciliation needle sent", { sent: true }); + + const { data } = await runSearch(["search", "Reconciliation needle", "--folder", "sent"]); + const rows = data as Array>; + + expect(rows.map((row) => row.subject)).toEqual(["Reconciliation needle sent"]); + }); + + it("names the folders it searched when it finds nothing", async () => { + seedMessage("Nothing relevant"); + + const { data, out } = await runSearch(["search", "no-such-string-anywhere-zzz"]); + + expect(data).toEqual([]); + expect(out).toContain("inbox"); + expect(out).toContain("sent"); + }); +}); diff --git a/src/cli/commands/email-log.local.ts b/src/cli/commands/email-log.local.ts index 353f97be..1e975272 100644 --- a/src/cli/commands/email-log.local.ts +++ b/src/cli/commands/email-log.local.ts @@ -20,6 +20,10 @@ import { handleError, parseCliPositiveIntOption, parseCliNonNegativeIntOption, r import { listReplies, listReplySummaries, getReplyCount } from "../../db/inbound.local.js"; import type { InboundEmail, InboundEmailSummary } from "../../db/inbound.local.js"; import { readableMessageText } from "../tui/format.js"; +import { resolveMailDataSource } from "../../lib/mail-data-source.js"; +// The mailbox-wide search is shared with the self-hosted surface: it reads +// through the routed MailDataSource, so it is mode-agnostic despite its module. +import { mailboxSearch, type MailSearchOpts } from "./email-log.remote.js"; const MAX_EMAIL_EXPORT_LIMIT = 10000; const DEFAULT_REPLY_LIMIT = 20; @@ -280,38 +284,22 @@ export function registerEmailLogCommands(program: Command, output: (data: unknow } catch (e) { handleError(e); } }); - // ─── SEARCH ───────────────────────────────────────────────────────────────── - program.command("search ").description("Search email by subject, from, or to") + // --- SEARCH ----------------------------------------------------------------- + // Received AND sent (task db244cd4). This surface carried the IDENTICAL + // sent-only blindness as the self-hosted one, by a different route: it called + // `searchEmails`, which enumerates the OUTBOUND ledger. Both now run ONE + // implementation over the routed mail data source, because two copies of a + // scope rule is exactly how this defect came to exist in two places at once. + // `emails email search` above remains the sent-only verb. + program.command("search ") + .description("Search received and sent email by subject, from, or to (default folders: inbox, sent)") + .option("--folder ", "Search one folder only: inbox, unread, starred, sent, archived, spam, trash") .option("--since ", "Show emails since date (ISO 8601)") .option("--limit ", "Max results", "20") .option("--offset ", "Skip first N results", "0") - .action(async (query: string, opts: { since?: string; limit?: string; offset?: string }) => { + .action(async (query: string, opts: MailSearchOpts) => { try { - const limit = parseCliPositiveIntOption(opts.limit, 20); - const emails = await searchEmails(query, { since: opts.since, limit, offset: parseCliNonNegativeIntOption(opts.offset) }); - if (emails.length === 0) { - const formatted = chalk.dim(`No sent emails matching "${query}".`); - output([], formatted); - return; - } - const lines: string[] = []; - lines.push(chalk.bold(`${("Date").padEnd(20)} ${("From").padEnd(30)} ${("To").padEnd(30)} ${("Subject").padEnd(40)} Status`)); - lines.push(chalk.dim("\u2500".repeat(130))); - for (const e of emails) { - const date = new Date(e.sent_at).toLocaleString(); - const from = e.from_address.length > 30 ? e.from_address.slice(0, 27) + "..." : e.from_address; - const to = (e.to_addresses[0] ?? "").length > 30 ? (e.to_addresses[0] ?? "").slice(0, 27) + "..." : (e.to_addresses[0] ?? ""); - const subj = e.subject.length > 40 ? e.subject.slice(0, 37) + "..." : e.subject; - let statusStr: string; - switch (e.status) { - case "delivered": statusStr = chalk.green(e.status); break; - case "bounced": case "complained": case "failed": statusStr = chalk.red(e.status); break; - default: statusStr = chalk.blue(e.status); - } - lines.push(`${date.padEnd(20)} ${from.padEnd(30)} ${to.padEnd(30)} ${subj.padEnd(40)} ${statusStr}`); - } - lines.push(""); - output(emails, lines.join("\n")); + await mailboxSearch(resolveMailDataSource(), query, opts, output); } catch (e) { handleError(e); } }); diff --git a/src/cli/commands/email-log.remote.ts b/src/cli/commands/email-log.remote.ts index dae83471..b1db73f8 100644 --- a/src/cli/commands/email-log.remote.ts +++ b/src/cli/commands/email-log.remote.ts @@ -12,6 +12,7 @@ import { registerEmailSendAlias } from "./email-send-alias.js"; import { handleError, parseCliPositiveIntOption, parseCliNonNegativeIntOption, resolveId } from "../utils.js"; import type { MessageBody, TuiMessage, TuiThreadMessage } from "../tui/data.js"; import { formatThreadLabel, readableMessageText } from "../tui/format.js"; +import { PARTITION_FOLDERS, parseCliFolder, type Mailbox } from "../../lib/mail-types.js"; const DEFAULT_REPLY_LIMIT = 20; const MAX_REPLY_LIMIT = 200; @@ -322,6 +323,88 @@ async function selfHostedSentSearch( output(summaries, formatSelfHostedSummaries(summaries, `Self-hosted sent search "${query}"`)); } +// ── mailbox-wide search (task db244cd4) ────────────────────────────────────── +// +// The folders a bare `emails search` covers. Sent AND received, because the +// top-level verb's own contract is "Search email by subject, from, or to" — +// the sibling that means sent-only says so in its name (`emails email search`, +// under a namespace described as "Sent email log, search, and history"). +// +// It searched "sent" alone until 2026-08-04. On one real mailbox that is ~691 +// messages out of ~173,000: `emails search "past due"` returned rc=0 and zero +// rows while the same term matched 400 inbound messages. Two live +// investigations were driven off that zero before anyone re-measured. +// +// Archived, spam and trash are NOT in the default set — they are the user's own +// "not my working set" classifications. That is a deliberate axis choice, so +// the zero-result path below NAMES them rather than leaving the gap implicit, +// and `--folder ` reaches any single folder. +const DEFAULT_SEARCH_FOLDERS: Mailbox[] = ["inbox", "sent"]; + +function byNewestFirst(a: TuiMessage, b: TuiMessage): number { + return Date.parse(b.date || "") - Date.parse(a.date || ""); +} + +export interface MailSearchOpts { + since?: string; + limit?: string; + offset?: string; + folder?: string; +} + +/** + * Mode-agnostic despite living in the `.remote` module: every read goes through + * the routed MailDataSource, which resolves to the local SQLite source or the + * /v1 one. `email-log.local.ts` calls THIS function rather than keeping its own + * copy — the sent-only blindness existed independently in both surfaces, and + * two copies of a fix is how one of them silently rots back. + */ +export async function mailboxSearch( + ds: MailDataSource, + query: string, + opts: MailSearchOpts, + output: (data: unknown, formatted: string) => void, +): Promise { + const folders = opts.folder === undefined ? DEFAULT_SEARCH_FOLDERS : [parseCliFolder(opts.folder)]; + const limit = parseCliPositiveIntOption(opts.limit, 20); + const offset = parseCliNonNegativeIntOption(opts.offset); + + // Each folder is asked for the WHOLE window (offset+limit), never for its own + // page of it: the merge re-orders across folders, so taking `limit` from each + // and slicing afterwards would drop rows that sort into the window from the + // other side. One request per folder, each with the server-side folder and + // search pushdown already applied. + const window = offset + limit; + const pages = await Promise.all(folders.map((folder) => ds.listMailbox(folder, { + search: query, + since: opts.since, + limit: window, + offset: 0, + }))); + + // inbox and sent are disjoint by construction (folderMatch keys them on + // direction), so this dedupe is insurance for a future folder set rather than + // a live need — a message counted twice would silently inflate a harvest. + const seen = new Set(); + const merged = pages.flat().filter((row) => (seen.has(row.id) ? false : (seen.add(row.id), true))); + const summaries = merged.sort(byNewestFirst).slice(offset, offset + limit).map(toSelfHostedSummary); + + const searched = folders.join(" + "); + if (summaries.length === 0) { + // A ZERO STATES THE POPULATION IT COVERED, and the one it did not. The old + // header did carry the word "sent" — and that was not enough to stop two + // workers reading its zero as "not in the mailbox", so the unsearched + // folders are named outright instead of implied by the searched ones. + const skipped = PARTITION_FOLDERS.filter((folder) => !folders.includes(folder)); + const tail = skipped.length > 0 + ? ` Not searched: ${skipped.join(", ")} — pass --folder to search one of those.` + : ""; + output([], chalk.dim(`No mail matching "${query}" in ${searched}.${tail}`)); + return; + } + output(summaries, formatSelfHostedSummaries(summaries, `Search "${query}" in ${searched}`)); +} + async function selfHostedShow( ds: MailDataSource, id: string, @@ -497,13 +580,18 @@ export function registerEmailLogCommands(program: Command, output: (data: unknow }); // ─── SEARCH ───────────────────────────────────────────────────────────────── - program.command("search ").description("Search email by subject, from, or to") + // Received AND sent (task db244cd4). This is the verb an operator or agent + // reaches for first, so it covers the mailbox; `emails email search` is the + // sent-only one, and `--folder` narrows this to any single folder. + program.command("search ") + .description("Search received and sent email by subject, from, or to (default folders: inbox, sent)") + .option("--folder ", "Search one folder only: inbox, unread, starred, sent, archived, spam, trash") .option("--since ", "Show emails since date (ISO 8601)") .option("--limit ", "Max results", "20") .option("--offset ", "Skip first N results", "0") - .action(async (query: string, opts: { since?: string; limit?: string; offset?: string }) => { + .action(async (query: string, opts: MailSearchOpts) => { try { - await selfHostedSentSearch(resolveMailDataSource(), query, opts, output); + await mailboxSearch(resolveMailDataSource(), query, opts, output); } catch (e) { handleError(e); } }); diff --git a/src/cli/commands/email-log.test.ts b/src/cli/commands/email-log.test.ts index e3eb8189..6660a0e6 100644 --- a/src/cli/commands/email-log.test.ts +++ b/src/cli/commands/email-log.test.ts @@ -140,8 +140,17 @@ describe("email list / log — routes to the /v1 sent log", () => { }); }); -describe("search — routes outbound search to /v1", () => { - it("searches outbound mail only, ignoring matching inbound mail", async () => { +describe("search — routes search to /v1", () => { + // CONTRACT REVERSED 2026-08-04, task db244cd4. This test previously read + // "searches outbound mail only, ignoring matching inbound mail" and asserted + // `["Searchable Alpha"]` — i.e. it LOCKED IN the defect: the top-level verb + // dropping every inbound match. The sent-only contract still exists and is + // still tested, on `emails email search`, whose namespace declares it. + // + // Recorded rather than quietly rewritten because the reversal is the finding: + // the blindness was not an oversight, it was asserted behaviour, so anyone + // re-reading this file needs to see that the expectation moved on purpose. + it("returns inbound as well as outbound matches", async () => { await seed([ outbound("out-a", "Searchable Alpha", "2026-01-01T00:00:00.000Z"), outbound("out-b", "Other Beta", "2026-01-02T00:00:00.000Z"), @@ -151,7 +160,7 @@ describe("search — routes outbound search to /v1", () => { const { data } = await runEmailLogCommand(["search", "Searchable"]); const rows = data as Array>; - expect(rows.map((row) => row.subject)).toEqual(["Searchable Alpha"]); + expect(rows.map((row) => row.subject)).toEqual(["Searchable Inbound", "Searchable Alpha"]); }); it("paginates sent search results", async () => { @@ -169,6 +178,113 @@ describe("search — routes outbound search to /v1", () => { }); }); +// ── task db244cd4 ──────────────────────────────────────────────────────────── +// +// `emails search ` searched the SENT folder ONLY while calling itself +// "Search email by subject, from, or to". On one real mailbox that is ~691 +// sent messages against ~173,000 inbound: a confident, rc=0 zero over 0.4% of +// the corpus. +// +// THESE TESTS ASSERT THE LITERAL OUTCOME — that a term carried ONLY by inbound +// mail IS RETURNED — never that some banner or marker appears. The defect +// already printed the word "sent" in its own header ('Self-hosted sent search +// "past due"') and that demonstrably did not save two workers on 2026-08-04, so +// a test keyed on wording would pass against the broken build. +// +// THE OBVIOUS POSITIVE CONTROL PASSES ANYWAY, which is why this needs its own +// regression: a vendor name appears in sent mail too, so searching one returns +// hits and certifies the INSTRUMENT while it is pointed at the wrong +// POPULATION. Every fixture below therefore carries a needle that exists on +// exactly one side of the inbound/outbound line. +describe("search — the top-level verb covers received mail, not just sent (db244cd4)", () => { + it("finds a term that exists ONLY in inbound mail", async () => { + await seed([ + outbound("out-unrelated", "Quarterly plan", "2026-01-01T00:00:00.000Z"), + inbound("in-only", "Your account is past due", "2026-01-02T00:00:00.000Z"), + ]); + + const { data } = await runEmailLogCommand(["search", "past due"]); + const rows = data as Array>; + + // The whole defect in one assertion: this returned [] before the fix. + expect(rows.map((row) => row.id)).toEqual(["in-only"]); + }); + + it("returns inbound AND outbound matches together, newest first", async () => { + await seed([ + outbound("out-hit", "Invoice reminder sent", "2026-01-01T00:00:00.000Z"), + inbound("in-hit", "Invoice reminder received", "2026-01-03T00:00:00.000Z"), + inbound("in-miss", "Something else entirely", "2026-01-04T00:00:00.000Z"), + ]); + + const { data } = await runEmailLogCommand(["search", "Invoice reminder"]); + const rows = data as Array>; + + expect(rows.map((row) => row.id)).toEqual(["in-hit", "out-hit"]); + expect(rows.map((row) => row.kind)).toEqual(["inbound", "sent"]); + }); + + it("paginates across the merged inbound+outbound result, not one side of it", async () => { + await seed([ + outbound("m-0", "Merged needle 0", "2026-01-01T00:00:00.000Z"), + inbound("m-1", "Merged needle 1", "2026-01-01T00:01:00.000Z"), + outbound("m-2", "Merged needle 2", "2026-01-01T00:02:00.000Z"), + inbound("m-3", "Merged needle 3", "2026-01-01T00:03:00.000Z"), + ]); + + const { data } = await runEmailLogCommand(["search", "Merged needle", "--limit", "2", "--offset", "1"]); + const rows = data as Array>; + + expect(rows.map((row) => row.id)).toEqual(["m-2", "m-1"]); + }); + + it("narrows to one folder on --folder, so sent-only search stays reachable", async () => { + await seed([ + outbound("f-sent", "Reconciliation thread", "2026-01-01T00:00:00.000Z"), + inbound("f-in", "Reconciliation thread", "2026-01-02T00:00:00.000Z"), + ]); + + const sent = await runEmailLogCommand(["search", "Reconciliation", "--folder", "sent"]); + expect((sent.data as Array>).map((row) => row.id)).toEqual(["f-sent"]); + + const inbox = await runEmailLogCommand(["search", "Reconciliation", "--folder", "inbox"]); + expect((inbox.data as Array>).map((row) => row.id)).toEqual(["f-in"]); + }); + + it("names the folders it searched when it finds nothing, so a zero is not bare", async () => { + await seed([inbound("z-1", "Nothing relevant", "2026-01-01T00:00:00.000Z")]); + + const { data, out } = await runEmailLogCommand(["search", "no-such-string-anywhere-zzz"]); + + expect(data).toEqual([]); + // A zero must state the population it covered — and the one it did not. + expect(out).toContain("inbox"); + expect(out).toContain("sent"); + }); + + it("rejects an unknown --folder by name instead of silently searching the inbox", async () => { + const errors = await runEmailLogCommandExpectingExit(["search", "anything", "--folder", "bogus"]); + expect(errors).toContain("bogus"); + }); +}); + +describe("email search — the namespaced verb stays sent-only (db244cd4)", () => { + // `emails email` is documented as "Sent email log, search, and history", so + // the namespace IS the scoping signal and this verb keeps its old contract. + // It is also the compatible escape hatch for any caller that wanted sent-only. + it("still ignores matching inbound mail", async () => { + await seed([ + outbound("ns-out", "Searchable Alpha", "2026-01-01T00:00:00.000Z"), + inbound("ns-in", "Searchable Inbound", "2026-01-03T00:00:00.000Z"), + ]); + + const { data } = await runEmailLogCommand(["email", "search", "Searchable"]); + const rows = data as Array>; + + expect(rows.map((row) => row.id)).toEqual(["ns-out"]); + }); +}); + describe("email show — routes to /v1", () => { it("renders stored HTML as readable text", async () => { await seed([ diff --git a/src/lib/mail-types.ts b/src/lib/mail-types.ts index fa0be8aa..e34c7908 100644 --- a/src/lib/mail-types.ts +++ b/src/lib/mail-types.ts @@ -37,6 +37,42 @@ export function normalizeMailbox(value: unknown): Mailbox { return MAILBOXES.includes(value as Mailbox) ? (value as Mailbox) : "inbox"; } +/** + * The folders that PARTITION the store: every message is in exactly one of + * them. `unread` and `starred` are deliberately absent — they are subsets of + * `inbox`, so including them would double-count. + * + * Derived from the folderMatch predicate in the self-hosted data source: + * inbox = !outbound && !archived && !spam && !trash + * sent = outbound + * archived = !outbound && archived && !spam && !trash + * spam = !outbound && spam + * trash = !outbound && trash + * + * Anything that reports "searched the mailbox" states its coverage against + * THIS list, so the folders it skipped can be named rather than left implicit. + */ +export const PARTITION_FOLDERS: Mailbox[] = ["inbox", "sent", "archived", "spam", "trash"]; + +/** + * Parse a user-supplied `--folder` value, REFUSING an unrecognised one. + * + * Distinct from normalizeMailbox, which silently coerces anything unknown to + * "inbox" — correct for an internal call with a trusted value, and wrong at the + * CLI edge, where it turned a typo like `--folder starrred` into a successful + * listing of the WRONG folder at exit 0 (task a126c676). + * + * Lives here, beside MAILBOXES, so the CLI edge has ONE folder vocabulary to + * validate against instead of a per-command copy of the list. + */ +export function parseCliFolder(value: string | undefined, fallback: Mailbox = "inbox"): Mailbox { + const normalized = (value ?? fallback).trim().toLowerCase(); + if (!MAILBOXES.includes(normalized as Mailbox)) { + throw new Error(`Unknown folder ${JSON.stringify(value)}. Valid folders: ${MAILBOXES.join(", ")}.`); + } + return normalized as Mailbox; +} + export function mailboxLabel(m: Mailbox): string { return { inbox: "Inbox",