Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions src/cli/commands/email-log-search.local.test.ts
Original file line number Diff line number Diff line change
@@ -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: `<local-${sequence}@example.com>`,
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<Record<string, unknown>>;

// 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<Record<string, unknown>>;

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<Record<string, unknown>>).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<Record<string, unknown>>;

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");
});
});
44 changes: 16 additions & 28 deletions src/cli/commands/email-log.local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -280,38 +284,22 @@ export function registerEmailLogCommands(program: Command, output: (data: unknow
} catch (e) { handleError(e); }
});

// ─── SEARCH ─────────────────────────────────────────────────────────────────
program.command("search <query>").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 <query>")
.description("Search received and sent email by subject, from, or to (default folders: inbox, sent)")
.option("--folder <folder>", "Search one folder only: inbox, unread, starred, sent, archived, spam, trash")
.option("--since <date>", "Show emails since date (ISO 8601)")
.option("--limit <n>", "Max results", "20")
.option("--offset <n>", "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); }
});

Expand Down
94 changes: 91 additions & 3 deletions src/cli/commands/email-log.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <name>` 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<void> {
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<string>();
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 <name> 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,
Expand Down Expand Up @@ -497,13 +580,18 @@ export function registerEmailLogCommands(program: Command, output: (data: unknow
});

// ─── SEARCH ─────────────────────────────────────────────────────────────────
program.command("search <query>").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 <query>")
.description("Search received and sent email by subject, from, or to (default folders: inbox, sent)")
.option("--folder <folder>", "Search one folder only: inbox, unread, starred, sent, archived, spam, trash")
.option("--since <date>", "Show emails since date (ISO 8601)")
.option("--limit <n>", "Max results", "20")
.option("--offset <n>", "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); }
});

Expand Down
Loading
Loading