Compare commits

...

6 commits

18 changed files with 1078 additions and 45 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/

21
README.md Normal file
View file

@ -0,0 +1,21 @@
Zetviel
-------
As some background, I've had some issues with the very usable [netviel](https://github.com/DavidMStraub/netviel).
I wanted to address those issues, but also simplify the deployment. And, I like zig,
so I decided this was small enough I'd just re-write the thing to make my own.
This is still very work in progress, to the point it is not yet usable. It has
some basic notmuch integration and a usable build system.
Building
--------
If you have notmuch installed (libnotmuch-dev on a debian-based system),
`zig build` is all you need. If you are using nix, you can `nix develop`, which
will install the necessary notmuch header/library, and the build system will
detect and use that. Again, `zig build` will work in that instance, but you must
`nix develop` first.
More to come...

View file

@ -76,7 +76,7 @@ pub fn build(b: *std.Build) !void {
.target = target,
.optimize = optimize,
});
configure(exe, paths, reload_discovered_native_paths);
configure(lib_unit_tests, paths, reload_discovered_native_paths);
const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests);
@ -109,39 +109,39 @@ 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 {
// All linux-specific stuff should be in here
if (@import("builtin").os.tag != .linux or !(target_query.os_tag == null or target_query.os_tag.? == .linux)) {
std.log.err("Only linux host and target builds supported right now", .{});
return error.NotImplemented;
}
// Capture the natively detected paths for potential future use
const native_result = b.resolveTargetQuery(target_query.*);
const paths = try std.zig.system.NativePaths.detect(b.allocator, native_result.result);
const nix_develop_bintools = std.posix.getenv("NIX_BINTOOLS");
if (nix_develop_bintools) |bintools| {
// std.debug.print("\nDetected nix bintools\n", .{});
// We'll capture the interpreter used in $NIX_BINTOOLS/bin/size
// We expect this to be a symlink to a native elf executable
// readlink $NIX_BINTOOLS/bin/size
var pathbuf: [std.posix.PATH_MAX]u8 = undefined;
const elf_path = try std.posix.readlink(
try std.fs.path.join(b.allocator, &[_][]const u8{
bintools,
"bin",
"size",
}),
&pathbuf,
);
// Setting the dynamic linker (necessary to avoid dll hell) will put
// zig into a non-native mode, and will therefore ignore all the native
// paths. We'll put these back from the values captured above in
// our configure function
target_query.dynamic_linker = try getDynamicLinker(elf_path);
}
// If we are not using nix, we can build anywhere provided the system dependencies exist
if (!std.process.hasEnvVarConstant("NIX_BINTOOLS")) return paths;
// Capture the natively detected paths for potential future use
const bintools = try std.process.getEnvVarOwned(b.allocator, "NIX_BINTOOLS");
// We'll capture the interpreter used in $NIX_BINTOOLS/bin/size
// We expect this to be a symlink to a native elf executable
// readlink $NIX_BINTOOLS/bin/size
var pathbuf: [std.posix.PATH_MAX]u8 = undefined;
// posix.readlink is supported on all OSs
const elf_path = try std.posix.readlink(
try std.fs.path.join(b.allocator, &[_][]const u8{
bintools,
"bin",
"size",
}),
&pathbuf,
);
// Setting the dynamic linker (necessary to avoid dll hell) will put
// zig into a non-native mode, and will therefore ignore all the native
// paths. We'll put these back from the values captured above in
// our configure function
target_query.dynamic_linker = try getDynamicLinker(elf_path);
return paths;
}
fn getDynamicLinker(elf_path: []const u8) !std.Target.DynamicLinker {
@ -209,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});
}
}
@ -219,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;
}

View file

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

View file

@ -14,6 +14,10 @@
devShells.default = systempkgs.mkShell {
buildInputs = with systempkgs; [
notmuch
gmime3
glibc
glibc_multi
pkg-config
];
};
}

Binary file not shown.

View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,77 @@
Return-Path: <mail@youpharm.co>
Delivered-To: lobo@lerch.org
Received: from mail.eler.ch
by mail.eler.ch with LMTP
id BJ9kLnLDm2aYDgQAyA9pPg
(envelope-from <mail@youpharm.co>)
for <lobo@lerch.org>; Sat, 20 Jul 2024 14:02:26 +0000
Received: from youpharm.co (youpharm.co [5.101.65.218])
by mail.eler.ch (Postfix) with ESMTP id E3C9F467AB
for <emil@lerch.org>; Sat, 20 Jul 2024 14:02:25 +0000 (UTC)
DKIM-Signature: v=1; a=rsa-sha1; c=relaxed/relaxed; s=s1; d=youpharm.co;
h=Message-ID:From:To:Subject:Date:MIME-Version:Content-Type; i=abuse@youpharm.co;
bh=Lnj6s7dL4V4gz92hab9GJ0HxEL8=;
b=MLiLXLaBVkYaJuabi+DsOFUkjmqsYJ0hsfHW5JKX61Fal+1j2iFjFWrggCv+m0zruA+j6W+iJ7CV
nxcDpT5mZe0+e2bOu1f8YEGNj7DPVpkYjeB8esR4qo/LSot0TIOU7YojSk8HP/hQVYEwpC58f21C
sXgqEyMn1bV4+UHE1QnhoyZRP/lyadba4SCCSeyG5VQMZ4cIZtlcBFA+yd6I03lZ2f/Lh7tinFXj
HrOyPjQLpk4VNbVsbbpsI+sKOEGlmgpRVIatV+Hcwk8ZuhFsubdF/cSc1p3jFbUdhBa3TMcqFVS2
L1UO4e13PWJVjHzAUXlGDF66PGIR2pHnBd1pew==
DomainKey-Signature: a=rsa-sha1; c=nofws; q=dns; s=s1; d=youpharm.co;
b=fFAj5RzuUx0++wASk/u6T0GlBurb2y6h/1WEls22gWKfoEKHzChyXYJNyknfho2r/3Cw0DNrWXFI
nRIivnoNX4rOvc4hsCieljl9lt0fOaYzLgHKS083D8JIYLLySX0Qwj7xydC3nB3WmHhOlrz6eM7d
lPIOT14K1e5LxQTLox8PaUqknSUNrsBZ8tREcVLqb7Ud9SVvdHjyccjampV70XPOeKMd9NLt4a/H
sEeS184PGBo7/uAuHojS2y2LDkY6nRdZPmjPvA9ghNU8udr+biG3NEX8V2v2ZJy7w9H6FfJfCb2/
MrxZmGPWgcYJ7cQ/pNMKcHM1QoAKYKMEG76V8g==;
Message-ID: <8afeb74dca321817e44e07ac4a2e040962e86e@youpharm.co>
From: Top Medications <mail@youpharm.co>
To: emil@lerch.org
Subject: ***SPAM*** Tablets without a prescription
Date: Sat, 20 Jul 2024 16:02:18 +0200
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="08044db730c8e2ed1eb4fd56b0d4fef3d9e86e"
X-Rspamd-Queue-Id: E3C9F467AB
X-Rspamd-Server: mail.eler.ch
X-Spamd-Result: default: False [10.00 / 11.00];
ABUSE_SURBL(5.50)[youpharm.co:helo,youpharm.co:dkim,youpharm.co:rdns];
RSPAMD_URIBL(4.50)[unmaskfauci.com:url];
BAD_REP_POLICIES(0.10)[];
MIME_GOOD(-0.10)[multipart/alternative,text/plain];
DKIM_TRACE(0.00)[youpharm.co:+];
ARC_NA(0.00)[];
DMARC_POLICY_ALLOW(0.00)[youpharm.co,none];
RCVD_COUNT_ZERO(0.00)[0];
MIME_TRACE(0.00)[0:+,1:+,2:~];
FROM_EQ_ENVFROM(0.00)[];
RCPT_COUNT_ONE(0.00)[1];
TO_DN_NONE(0.00)[];
R_SPF_ALLOW(0.00)[+a];
R_DKIM_ALLOW(0.00)[youpharm.co:s=s1];
ASN(0.00)[asn:34665, ipnet:5.101.65.0/24, country:RU];
FROM_HAS_DN(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
DWL_DNSWL_BLOCKED(0.00)[youpharm.co:dkim];
MID_RHS_MATCH_FROM(0.00)[]
X-Rspamd-Action: rewrite subject
Content-Length: 789
--08044db730c8e2ed1eb4fd56b0d4fef3d9e86e
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
--08044db730c8e2ed1eb4fd56b0d4fef3d9e86e
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
<html>
<head>
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dutf-8">
</head>
<body><a href=3D"https://unmaskfauci.com/assets/images/chw.php"><img src=3D=
"https://imgpx.com/dfE6oYsvHoYw.png"></a> <div><img width=3D1 height=3D1 =
alt=3D"" src=3D"https://vnevent.net/wp-content/plugins/wp-automatic/awe.p=
hp?QFYiTaVCm0ogM30sC5RNRb%2FKLO0%2FqO3iN9A89RgPbrGjPGsdVierqrtB7w8mnIqJug=
BVA5TZVG%2F6MFLMOrK9z4D6vgFBDRgH88%2FpEmohBbpaSFf4wx1l9S4LGJd87EK6"></div=
></body></html>
--08044db730c8e2ed1eb4fd56b0d4fef3d9e86e--

242
src/Email.zig Normal file
View file

@ -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(
\\<html>
\\<head>
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
\\</head>
\\<body><a href="https://unmaskfauci.com/assets/images/chw.php"><img src="https://imgpx.com/dfE6oYsvHoYw.png"></a> <div><img width=1 height=1 alt="" src="https://vnevent.net/wp-content/plugins/wp-automatic/awe.php?QFYiTaVCm0ogM30sC5RNRb%2FKLO0%2FqO3iN9A89RgPbrGjPGsdVierqrtB7w8mnIqJugBVA5TZVG%2F6MFLMOrK9z4D6vgFBDRgH88%2FpEmohBbpaSFf4wx1l9S4LGJd87EK6"></div></body></html>
, 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(
\\<html>
\\<head>
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
\\</head>
\\<body><a href="https://unmaskfauci.com/assets/images/chw.php"><img src="https://imgpx.com/dfE6oYsvHoYw.png"></a> <div><img width=1 height=1 alt="" src="https://vnevent.net/wp-content/plugins/wp-automatic/awe.php?QFYiTaVCm0ogM30sC5RNRb%2FKLO0%2FqO3iN9A89RgPbrGjPGsdVierqrtB7w8mnIqJugBVA5TZVG%2F6MFLMOrK9z4D6vgFBDRgH88%2FpEmohBbpaSFf4wx1l9S4LGJd87EK6"></div></body></html>
, body);
}

6
src/c.zig Normal file
View file

@ -0,0 +1,6 @@
pub const c = @cImport({
@cInclude("time.h");
@cInclude("fcntl.h");
@cInclude("notmuch.h");
@cInclude("gmime/gmime.h");
});

View file

@ -1,11 +1,9 @@
const std = @import("std");
const c = @cImport({
@cInclude("notmuch.h");
});
const root = @import("root.zig");
pub fn main() !void {
// Prints to stderr (it's a shortcut based on `std.io.getStdErr()`)
std.debug.print("All your {s} are belong to us. Status: {s}\n", .{ "codebase", c.notmuch_status_to_string(0) });
std.debug.print("All your {s} are belong to us. \n", .{"codebase"});
// stdout is for the actual output of your application, for example if you
// are implementing gzip, then only the compressed bytes should be sent to
@ -16,9 +14,15 @@ pub fn main() !void {
try stdout.print("Run `zig build test` to run the tests.\n", .{});
// Example of using the root.zig functionality
const allocator = std.heap.page_allocator;
var db_result = root.openNotmuchDb(allocator, "mail", null) catch |err| {
std.debug.print("Failed to open notmuch database: {}\n", .{err});
return;
};
defer db_result.close();
std.debug.print("Successfully opened notmuch database at: {s}\n", .{db_result.path});
try bw.flush(); // don't forget to flush!
}
test "can get status" {
try std.testing.expectEqualStrings("No error occurred", std.mem.span(c.notmuch_status_to_string(0)));
}

367
src/notmuch.zig Normal file
View file

@ -0,0 +1,367 @@
//! 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 = @import("c.zig").c;
pub const Status = struct {
err: ?anyerror = null,
status: c.notmuch_status_t = c.NOTMUCH_STATUS_SUCCESS,
msg: ?[*:0]u8 = null,
pub fn deinit(status: *Status) void {
if (status.msg) |m| std.c.free(m);
status.err = undefined;
status.status = c.NOTMUCH_STATUS_SUCCESS;
status.msg = null;
}
pub fn statusString(status: Status) []const u8 {
return std.mem.span(c.notmuch_status_to_string(status.status));
}
};
pub const Db = struct {
handle: *c.notmuch_database_t,
pub fn open(path: [:0]const u8, status: ?*Status) !Db {
var db: ?*c.notmuch_database_t = null;
var err: ?[*:0]u8 = null;
const open_status = c.notmuch_database_open_with_config(
path,
c.NOTMUCH_DATABASE_MODE_READ_ONLY,
"",
null,
&db,
@ptrCast(&err),
);
defer if (err) |e| if (status == null) std.c.free(e);
if (open_status != c.NOTMUCH_STATUS_SUCCESS) {
if (status) |s| s.* = .{
.msg = err,
.status = open_status,
.err = error.CouldNotOpenDatabase,
};
return error.CouldNotOpenDatabase;
}
if (db == null) unreachable; // If we opened the database successfully, this should never be null
if (status) |s| s.* = .{};
return .{ .handle = db.? };
}
pub fn close(db: *Db) void {
_ = c.notmuch_database_close(db.handle);
}
pub fn deinit(db: *Db) void {
_ = c.notmuch_database_destroy(db.handle);
db.handle = undefined;
}
//
// Execute a query for threads, returning a notmuch_threads_t object
// which can be used to iterate over the results. The returned threads
// object is owned by the query and as such, will only be valid until
// notmuch_query_destroy.
//
// Typical usage might be:
//
// notmuch_query_t *query;
// notmuch_threads_t *threads;
// notmuch_thread_t *thread;
// notmuch_status_t stat;
//
// query = notmuch_query_create (database, query_string);
//
// for (stat = notmuch_query_search_threads (query, &threads);
// stat == NOTMUCH_STATUS_SUCCESS &&
// notmuch_threads_valid (threads);
// notmuch_threads_move_to_next (threads))
// {
// thread = notmuch_threads_get (threads);
// ....
// notmuch_thread_destroy (thread);
// }
//
// notmuch_query_destroy (query);
//
// Note: If you are finished with a thread before its containing
// query, you can call notmuch_thread_destroy to clean up some memory
// sooner (as in the above example). Otherwise, if your thread objects
// are long-lived, then you don't need to call notmuch_thread_destroy
// and all the memory will still be reclaimed when the query is
// destroyed.
//
// Note that there's no explicit destructor needed for the
// notmuch_threads_t object. (For consistency, we do provide a
// notmuch_threads_destroy function, but there's no good reason
// to call it if the query is about to be destroyed).
pub fn searchThreads(db: Db, query: [:0]const u8) !ThreadIterator {
const nm_query = c.notmuch_query_create(db.handle, query);
if (nm_query == null) return error.CouldNotCreateQuery;
const handle = nm_query.?;
errdefer c.notmuch_query_destroy(handle);
var threads: ?*c.notmuch_threads_t = undefined;
const status = c.notmuch_query_search_threads(handle, &threads);
if (status != c.NOTMUCH_STATUS_SUCCESS) return error.CouldNotSearchThreads;
return .{
.query = handle,
.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));
}
pub fn deinit(self: Message) void {
c.notmuch_message_destroy(self.message_handle);
}
};
pub const MessageIterator = struct {
messages_state: *c.notmuch_messages_t,
first: bool = true,
pub fn next(self: *MessageIterator) ?Message {
if (!self.first) c.notmuch_messages_move_to_next(self.messages_state);
self.first = false;
if (c.notmuch_messages_valid(self.messages_state) == 0) return null;
const message = c.notmuch_messages_get(self.messages_state) orelse return null;
return .{
.message_handle = message,
};
}
// Docs imply strongly not to bother with deinitialization here
};
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).
pub fn getThreadId(self: Thread) []const u8 {
return std.mem.span(c.notmuch_thread_get_thread_id(self.thread_handle));
}
/// 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
pub fn getTotalFiles(self: Thread) c_int {
return c.notmuch_thread_get_total_files(self.thread_handle);
}
pub fn getMessages(self: Thread) !MessageIterator {
return .{
.messages_state = c.notmuch_thread_get_messages(self.thread_handle) orelse return error.CouldNotGetIterator,
};
}
pub fn deinit(self: Thread) void {
c.notmuch_thread_destroy(self.thread_handle);
// self.thread_handle = undefined;
}
};
pub const ThreadIterator = struct {
query: *c.notmuch_query_t,
thread_state: *c.notmuch_threads_t,
first: bool = true,
pub fn next(self: *ThreadIterator) ?Thread {
if (!self.first) c.notmuch_threads_move_to_next(self.thread_state);
self.first = false;
if (c.notmuch_threads_valid(self.thread_state) == 0) return null;
const thread = c.notmuch_threads_get(self.thread_state) orelse return null;
return .{
.thread_handle = thread,
};
}
pub fn deinit(self: *ThreadIterator) void {
c.notmuch_query_destroy(self.query);
self.query = undefined;
}
};
};
test "can get status" {
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: Status = undefined;
var db = try Db.open(db_path, &status);
defer db.deinit();
defer db.close();
defer status.deinit();
try std.testing.expectEqualStrings("No error occurred", status.statusString());
}
{
var db = try Db.open(db_path, null);
defer db.deinit();
defer db.close();
}
{
var status: Status = undefined;
try std.testing.expectError(error.CouldNotOpenDatabase, Db.open(
"NON-EXISTANT",
&status,
));
defer status.deinit();
try std.testing.expectEqualStrings(
"Path supplied is illegal for this function",
status.statusString(),
);
}
}
test "can search 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: Status = undefined;
var db = try Db.open(db_path, &status);
defer db.deinit();
defer db.close();
defer status.deinit();
try std.testing.expectEqualStrings("No error occurred", status.statusString());
var t_iter = try db.searchThreads("Tablets");
defer t_iter.deinit();
var inx: usize = 0;
while (t_iter.next()) |t| : (inx += 1) {
defer t.deinit();
try std.testing.expectEqual(@as(c_int, 1), t.getTotalMessages());
try std.testing.expectEqualStrings("0000000000000001", t.getThreadId());
var message_iter = try t.getMessages();
var jnx: usize = 0;
while (message_iter.next()) |m| : (jnx += 1) {
defer m.deinit();
try std.testing.expectStringEndsWith(m.getFilename(), "/1721591945.R4187135327503631514.nucman:2,S");
}
try std.testing.expectEqual(@as(usize, 1), jnx);
}
try std.testing.expectEqual(@as(usize, 1), inx);
}
}

