committing with one failing test, because now we need to go read files

This commit is contained in:
Emil Lerch 2025-04-19 12:55:02 -07:00
parent 5d6a777965
commit 4718bce5af
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 317 additions and 14 deletions

9
.envrc Normal file
View file

@ -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

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
.zig-cache/
zig-out/
.direnv/

View file

@ -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 <email@washingtonpost.com>",
// "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 <mail@youpharm.co>",
\\ "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);
}
}

View file

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