From f378460553d889cc2a98517c84627ebab2266c75 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 22 Jul 2026 07:57:12 -0700 Subject: [PATCH] message thread handling --- src/notmuch.zig | 14 ++++++++++++++ src/root.zig | 49 ++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/notmuch.zig b/src/notmuch.zig index b554230..791e006 100644 --- a/src/notmuch.zig +++ b/src/notmuch.zig @@ -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 diff --git a/src/root.zig b/src/root.zig index 2e1090d..845ac36 100644 --- a/src/root.zig +++ b/src/root.zig @@ -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); +}