View file

@ -1,10 +1,308 @@
const std = @import("std");
const testing = std.testing;
const notmuch = @import("notmuch.zig");
const Email = @import("Email.zig");
export fn add(a: i32, b: i32) i32 {
return a + b;
pub const Thread = struct {
allocator: std.mem.Allocator,
thread: *notmuch.Db.Thread,
pub fn init(allocator: std.mem.Allocator, t: *notmuch.Db.Thread) Thread {
return .{ .allocator = allocator, .thread = t };
}
pub fn deinit(self: Thread) void {
self.allocator.destroy(self.thread);
}
pub fn jsonStringify(self: Thread, jws: anytype) !void {
// Format we're looking for on api/thread/<threadid>
//[
// {
// "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"
// }
//]
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);
// content, content-type, and attachments are all based on the file itself
// 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());
try jws.endObject();
}
try jws.endArray();
}
};
pub const Threads = struct {
allocator: std.mem.Allocator,
iterator: *notmuch.Db.ThreadIterator,
pub fn init(allocator: std.mem.Allocator, it: *notmuch.Db.ThreadIterator) Threads {
return .{
.allocator = allocator,
.iterator = it,
};
}
pub fn deinit(self: *Threads) void {
self.iterator.deinit();
self.allocator.destroy(self.iterator);
}
pub fn next(self: *Threads) !?Thread {
const nxt = self.iterator.next();
if (nxt) |_| {
const tptr = try self.allocator.create(notmuch.Db.Thread);
tptr.* = nxt.?;
return Thread.init(self.allocator, tptr);
}
return null;
}
pub fn jsonStringify(self: Threads, jws: anytype) !void {
// This is the json we're looking to match on api/query/<term>
// [
// {
// "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
// },
// {
// "authors": "The Washington Post",
// "matched_messages": 1,
// "newest_date": 1721603115,
// "oldest_date": 1721603115,
// "subject": "Upcoming Virtual Programs",
// "tags": [
// "inbox",
// "unread"
// ],
// "thread_id": "0000000000031712",
// "total_messages": 1
// },
// {
// "authors": "The Washington Post",
// "matched_messages": 1,
// "newest_date": 1721590157,
// "oldest_date": 1721590157,
// "subject": "Biden Steps Aside",
// "tags": [
// "inbox"
// ],
// "thread_id": "000000000003170d",
// "total_messages": 1
// }
// ]
try jws.beginArray();
while (self.iterator.next()) |t| {
defer t.deinit();
try jws.beginObject();
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();
}
};
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 {
var query_buf: [1024:0]u8 = undefined;
const query_z = try std.fmt.bufPrintZ(&query_buf, "{s}", .{query});
const ti = try self.allocator.create(notmuch.Db.ThreadIterator);
ti.* = try self.db.searchThreads(query_z);
return Threads.init(self.allocator, ti);
}
pub fn getThread(self: *NotmuchDb, thread_id: []const u8) !Thread {
var query_buf: [1024:0]u8 = undefined;
const query_z = try std.fmt.bufPrintZ(&query_buf, "thread:{s}", .{thread_id});
var thread_iter = try self.db.searchThreads(query_z);
defer thread_iter.deinit();
const thread = thread_iter.next();
if (thread) |t| {
const tptr = try self.allocator.create(notmuch.Db.Thread);
tptr.* = t;
return Thread.init(self.allocator, tptr);
}
return error.ThreadNotFound;
}
};
/// 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,
};
}
test "basic add functionality" {
try testing.expect(add(3, 7) == 10);
test "ensure all references are observed" {
std.testing.refAllDeclsRecursive(@This());
}
test "open database with public api" {
const allocator = std.testing.allocator;
var db = try openNotmuchDb(allocator, "mail", null);
defer db.close();
}
test "can stringify general queries" {
const allocator = std.testing.allocator;
// const db_path = try std.fs.path.join(
// allocator,
// std.fs.cwd(),
// "mail",
// );
var db = try openNotmuchDb(allocator, "mail", null);
defer db.close();
var threads = try db.search("Tablets");
defer threads.deinit();
const actual = try std.json.stringifyAlloc(allocator, threads, .{ .whitespace = .indent_2 });
defer allocator.free(actual);
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" {
if (true) return error.SkipZigTest;
const allocator = std.testing.allocator;
var db = try openNotmuchDb(allocator, "mail", null);
defer db.close();
var threads = try db.search("Tablets");
defer threads.deinit();
var maybe_first_thread = try threads.next();
defer if (maybe_first_thread) |*t| t.deinit();
try std.testing.expect(maybe_first_thread != null);
const first_thread = maybe_first_thread.?;
const actual = try std.json.stringifyAlloc(allocator, first_thread, .{ .whitespace = .indent_2 });
defer allocator.free(actual);
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);
}