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

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");
});
});

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));
}

View File

@@ -0,0 +1,24 @@
[
{
"thread": "0000000000000001",
"timestamp": 1709884532,
"date_relative": "2024-03-08",
"matched": 1,
"total": 3,
"authors": "Alice, Bob",
"subject": "Q1 Planning",
"query": ["id:msg001@example.com", null],
"tags": ["inbox", "unread"]
},
{
"thread": "0000000000000002",
"timestamp": 1709798132,
"date_relative": "2024-03-07",
"matched": 1,
"total": 1,
"authors": "Charlie",
"subject": "Invoice #4521",
"query": ["id:msg002@example.com", null],
"tags": ["inbox"]
}
]

View File

@@ -0,0 +1,61 @@
[
[
[
{
"id": "msg001@example.com",
"match": true,
"excluded": false,
"filename": ["/home/user/Mail/personal/Inbox/cur/1709884532.M1P99.host:2,S"],
"timestamp": 1709884532,
"date_relative": "2024-03-08",
"tags": ["inbox", "unread"],
"headers": {
"Subject": "Q1 Planning",
"From": "Alice <alice@example.com>",
"To": "user@example.com",
"Date": "Fri, 08 Mar 2024 10:15:32 +0100",
"Message-ID": "msg001@example.com",
"In-Reply-To": "",
"References": ""
},
"body": [
{
"id": 1,
"content-type": "text/plain",
"content": "Hey, let's plan Q1.\n"
}
]
},
[
[
{
"id": "msg003@example.com",
"match": true,
"excluded": false,
"filename": ["/home/user/Mail/personal/Inbox/cur/1709884600.M2P99.host:2,RS"],
"timestamp": 1709884600,
"date_relative": "2024-03-08",
"tags": ["inbox"],
"headers": {
"Subject": "Re: Q1 Planning",
"From": "Bob <bob@example.com>",
"To": "alice@example.com, user@example.com",
"Date": "Fri, 08 Mar 2024 10:16:40 +0100",
"Message-ID": "msg003@example.com",
"In-Reply-To": "msg001@example.com",
"References": "msg001@example.com"
},
"body": [
{
"id": 1,
"content-type": "text/plain",
"content": "Sounds good. Tuesday work?\n"
}
]
},
[]
]
]
]
]
]