add notmuch query service with JSON parsing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
31
backend/src/services/notmuch.test.ts
Normal file
31
backend/src/services/notmuch.test.ts
Normal 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
backend/src/services/notmuch.ts
Normal file
87
backend/src/services/notmuch.ts
Normal 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));
|
||||
}
|
||||
24
backend/test/fixtures/notmuch-search-output.json
vendored
Normal file
24
backend/test/fixtures/notmuch-search-output.json
vendored
Normal 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"]
|
||||
}
|
||||
]
|
||||
61
backend/test/fixtures/notmuch-show-output.json
vendored
Normal file
61
backend/test/fixtures/notmuch-show-output.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
},
|
||||
[]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
Reference in New Issue
Block a user