Skip to content
92 changes: 92 additions & 0 deletions src/e2e/macos-mail.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from "bun:test";
import { SafeJSON } from "@app/utils/json";
import { runTool, stripAnsi } from "./helpers";

describe("tools macos mail", () => {
describe("help", () => {
it("--help exits 0", async () => {
const r = await runTool(["macos", "mail", "--help"]);
expect(r.exitCode).toBe(0);
});

it("list --help exits 0 and mentions --columns, --format, --limit", async () => {
const r = await runTool(["macos", "mail", "list", "--help"]);
expect(r.exitCode).toBe(0);
const out = stripAnsi(r.stdout);

expect(out).toContain("--columns");
expect(out).toContain("--format");
expect(out).toContain("--limit");
});

it("search --help exits 0 and mentions --columns, --format", async () => {
const r = await runTool(["macos", "mail", "search", "--help"]);
expect(r.exitCode).toBe(0);
const out = stripAnsi(r.stdout);

expect(out).toContain("--columns");
expect(out).toContain("--format");
});
});

describe("list --help column names", () => {
const ALL_COLUMNS = [
"date",
"from",
"fromEmail",
"to",
"toEmail",
"cc",
"subject",
"mailbox",
"account",
"read",
"flagged",
"size",
"attachments",
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ALL_COLUMNS array is missing the body and relevance columns, which are defined in src/macos/lib/mail/columns.ts. This means the test "list --help output contains all column names" isn't verifying that all available columns are mentioned in the help output.

        const ALL_COLUMNS = [
            "date",
            "from",
            "fromEmail",
            "to",
            "toEmail",
            "cc",
            "subject",
            "mailbox",
            "account",
            "read",
            "flagged",
            "size",
            "attachments",
            "body",
            "relevance",
        ];


it("list --help output contains all column names", async () => {
const r = await runTool(["macos", "mail", "list", "--help"]);
const out = stripAnsi(r.stdout);

for (const col of ALL_COLUMNS) {
expect(out).toContain(col);
}
});
});

describe("invalid column", () => {
it("list --columns invalid_col should warn about unknown column", async () => {
const r = await runTool(["macos", "mail", "list", "--columns", "invalid_col"]);
const combined = stripAnsi(r.stdout + r.stderr);
expect(combined).toContain("Unknown column");
});
});

describe("format flag", () => {
it("list --format json --limit 1 accepts the format flag", async () => {
const r = await runTool(["macos", "mail", "list", "--format", "json", "--limit", "1"], 30_000);

// If mail DB is accessible, we get valid JSON output.
// If not (e.g. no Full Disk Access), we still verify the flags are accepted
// by checking it doesn't fail with "unknown option".
const combined = stripAnsi(r.stdout + r.stderr);
expect(combined).not.toContain("unknown option");

if (r.exitCode === 0 && r.stdout.trim().length > 0) {
// The output contains clack spinner control sequences mixed with JSON.
// Find the line that starts with '[' and collect until the matching ']'.
const lines = r.stdout.split("\n");
const startIdx = lines.findIndex((l) => l.trim() === "[");
const endIdx = lines.findLastIndex((l) => l.trim() === "]");

if (startIdx !== -1 && endIdx > startIdx) {
const jsonStr = lines.slice(startIdx, endIdx + 1).join("\n");
const parsed = SafeJSON.parse(jsonStr);
expect(Array.isArray(parsed)).toBe(true);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The method used to extract the JSON object from the command's stdout is quite brittle. It relies on the opening [ and closing ] brackets being on their own separate lines. This could easily break if the JSON output formatting changes, for example, to be more compact. A more robust approach would be to find the first [ and last ] in the output string.

                // The output can contain clack spinner control sequences mixed with JSON.
                // Find the first '[' and last ']' to extract the JSON array string.
                const stdout = r.stdout;
                const startIdx = stdout.indexOf("[");
                const endIdx = stdout.lastIndexOf("]");

                if (startIdx !== -1 && endIdx > startIdx) {
                    const jsonStr = stdout.substring(startIdx, endIdx + 1);
                    const parsed = SafeJSON.parse(jsonStr);
                    expect(Array.isArray(parsed)).toBe(true);
                }

}
}, 30_000);
});
});
136 changes: 132 additions & 4 deletions src/macos/commands/mail/list.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,121 @@
import {
ALL_COLUMN_KEYS,
DEFAULT_LIST_COLUMNS,
MAIL_COLUMNS,
type MailColumnKey,
RECIPIENT_COLUMNS,
} from "@app/macos/lib/mail/columns";
import { formatResultsTable } from "@app/macos/lib/mail/format";
import { cleanup, getAttachments, listMessages } from "@app/macos/lib/mail/sqlite";
import { cleanup, getAttachments, getRecipients, listMessages } from "@app/macos/lib/mail/sqlite";
import { rowToMessage } from "@app/macos/lib/mail/transform";
import { SafeJSON } from "@app/utils/json";
import type { MailMessage } from "@app/macos/lib/mail/types";
import { parseVariadic } from "@app/utils/cli/variadic";
import * as p from "@clack/prompts";
import type { Command } from "commander";

interface ListOptions {
limit?: string;
columns?: string | true;
format?: string;
}

function resolveColumns(rawColumns: string | true | undefined): MailColumnKey[] | "interactive" {
if (rawColumns === undefined) {
return DEFAULT_LIST_COLUMNS;
}

if (rawColumns === true) {
return "interactive";
}

const parsed = parseVariadic(rawColumns);
const valid: MailColumnKey[] = [];

for (const col of parsed) {
if (ALL_COLUMN_KEYS.includes(col as MailColumnKey)) {
valid.push(col as MailColumnKey);
} else {
p.log.warn(`Unknown column "${col}" — available: ${ALL_COLUMN_KEYS.join(", ")}`);
}
}

if (valid.length === 0) {
return DEFAULT_LIST_COLUMNS;
}

return valid;
}

async function pickColumnsInteractively(): Promise<MailColumnKey[] | null> {
const result = await p.multiselect({
message: "Select columns to display:",
options: ALL_COLUMN_KEYS.map((key) => ({
value: key,
label: MAIL_COLUMNS[key].label,
hint: DEFAULT_LIST_COLUMNS.includes(key) ? "default" : undefined,
})),
initialValues: [...DEFAULT_LIST_COLUMNS],
required: true,
});

if (p.isCancel(result)) {
p.cancel("Operation cancelled");
return null;
}

return result as MailColumnKey[];
}

function needsRecipients(columns: MailColumnKey[]): boolean {
return columns.some((col) => RECIPIENT_COLUMNS.includes(col));
}

function formatJsonOutput(messages: MailMessage[], columns: MailColumnKey[]): string {
const data = messages.map((msg) => {
const obj: Record<string, string> = {};

for (const col of columns) {
obj[col] = MAIL_COLUMNS[col].get(msg);
}

return obj;
});

return SafeJSON.stringify(data, null, 2);
}

export function registerListCommand(program: Command): void {
program
.command("list [mailbox]")
.description("List recent emails from a mailbox (default: INBOX)")
.option("--limit <n>", "Number of emails to show", "20")
.action(async (mailbox: string | undefined, options: { limit?: string }) => {
.option("--columns [cols]", "Columns to show (" + ALL_COLUMN_KEYS.join(",") + ")")
.option("-f, --format <type>", "Output format: table, json, toon", "table")
.action(async (mailbox: string | undefined, options: ListOptions) => {
try {
const targetMailbox = mailbox ?? "INBOX";
const limit = Number.parseInt(options.limit ?? "20", 10);

// Resolve columns
let columnsResolved = resolveColumns(options.columns);

if (columnsResolved === "interactive") {
if (!process.stdout.isTTY) {
columnsResolved = DEFAULT_LIST_COLUMNS;
} else {
const picked = await pickColumnsInteractively();

if (!picked) {
return;
}

columnsResolved = picked;
}
}

const columns = columnsResolved;

const spinner = p.spinner();
spinner.start(`Fetching latest ${limit} emails from ${targetMailbox}...`);

Expand All @@ -35,10 +136,37 @@ export function registerListCommand(program: Command): void {
return msg;
});

// Enrich with recipients if any recipient column is selected
if (needsRecipients(columns)) {
const recipientsMap = getRecipients(rowids);

for (const msg of messages) {
msg.recipients = recipientsMap.get(msg.rowid) ?? [];
}
}

spinner.stop(`${messages.length} emails from ${targetMailbox}`);

console.log("");
console.log(formatResultsTable(messages));
const format = options.format ?? "table";

if (format === "table") {
console.log("");
console.log(formatResultsTable(messages, columns));
} else if (format === "json") {
console.log(formatJsonOutput(messages, columns));
} else if (format === "toon") {
const jsonStr = formatJsonOutput(messages, columns);
const proc = Bun.spawn(["tools", "json"], {
stdin: new Blob([jsonStr]),
stdout: "inherit",
stderr: "inherit",
});
await proc.exited;
} else {
p.log.warn(`Unknown format "${format}" — using table`);
console.log("");
console.log(formatResultsTable(messages, columns));
}
} catch (error) {
p.log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
Expand Down
Loading