Zig bindings for the notmuch mail indexer's C library, libnotmuch.
  • Zig 99.2%
  • Nix 0.4%
  • Shell 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie de1e428ed4
All checks were successful
Lint licensing / lint (push) Successful in 15s
Publish docs / publish (push) Successful in 4m53s
update checkout action and drop the cache step
actions/checkout moves to v7.0.1 in both workflows, and the publish
workflow no longer sets up the attic cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017i7R7ReKJAHGQpitxGXqrb
2026-08-30 02:04:55 -05:00
.forgejo/workflows update checkout action and drop the cache step 2026-08-30 02:04:55 -05:00
.tangled/workflows fix reuse lint 2026-08-23 19:53:44 -05:00
LICENSES more documentation updates and license change 2026-03-01 18:06:09 -06:00
src document ownership of all memory returned by libnotmuch 2026-08-30 01:04:23 -05:00
test add test script 2026-05-19 17:07:46 -05:00
.gitignore more documentation updates and license change 2026-03-01 18:06:09 -06:00
build.zig more documentation updates and license change 2026-03-01 18:06:09 -06:00
build.zig.zon more documentation updates and license change 2026-03-01 18:06:09 -06:00
flake.lock update nixpkgs 2026-08-29 19:23:29 -05:00
flake.nix use zig from nixpkgs 2026-05-19 18:24:49 -05:00
README.md wrap the remaining introspection functions 2026-08-30 00:02:09 -05:00
REUSE.toml add test helper that builds a database from an embedded corpus 2026-08-29 19:33:46 -05:00

notmuch.zig

Zig bindings for the notmuch mail indexer's C library, libnotmuch.

The bindings wrap the C API in idiomatic Zig: opaque C pointers become structs with methods, notmuch_status_t return codes become Zig error sets (narrowed per function, so you only handle the errors a given call can actually return), C enums become Zig enums that are checked against the header at compile time, and the various notmuch_*_t list types become iterators with a next method.

The narrowed error sets are based on the status codes each function documents, plus the undocumented ones it has been observed to return in practice (for example, opening a missing database yields error.NoDatabase). Because libnotmuch can still surprise — a newer version may add status codes, and operations on a closed database return codes no function documents — every narrowed set also includes error.Unexpected as a catch-all, so an undocumented status surfaces as a recoverable error rather than a crash.

Status: early. The API still changes without notice. All of the current (non-deprecated) libnotmuch API is wrapped.

Requirements

  • Zig 0.16.0-dev.2682+02142a54d or newer
  • libnotmuch 5.6 or newer (notmuch 0.32+), including its headers

build.zig links notmuch as a system library and runs translate-c over notmuch.h. If the header is not on the default include path, point at it with the NOTMUCH_INCLUDE environment variable:

$ export NOTMUCH_INCLUDE=/usr/include

Installation

Add the package to your project:

$ zig fetch --save git+https://git.ocjtech.us/jeff/notmuch.zig#main

Then wire the module up in your build.zig:

const notmuch = b.dependency("notmuch", .{
    .target = target,
    .optimize = optimize,
});

exe.root_module.addImport("notmuch", notmuch.module("notmuch"));

The module links libnotmuch and libc itself, so there is nothing else to add.

Cloning with Radicle

The repository is also published on the Radicle peer-to-peer network under the repository ID

rad:zQWqvMomwbxzKg6pd4ktY65B8oyY

With a local Radicle node running, clone it with:

$ rad clone rad:zQWqvMomwbxzKg6pd4ktY65B8oyY

That fetches the repository, checks out the default branch, and starts seeding it so other peers can fetch from you. To follow the repository without checking out a working copy, use rad seed instead:

$ rad seed rad:zQWqvMomwbxzKg6pd4ktY65B8oyY

If you already have a clone from one of the other remotes, you can attach it to the same Radicle repository instead of cloning again:

$ git remote add rad rad://zQWqvMomwbxzKg6pd4ktY65B8oyY
$ git fetch rad

Usage

Opening a database returns a tagged union rather than an error union, because libnotmuch supplies a human-readable message alongside the status code and that message has to be freed:

const std = @import("std");
const notmuch = @import("notmuch");

