Compare commits
6 commits
4318c14fe3
...
afea8dd24b
Author | SHA1 | Date | |
---|---|---|---|
afea8dd24b | |||
474aadd498 | |||
2febea12d7 | |||
4718bce5af | |||
5d6a777965 | |||
bfea6e15ef |
18 changed files with 1078 additions and 45 deletions
9
.envrc
Normal file
9
.envrc
Normal 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
1
.gitignore
vendored
|
@ -1,2 +1,3 @@
|
|||
.zig-cache/
|
||||
zig-out/
|
||||
.direnv/
|
||||
|
|
21
README.md
Normal file
21
README.md
Normal 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...
|
62
build.zig
62
build.zig
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -14,6 +14,10 @@
|
|||
devShells.default = systempkgs.mkShell {
|
||||
buildInputs = with systempkgs; [
|
||||
notmuch
|
||||
gmime3
|
||||
glibc
|
||||
glibc_multi
|
||||
pkg-config
|
||||
];
|
||||
};
|
||||
}
|
||||
|
|
BIN
mail/.notmuch/xapian/docdata.glass
Normal file
BIN
mail/.notmuch/xapian/docdata.glass
Normal file
Binary file not shown.
0
mail/.notmuch/xapian/flintlock
Normal file
0
mail/.notmuch/xapian/flintlock
Normal file
BIN
mail/.notmuch/xapian/iamglass
Normal file
BIN
mail/.notmuch/xapian/iamglass
Normal file
Binary file not shown.
BIN
mail/.notmuch/xapian/position.glass
Normal file
BIN
mail/.notmuch/xapian/position.glass
Normal file
Binary file not shown.
BIN
mail/.notmuch/xapian/postlist.glass
Normal file
BIN
mail/.notmuch/xapian/postlist.glass
Normal file
Binary file not shown.
BIN
mail/.notmuch/xapian/termlist.glass
Normal file
BIN
mail/.notmuch/xapian/termlist.glass
Normal file
Binary file not shown.
77
mail/Inbox/cur/1721591945.R4187135327503631514.nucman:2,S
Normal file
77
mail/Inbox/cur/1721591945.R4187135327503631514.nucman:2,S
Normal 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
242
src/Email.zig
Normal 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
6
src/c.zig
Normal file
|
@ -0,0 +1,6 @@
|
|||
pub const c = @cImport({
|
||||
@cInclude("time.h");
|
||||
@cInclude("fcntl.h");
|
||||
@cInclude("notmuch.h");
|
||||
@cInclude("gmime/gmime.h");
|
||||
});
|
20
src/main.zig
20
src/main.zig
|
@ -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
367
src/notmuch.zig
Normal 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);
|
||||
}
|
||||
}
|
308
src/root.zig
308
src/root.zig
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue