From 64e35177f3cf106d04a717b3087e692eabfc603f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Tue, 10 Mar 2026 11:28:21 +0100 Subject: [PATCH] add notmuch query service with JSON parsing Co-Authored-By: Claude Opus 4.6 --- backend/src/services/notmuch.test.ts | 31 +++++++ backend/src/services/notmuch.ts | 87 +++++++++++++++++++ .../test/fixtures/notmuch-search-output.json | 24 +++++ .../test/fixtures/notmuch-show-output.json | 61 +++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 backend/src/services/notmuch.test.ts create mode 100644 backend/src/services/notmuch.ts create mode 100644 backend/test/fixtures/notmuch-search-output.json create mode 100644 backend/test/fixtures/notmuch-show-output.json diff --git a/backend/src/services/notmuch.test.ts b/backend/src/services/notmuch.test.ts new file mode 100644 index 0000000..e9aec49 --- /dev/null +++ b/backend/src/services/notmuch.test.ts @@ -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 "); + 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"); + }); +}); diff --git a/backend/src/services/notmuch.ts b/backend/src/services/notmuch.ts new file mode 100644 index 0000000..1326976 --- /dev/null +++ b/backend/src/services/notmuch.ts @@ -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>).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; + const bodyParts = item.body as Array>; + 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 { + return typeof item === "object" && item !== null && "id" in item && "headers" in item; +} + +export async function searchThreads(query: string): Promise { + 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 { + 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)); +} diff --git a/backend/test/fixtures/notmuch-search-output.json b/backend/test/fixtures/notmuch-search-output.json new file mode 100644 index 0000000..59604ec --- /dev/null +++ b/backend/test/fixtures/notmuch-search-output.json @@ -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"] + } +] diff --git a/backend/test/fixtures/notmuch-show-output.json b/backend/test/fixtures/notmuch-show-output.json new file mode 100644 index 0000000..53cf9d9 --- /dev/null +++ b/backend/test/fixtures/notmuch-show-output.json @@ -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 ", + "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 ", + "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" + } + ] + }, + [] + ] + ] + ] + ] +]