pub fn main() !void {
    // With no options, the database and config locations are taken from the
    // environment ($NOTMUCH_DATABASE, $NOTMUCH_CONFIG, $NOTMUCH_PROFILE) and
    // the usual notmuch configuration files.
    const db = switch (notmuch.Database.open(.read_only, .{})) {
        .ok => |db| db,
        .err => |e| {
            defer e.deinit();
            std.log.err("unable to open database: {s}", .{e.message() orelse "unknown error"});
            return e.err;
        },
    };
    defer db.deinit() catch |err| std.log.err("unable to close database: {t}", .{err});

    const query = try db.queryCreate("tag:inbox");
    defer query.deinit();
    query.setSort(.newest_first);

    var messages = try query.searchMessages();
    defer messages.deinit();

    while (try messages.next()) |message| {
        defer message.deinit();

        const subject = message.getHeader("Subject") orelse "(no subject)";
        std.debug.print("{s}\n", .{subject});

        var tags = message.getTags();
        defer tags.deinit();
        while (tags.next()) |tag| std.debug.print("  +{s}\n", .{tag});
    }
}

Everything returned by a query — messages, threads, tags, filenames — is owned by the object it came from, so the deinit calls above are optional; they just release memory sooner than the enclosing query would.

Helpers

Beyond the wrappers, notmuch.helpers composes the bindings with the Zig standard library. MessageWriter streams an email into the mail store through a std.Io.Writer and indexes it: a unique, timestamp-prefixed maildir filename is generated, and the file is materialized atomically, so no partially written message is ever visible. MessageReader reads a message's raw content back through a std.Io.Reader:

var buffer: [4096]u8 = undefined;

var mw = try notmuch.helpers.MessageWriter.init(io, db, &buffer, .{ .subdir = .new });
try mw.writer().print("Message-ID: <{s}>\r\n\r\nHello!\r\n", .{message_id});
const message = try mw.finish(null);
defer message.deinit();

var mr = try notmuch.helpers.MessageReader.init(io, message, &buffer);
defer mr.deinit();
const content = try mr.reader().allocRemaining(allocator, .unlimited);
defer allocator.free(content);

What's available

Type Covers
notmuch.Database opening, creating, closing, upgrading and compacting databases; configuration-only loading; atomic sections; revisions; indexing and removing files; message lookup; directory lookup; typed and string-keyed configuration access, values and pairs; status strings
notmuch.Query query strings and syntax, sort order, tag exclusions, message and thread searches, result counts
notmuch.Message message and thread IDs, headers, dates, filenames, flags, tags, maildir flag synchronization, properties, freeze/thaw, reindexing
notmuch.Thread thread ID, subject, authors, oldest/newest dates, message and file counts, contained messages, tags
notmuch.Directory directory mtimes, child files and subdirectories, deletion
notmuch.IndexOpts indexing options, including the decryption policy
notmuch.MessagesIterator, notmuch.ThreadsIterator, notmuch.FilenamesIterator iteration over search results
notmuch.TagsIterator, notmuch.PropertiesIterator, notmuch.PairsIterator, notmuch.ValuesIterator iteration over tags, message properties, and configuration data
notmuch.Error the full set of notmuch_status_t codes as Zig errors, plus error.Unexpected for status codes a function is not documented to return
notmuch.Status the raw notmuch_status_t codes as a Zig enum, with libnotmuch's human-readable descriptions via toString
notmuch.compact, notmuch.builtWith, notmuch.tag_max module-level helpers
notmuch.helpers.MessageWriter, notmuch.helpers.MessageReader streaming an email through a std.Io.Writer into the mail store and indexing it, and reading a message's raw content through a std.Io.Reader (conveniences unique to these bindings)

Full API documentation is generated from the source and published at https://notmuch-zig.ocjtech.us. To read it locally instead:

$ zig build docs
$ xdg-open zig-out/docs/index.html

Development

A Nix flake provides a development shell with Zig, notmuch, and the linting tools, and sets NOTMUCH_INCLUDE for you:

$ nix develop

Run the test suite with:

$ zig build test

The suite exercises the bindings against real notmuch databases built in temporary directories from a small embedded email corpus (src/testing.zig). Usage-example tests sit next to the functions they exercise as doctests, so they double as examples in the generated API documentation; edge cases and error paths live in src/tests.zig. A compile-check test (src/compile_check.zig) gives every public wrapper a real call site, since Zig never analyzes the body of a function that is never called.

test/build-archive.sh builds a throwaway notmuch database from a git repository's commit log, which is useful for exercising the bindings against larger volumes of real mail-shaped data.

The project is REUSE compliant; reuse lint checks that every file carries copyright and licensing information.

License

GPL-3.0-or-later. See the LICENSES directory for the full texts.