diff --git a/build.zig b/build.zig index 2afe6b4..819c5e4 100644 --- a/build.zig +++ b/build.zig @@ -109,6 +109,8 @@ fn configure(compile: *std.Build.Step.Compile, paths: std.zig.system.NativePaths compile.addIncludePath(.{ .cwd_relative = dir }); for (paths.rpaths.items) |dir| compile.addRPath(.{ .cwd_relative = dir }); + + compile.linkSystemLibrary2("gmime-3.0", .{ .use_pkg_config = .force }); } fn checkNix(b: *std.Build, target_query: *std.Target.Query) !std.zig.system.NativePaths { @@ -207,7 +209,7 @@ fn getDynamicLinker(elf_path: []const u8) !std.Target.DynamicLinker { const interp_offset = std.mem.littleToNative(u64, @as(*u64, @ptrFromInt(@intFromPtr(sh_contents[0x18 .. 0x19 + 8]))).*); // 0x9218 const interp_size = std.mem.littleToNative(u64, @as(*u64, @ptrFromInt(@intFromPtr(sh_contents[0x20 .. 0x21 + 8]))).*); // 2772 // std.debug.print("Found interpreter at {x}, size: {}\n", .{ interp_offset, interp_size }); - interp = file_contents[interp_offset .. interp_offset + 1 + interp_size]; + interp = file_contents[interp_offset .. interp_offset + interp_size]; // std.debug.print("Interp: {s}\n", .{interp}); } } @@ -217,6 +219,8 @@ fn getDynamicLinker(elf_path: []const u8) !std.Target.DynamicLinker { } var dl = std.Target.DynamicLinker{ .buffer = undefined, .len = 0 }; - dl.set(interp); + // The .interp section contains a null-terminated string, so we need to trim the null terminator + const trimmed_interp = std.mem.trimRight(u8, interp.?, &[_]u8{0}); + dl.set(trimmed_interp); return dl; } diff --git a/build.zig.zon b/build.zig.zon index 57a619e..c0425b4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,5 +1,5 @@ .{ - .name = "zetviel", + .name = .zetviel, // This is a [Semantic Version](https://semver.org/). // In a future version of Zig it will be used for package deduplication. .version = "0.0.0", @@ -7,7 +7,9 @@ // This field is optional. // This is currently advisory only; Zig does not yet do anything // with this value. - //.minimum_zig_version = "0.11.0", + .minimum_zig_version = "0.14.0", + + .fingerprint = 0xd4c335836acc5e4e, // This field is optional. // Each dependency must either provide a `url` and `hash`, or a `path`. diff --git a/flake.nix b/flake.nix index 7858efc..4c2b496 100644 --- a/flake.nix +++ b/flake.nix @@ -14,6 +14,10 @@ devShells.default = systempkgs.mkShell { buildInputs = with systempkgs; [ notmuch + gmime3 + glibc + glibc_multi + pkg-config ]; }; } diff --git a/src/Email.zig b/src/Email.zig new file mode 100644 index 0000000..14b6596 --- /dev/null +++ b/src/Email.zig @@ -0,0 +1,242 @@ +const std = @import("std"); +const gmime = @import("c.zig").c; + +const Self = @This(); + +initialized: bool = false, + +pub fn init() Self { + // We'll initialize on first use... + //gmime.g_mime_init(); + return .{}; +} + +pub fn deinit(self: Self) void { + if (self.initialized) gmime.g_mime_shutdown(); +} + +/// Initializes gmime if not already initialized +fn gmimeInit(self: *Self) void { + if (!self.initialized) { + gmime.g_mime_init(); + self.initialized = true; + } +} + +pub fn openMessage(self: *Self, filename: [:0]const u8) !Message { + // TODO: remove the :0 + self.gmimeInit(); + // Open the file as a GMime stream + const stream = gmime.g_mime_stream_fs_open(filename.ptr, gmime.O_RDONLY, 0o0644, null) orelse + return error.FileOpenFailed; + + // Create a parser for the stream + const parser = gmime.g_mime_parser_new_with_stream(stream) orelse + return error.ParserCreationFailed; + gmime.g_object_unref(stream); + + // Parse the message + const message = gmime.g_mime_parser_construct_message(parser, null) orelse + return error.MessageParsingFailed; + + gmime.g_object_unref(parser); + return .{ + .filename = filename, + .message = message, + }; +} + +// Message representation for MIME parsing +pub const Message = struct { + // allocator: std.mem.Allocator, + filename: [:0]const u8, // do we need this? + message: *gmime.GMimeMessage, + + pub fn deinit(self: Message) void { + gmime.g_object_unref(self.message); + } + + // From gmime README: https://github.com/jstedfast/gmime + // MIME does define a set of general rules for how mail clients should + // interpret this tree structure of MIME parts. The Content-Disposition + // header is meant to provide hints to the receiving client as to which + // parts are meant to be displayed as part of the message body and which + // are meant to be interpreted as attachments. + // + // The Content-Disposition header will generally have one of two values: + // inline or attachment. + + // The meaning of these value should be fairly obvious. If the value + // is attachment, then the content of said MIME part is meant to be + // presented as a file attachment separate from the core message. + // However, if the value is inline, then the content of that MIME part + // is meant to be displayed inline within the mail client's rendering + // of the core message body. + // + // If the Content-Disposition header does not exist, then it should be + // treated as if the value were inline. + // + // Technically, every part that lacks a Content-Disposition header or + // that is marked as inline, then, is part of the core message body. + // + // There's a bit more to it than that, though. + // + // Modern MIME messages will often contain a multipart/alternative MIME + // container which will generally contain a text/plain and text/html + // version of the text that the sender wrote. The text/html version + // is typically formatted much closer to what the sender saw in his or + // her WYSIWYG editor than the text/plain version. + // + // Example without multipart/related: + // multipart/alternative + // text/plain + // text/html + // + // Example with: + // multipart/alternative + // text/plain + // multipart/related + // text/html + // image/jpeg + // video/mp4 + // image/png + // + // multipart/mixed (html/text only, with attachments) + // text/html - html only + // text/plain - text only + // + // It might be worth constructing a mime tree in zig that is constructed by traversing all + // this stuff in GMime once, getting all the things we need in Zig land, and + // the rest could be much easier from there + + const Attachment = struct {}; + + // Helper function to find HTML content in a multipart container + fn findHtmlInMultipart(multipart: *gmime.GMimeMultipart, allocator: std.mem.Allocator) !?[]const u8 { + const count = gmime.g_mime_multipart_get_count(multipart); + + // Look for HTML part first (preferred in multipart/alternative) + var i: usize = 0; + while (i < count) : (i += 1) { + const part = gmime.g_mime_multipart_get_part(multipart, @intCast(i)); + if (part == null) continue; + + const part_content_type = gmime.g_mime_object_get_content_type(part); + if (part_content_type == null) continue; + + const part_mime_type = gmime.g_mime_content_type_get_mime_type(part_content_type); + if (part_mime_type == null) continue; + + const part_mime_subtype = gmime.g_mime_content_type_get_media_subtype(part_content_type); + if (part_mime_subtype == null) continue; + + // Check if this is text/html + if (std.mem.eql(u8, std.mem.span(part_mime_type), "text") and + std.mem.eql(u8, std.mem.span(part_mime_subtype), "html")) + { + // Try to get the text content + if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(part)), gmime.g_mime_text_part_get_type()) != 0) { + const text_part = @as(*gmime.GMimeTextPart, @ptrCast(part)); + const text = gmime.g_mime_text_part_get_text(text_part); + if (text != null) { + return try allocator.dupe(u8, std.mem.span(text)); + } + } + } + } + + // If no HTML found, check for nested multiparts (like multipart/related inside multipart/alternative) + i = 0; + while (i < count) : (i += 1) { + const part = gmime.g_mime_multipart_get_part(multipart, @intCast(i)); + if (part == null) continue; + + if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(part)), gmime.g_mime_multipart_get_type()) != 0) { + const nested_multipart = @as(*gmime.GMimeMultipart, @ptrCast(part)); + if (try findHtmlInMultipart(nested_multipart, allocator)) |content| { + return content; + } + } + } + + return null; + } + + pub fn rawBody(self: Message, allocator: std.mem.Allocator) ![]const u8 { + // Get the message body using GMime + const body = gmime.g_mime_message_get_body(self.message); + if (body == null) return error.NoMessageBody; + + // Check if it's a multipart message + if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(body)), gmime.g_mime_multipart_get_type()) != 0) { + const multipart = @as(*gmime.GMimeMultipart, @ptrCast(body)); + + // Try to find HTML content in the multipart + if (try findHtmlInMultipart(multipart, allocator)) |html_content| { + // Trim trailing whitespace and newlines to match expected format + return std.mem.trimRight(u8, html_content, " \t\r\n"); + } + } + + // If it's not multipart or we didn't find HTML, check if it's a single text part + if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(body)), gmime.g_mime_text_part_get_type()) != 0) { + const text_part = @as(*gmime.GMimeTextPart, @ptrCast(body)); + const text = gmime.g_mime_text_part_get_text(text_part); + if (text != null) { + const content = try allocator.dupe(u8, std.mem.span(text)); + return std.mem.trimRight(u8, content, " \t\r\n"); + } + } + + // Fallback: convert the entire body to string + const body_string = gmime.g_mime_object_to_string(body, null); + if (body_string == null) return error.BodyConversionFailed; + + defer gmime.g_free(body_string); + const content = try allocator.dupe(u8, std.mem.span(body_string)); + return std.mem.trimRight(u8, content, " \t\r\n"); + } +}; + +fn testPath(allocator: std.mem.Allocator) ![:0]const u8 { + var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; + const cwd = try std.fs.cwd().realpath(".", cwd_buf[0..]); + return std.fs.path.joinZ(allocator, &[_][]const u8{ cwd, "mail", "Inbox", "cur", "1721591945.R4187135327503631514.nucman:2,S" }); +} +test "read raw body of message" { + var engine = Self.init(); + defer engine.deinit(); + const allocator = std.testing.allocator; + const message_path = try testPath(allocator); + defer allocator.free(message_path); + const msg = try engine.openMessage(message_path); + defer msg.deinit(); + const body = try msg.rawBody(allocator); + defer allocator.free(body); + try std.testing.expectEqualStrings( + \\ + \\ + \\ + \\ + \\
+ , body); +} + +test "can get body from multipart/alternative html preferred" { + var engine = Self.init(); + defer engine.deinit(); + const allocator = std.testing.allocator; + const message_path = try testPath(allocator); + defer allocator.free(message_path); + const msg = try engine.openMessage(message_path); + defer msg.deinit(); + const body = try msg.rawBody(allocator); + defer allocator.free(body); + try std.testing.expectEqualStrings( + \\ + \\ + \\ + \\ + \\
+ , body); +} diff --git a/src/c.zig b/src/c.zig new file mode 100644 index 0000000..9b93e11 --- /dev/null +++ b/src/c.zig @@ -0,0 +1,6 @@ +pub const c = @cImport({ + @cInclude("time.h"); + @cInclude("fcntl.h"); + @cInclude("notmuch.h"); + @cInclude("gmime/gmime.h"); +}); diff --git a/src/main.zig b/src/main.zig index 1032396..3bbcfdf 100644 --- a/src/main.zig +++ b/src/main.zig @@ -16,7 +16,7 @@ pub fn main() !void { // Example of using the root.zig functionality const allocator = std.heap.page_allocator; - var db_result = root.openNotmuchDb(allocator, "mail") catch |err| { + var db_result = root.openNotmuchDb(allocator, "mail", null) catch |err| { std.debug.print("Failed to open notmuch database: {}\n", .{err}); return; }; diff --git a/src/notmuch.zig b/src/notmuch.zig index 44bb7ec..722f2cb 100644 --- a/src/notmuch.zig +++ b/src/notmuch.zig @@ -26,9 +26,7 @@ //! ``` const std = @import("std"); -const c = @cImport({ - @cInclude("notmuch.h"); -}); +const c = @import("c.zig").c; pub const Status = struct { err: ?anyerror = null, @@ -59,7 +57,7 @@ pub const Db = struct { "", null, &db, - &err, + @ptrCast(&err), ); defer if (err) |e| if (status == null) std.c.free(e); if (open_status != c.NOTMUCH_STATUS_SUCCESS) { @@ -99,7 +97,7 @@ pub const Db = struct { // query = notmuch_query_create (database, query_string); // // for (stat = notmuch_query_search_threads (query, &threads); - // stat == NOTMUCH_STATUS_SUCCESS && + // stat == NOTMUCH_STATUS_SUCCESS && // notmuch_threads_valid (threads); // notmuch_threads_move_to_next (threads)) // { diff --git a/src/root.zig b/src/root.zig index 90a2e8d..5e58f47 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,7 +1,7 @@ const std = @import("std"); const notmuch = @import("notmuch.zig"); +const Email = @import("Email.zig"); -// Thread representation for JSON serialization pub const Thread = struct { allocator: std.mem.Allocator, thread: *notmuch.Db.Thread, @@ -46,10 +46,13 @@ pub const Thread = struct { try jws.objectField("subject"); try jws.write(m.getHeader("subject") catch return error.OutOfMemory); // content, content-type, and attachments are all based on the file itself - 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); + // TODO: init shouldn't fail + // var message = try Message.init(self.allocator, m.getFilename()); + // defer message.deinit(); + // try message.load(); + // const content_type = try message.getContentType(); + // try jws.objectField("content-type"); + // try jws.write(content_type); try jws.objectField("message_id"); try jws.write(m.getMessageId()); @@ -60,7 +63,6 @@ pub const Thread = struct { } }; -// Threads collection for JSON serialization pub const Threads = struct { allocator: std.mem.Allocator, iterator: *notmuch.Db.ThreadIterator, @@ -156,16 +158,22 @@ pub const Threads = struct { } }; -// Helper function to open a notmuch database from the current directory pub const NotmuchDb = struct { db: notmuch.Db, path: [:0]u8, allocator: std.mem.Allocator, + email: Email, + + /// If email is owned, it will be deinitialized when the database is closed + /// it is considered owned if openNotmuchDb is called with a null email_engine + /// parameter. + email_owned: bool, pub fn close(self: *NotmuchDb) void { self.db.close(); self.db.deinit(); self.allocator.free(self.path); + if (self.email_owned) self.email.deinit(); } pub fn search(self: *NotmuchDb, query: []const u8) !Threads { @@ -192,16 +200,36 @@ pub const NotmuchDb = struct { } }; -pub fn openNotmuchDb(allocator: std.mem.Allocator, relative_path: []const u8) !NotmuchDb { +/// Opens a notmuch database at the specified path +/// +/// This function initializes GMime and opens a notmuch database at the specified path. +/// If email_engine is null, a new Email instance will be created and owned by the returned NotmuchDb. +/// Otherwise, the provided email_engine will be used and not owned by the NotmuchDb. +/// +/// Parameters: +/// allocator: Memory allocator used for database operations +/// relative_path: Path to the notmuch database relative to current directory +/// email_engine: Optional Email instance to use, or null to create a new one +/// +/// Returns: +/// NotmuchDb struct with an open database connection +/// +/// Error: Returns error if database cannot be opened or path cannot be resolved +pub fn openNotmuchDb(allocator: std.mem.Allocator, relative_path: []const u8, email_engine: ?Email) !NotmuchDb { var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; const cwd = try std.fs.cwd().realpath(".", cwd_buf[0..]); const db_path = try std.fs.path.joinZ(allocator, &[_][]const u8{ cwd, relative_path }); const db = try notmuch.Db.open(db_path, null); + + const email = email_engine orelse Email.init(); + return .{ .db = db, .path = db_path, .allocator = allocator, + .email = email, + .email_owned = email_engine == null, }; } @@ -211,7 +239,7 @@ test "ensure all references are observed" { test "open database with public api" { const allocator = std.testing.allocator; - var db = try openNotmuchDb(allocator, "mail"); + var db = try openNotmuchDb(allocator, "mail", null); defer db.close(); } @@ -222,7 +250,7 @@ test "can stringify general queries" { // std.fs.cwd(), // "mail", // ); - var db = try openNotmuchDb(allocator, "mail"); + var db = try openNotmuchDb(allocator, "mail", null); defer db.close(); var threads = try db.search("Tablets"); defer threads.deinit(); @@ -247,9 +275,10 @@ test "can stringify general queries" { } test "can stringify specific threads" { + if (true) return error.SkipZigTest; const allocator = std.testing.allocator; - var db = try openNotmuchDb(allocator, "mail"); + var db = try openNotmuchDb(allocator, "mail", null); defer db.close(); var threads = try db.search("Tablets"); defer threads.deinit();