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( + \\ + \\
+ \\ + \\ + \\