diff --git a/.changeset/fix-email-search-operators.md b/.changeset/fix-email-search-operators.md new file mode 100644 index 0000000..0f5756a --- /dev/null +++ b/.changeset/fix-email-search-operators.md @@ -0,0 +1,18 @@ +--- +"fastmail-cli": patch +--- + +Fix `email search` to parse fielded query operators. + +Previously, `fastmail email search "from:alice@example.com"` sent the +entire string as a plain text search, so sender-based searches would +never return results. The CLI now parses field operators before building +the JMAP filter: + +- `from:` maps to `filter.from` +- `to:` maps to `filter.to` +- `cc:` maps to `filter.cc` +- `bcc:` maps to `filter.bcc` +- `subject:` maps to `filter.subject` (supports quoted values: `subject:"hello world"`) +- `body:` maps to `filter.body` +- Any remaining text is passed as `filter.text` for full-text search diff --git a/src/commands/email.test.ts b/src/commands/email.test.ts index e9df55b..63eed42 100644 --- a/src/commands/email.test.ts +++ b/src/commands/email.test.ts @@ -1,6 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; import type { JmapClient } from "../jmap/client.ts"; -import { getReplyContext, parseDate } from "./email.ts"; +import { getReplyContext, parseDate, parseSearchQuery } from "./email.ts"; describe("parseDate", () => { test("handles ISO 8601 format", () => { @@ -561,3 +561,73 @@ describe("getReplyContext", () => { ).rejects.toThrow("Email not found: email-123"); }); }); + +describe("parseSearchQuery", () => { + test("plain text becomes filter.text", () => { + expect(parseSearchQuery("project update")).toEqual({ + text: "project update", + }); + }); + + test("from: operator", () => { + expect(parseSearchQuery("from:foo@bar.com")).toEqual({ + from: "foo@bar.com", + }); + }); + + test("to: operator", () => { + expect(parseSearchQuery("to:baz@qux.com")).toEqual({ to: "baz@qux.com" }); + }); + + test("cc: operator", () => { + expect(parseSearchQuery("cc:someone@example.com")).toEqual({ + cc: "someone@example.com", + }); + }); + + test("bcc: operator", () => { + expect(parseSearchQuery("bcc:hidden@example.com")).toEqual({ + bcc: "hidden@example.com", + }); + }); + + test("body: operator", () => { + expect(parseSearchQuery("body:invoice")).toEqual({ body: "invoice" }); + }); + + test("quoted subject operator", () => { + expect(parseSearchQuery('subject:"hello world"')).toEqual({ + subject: "hello world", + }); + }); + + test("from: combined with free text", () => { + expect(parseSearchQuery("from:foo@bar.com project update")).toEqual({ + from: "foo@bar.com", + text: "project update", + }); + }); + + test("multiple operators", () => { + expect(parseSearchQuery("from:foo@bar.com to:baz@qux.com")).toEqual({ + from: "foo@bar.com", + to: "baz@qux.com", + }); + }); + + test("from: and quoted subject with free text", () => { + expect( + parseSearchQuery( + 'from:alice@example.com subject:"meeting notes" important', + ), + ).toEqual({ + from: "alice@example.com", + subject: "meeting notes", + text: "important", + }); + }); + + test("empty string produces empty filter", () => { + expect(parseSearchQuery("")).toEqual({}); + }); +}); diff --git a/src/commands/email.ts b/src/commands/email.ts index 54cf76d..af7529c 100644 --- a/src/commands/email.ts +++ b/src/commands/email.ts @@ -19,6 +19,7 @@ import { type EmailAddress, type EmailBodyPart, type EmailFilter, + type EmailFilterCondition, getEmails, queryEmails, setEmails, @@ -454,6 +455,55 @@ Options: console.log(JSON.stringify(formatEmail(email, true), null, 2)); } +type SearchQueryField = "from" | "to" | "cc" | "bcc" | "subject" | "body"; + +/** + * Parse a search query string into a structured JMAP EmailFilterCondition. + * + * Supports field operators: from:, to:, cc:, bcc:, subject:, body: + * Values can be quoted (subject:"hello world") or unquoted (to next space). + * Any remaining text that is not a field operator is set as filter.text. + */ +export function parseSearchQuery(query: string): EmailFilterCondition { + const filter: EmailFilterCondition = {}; + const fields: SearchQueryField[] = [ + "from", + "to", + "cc", + "bcc", + "subject", + "body", + ]; + + let remaining = query; + + for (const field of fields) { + // Match field:"quoted value" or field:unquoted-value + const quotedPattern = new RegExp(`(?:^|\\s)${field}:"([^"]*)"`, "g"); + const unquotedPattern = new RegExp(`(?:^|\\s)${field}:(\\S+)`, "g"); + + let match = quotedPattern.exec(remaining); + if (match) { + filter[field as SearchQueryField] = match[1]; + remaining = remaining.replace(match[0], " "); + continue; + } + + match = unquotedPattern.exec(remaining); + if (match) { + filter[field as SearchQueryField] = match[1]; + remaining = remaining.replace(match[0], " "); + } + } + + const text = remaining.trim(); + if (text) { + filter.text = text; + } + + return filter; +} + /** * Search emails. */ @@ -464,7 +514,7 @@ export async function searchEmails(args: string[]): Promise { console.log(` Usage: fastmail email search [options] -Search emails using text query. +Search emails using text query with optional field operators. Options: --limit Max results (default: 20) @@ -473,8 +523,18 @@ Options: --before Received before date (ISO 8601, YYYY-MM-DD, or 7d) -h, --help Show this help +Query operators: + from: Match sender address + to: Match recipient address + cc: Match CC address + bcc: Match BCC address + subject: Match subject (quote multi-word: subject:"hello world") + body: Match body text + (other text) Full-text search + Examples: - fastmail email search "from:newsletter" --after "7d" + fastmail email search "from:newsletter@example.com" --after "7d" + fastmail email search 'from:alice@example.com subject:"project update"' fastmail email search "project update" --after "2026-01-01" --before "2026-01-15" `); return; @@ -491,9 +551,7 @@ Examples: const limit = getNumber(parsed, "limit") ?? 20; // Build filter - const filter: EmailFilter = { - text: query, - }; + const filter: EmailFilter = parseSearchQuery(query); const mailbox = getString(parsed, "mailbox"); if (mailbox) {