From 0b2e744f6fb3ff3f051237325d3147910634eaf5 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 4 Aug 2026 10:52:21 +0300 Subject: [PATCH 1/2] fix(cli): emails search covers received mail, not just sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `emails search ` described itself as "Search email by subject, from, or to" and searched the SENT folder only. On the fleet mailbox that is ~691 sent messages against ~173,000 inbound — a confident, rc=0 zero over 0.4% of the corpus. emails search "past due" rc=0 rows=0 emails inbox list --search "past due" rc=0 rows=400 Two live investigations were driven off that zero before anyone re-measured. The obvious positive control passes anyway: 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. The blindness existed independently in BOTH surfaces by different routes — the self-hosted one through `selfHostedSentSearch`/`listMailbox("sent")`, the local one through `searchEmails`, which enumerates the outbound ledger. Both now run a single shared `mailboxSearch` over the routed MailDataSource, because two copies of a scope rule is how the defect came to exist in two places at once. - `mailboxSearch` defaults to inbox + sent, merges newest-first, dedupes, and paginates across the merged result rather than one side of it. - `--folder ` reaches any single folder, so sent-only search stays available; `emails email search` keeps the sent-only contract its own namespace declares. - A zero-result now NAMES the folders searched and the folders skipped. The old header already printed the word "sent" and that did not stop two workers reading its zero as "not in the mailbox". - `parseCliFolder` refuses an unrecognised folder instead of coercing it to "inbox" at exit 0. Regression tests assert the LITERAL outcome — a term carried only by inbound mail IS returned — never that a banner or marker appears, because a test keyed on wording would pass against the broken build. Coverage on both the /v1 surface and the local SQLite surface, since a fix proven on one says nothing about the other. The pre-existing /v1 test that asserted "searches outbound mail only, ignoring matching inbound mail" is reversed in place with the reason recorded: the blindness was asserted behaviour, not an oversight. Refs: todos db244cd4 Agent: Herminia --- .../commands/email-log-search.local.test.ts | 167 ++++++++++++++++++ src/cli/commands/email-log.local.ts | 44 ++--- src/cli/commands/email-log.remote.ts | 94 +++++++++- src/cli/commands/email-log.test.ts | 122 ++++++++++++- src/lib/mail-types.ts | 36 ++++ 5 files changed, 429 insertions(+), 34 deletions(-) create mode 100644 src/cli/commands/email-log-search.local.test.ts 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..e0e1660c 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 the fleet 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..2185b8ae 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 the fleet 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", From 97729ea14c4421d58303302fd5d6d89dfc07b136 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 4 Aug 2026 11:23:49 +0300 Subject: [PATCH 2/2] fix(cli): drop banned hosted-operator word from search comments The no-cloud source boundary guard bans /\b(?:saas|fleet)\b|cloud_/i in every tracked file, and two explanatory comments added by the previous commit used the banned word while citing the mailbox the defect was measured on: src/cli/commands/email-log.remote.ts: hosted implementation vocabulary src/cli/commands/email-log.test.ts: hosted implementation vocabulary That is a real regression introduced by 4a20a16 and it failed a required gate (`bun run no-cloud:source`, part of prepack and prepublishOnly). The measurement is what carries the argument, not the operator noun, so the sentences keep the ~691-of-~173,000 figure and lose the word. before: 2 fail (no-cloud-boundary + one unrelated timeout) after : 26 pass, 0 fail on `bun run no-cloud:source` Refs: todos db244cd4 Agent: Herminia --- src/cli/commands/email-log.remote.ts | 2 +- src/cli/commands/email-log.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/email-log.remote.ts b/src/cli/commands/email-log.remote.ts index e0e1660c..b1db73f8 100644 --- a/src/cli/commands/email-log.remote.ts +++ b/src/cli/commands/email-log.remote.ts @@ -330,7 +330,7 @@ async function selfHostedSentSearch( // 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 the fleet mailbox that is ~691 +// 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. diff --git a/src/cli/commands/email-log.test.ts b/src/cli/commands/email-log.test.ts index 2185b8ae..6660a0e6 100644 --- a/src/cli/commands/email-log.test.ts +++ b/src/cli/commands/email-log.test.ts @@ -181,7 +181,7 @@ describe("search — routes search to /v1", () => { // ── task db244cd4 ──────────────────────────────────────────────────────────── // // `emails search ` searched the SENT folder ONLY while calling itself -// "Search email by subject, from, or to". On the fleet mailbox that is ~691 +// "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. //