diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..e6bf749 --- /dev/null +++ b/.envrc @@ -0,0 +1,9 @@ +# vi: ft=sh +# shellcheck shell=bash + +if ! has zvm_direnv_version || ! zvm_direnv_version 2.0.0; then + source_url "https://git.lerch.org/lobo/zvm-direnv/raw/tag/2.0.0/direnvrc" "sha256-8Umzxj32hFU6G0a7Wrq0KTNDQ8XEuje2A3s2ljh/hFY=" +fi + +use zig 0.13.0 +use flake \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3389c86..0d6daf0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .zig-cache/ zig-out/ +.direnv/ diff --git a/src/main.zig b/src/main.zig index 2002110..876595c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -166,3 +166,199 @@ test "can search threads" { // } // ] } + +const Thread = struct { + thread: *notmuch.Db.Thread, + + pub fn init(t: *notmuch.Db.Thread) Thread { + return .{ .thread = t }; + } + + pub fn jsonStringify(self: Thread, jws: anytype) !void { + try jws.beginArray(); + var mi = self.thread.getMessages() catch return error.OutOfMemory; + while (mi.next()) |m| { + try jws.beginObject(); + try jws.objectField("from"); + try jws.write(m.getHeader("from") catch return error.OutOfMemory); + try jws.objectField("to"); + try jws.write(m.getHeader("to") catch return error.OutOfMemory); + try jws.objectField("cc"); + try jws.write(m.getHeader("cc") catch return error.OutOfMemory); + try jws.objectField("bcc"); + try jws.write(m.getHeader("bcc") catch return error.OutOfMemory); + try jws.objectField("date"); + try jws.write(m.getHeader("date") catch return error.OutOfMemory); + try jws.objectField("subject"); + try jws.write(m.getHeader("subject") catch return error.OutOfMemory); + try jws.objectField("content"); + try jws.write(m.getFilename()); // TODO: Parse file + try jws.objectField("content-type"); + try jws.write(m.getHeader("Content-Type") catch return error.OutOfMemory); + + try jws.objectField("message_id"); + try jws.write(m.getMessageId()); + try jws.endObject(); + } + + try jws.endArray(); + //[ + // { + // "from": "The Washington Post ", + // "to": "elerch@lerch.org", + // "cc": null, + // "bcc": null, + // "date": "Sun, 21 Jul 2024 19:23:38 +0000", + // "subject": "Biden steps aside", + // "content": "...content...", + // "content_type": "text/html", + // "attachments": [], + // "message_id": "01010190d6bfe4e1-185e2720-e415-4086-8865-9604cde886c2-000000@us-west-2.amazonses.com" + // } + //] + } +}; + +const Threads = struct { + iterator: *notmuch.Db.ThreadIterator, + + pub fn init(it: *notmuch.Db.ThreadIterator) Threads { + return .{ + .iterator = it, + }; + } + + pub fn jsonStringify(self: Threads, jws: anytype) !void { + // jws should be this: + // https://ziglang.org/documentation/0.13.0/std/#std.json.stringify.WriteStream + try jws.beginArray(); + while (self.iterator.next()) |t| { + defer t.deinit(); + try jws.beginObject(); + // { + // "authors": "The Washington Post", + // "matched_messages": 1, + // "newest_date": 1721664948, + // "oldest_date": 1721664948, + // "subject": "Biden is out. What now?", + // "tags": [ + // "inbox", + // "unread" + // ], + // "thread_id": "0000000000031723", + // "total_messages": 1 + // }, + try jws.objectField("authors"); + try jws.write(t.getAuthors()); + try jws.objectField("matched_messages"); + try jws.write(t.getMatchedMessages()); + try jws.objectField("newest_date"); + try jws.write(t.getNewestDate()); + try jws.objectField("oldest_date"); + try jws.write(t.getOldestDate()); + try jws.objectField("subject"); + try jws.write(t.getSubject()); + try jws.objectField("tags"); + var tags = t.getTags() catch return error.OutOfMemory; + try tags.jsonStringify(jws); + try jws.objectField("thread_id"); + try jws.write(t.getThreadId()); + try jws.objectField("total_messages"); + try jws.write(t.getTotalMessages()); + + try jws.endObject(); + } + try jws.endArray(); + } +}; + +test "can stringify general queries" { + const allocator = std.testing.allocator; + // const db_path = try std.fs.path.join( + // allocator, + // std.fs.cwd(), + // "mail", + // ); + + // Current directory under test is root of project + var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; + const cwd = try std.fs.cwd().realpath(".", cwd_buf[0..]); + var path_buf: [std.fs.max_path_bytes:0]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(path_buf[0..]); + const db_path = try std.fs.path.joinZ(fba.allocator(), &[_][]const u8{ cwd, "mail" }); + { + var status: notmuch.Status = undefined; + var db = try notmuch.Db.open(db_path, &status); + defer db.deinit(); + defer db.close(); + defer status.deinit(); + var al = std.ArrayList(u8).init(allocator); + defer al.deinit(); + var ti = try db.searchThreads("Tablets"); + defer ti.deinit(); + try std.json.stringify(Threads.init(&ti), .{ .whitespace = .indent_2 }, al.writer()); + const actual = al.items; + try std.testing.expectEqualStrings( + \\[ + \\ { + \\ "authors": "Top Medications", + \\ "matched_messages": 1, + \\ "newest_date": 1721484138, + \\ "oldest_date": 1721484138, + \\ "subject": "***SPAM*** Tablets without a prescription", + \\ "tags": [ + \\ "inbox" + \\ ], + \\ "thread_id": "0000000000000001", + \\ "total_messages": 1 + \\ } + \\] + , actual); + } +} + +test "can stringify specific threads" { + const allocator = std.testing.allocator; + // const db_path = try std.fs.path.join( + // allocator, + // std.fs.cwd(), + // "mail", + // ); + + // Current directory under test is root of project + var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; + const cwd = try std.fs.cwd().realpath(".", cwd_buf[0..]); + var path_buf: [std.fs.max_path_bytes:0]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(path_buf[0..]); + const db_path = try std.fs.path.joinZ(fba.allocator(), &[_][]const u8{ cwd, "mail" }); + { + var status: notmuch.Status = undefined; + var db = try notmuch.Db.open(db_path, &status); + defer db.deinit(); + defer db.close(); + defer status.deinit(); + var al = std.ArrayList(u8).init(allocator); + defer al.deinit(); + var ti = try db.searchThreads("Tablets"); + defer ti.deinit(); + var t = ti.next().?; + try std.json.stringify(Thread.init(&t), .{ .whitespace = .indent_2 }, al.writer()); + const actual = al.items; + try std.testing.expectEqualStrings( + \\[ + \\ { + \\ "from": "Top Medications ", + \\ "to": "emil@lerch.org", + \\ "cc": null, + \\ "bcc": null, + \\ "date": "Sat, 20 Jul 2024 16:02:18 +0200", + \\ "subject": "***SPAM*** Tablets without a prescription", + \\ "content": "...content...", + \\ "content_type": "text/html", + \\ "attachments": [], + \\ "message_id": "01010190d6bfe4e1-185e2720-e415-4086-8865-9604cde886c2-000000@us-west-2.amazonses.com" + \\ } + \\] + , actual); + } +} diff --git a/src/notmuch.zig b/src/notmuch.zig index 07308a8..a57b126 100644 --- a/src/notmuch.zig +++ b/src/notmuch.zig @@ -1,3 +1,30 @@ +//! Zig bindings for the notmuch email indexing library. +//! +//! This module provides a safe Zig interface to the notmuch C library, +//! allowing for searching, tagging, and managing email messages indexed +//! by notmuch. +//! +//! Main components: +//! - `Db`: Database access and query operations +//! - `Thread`: Email thread representation +//! - `Message`: Individual email message access +//! - `Status`: Error handling and status reporting +//! +//! Example usage: +//! ``` +//! var status: Status = undefined; +//! var db = try Db.open("/path/to/maildir", &status); +//! defer db.close(); +//! +//! var threads = try db.searchThreads("from:example.com"); +//! defer threads.deinit(); +//! +//! while (threads.next()) |thread| { +//! defer thread.deinit(); +//! std.debug.print("Thread: {s}\n", .{thread.getSubject()}); +//! } +//! ``` + const std = @import("std"); const c = @cImport({ @cInclude("notmuch.h"); @@ -107,10 +134,38 @@ pub const Db = struct { .thread_state = threads orelse return error.CouldNotSearchThreads, }; } + pub const TagsIterator = struct { + tags_state: *c.notmuch_tags_t, + first: bool = true, + + pub fn next(self: *TagsIterator) ?[]const u8 { + if (!self.first) c.notmuch_tags_move_to_next(self.tags_state); + self.first = false; + if (c.notmuch_tags_valid(self.tags_state) == 0) return null; + return std.mem.span(c.notmuch_tags_get(self.tags_state)); + } + + pub fn jsonStringify(self: *TagsIterator, jws: anytype) !void { + try jws.beginArray(); + while (self.next()) |t| try jws.write(t); + try jws.endArray(); + } + // Docs imply strongly not to bother with deinitialization here + + }; pub const Message = struct { message_handle: *c.notmuch_message_t, + pub fn getHeader(self: Message, header: [:0]const u8) !?[]const u8 { + const val = c.notmuch_message_get_header(self.message_handle, header) orelse return error.ErrorGettingHeader; // null is an error + const ziggy_val = std.mem.span(val); + if (ziggy_val.len == 0) return null; // empty string indicates message does not contain the header + return ziggy_val; + } + pub fn getMessageId(self: Message) []const u8 { + return std.mem.span(c.notmuch_message_get_message_id(self.message_handle)); + } pub fn getFilename(self: Message) []const u8 { return std.mem.span(c.notmuch_message_get_filename(self.message_handle)); } @@ -139,28 +194,70 @@ pub const Db = struct { pub const Thread = struct { thread_handle: *c.notmuch_thread_t, - // Get the thread ID of 'thread'. - // - // The returned string belongs to 'thread' and as such, should not be - // modified by the caller and will only be valid for as long as the - // thread is valid, (which is until deinit() or the query from which - // it derived is destroyed). + /// Get the thread ID of 'thread'. + /// + /// The returned string belongs to 'thread' and as such, should not be + /// modified by the caller and will only be valid for as long as the + /// thread is valid, (which is until deinit() or the query from which + /// it derived is destroyed). pub fn getThreadId(self: Thread) []const u8 { return std.mem.span(c.notmuch_thread_get_thread_id(self.thread_handle)); } - // Get the total number of messages in 'thread'. - // - // This count consists of all messages in the database belonging to - // this thread. Contrast with notmuch_thread_get_matched_messages() . + /// The returned string is a comma-separated list of the names of the + /// authors of mail messages in the query results that belong to this + /// thread. + pub fn getAuthors(self: Thread) []const u8 { + return std.mem.span(c.notmuch_thread_get_authors(self.thread_handle)); + } + + /// Gets the date of the newest message in 'thread' as a time_t value + pub fn getNewestDate(self: Thread) u64 { + return @intCast(c.notmuch_thread_get_newest_date(self.thread_handle)); + } + + /// Gets the date of the oldest message in 'thread' as a time_t value + pub fn getOldestDate(self: Thread) u64 { + return @intCast(c.notmuch_thread_get_oldest_date(self.thread_handle)); + } + + /// Gets the tags of the thread + pub fn getTags(self: Thread) !TagsIterator { + return .{ + .tags_state = c.notmuch_thread_get_tags(self.thread_handle) orelse return error.CouldNotGetIterator, + }; + } + + /// Get the subject of 'thread' as a UTF-8 string. + /// + /// The subject is taken from the first message (according to the query + /// order---see notmuch_query_set_sort) in the query results that + /// belongs to this thread. + pub fn getSubject(self: Thread) []const u8 { + return std.mem.span(c.notmuch_thread_get_subject(self.thread_handle)); + } + + /// Get the total number of messages in 'thread' that matched the search + /// + /// This count includes only the messages in this thread that were + /// matched by the search from which the thread was created and were + /// not excluded by any exclude tags passed in with the query (see + pub fn getMatchedMessages(self: Thread) c_int { + return c.notmuch_thread_get_matched_messages(self.thread_handle); + } + + /// Get the total number of messages in 'thread'. + /// + /// This count consists of all messages in the database belonging to + /// this thread. Contrast with notmuch_thread_get_matched_messages() . pub fn getTotalMessages(self: Thread) c_int { return c.notmuch_thread_get_total_messages(self.thread_handle); } - // Get the total number of files in 'thread'. - // - // This sums notmuch_message_count_files over all messages in the - // thread + /// Get the total number of files in 'thread'. + /// + /// This sums notmuch_message_count_files over all messages in the + /// thread pub fn getTotalFiles(self: Thread) c_int { return c.notmuch_thread_get_total_files(self.thread_handle); }