message thread handling
All checks were successful
Generic zig build / build (push) Successful in 6m27s
Generic zig build / deploy (push) Successful in 38s

This commit is contained in:
Emil Lerch 2026-07-22 07:57:12 -07:00
parent 1a60ca57f9
commit f378460553
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 54 additions and 9 deletions

View file

@ -94,6 +94,20 @@ pub const Db = struct {
if (status != c.NOTMUCH_STATUS_SUCCESS) return error.CouldNotReopenDatabase;
}
/// Look up a single message by its exact message-id.
///
/// The id must be the bare message-id (no `mid:`/`id:` prefix and no angle
/// brackets), exactly as notmuch stores it. Returns null when no message
/// has that id. The returned Message owns a notmuch_message_t and must be
/// released with Message.deinit.
pub fn findMessage(db: Db, message_id: [:0]const u8) !?Message {
// SAFETY: out parameter, set by notmuch_database_find_message
var msg: ?*c.notmuch_message_t = null;
const status = c.notmuch_database_find_message(db.handle, message_id.ptr, &msg);
if (status != c.NOTMUCH_STATUS_SUCCESS) return error.CouldNotFindMessage;
return if (msg) |m| Message{ .message_handle = m } else null;
}
//
// Execute a query for threads, returning a notmuch_threads_t object
// which can be used to iterate over the results. The returned threads

View file

@ -265,17 +265,24 @@ pub const NotmuchDb = struct {
}
};
/// Find a message by its exact id, reopening the snapshot and retrying
/// once on failure (mirrors openThreads). Callers must hold self.mutex.
fn findMessage(self: *NotmuchDb, message_id: [:0]const u8) !?notmuch.Db.Message {
return self.db.findMessage(message_id) catch |first_err| {
self.db.reopen() catch return first_err;
return self.db.findMessage(message_id);
};
}
pub fn getMessage(self: *NotmuchDb, message_id: []const u8) !MessageDetail {
var query_buf: [1024:0]u8 = undefined;
const query_z = try std.fmt.bufPrintZ(&query_buf, "mid:{s}", .{message_id});
var thread_iter = try self.openThreads(query_z);
defer thread_iter.deinit();
const message_id_z = try self.allocator.dupeZ(u8, message_id);
defer self.allocator.free(message_id_z);
const thread = thread_iter.next() orelse return error.MessageNotFound;
defer thread.deinit();
var msg_iter = try thread.getMessages();
const notmuch_msg = msg_iter.next() orelse return error.MessageNotFound;
// Look the message up by id directly. Previously this searched for the
// thread containing the id and returned the thread's first message, so
// every message in a reply thread rendered the same body.
const notmuch_msg = (try self.findMessage(message_id_z)) orelse return error.MessageNotFound;
defer notmuch_msg.deinit();
const filename_z = try self.allocator.dupeZ(u8, notmuch_msg.getFilename());
defer self.allocator.free(filename_z);
@ -447,3 +454,27 @@ test "can get message details with content" {
// TODO: Add test with attachment once we have a sample email with attachments
}
test "getMessage returns the requested message, not the thread's first" {
// Regression: getMessage used to search for the *thread* containing the id
// and return that thread's first message, so every message in a reply
// thread rendered the same (first) body. The fixture has a two-message
// thread (mail/Inbox/cur/*.Zthreadtest*): an original and a reply with
// distinct bodies. Fetching either id must return THAT message.
const allocator = std.testing.allocator;
var db = try openNotmuchDb(allocator, "mail", null);
defer db.close();
const orig_id = "zetviel-thread-orig@example.com";
const reply_id = "zetviel-thread-reply@example.com";
const orig = try db.getMessage(orig_id);
defer orig.deinit(allocator);
try std.testing.expectEqualStrings(orig_id, orig.message_id);
try std.testing.expect(std.mem.indexOf(u8, orig.text_content, "UNIQUE-ORIGINAL-BODY-MARKER") != null);
const reply = try db.getMessage(reply_id);
defer reply.deinit(allocator);
try std.testing.expectEqualStrings(reply_id, reply.message_id);
try std.testing.expect(std.mem.indexOf(u8, reply.text_content, "UNIQUE-REPLY-BODY-MARKER") != null);
}