add notmuch query service with JSON parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 11:28:21 +01:00
parent e3bd05ca21
commit 64e35177f3
4 changed files with 203 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { parseSearchResults, parseThreadMessages } from "./notmuch";
const searchFixture = await Bun.file("test/fixtures/notmuch-search-output.json").json();
const showFixture = await Bun.file("test/fixtures/notmuch-show-output.json").json();
describe("parseSearchResults", () => {
test("parses notmuch search JSON into thread summaries", () => {
const threads = parseSearchResults(searchFixture);
expect(threads).toHaveLength(2);
expect(threads[0].threadId).toBe("0000000000000001");
expect(threads[0].subject).toBe("Q1 Planning");
expect(threads[0].authors).toBe("Alice, Bob");
expect(threads[0].totalMessages).toBe(3);
expect(threads[0].tags).toContain("unread");
expect(threads[0].timestamp).toBe(1709884532);
});
});
describe("parseThreadMessages", () => {
test("flattens notmuch show JSON into ordered messages", () => {
const messages = parseThreadMessages(showFixture);
expect(messages).toHaveLength(2);
expect(messages[0].messageId).toBe("msg001@example.com");
expect(messages[0].from).toBe("Alice <alice@example.com>");
expect(messages[0].subject).toBe("Q1 Planning");
expect(messages[0].body).toBe("Hey, let's plan Q1.\n");
expect(messages[1].messageId).toBe("msg003@example.com");
expect(messages[1].inReplyTo).toBe("msg001@example.com");
});
});
+87
View File
@@ -0,0 +1,87 @@
import { runCommand } from "./orchestrator";
export interface ThreadSummary {
threadId: string;
subject: string;
authors: string;
totalMessages: number;
tags: string[];
timestamp: number;
}
export interface Message {
messageId: string;
from: string;
to: string;
subject: string;
date: string;
inReplyTo: string;
references: string;
body: string;
tags: string[];
timestamp: number;
}
export function parseSearchResults(raw: unknown[]): ThreadSummary[] {
return (raw as Array<Record<string, unknown>>).map((entry) => ({
threadId: entry.thread as string,
subject: entry.subject as string,
authors: entry.authors as string,
totalMessages: entry.total as number,
tags: entry.tags as string[],
timestamp: entry.timestamp as number,
}));
}
export function parseThreadMessages(raw: unknown[]): Message[] {
const messages: Message[] = [];
flattenThread(raw, messages);
return messages.sort((a, b) => a.timestamp - b.timestamp);
}
function flattenThread(node: unknown, messages: Message[]): void {
if (!Array.isArray(node)) return;
for (const item of node) {
if (isMessageNode(item)) {
const headers = item.headers as Record<string, string>;
const bodyParts = item.body as Array<Record<string, unknown>>;
const plainPart = bodyParts.find((p) => p["content-type"] === "text/plain");
messages.push({
messageId: item.id as string,
from: headers.From ?? "",
to: headers.To ?? "",
subject: headers.Subject ?? "",
date: headers.Date ?? "",
inReplyTo: headers["In-Reply-To"] ?? "",
references: headers.References ?? "",
body: (plainPart?.content as string) ?? "",
tags: item.tags as string[],
timestamp: item.timestamp as number,
});
} else if (Array.isArray(item)) {
flattenThread(item, messages);
}
}
}
function isMessageNode(item: unknown): item is Record<string, unknown> {
return typeof item === "object" && item !== null && "id" in item && "headers" in item;
}
export async function searchThreads(query: string): Promise<ThreadSummary[]> {
const result = await runCommand("notmuch", ["search", "--format=json", query]);
if (result.exitCode !== 0) {
throw new Error(`notmuch search failed: ${result.stderr}`);
}
return parseSearchResults(JSON.parse(result.stdout));
}
export async function getThread(threadId: string): Promise<Message[]> {
const result = await runCommand("notmuch", ["show", "--format=json", `thread:${threadId}`]);
if (result.exitCode !== 0) {
throw new Error(`notmuch show failed: ${result.stderr}`);
}
return parseThreadMessages(JSON.parse(result.stdout));
}