Compare commits

...

22 commits

Author SHA1 Message Date
8a03ce4b79
add dockerfile multi-stage build
Some checks are pending
Generic zig build / build (push) Waiting to run
2025-10-15 17:32:15 -07:00
39a1cb530e
always link gmime 2025-10-15 17:20:58 -07:00
2941a4fc5e
help overlay 2025-10-15 17:07:05 -07:00
07a7521c52
add up/down keyboard shortcuts 2025-10-15 16:55:50 -07:00
75535c19d1
add "/" shortcut to search 2025-10-15 16:51:40 -07:00
7c9d1170e2
reposition showText/showHtml 2025-10-15 16:51:24 -07:00
22aad3ed8f
return message if no text available 2025-10-15 16:44:58 -07:00
a64e35ed75
add html/text versions of body 2025-10-15 16:38:10 -07:00
c3c41ba080
remove dead files 2025-10-15 16:11:56 -07:00
44cd018e09
functioning app 2025-10-15 15:42:48 -07:00
6341af1cac
update README 2025-10-15 15:28:47 -07:00
2c0e7850d3
add help/static file handling 2025-10-15 15:26:26 -07:00
463cc80c05
add security headers to requests 2025-10-15 15:03:06 -07:00
d2be071265
add initial backend web routes 2025-10-15 14:50:37 -07:00
080ea81ef5
add attachment functionality 2025-10-15 14:12:13 -07:00
3c5edacf26
upgrade to zig 0.15.2 2025-10-15 12:27:04 -07:00
1cb86f085d
add mise config 2025-10-15 11:51:42 -07:00
1252885423
fix test and clean up after AI 2025-08-15 18:33:21 -07:00
22e4a301a3
start email processing (findHtmlInMultipart/rawBody AI generated) 2025-08-15 16:59:50 -07:00
60aa457d78
fix compile error on pointer cast 2025-08-15 16:43:55 -07:00
b4d645bca0
update to zig 0.14.1 2025-08-15 16:40:04 -07:00
3a6d7c4bea
add beginning of email parsing (tests failing) 2025-08-15 16:39:01 -07:00
17 changed files with 1772 additions and 110 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
.zig-cache/
zig-out/
mail/
*.md
.mise.toml
.tool-versions

11
.mise.toml Normal file
View file

@ -0,0 +1,11 @@
[tools]
pre-commit = "latest"
"ubi:DonIsaac/zlint" = "latest"
zig = "0.15.2"
zls = "0.15.0"
[hooks]
enter = 'echo use "nix develop" if you want to build'
[settings]
experimental = true

30
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,30 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/batmac/pre-commit-zig
rev: v0.3.0
hooks:
- id: zig-fmt
- id: zig-build
- repo: local
hooks:
- id: smoke-test
name: Run zig build test
entry: zig
args: ["build", "--verbose", "test"]
language: system
types: [file]
pass_filenames: false
- id: zlint
name: Run zlint
entry: zlint
args: ["--deny-warnings", "--fix"]
language: system
types: [zig]

47
Dockerfile Normal file
View file

@ -0,0 +1,47 @@
# Build stage
FROM debian:bookworm-slim AS builder
# Install build dependencies
RUN apt-get update && apt-get install -y \
curl \
xz-utils \
git \
pkg-config \
libnotmuch-dev \
libgmime-3.0-dev \
libglib2.0-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Zig 0.15.2
RUN curl -fL https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz | tar -xJ -C /usr/local && \
ln -s /usr/local/zig-x86_64-linux-0.15.2/zig /usr/local/bin/zig
# Copy source code
WORKDIR /build
COPY . .
# Build in release mode with baseline CPU features for portability
RUN zig build -Doptimize=ReleaseSafe -Dcpu=baseline
# Runtime stage
FROM debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libnotmuch5 \
libgmime-3.0-0 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# Copy binary and static files
COPY --from=builder /build/zig-out/bin/zetviel /usr/local/bin/zetviel
COPY --from=builder /build/static /app/static
WORKDIR /app
# Set environment variable for notmuch database
ENV NOTMUCH_PATH=/mail
EXPOSE 5000
ENTRYPOINT ["/usr/local/bin/zetviel"]

79
PLAN.md Normal file
View file

@ -0,0 +1,79 @@
# Zetviel Development Plan
## Project Rules
1. **Always run `zig fmt .` after any change to a zig file**
2. **Before considering a task complete: `zig build` must have no errors/output**
3. **Before considering a task complete: all tests must pass with `zig build test`**
## Goal
Create a netviel clone with improvements:
- Visual indication that server is working
- URL changes with UI state for deep linking
- Custom frontend (not copying netviel's JavaScript)
## Phase 1: Upgrade Zig ✅ COMPLETE
- [x] Update `build.zig.zon` to Zig 0.15.2
- [x] Update `.mise.toml` to use Zig 0.15.2
- [x] Fix breaking changes in `build.zig` (Module API, alignment issues)
- [x] Fix breaking changes in `src/main.zig` (stdout API)
- [x] Fix JSON API changes in `src/root.zig` (converted OutOfMemory to WriteFailed)
- [x] Verify all tests pass
- [x] Run `zig fmt .`
## Phase 2: Complete Email Parsing API ✅ COMPLETE
- [x] Finish `Email.zig` implementation:
- [x] Extract HTML/plain text content with preference (html > plain)
- [x] Parse and list attachments (filename, content-type)
- [x] Extract all standard headers (from, to, cc, bcc, date, subject)
- [x] Add attachment retrieval by index (getAttachments method)
- [x] Integrate Email parsing into `root.zig` Thread API
- [x] Add tests for new functionality (existing tests pass)
- [x] Run `zig fmt .`
## Phase 3: HTTP Server & REST API ✅ COMPLETE
- [x] Research and choose HTTP framework (httpz)
- [x] Add HTTP server dependency
- [x] Implement REST endpoints:
- [x] `GET /api/query/<query_string>` - search threads
- [x] `GET /api/thread/<thread_id>` - get thread messages
- [x] `GET /api/attachment/<message_id>/<num>` - download attachment
- [x] `GET /api/message/<message_id>` - get message details
- [x] Complete JSON serialization (extend existing in root.zig)
- [x] Add security headers via httpz middleware
- [x] Add tests for API endpoints
- [x] Run `zig fmt .`
## Phase 4: Static File Serving ✅ COMPLETE
- [x] Implement static file serving:
- [x] Serve `index.html` at `/`
- [x] Serve static assets (placeholder 404 handler)
- [x] Handle SPA routing (all non-API paths ready)
- [x] Add `--port` CLI argument
- [x] Run `zig fmt .`
## Phase 5: Frontend Development
- [ ] Design minimal UI (list threads, view messages, search)
- [ ] Implement frontend features:
- [ ] Thread list view
- [ ] Message detail view
- [ ] Search functionality
- [ ] Visual server status indicator
- [ ] URL-based routing for deep linking
- [ ] Attachment download links
- [ ] Ensure API compatibility
## Phase 6: Polish
- [ ] Add proper error handling throughout
- [ ] Add logging
- [x] Update README with usage instructions
- [x] Add configuration options (NOTMUCH_PATH env var)
- [x] Security audit and warnings (local-only usage)
- [ ] Run `zig fmt .`
## Notes
- Frontend will be custom-built, not copied from netviel
- HTTP framework choice deferred to Phase 3
- HTML sanitization will use simple allowlist approach (not porting bleach)
## Current Status
Ready to begin Phase 1: Zig upgrade to 0.15.2

View file

@ -1,13 +1,20 @@
Zetviel
-------
As some background, I've had some issues with the very usable [netviel](https://github.com/DavidMStraub/netviel).
A web-based email client for [notmuch](https://notmuchmail.org/), written in Zig.
I wanted to address those issues, but also simplify the deployment. And, I like zig,
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.
Features
--------
- REST API for notmuch queries
- Thread and message viewing
- Attachment handling
- Security headers for safe browsing
- Configurable port
Building
--------
@ -18,4 +25,39 @@ 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...
Usage
-----
```sh
# Start server on default port (5000)
zetviel
# Start server on custom port
zetviel --port 8080
# Show help
zetviel --help
# Show version
zetviel --version
```
Configuration
-------------
- `NOTMUCH_PATH` environment variable: Path to notmuch database (default: `mail`)
- `--port`: HTTP server port (default: 5000)
API Endpoints
-------------
- `GET /api/query/<query>` - Search threads using notmuch query syntax
- `GET /api/thread/<thread_id>` - Get messages in a thread
- `GET /api/message/<message_id>` - Get message details with content
- `GET /api/attachment/<message_id>/<num>` - Get attachment metadata
Security
--------
**WARNING**: Zetviel is intended for local use only. It binds to 127.0.0.1 and should
not be exposed to the internet without additional security measures.

View file

@ -19,26 +19,44 @@ pub fn build(b: *std.Build) !void {
// set a preferred release mode, allowing the user to decide how to optimize.
const optimize = b.standardOptimizeOption(.{});
const lib = b.addStaticLibrary(.{
.name = "zetviel",
// In this case the main source file is merely a path, however, in more
// complicated build scripts, this could be a generated file.
const lib_module = b.createModule(.{
.root_source_file = b.path("src/root.zig"),
.target = target,
.optimize = optimize,
});
const lib = b.addLibrary(.{
.name = "zetviel",
.linkage = .static,
.root_module = lib_module,
});
// This declares intent for the library to be installed into the standard
// location when the user invokes the "install" step (the default step when
// running `zig build`).
b.installArtifact(lib);
const exe = b.addExecutable(.{
.name = "zetviel",
const httpz = b.dependency("httpz", .{
.target = target,
.optimize = optimize,
});
const git_rev = b.run(&.{ "git", "describe", "--always", "--dirty=*" });
const options = b.addOptions();
options.addOption([]const u8, "git_revision", git_rev);
const exe_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe_module.addImport("httpz", httpz.module("httpz"));
exe_module.addImport("build_options", options.createModule());
const exe = b.addExecutable(.{
.name = "zetviel",
.root_module = exe_module,
});
configure(exe, paths, reload_discovered_native_paths);
// This declares intent for the executable to be installed into the
@ -71,20 +89,37 @@ pub fn build(b: *std.Build) !void {
// Creates a step for unit testing. This only builds the test executable
// but does not run it.
const lib_unit_tests = b.addTest(.{
const lib_test_module = b.createModule(.{
.root_source_file = b.path("src/root.zig"),
.target = target,
.optimize = optimize,
});
const lib_unit_tests = b.addTest(.{
.root_module = lib_test_module,
});
configure(lib_unit_tests, paths, reload_discovered_native_paths);
const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests);
const exe_unit_tests = b.addTest(.{
const exe_test_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe_test_module.addImport("httpz", httpz.module("httpz"));
const exe_unit_tests = b.addTest(.{
.root_module = exe_test_module,
});
const valgrind = b.option(bool, "valgrind", "Check for leaks with valgrind") orelse false;
if (valgrind)
exe_unit_tests.setExecCmd(&[_]?[]const u8{
"valgrind",
"--leak-check=full",
"--show-leak-kinds=all",
null,
});
configure(exe_unit_tests, paths, reload_discovered_native_paths);
const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
@ -100,6 +135,7 @@ pub fn build(b: *std.Build) !void {
fn configure(compile: *std.Build.Step.Compile, paths: std.zig.system.NativePaths, reload_paths: bool) void {
compile.linkLibC();
compile.linkSystemLibrary("notmuch");
compile.linkSystemLibrary2("gmime-3.0", .{ .use_pkg_config = .force });
// These are only needed if we are in nix develop shell
if (!reload_paths) return;
@ -113,7 +149,7 @@ fn configure(compile: *std.Build.Step.Compile, paths: std.zig.system.NativePaths
fn checkNix(b: *std.Build, target_query: *std.Target.Query) !std.zig.system.NativePaths {
const native_result = b.resolveTargetQuery(target_query.*);
const paths = try std.zig.system.NativePaths.detect(b.allocator, native_result.result);
const paths = try std.zig.system.NativePaths.detect(b.allocator, &native_result.result);
// If we are not using nix, we can build anywhere provided the system dependencies exist
if (!std.process.hasEnvVarConstant("NIX_BINTOOLS")) return paths;
@ -171,19 +207,19 @@ fn getDynamicLinker(elf_path: []const u8) !std.Target.DynamicLinker {
return error.FileNotExpectedElf;
}
// Section header table
const e_shoff = std.mem.littleToNative(u64, @as(*u64, @ptrFromInt(@intFromPtr(file_contents[0x28 .. 0x29 + 8]))).*); // E8 9D 00 00 00 00 00 00
const e_shoff = std.mem.readInt(u64, file_contents[0x28..][0..8], .little); // E8 9D 00 00 00 00 00 00
// Number of sections
const e_shnum = std.mem.littleToNative(u16, @as(*u16, @ptrFromInt(@intFromPtr(file_contents[0x3c .. 0x3d + 2]))).*); // 1d
const e_shnum = std.mem.readInt(u16, file_contents[0x3c..][0..2], .little); // 1d
// Index of section header that contains section header names
const e_shstrndx = std.mem.littleToNative(u16, @as(*u16, @ptrFromInt(@intFromPtr(file_contents[0x3e .. 0x3f + 2]))).*); // 1c
const e_shstrndx = std.mem.readInt(u16, file_contents[0x3e..][0..2], .little); // 1c
// Beginning of section 0x1c (28) that contains header names
const e_shstroff = e_shoff + (64 * e_shstrndx); // 0xa4e8
const shstrtab_contents = file_contents[e_shstroff .. e_shstroff + 1 + (e_shnum * 64)];
// Offset for my set of null terminated strings
const shstrtab_sh_offset = std.mem.littleToNative(u64, @as(*u64, @ptrFromInt(@intFromPtr(shstrtab_contents[0x18 .. 0x19 + 8]))).*); // 0x9cec
const shstrtab_sh_offset = std.mem.readInt(u64, shstrtab_contents[0x18..][0..8], .little); // 0x9cec
// Total size of section
const shstrtab_sh_size = std.mem.littleToNative(u64, @as(*u64, @ptrFromInt(@intFromPtr(shstrtab_contents[0x20 .. 0x21 + 8]))).*); // 250
const shstrtab_sh_size = std.mem.readInt(u64, shstrtab_contents[0x20..][0..8], .little); // 250
// std.debug.print("e_shoff: {x}, e_shstrndx: {x}, e_shstroff: {x}, e_shnum: {x}, shstrtab_sh_offset: {x}, shstrtab_sh_size: {}\n", .{ e_shoff, e_shstrndx, e_shstroff, e_shnum, shstrtab_sh_offset, shstrtab_sh_size });
const shstrtab_strings = file_contents[shstrtab_sh_offset .. shstrtab_sh_offset + 1 + shstrtab_sh_size];
var interp: ?[]const u8 = null;
@ -191,10 +227,10 @@ fn getDynamicLinker(elf_path: []const u8) !std.Target.DynamicLinker {
// get section offset. Look for type == SHT_PROGBITS, then go fetch name
const sh_off = e_shoff + (64 * shndx);
const sh_contents = file_contents[sh_off .. sh_off + 1 + 64];
const sh_type = std.mem.littleToNative(u16, @as(*u16, @ptrFromInt(@intFromPtr(sh_contents[0x04 .. 0x05 + 2]))).*);
const sh_type = std.mem.readInt(u16, sh_contents[0x04..][0..2], .little);
if (sh_type != 0x01) continue;
// This is an offset to the null terminated string in our string content
const sh_name_offset = std.mem.littleToNative(u16, @as(*u16, @ptrFromInt(@intFromPtr(sh_contents[0x00 .. 0x01 + 2]))).*);
const sh_name_offset = std.mem.readInt(u16, sh_contents[0x00..][0..2], .little);
const sentinel = std.mem.indexOfScalar(u8, shstrtab_strings[sh_name_offset..], 0);
if (sentinel == null) {
std.log.err("Invalid ELF file", .{});
@ -204,10 +240,10 @@ fn getDynamicLinker(elf_path: []const u8) !std.Target.DynamicLinker {
// std.debug.print("section name: {s}\n", .{sh_name});
if (std.mem.eql(u8, ".interp", sh_name)) {
// found interpreter
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
const interp_offset = std.mem.readInt(u64, sh_contents[0x18..][0..8], .little); // 0x9218
const interp_size = std.mem.readInt(u64, sh_contents[0x20..][0..8], .little); // 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});
}
}
@ -216,7 +252,13 @@ fn getDynamicLinker(elf_path: []const u8) !std.Target.DynamicLinker {
return error.CouldNotLocateInterpreter;
}
var dl = std.Target.DynamicLinker{ .buffer = undefined, .len = 0 };
dl.set(interp);
// SAFETY: buffer is set in shortly in dl.set() call
var dl = std.Target.DynamicLinker{
.buffer = undefined,
.len = 0,
};
// 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.15.2",
.fingerprint = 0xd4c335836acc5e4e,
// This field is optional.
// Each dependency must either provide a `url` and `hash`, or a `path`.
@ -15,42 +17,11 @@
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.dependencies = .{
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding
// // `hash`, otherwise you are communicating that you expect to find the old hash at
// // the new URL.
// .url = "https://example.com/foo.tar.gz",
//
// // This is computed from the file contents of the directory of files that is
// // obtained after fetching `url` and applying the inclusion rules given by
// // `paths`.
// //
// // This field is the source of truth; packages do not come from a `url`; they
// // come from a `hash`. `url` is just one of many possible mirrors for how to
// // obtain a package matching this `hash`.
// //
// // Uses the [multihash](https://multiformats.io/multihash/) format.
// .hash = "...",
//
// // When this is provided, the package is found in a directory relative to the
// // build root. In this case the package's hash is irrelevant and therefore not
// // computed. This field and `url` are mutually exclusive.
// .path = "foo",
// // When this is set to `true`, a package is declared to be lazily
// // fetched. This makes the dependency only get fetched if it is
// // actually used.
// .lazy = false,
//},
.httpz = .{
.url = "git+https://github.com/karlseguin/http.zig?ref=master#5e5ab5f82477252fd85943bcb33db483bde6de86",
.hash = "httpz-0.0.0-PNVzrLndBgBwKYPO0v3OFD-6741_9uKdWtU27sil2-df",
},
},
// Specifies the set of files and directories that are included in this package.
// Only files and directories listed here are included in the `hash` that
// is computed for this package.
// Paths are relative to the build root. Use the empty string (`""`) to refer to
// the build root itself.
// A directory listed here means that all files within, recursively, are included.
.paths = .{
// This makes *all* files, recursively, included in this package. It is generally
// better to explicitly list the files and directories instead, to insure that

View file

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

View file

@ -0,0 +1,79 @@
Return-Path: <lobo@lerch.org>
Delivered-To: lobo@lerch.org
Received: from mail.eler.ch
by mail.eler.ch with LMTP
id mLU6K98I8GhCBwAAyA9pPg
(envelope-from <lobo@lerch.org>)
for <lobo@lerch.org>; Wed, 15 Oct 2025 20:49:35 +0000
Date: Wed, 15 Oct 2025 13:48:55 -0700
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=lerch.org; s=2023;
t=1760561375;
h=from:from:reply-to:subject:subject:date:date:message-id:message-id:
to:to:cc:mime-version:mime-version:content-type:content-type;
bh=fK/8hI6FilkYWKOrKBjauM3coZWekxJWs7kueYzsykk=;
b=ARh96zUwRhAoTOSvAwdf1758NnPijJZY1UH5umSuZvqLwQKTZpNUpgKOwqg1S84gcbVLCQ
g9+6B3FS3EFfRcVwBCQ7AOX5SezMSeMZUWh0lXOA5COVpiTSsn5ZJrDqKo/4gn9DlZMNY2
DKfCW19OcTScS5GqJ0X0cwiPhJVcC6XJccVUUPESm4eRMndu4cyRL9PesIPnUBM4bX3vZE
r84srk5AaDd8R526x1qgZTK0ModoH/UrOEbOBgon/OKklnBelOaEwmMUlQszxV8dqCscJB
bGNSYVPVFbDbzeqr47hQ4bGGpvvBqnNpvi/ZUAhMObqSUK6fm68ZTPwghm0SVA==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=lerch.org; s=2023;
t=1760561374;
h=from:from:reply-to:subject:subject:date:date:message-id:message-id:
to:to:cc:mime-version:mime-version:content-type:content-type;
bh=fK/8hI6FilkYWKOrKBjauM3coZWekxJWs7kueYzsykk=;
b=gXz65hYYxys2vqNPzcCxCgPtKewLwSmnSkZGwnsnNIU7/AxQSMKY3w+Q5ZelzK0ApAARqn
CvLpCajueXfrYyMcM3nMY/vysCTbpFAejCMzkOxQkEZ7XkioZx/o5PChmsBcrvB7MHXqSo
mQBKxA3JpPTq4s13xmFwiAZxmxsOV0Ibddzn7OylrMTn5h8yMAtXs6bwIfyXnZLLLGiQWz
oFrzFBr+9anwKsLLgT4LGMoRLrp9sLAmgf6c5WhuLxbqR2Khlxma3t+6MeW1yliqwgs7C2
elut2+rvcLL0jVQeMD4ABjU4DeTyZHvqlZDiJRAKJ0e0wWpcWYU0/rSS5X0PuA==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=lerch.org; s=2023;
t=1760561373;
h=from:from:reply-to:subject:subject:date:date:message-id:message-id:
to:to:cc:mime-version:mime-version:content-type:content-type;
bh=fK/8hI6FilkYWKOrKBjauM3coZWekxJWs7kueYzsykk=;
b=CkLb+hn4F61VPfutWVY6WrtNhUzLTyt0ek7eQw2uOqjvdsyctLEGSEotbaAlE2O7fygSBi
RZ0xdxbEkwvmGR4BhBXDLopVJqDwOgcE3tmyZTmSNchGne8kKZrfnffWUxMXybLfbGjRrO
W8mkDsrtGKESv2W/VrKhzb8LU0JS8LNMFBr6C4yaVxHYeljbzTLMAonjLpe4G/TSRdqJ/3
n9sVk3S3icP0KPPPqiT1Qf4eZDR5A9kfe8ck9aKdG3SAAx/6RhOSi73UVvp/5MGROW0Nm8
VWfgM/S2MwZwN327BMWPlh9lWEYGJmz6HkxZriSgzpA4g22ATWXjv8AAHPhFnA==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=lerch.org; s=2023;
t=1760561371;
h=from:from:reply-to:subject:subject:date:date:message-id:message-id:
to:to:cc:mime-version:mime-version:content-type:content-type;
bh=fK/8hI6FilkYWKOrKBjauM3coZWekxJWs7kueYzsykk=;
b=eew4LpLzaFedigVWolli8Sc68l86F2IrsYnHfJ5INqv25iI4gkS935Zlg6kKMPzA090zNX
Ne0QnLHb0zK8AXKBLVhvb7hCIdU1fACx+p+K6UhDc6uIQrqUo3Mesdin+XISTa7hvhaTWn
vWpMsuPyu1+fBN8JMjpu/Fa0XWxhTH8eB5fDi3G/gDxoxRm/visTlSXURq8FcM8Z7wKW6O
TM6cFrk5KP2luxkt+GItWdPKU5D/fMLcu4bTc3aWXQ9WJONpUlMD7Fr9c8btat5VnX/lxh
F+3dHyiU9a+/H6H/0eFPBzCauMWU7Jl8wStOJMecxGtv+Rw0bl6qTyElTSPtbA==
From: Emil Lerch <lobo@lerch.org>
To: lobo@lerch.org
Subject: Attachment
Message-ID: <4n4wn5ogmkpcboi2ipxj3wnt5iqe6rcb5bv5jvtseya7k2yqmi@7drgod2d766o>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="6ecwttx4c7nftwnw"
Content-Disposition: inline
--6ecwttx4c7nftwnw
Content-Type: text/plain; charset=us-ascii
Content-Disposition: inline
Attached is an attachment for attachment needs. Please see attached attachment.
--6ecwttx4c7nftwnw
Content-Type: text/plain; charset=us-ascii
Content-Disposition: attachment; filename="a.txt"
xwininfo: Window id: 0x1eb (the root window) "i3"
Root window id: 0x1eb (the root window) "i3"
Parent window id: 0x0 (none)
154 children:
0x600071b "CanvasBlocker": ("Firefox" "LibreWolf") 127x47+2378+89 +2378+89
2 children:
0x6000a12 (has no name): () 127x47+0+0 +2378+89
0x600071c (has no name): () 1x1+-1+-1 +2377+88
0x60004bf "Firefox": ("Firefox" "LibreWolf") 298x402+2113+801 +2113+801
--6ecwttx4c7nftwnw--

484
src/Email.zig Normal file
View file

@ -0,0 +1,484 @@
const std = @import("std");
const gmime = @import("c.zig").c;
const textTransformation = @import("textTransformation.zig");
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;
defer gmime.g_object_unref(stream);
// Create a parser for the stream
const parser = gmime.g_mime_parser_new_with_stream(stream) orelse
return error.ParserCreationFailed;
defer gmime.g_object_unref(parser);
// Parse the message
const message = gmime.g_mime_parser_construct_message(parser, null) orelse
return error.MessageParsingFailed;
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 mpgc = gmime.g_mime_multipart_get_count(multipart);
if (mpgc == -1) return error.NoMultipartCount;
const count: usize = @intCast(mpgc);
// std.debug.print("\n\nCount: {}\n", .{count});
// Look for HTML part first (preferred in multipart/alternative)
for (0..count) |i| {
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;
defer gmime.g_free(part_mime_type);
// std.debug.print("Mime type: {s}\n", .{std.mem.span(part_mime_type)});
// subtype is "html", but mime type is "text/html", so we don't need this
// const part_mime_subtype = gmime.g_mime_content_type_get_media_subtype(part_content_type);
// if (part_mime_subtype == null) continue;
// std.debug.print("Media subtype type: {s}\n", .{std.mem.span(part_mime_subtype)});
// Check if this is text/html
if (std.mem.eql(u8, std.mem.span(part_mime_type), "text/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: *gmime.GMimeTextPart = @ptrCast(part);
const text = gmime.g_mime_text_part_get_text(text_part);
if (text != null) {
defer gmime.g_free(text);
return try allocator.dupe(u8, std.mem.span(text));
}
}
}
}
// If no HTML found, check for nested multiparts (like multipart/related inside multipart/alternative)
// TODO: Test this code path
for (0..count) |i| {
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: *gmime.GMimeMultipart = @ptrCast(part);
if (try findHtmlInMultipart(nested_multipart, allocator)) |content|
return content;
}
}
std.log.debug("No HTML Multipart found", .{});
return null;
}
fn findTextInMultipart(multipart: *gmime.GMimeMultipart, allocator: std.mem.Allocator) !?[]const u8 {
const mpgc = gmime.g_mime_multipart_get_count(multipart);
if (mpgc == -1) return error.NoMultipartCount;
const count: usize = @intCast(mpgc);
for (0..count) |i| {
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;
defer gmime.g_free(part_mime_type);
if (std.mem.eql(u8, std.mem.span(part_mime_type), "text/plain")) {
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(part)), gmime.g_mime_text_part_get_type()) != 0) {
const text_part: *gmime.GMimeTextPart = @ptrCast(part);
const text = gmime.g_mime_text_part_get_text(text_part);
if (text != null) {
defer gmime.g_free(text);
return try allocator.dupe(u8, std.mem.span(text));
}
}
}
}
for (0..count) |i| {
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: *gmime.GMimeMultipart = @ptrCast(part);
if (try findTextInMultipart(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: *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 html_content;
}
}
// 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: *gmime.GMimeTextPart = @ptrCast(body);
const text = gmime.g_mime_text_part_get_text(text_part);
if (text != null) {
defer gmime.g_free(text);
const content = try allocator.dupe(u8, std.mem.span(text));
return content;
}
}
// 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);
return try allocator.dupe(u8, std.mem.span(body_string));
}
pub fn getContent(self: Message, allocator: std.mem.Allocator) !struct { content: []const u8, content_type: []const u8 } {
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: *gmime.GMimeMultipart = @ptrCast(body);
if (try findHtmlInMultipart(multipart, allocator)) |html_content| {
return .{ .content = html_content, .content_type = "text/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: *gmime.GMimeTextPart = @ptrCast(body);
const text = gmime.g_mime_text_part_get_text(text_part);
if (text != null) {
defer gmime.g_free(text);
const content = try allocator.dupe(u8, std.mem.span(text));
const content_type_obj = gmime.g_mime_object_get_content_type(body);
const mime_type = if (content_type_obj != null)
gmime.g_mime_content_type_get_mime_type(content_type_obj)
else
null;
const ct = if (mime_type != null) std.mem.span(mime_type) else "text/plain";
return .{ .content = content, .content_type = ct };
}
}
return error.NoTextContent;
}
pub fn getTextAndHtmlBodyVersions(self: Message, allocator: std.mem.Allocator) !struct { text: []const u8, html: []const u8 } {
const body = gmime.g_mime_message_get_body(self.message);
if (body == null) return error.NoMessageBody;
var text_content: ?[]const u8 = null;
var html_content: ?[]const u8 = null;
// 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: *gmime.GMimeMultipart = @ptrCast(body);
text_content = try findTextInMultipart(multipart, allocator);
html_content = try findHtmlInMultipart(multipart, allocator);
} else if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(body)), gmime.g_mime_text_part_get_type()) != 0) {
const text_part: *gmime.GMimeTextPart = @ptrCast(body);
const text = gmime.g_mime_text_part_get_text(text_part);
if (text != null) {
defer gmime.g_free(text);
const content_type_obj = gmime.g_mime_object_get_content_type(body);
const mime_type = if (content_type_obj != null)
gmime.g_mime_content_type_get_mime_type(content_type_obj)
else
null;
const ct = if (mime_type != null) std.mem.span(mime_type) else "text/plain";
const content = try allocator.dupe(u8, std.mem.span(text));
if (std.mem.eql(u8, ct, "text/html")) {
html_content = content;
} else {
text_content = content;
}
}
}
// Ensure we have both text and html versions
if (text_content == null and html_content != null) {
text_content = try textTransformation.htmlToText(allocator, html_content.?);
}
if (html_content == null and text_content != null) {
html_content = try std.fmt.allocPrint(allocator,
\\<html>
\\<head><title>No HTML version available</title></head>
\\<body>No HTML version available. Text is:<br><pre>{s}</pre></body>
\\</html>
, .{text_content.?});
}
var final_text = text_content orelse try allocator.dupe(u8, "no text or html versions available");
// If text is empty (e.g., HTML with only images without alt tags), provide fallback
if (final_text.len == 0) {
allocator.free(final_text);
final_text = try allocator.dupe(u8, "Message contains only image data without alt tags");
}
return .{
.text = final_text,
.html = html_content orelse try allocator.dupe(u8,
\\<html>
\\<head><title>No text or HTML version available</title></head>
\\<body>No text or HTML versions available</body>
\\</html>
),
};
}
pub fn getHeader(self: Message, name: []const u8) ?[]const u8 {
const name_z = std.mem.sliceTo(name, 0);
const header = gmime.g_mime_message_get_header(self.message, name_z.ptr);
if (header == null) return null;
return std.mem.span(header);
}
pub const AttachmentInfo = struct {
filename: []const u8,
content_type: []const u8,
};
pub fn getAttachments(self: Message, allocator: std.mem.Allocator) ![]AttachmentInfo {
var list = std.ArrayList(AttachmentInfo){};
defer list.deinit(allocator);
// Get the MIME part from the message (not just the body)
const mime_part = gmime.g_mime_message_get_mime_part(self.message);
if (mime_part == null) return try allocator.dupe(AttachmentInfo, &.{});
try collectAttachments(mime_part, &list, allocator);
return list.toOwnedSlice(allocator);
}
fn collectAttachments(part: *gmime.GMimeObject, list: *std.ArrayList(AttachmentInfo), allocator: std.mem.Allocator) !void {
// Check if this is a multipart
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(part)), gmime.g_mime_multipart_get_type()) != 0) {
const multipart: *gmime.GMimeMultipart = @ptrCast(part);
const count_i = gmime.g_mime_multipart_get_count(multipart);
if (count_i == -1) return;
const count: usize = @intCast(count_i);
for (0..count) |i| {
const subpart = gmime.g_mime_multipart_get_part(multipart, @intCast(i));
if (subpart != null) {
try collectAttachments(subpart, list, allocator);
}
}
return;
}
// Check if this part is an attachment
const disposition = gmime.g_mime_object_get_content_disposition(part);
if (disposition != null) {
const disp_str = gmime.g_mime_content_disposition_get_disposition(disposition);
if (disp_str != null and (std.mem.eql(u8, std.mem.span(disp_str), "attachment") or
std.mem.eql(u8, std.mem.span(disp_str), "inline")))
{
const filename_c = gmime.g_mime_part_get_filename(@as(*gmime.GMimePart, @ptrCast(part)));
if (filename_c != null) {
const content_type_obj = gmime.g_mime_object_get_content_type(part);
const mime_type = if (content_type_obj != null)
gmime.g_mime_content_type_get_mime_type(content_type_obj)
else
null;
try list.append(allocator, .{
.filename = try allocator.dupe(u8, std.mem.span(filename_c)),
.content_type = if (mime_type != null)
try allocator.dupe(u8, std.mem.span(mime_type))
else
try allocator.dupe(u8, "application/octet-stream"),
});
}
}
}
}
};
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>
, std.mem.trimRight(u8, body, "\r\n"));
}
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);
const b = "hi";
_ = b;
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>
, std.mem.trimRight(u8, body, "\r\n"));
}
test "can parse attachments" {
var engine = Self.init();
defer engine.deinit();
const allocator = std.testing.allocator;
var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
const cwd = try std.fs.cwd().realpath(".", cwd_buf[0..]);
const attachment_path = try std.fs.path.joinZ(allocator, &[_][]const u8{ cwd, "mail", "Inbox", "cur", "attachmentmcattachface.msg" });
defer allocator.free(attachment_path);
const msg = try engine.openMessage(attachment_path);
defer msg.deinit();
const attachments = try msg.getAttachments(allocator);
defer {
for (attachments) |att| {
allocator.free(att.filename);
allocator.free(att.content_type);
}
allocator.free(attachments);
}
// Should have one attachment
try std.testing.expectEqual(@as(usize, 1), attachments.len);
try std.testing.expectEqualStrings("a.txt", attachments[0].filename);
try std.testing.expectEqualStrings("text/plain", attachments[0].content_type);
}

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,28 +1,315 @@
const std = @import("std");
const httpz = @import("httpz");
const root = @import("root.zig");
const version = @import("build_options").git_revision;
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. \n", .{"codebase"});
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// 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
// stdout, not any debugging messages.
const stdout_file = std.io.getStdOut().writer();
var bw = std.io.bufferedWriter(stdout_file);
const stdout = bw.writer();
// Parse CLI arguments
var port: u16 = 5000;
var args = try std.process.argsWithAllocator(allocator);
defer args.deinit();
_ = args.skip(); // skip program name
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
std.debug.print(
\\Zetviel - Email client for notmuch
\\
\\Usage: zetviel [OPTIONS]
\\
\\Options:
\\ --port <PORT> Port to listen on (default: 5000)
\\ --help, -h Show this help message
\\ --version, -v Show version information
\\
\\Environment:
\\ NOTMUCH_PATH Path to notmuch database (default: mail)
\\
, .{});
std.process.exit(0);
} else if (std.mem.eql(u8, arg, "--version") or std.mem.eql(u8, arg, "-v")) {
std.debug.print("Zetviel {s}\n", .{version});
std.process.exit(0);
} else if (std.mem.eql(u8, arg, "--port")) {
const port_str = args.next() orelse {
std.debug.print("Error: --port requires a value\n", .{});
std.process.exit(1);
};
port = std.fmt.parseInt(u16, port_str, 10) catch {
std.debug.print("Error: invalid port number\n", .{});
std.process.exit(1);
};
} else {
std.debug.print("Error: unknown argument '{s}'\n", .{arg});
std.debug.print("Use --help for usage information\n", .{});
std.process.exit(1);
}
}
try stdout.print("Run `zig build test` to run the tests.\n", .{});
// Get notmuch database path from environment or use default
const db_path = std.posix.getenv("NOTMUCH_PATH") orelse "mail";
// Example of using the root.zig functionality
const allocator = std.heap.page_allocator;
var db_result = root.openNotmuchDb(allocator, "mail") catch |err| {
std.debug.print("Failed to open notmuch database: {}\n", .{err});
// Open notmuch database
var db = try root.openNotmuchDb(allocator, db_path, null);
defer db.close();
std.debug.print("Zetviel starting on http://localhost:{d}\n", .{port});
std.debug.print("Notmuch database: {s}\n", .{db.path});
// Create HTTP server
var server = try httpz.Server(*root.NotmuchDb).init(allocator, .{
.port = port,
.address = "127.0.0.1",
}, &db);
defer server.deinit();
// API routes
var security_headers = SecurityHeaders{};
const security_middleware = httpz.Middleware(*root.NotmuchDb).init(&security_headers);
var router = try server.router(.{ .middlewares = &.{security_middleware} });
router.get("/api/query/*", queryHandler, .{});
router.get("/api/thread/:thread_id", threadHandler, .{});
router.get("/api/message/:message_id", messageHandler, .{});
router.get("/api/attachment/:message_id/:num", attachmentHandler, .{});
// Static file serving
router.get("/", indexHandler, .{});
router.get("/*", staticHandler, .{});
try server.listen();
}
fn indexHandler(db: *root.NotmuchDb, _: *httpz.Request, res: *httpz.Response) !void {
const file = std.fs.cwd().openFile("static/index.html", .{}) catch {
res.status = 500;
res.body = "Error loading index.html";
return;
};
defer db_result.close();
defer file.close();
std.debug.print("Successfully opened notmuch database at: {s}\n", .{db_result.path});
const content = file.readToEndAlloc(db.allocator, 1024 * 1024) catch {
res.status = 500;
res.body = "Error reading index.html";
return;
};
try bw.flush(); // don't forget to flush!
res.header("Content-Type", "text/html");
res.body = content;
}
fn staticHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response) !void {
const path = req.url.path;
const file_path = if (std.mem.eql(u8, path, "/style.css"))
"static/style.css"
else if (std.mem.eql(u8, path, "/app.js"))
"static/app.js"
else {
res.status = 404;
res.body = "Not Found";
return;
};
const file = std.fs.cwd().openFile(file_path, .{}) catch {
res.status = 404;
res.body = "Not Found";
return;
};
defer file.close();
const content = file.readToEndAlloc(db.allocator, 1024 * 1024) catch {
res.status = 500;
res.body = "Error reading file";
return;
};
if (std.mem.endsWith(u8, file_path, ".css")) {
res.header("Content-Type", "text/css");
} else if (std.mem.endsWith(u8, file_path, ".js")) {
res.header("Content-Type", "application/javascript");
}
res.body = content;
}
const SecurityHeaders = struct {
pub fn execute(_: *SecurityHeaders, req: *httpz.Request, res: *httpz.Response, executor: anytype) !void {
res.header("X-Frame-Options", "deny");
res.header("X-Content-Type-Options", "nosniff");
res.header("X-XSS-Protection", "1; mode=block");
res.header("Referrer-Policy", "no-referrer");
_ = req;
return executor.next();
}
};
fn queryHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response) !void {
const encoded_query = req.url.path[11..]; // Skip "/api/query/"
if (encoded_query.len == 0) {
res.status = 400;
try res.json(.{ .@"error" = "Query parameter required" }, .{});
return;
}
// URL decode the query
const query_buf = try db.allocator.dupe(u8, encoded_query);
defer db.allocator.free(query_buf);
const query = std.Uri.percentDecodeInPlace(query_buf);
var threads = db.search(query) catch |err| {
res.status = 500;
try res.json(.{ .@"error" = @errorName(err) }, .{});
return;
};
defer threads.deinit();
try res.json(threads, .{});
}
fn threadHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response) !void {
const thread_id = req.param("thread_id") orelse {
res.status = 400;
try res.json(.{ .@"error" = "Thread ID required" }, .{});
return;
};
var thread = db.getThread(thread_id) catch |err| {
res.status = 404;
try res.json(.{ .@"error" = @errorName(err) }, .{});
return;
};
defer thread.deinit();
try res.json(thread, .{});
}
fn messageHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response) !void {
const message_id = req.param("message_id") orelse {
res.status = 400;
try res.json(.{ .@"error" = "Message ID required" }, .{});
return;
};
const msg = db.getMessage(message_id) catch |err| {
res.status = 404;
try res.json(.{ .@"error" = @errorName(err) }, .{});
return;
};
defer msg.deinit(db.allocator);
try res.json(msg, .{});
}
fn attachmentHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response) !void {
const message_id = req.param("message_id") orelse {
res.status = 400;
try res.json(.{ .@"error" = "Message ID required" }, .{});
return;
};
const num_str = req.param("num") orelse {
res.status = 400;
try res.json(.{ .@"error" = "Attachment number required" }, .{});
return;
};
const num = std.fmt.parseInt(usize, num_str, 10) catch {
res.status = 400;
try res.json(.{ .@"error" = "Invalid attachment number" }, .{});
return;
};
const msg = db.getMessage(message_id) catch |err| {
res.status = 404;
try res.json(.{ .@"error" = @errorName(err) }, .{});
return;
};
defer msg.deinit(db.allocator);
if (num >= msg.attachments.len) {
res.status = 404;
try res.json(.{ .@"error" = "Attachment not found" }, .{});
return;
}
const att = msg.attachments[num];
res.header("Content-Type", att.content_type);
res.header("Content-Disposition", try std.fmt.allocPrint(db.allocator, "attachment; filename=\"{s}\"", .{att.filename}));
// TODO: Actually retrieve and send attachment content
// For now, just send metadata
try res.json(.{ .filename = att.filename, .content_type = att.content_type }, .{});
}
test "queryHandler with valid query" {
const allocator = std.testing.allocator;
var db = try root.openNotmuchDb(allocator, "mail", null);
defer db.close();
var t = httpz.testing.init(.{});
defer t.deinit();
t.url("/api/query/tag:inbox");
try queryHandler(&db, t.req, t.res);
try std.testing.expect(t.res.status != 400);
}
test "queryHandler with empty query" {
const allocator = std.testing.allocator;
var db = try root.openNotmuchDb(allocator, "mail", null);
defer db.close();
var t = httpz.testing.init(.{});
defer t.deinit();
t.url("/api/query/");
try queryHandler(&db, t.req, t.res);
try std.testing.expectEqual(@as(u16, 400), t.res.status);
}
test "messageHandler with valid message" {
const allocator = std.testing.allocator;
var db = try root.openNotmuchDb(allocator, "mail", null);
defer db.close();
var threads = try db.search("*");
defer threads.deinit();
var maybe_thread = (try threads.next()).?;
defer maybe_thread.deinit();
var mi = try maybe_thread.thread.getMessages();
const msg_id = mi.next().?.getMessageId();
var t = httpz.testing.init(.{});
defer t.deinit();
t.param("message_id", msg_id);
try messageHandler(&db, t.req, t.res);
try std.testing.expect(t.res.status != 404);
}
test "threadHandler with valid thread" {
const allocator = std.testing.allocator;
var db = try root.openNotmuchDb(allocator, "mail", null);
defer db.close();
var threads = try db.search("*");
defer threads.deinit();
var maybe_thread = (try threads.next()).?;
defer maybe_thread.deinit();
const thread_id = maybe_thread.thread.getThreadId();
var t = httpz.testing.init(.{});
defer t.deinit();
t.param("thread_id", thread_id);
try threadHandler(&db, t.req, t.res);
try std.testing.expect(t.res.status != 404);
}

View file

@ -26,9 +26,7 @@
//! ```
const std = @import("std");
const c = @cImport({
@cInclude("notmuch.h");
});
const c = @import("c.zig").c;
pub const Status = struct {
err: ?anyerror = null,
@ -59,7 +57,7 @@ pub const Db = struct {
"",
null,
&db,
&err,
@ptrCast(&err),
);
defer if (err) |e| if (status == null) std.c.free(e);
if (open_status != c.NOTMUCH_STATUS_SUCCESS) {
@ -99,7 +97,7 @@ pub const Db = struct {
// query = notmuch_query_create (database, query_string);
//
// for (stat = notmuch_query_search_threads (query, &threads);
// stat == NOTMUCH_STATUS_SUCCESS &&
// stat == NOTMUCH_STATUS_SUCCESS &&
// notmuch_threads_valid (threads);
// notmuch_threads_move_to_next (threads))
// {

View file

@ -1,15 +1,20 @@
const std = @import("std");
const notmuch = @import("notmuch.zig");
const Email = @import("Email.zig");
// Thread representation for JSON serialization
pub const Thread = struct {
allocator: std.mem.Allocator,
thread: *notmuch.Db.Thread,
iterator: ?*notmuch.Db.ThreadIterator = null,
pub fn init(allocator: std.mem.Allocator, t: *notmuch.Db.Thread) Thread {
return .{ .allocator = allocator, .thread = t };
}
pub fn deinit(self: Thread) void {
if (self.iterator) |iter| {
iter.deinit();
self.allocator.destroy(iter);
}
self.allocator.destroy(self.thread);
}
@ -30,27 +35,21 @@ pub const Thread = struct {
// }
//]
try jws.beginArray();
var mi = self.thread.getMessages() catch return error.OutOfMemory;
var mi = self.thread.getMessages() catch return error.WriteFailed;
while (mi.next()) |m| {
try jws.beginObject();
try jws.objectField("from");
try jws.write(m.getHeader("from") catch return error.OutOfMemory);
try jws.write(m.getHeader("from") catch return error.WriteFailed);
try jws.objectField("to");
try jws.write(m.getHeader("to") catch return error.OutOfMemory);
try jws.write(m.getHeader("to") catch return error.WriteFailed);
try jws.objectField("cc");
try jws.write(m.getHeader("cc") catch return error.OutOfMemory);
try jws.write(m.getHeader("cc") catch return error.WriteFailed);
try jws.objectField("bcc");
try jws.write(m.getHeader("bcc") catch return error.OutOfMemory);
try jws.write(m.getHeader("bcc") catch return error.WriteFailed);
try jws.objectField("date");
try jws.write(m.getHeader("date") catch return error.OutOfMemory);
try jws.write(m.getHeader("date") catch return error.WriteFailed);
try jws.objectField("subject");
try jws.write(m.getHeader("subject") catch return error.OutOfMemory);
// content, content-type, and attachments are all based on the file itself
try jws.objectField("content");
try jws.write(m.getFilename()); // TODO: Parse file
try jws.objectField("content-type");
try jws.write(m.getHeader("Content-Type") catch return error.OutOfMemory);
try jws.write(m.getHeader("subject") catch return error.WriteFailed);
try jws.objectField("message_id");
try jws.write(m.getMessageId());
try jws.endObject();
@ -60,7 +59,6 @@ pub const Thread = struct {
}
};
// Threads collection for JSON serialization
pub const Threads = struct {
allocator: std.mem.Allocator,
iterator: *notmuch.Db.ThreadIterator,
@ -144,7 +142,7 @@ pub const Threads = struct {
try jws.objectField("subject");
try jws.write(t.getSubject());
try jws.objectField("tags");
var tags = t.getTags() catch return error.OutOfMemory;
var tags = t.getTags() catch return error.WriteFailed;
try tags.jsonStringify(jws);
try jws.objectField("thread_id");
try jws.write(t.getThreadId());
@ -156,16 +154,22 @@ pub const Threads = struct {
}
};
// Helper function to open a notmuch database from the current directory
pub const NotmuchDb = struct {
db: notmuch.Db,
path: [:0]u8,
allocator: std.mem.Allocator,
email: Email,
/// If email is owned, it will be deinitialized when the database is closed
/// it is considered owned if openNotmuchDb is called with a null email_engine
/// parameter.
email_owned: bool,
pub fn close(self: *NotmuchDb) void {
self.db.close();
self.db.deinit();
self.allocator.free(self.path);
if (self.email_owned) self.email.deinit();
}
pub fn search(self: *NotmuchDb, query: []const u8) !Threads {
@ -179,29 +183,126 @@ pub const NotmuchDb = struct {
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 iter_ptr = try self.allocator.create(notmuch.Db.ThreadIterator);
errdefer self.allocator.destroy(iter_ptr);
iter_ptr.* = try self.db.searchThreads(query_z);
errdefer iter_ptr.deinit();
const thread = thread_iter.next();
const thread = iter_ptr.next();
if (thread) |t| {
const tptr = try self.allocator.create(notmuch.Db.Thread);
tptr.* = t;
return Thread.init(self.allocator, tptr);
var result = Thread.init(self.allocator, tptr);
result.iterator = iter_ptr;
return result;
}
return error.ThreadNotFound;
}
pub const MessageDetail = struct {
from: ?[]const u8,
to: ?[]const u8,
cc: ?[]const u8,
bcc: ?[]const u8,
date: ?[]const u8,
subject: ?[]const u8,
text_content: []const u8,
html_content: []const u8,
attachments: []Email.Message.AttachmentInfo,
message_id: []const u8,
pub fn deinit(self: MessageDetail, allocator: std.mem.Allocator) void {
if (self.from) |f| allocator.free(f);
if (self.to) |t| allocator.free(t);
if (self.cc) |c| allocator.free(c);
if (self.bcc) |b| allocator.free(b);
if (self.date) |d| allocator.free(d);
if (self.subject) |s| allocator.free(s);
allocator.free(self.text_content);
allocator.free(self.html_content);
for (self.attachments) |att| {
allocator.free(att.filename);
allocator.free(att.content_type);
}
allocator.free(self.attachments);
allocator.free(self.message_id);
}
};
pub fn getMessage(self: *NotmuchDb, message_id: []const u8) !MessageDetail {
var query_buf: [1024:0]u8 = undefined;
const query_z = try std.fmt.bufPrintZ(&query_buf, "mid:{s}", .{message_id});
var thread_iter = try self.db.searchThreads(query_z);
defer thread_iter.deinit();
const thread = thread_iter.next() orelse return error.MessageNotFound;
defer thread.deinit();
var msg_iter = try thread.getMessages();
const notmuch_msg = msg_iter.next() orelse return error.MessageNotFound;
const filename_z = try self.allocator.dupeZ(u8, notmuch_msg.getFilename());
defer self.allocator.free(filename_z);
const email_msg = try self.email.openMessage(filename_z);
defer email_msg.deinit();
const content_info = try email_msg.getTextAndHtmlBodyVersions(self.allocator);
const attachments = try email_msg.getAttachments(self.allocator);
const from = if (notmuch_msg.getHeader("from") catch null) |h| try self.allocator.dupe(u8, h) else null;
const to = if (notmuch_msg.getHeader("to") catch null) |h| try self.allocator.dupe(u8, h) else null;
const cc = if (notmuch_msg.getHeader("cc") catch null) |h| try self.allocator.dupe(u8, h) else null;
const bcc = if (notmuch_msg.getHeader("bcc") catch null) |h| try self.allocator.dupe(u8, h) else null;
const date = if (notmuch_msg.getHeader("date") catch null) |h| try self.allocator.dupe(u8, h) else null;
const subject = if (notmuch_msg.getHeader("subject") catch null) |h| try self.allocator.dupe(u8, h) else null;
const msg_id = try self.allocator.dupe(u8, notmuch_msg.getMessageId());
return .{
.from = from,
.to = to,
.cc = cc,
.bcc = bcc,
.date = date,
.subject = subject,
.text_content = content_info.text,
.html_content = content_info.html,
.attachments = attachments,
.message_id = msg_id,
};
}
};
pub fn openNotmuchDb(allocator: std.mem.Allocator, relative_path: []const u8) !NotmuchDb {
/// Opens a notmuch database at the specified path
///
/// This function initializes GMime and opens a notmuch database at the specified path.
/// If email_engine is null, a new Email instance will be created and owned by the returned NotmuchDb.
/// Otherwise, the provided email_engine will be used and not owned by the NotmuchDb.
///
/// Parameters:
/// allocator: Memory allocator used for database operations
/// relative_path: Path to the notmuch database relative to current directory
/// email_engine: Optional Email instance to use, or null to create a new one
///
/// Returns:
/// NotmuchDb struct with an open database connection
///
/// Error: Returns error if database cannot be opened or path cannot be resolved
pub fn openNotmuchDb(allocator: std.mem.Allocator, relative_path: []const u8, email_engine: ?Email) !NotmuchDb {
var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
const cwd = try std.fs.cwd().realpath(".", cwd_buf[0..]);
const db_path = try std.fs.path.joinZ(allocator, &[_][]const u8{ cwd, relative_path });
const db = try notmuch.Db.open(db_path, null);
const email = email_engine orelse Email.init();
return .{
.db = db,
.path = db_path,
.allocator = allocator,
.email = email,
.email_owned = email_engine == null,
};
}
@ -211,7 +312,7 @@ test "ensure all references are observed" {
test "open database with public api" {
const allocator = std.testing.allocator;
var db = try openNotmuchDb(allocator, "mail");
var db = try openNotmuchDb(allocator, "mail", null);
defer db.close();
}
@ -222,11 +323,11 @@ test "can stringify general queries" {
// std.fs.cwd(),
// "mail",
// );
var db = try openNotmuchDb(allocator, "mail");
var db = try openNotmuchDb(allocator, "mail", null);
defer db.close();
var threads = try db.search("Tablets");
defer threads.deinit();
const actual = try std.json.stringifyAlloc(allocator, threads, .{ .whitespace = .indent_2 });
const actual = try std.fmt.allocPrint(allocator, "{f}", .{std.json.fmt(threads, .{ .whitespace = .indent_2 })});
defer allocator.free(actual);
try std.testing.expectEqualStrings(
\\[
@ -247,9 +348,10 @@ test "can stringify general queries" {
}
test "can stringify specific threads" {
if (true) return error.SkipZigTest;
const allocator = std.testing.allocator;
var db = try openNotmuchDb(allocator, "mail");
var db = try openNotmuchDb(allocator, "mail", null);
defer db.close();
var threads = try db.search("Tablets");
defer threads.deinit();
@ -277,3 +379,27 @@ test "can stringify specific threads" {
\\]
, actual);
}
test "can get message details with content" {
const allocator = std.testing.allocator;
var db = try openNotmuchDb(allocator, "mail", null);
defer db.close();
// Get a message by its ID
const message_id = "8afeb74dca321817e44e07ac4a2e040962e86e@youpharm.co";
const msg_detail = try db.getMessage(message_id);
defer msg_detail.deinit(allocator);
// Verify headers
try std.testing.expect(msg_detail.from != null);
try std.testing.expect(msg_detail.subject != null);
// Verify content was extracted - we should always have both text and html
try std.testing.expect(msg_detail.text_content.len >= 0);
try std.testing.expect(msg_detail.html_content.len > 0);
// This message has no attachments
try std.testing.expectEqual(@as(usize, 0), msg_detail.attachments.len);
// TODO: Add test with attachment once we have a sample email with attachments
}

154
src/textTransformation.zig Normal file
View file

@ -0,0 +1,154 @@
const std = @import("std");
pub fn htmlToText(allocator: std.mem.Allocator, html: []const u8) ![]const u8 {
var result = std.ArrayList(u8){};
errdefer result.deinit(allocator);
var i: usize = 0;
var in_tag = false;
var in_script = false;
var in_style = false;
while (i < html.len) {
if (html[i] == '<') {
in_tag = true;
if (i + 7 <= html.len and std.mem.eql(u8, html[i .. i + 7], "<script")) {
in_script = true;
} else if (i + 6 <= html.len and std.mem.eql(u8, html[i .. i + 6], "<style")) {
in_style = true;
} else if (i + 9 <= html.len and std.mem.eql(u8, html[i .. i + 9], "</script>")) {
in_script = false;
i += 8;
} else if (i + 8 <= html.len and std.mem.eql(u8, html[i .. i + 8], "</style>")) {
in_style = false;
i += 7;
} else if (i + 4 <= html.len and std.mem.eql(u8, html[i .. i + 4], "<img")) {
// Extract alt attribute from img tags
const tag_end = std.mem.indexOfScalarPos(u8, html, i, '>') orelse html.len;
const tag_content = html[i..tag_end];
if (std.mem.indexOf(u8, tag_content, "alt=\"")) |alt_start| {
const alt_value_start = alt_start + 5;
if (std.mem.indexOfScalarPos(u8, tag_content, alt_value_start, '"')) |alt_end| {
const alt_text = tag_content[alt_value_start..alt_end];
if (alt_text.len > 0) {
try result.append(allocator, '[');
for (alt_text) |c| {
try result.append(allocator, c);
}
try result.append(allocator, ']');
}
}
}
i = tag_end;
in_tag = false;
} else if ((i + 3 <= html.len and std.mem.eql(u8, html[i .. i + 3], "<br")) or
(i + 3 <= html.len and std.mem.eql(u8, html[i .. i + 3], "<p>")) or
(i + 4 <= html.len and std.mem.eql(u8, html[i .. i + 4], "<div")))
{
try result.append(allocator, '\n');
}
i += 1;
continue;
} else if (html[i] == '>') {
in_tag = false;
i += 1;
continue;
}
if (!in_tag and !in_script and !in_style) {
try result.append(allocator, html[i]);
}
i += 1;
}
return result.toOwnedSlice(allocator);
}
test "htmlToText - simple text" {
const allocator = std.testing.allocator;
const html = "<p>Hello World</p>";
const text = try htmlToText(allocator, html);
defer allocator.free(text);
try std.testing.expectEqualStrings("\nHello World", text);
}
test "htmlToText - strips script tags" {
const allocator = std.testing.allocator;
const html = "<p>Before</p><script>alert('test');</script><p>After</p>";
const text = try htmlToText(allocator, html);
defer allocator.free(text);
try std.testing.expectEqualStrings("\nBefore\nAfter", text);
}
test "htmlToText - strips style tags" {
const allocator = std.testing.allocator;
const html = "<style>body { color: red; }</style><p>Content</p>";
const text = try htmlToText(allocator, html);
defer allocator.free(text);
try std.testing.expectEqualStrings("\nContent", text);
}
test "htmlToText - handles br tags" {
const allocator = std.testing.allocator;
const html = "Line 1<br>Line 2<br/>Line 3";
const text = try htmlToText(allocator, html);
defer allocator.free(text);
try std.testing.expectEqualStrings("Line 1\nLine 2\nLine 3", text);
}
test "htmlToText - handles div tags" {
const allocator = std.testing.allocator;
const html = "<div>First</div><div class='test'>Second</div>";
const text = try htmlToText(allocator, html);
defer allocator.free(text);
try std.testing.expectEqualStrings("\nFirst\nSecond", text);
}
test "htmlToText - complex html" {
const allocator = std.testing.allocator;
const html =
\\<html>
\\<head><style>body { margin: 0; }</style></head>
\\<body>
\\<p>Hello</p>
\\<script>console.log('test');</script>
\\<div>World</div>
\\</body>
\\</html>
;
const text = try htmlToText(allocator, html);
defer allocator.free(text);
try std.testing.expectEqualStrings("\n\n\n\nHello\n\nWorld\n\n", text);
}
test "htmlToText - empty string" {
const allocator = std.testing.allocator;
const html = "";
const text = try htmlToText(allocator, html);
defer allocator.free(text);
try std.testing.expectEqualStrings("", text);
}
test "htmlToText - plain text" {
const allocator = std.testing.allocator;
const html = "Just plain text";
const text = try htmlToText(allocator, html);
defer allocator.free(text);
try std.testing.expectEqualStrings("Just plain text", text);
}
test "htmlToText - extracts img alt attributes" {
const allocator = std.testing.allocator;
const html = "<img src=\"test.jpg\" alt=\"Test Image\"><img alt=\"Another\" src=\"x.png\">";
const text = try htmlToText(allocator, html);
defer allocator.free(text);
try std.testing.expectEqualStrings("[Test Image][Another]", text);
}
test "htmlToText - img without alt" {
const allocator = std.testing.allocator;
const html = "<img src=\"test.jpg\">";
const text = try htmlToText(allocator, html);
defer allocator.free(text);
try std.testing.expectEqualStrings("", text);
}

296
static/index.html Normal file
View file

@ -0,0 +1,296 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zetviel</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.5; background: #1e1e1e; color: #e0e0e0; }
header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid #333; background: #252525; }
h1 { font-size: 1.5rem; }
.header-left { display: flex; align-items: center; gap: 1rem; }
.help-icon { cursor: pointer; font-size: 1.2rem; color: #0066cc; }
.help-icon:hover { color: #0052a3; }
.status-ok { color: #0f0; }
.status-error { color: #f00; }
.status-loading { color: #ff0; }
.search-bar { padding: 1rem; border-bottom: 1px solid #333; background: #252525; }
#search { width: 70%; padding: 0.5rem; border: 1px solid #444; border-radius: 4px; background: #2a2a2a; color: #e0e0e0; }
button { padding: 0.5rem 1rem; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #0052a3; }
button:disabled { background: #444; cursor: not-allowed; }
.container { display: flex; height: calc(100vh - 140px); }
.thread-list { width: 40%; overflow-y: auto; border-right: 1px solid #333; }
.thread { padding: 1rem; border-bottom: 1px solid #2a2a2a; cursor: pointer; }
.thread:hover { background: #2a2a2a; }
.thread-subject { font-weight: bold; margin-bottom: 0.25rem; }
.thread-authors { color: #999; font-size: 0.9rem; }
.thread-date { color: #666; font-size: 0.85rem; }
.message-view { width: 60%; overflow-y: auto; padding: 1rem; }
.message { margin-bottom: 2rem; padding: 1rem; border: 1px solid #333; border-radius: 4px; background: #252525; }
.message-header { margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #333; }
.message-content { margin-top: 1rem; }
.content { padding: 1rem; background: #2a2a2a; border-radius: 4px; }
.content pre { white-space: pre-wrap; word-wrap: break-word; }
.attachments { margin-top: 1rem; padding: 0.5rem; background: #3a3a00; border-radius: 4px; }
.loading { padding: 1rem; text-align: center; color: #999; }
.error { padding: 1rem; background: #3a0000; border: 1px solid #600; border-radius: 4px; margin: 1rem; }
.help-overlay { display: none; position: fixed; bottom: 1rem; right: 1rem; width: 425px; max-height: 500px; overflow-y: auto; background: #252525; border: 1px solid #444; border-radius: 8px; padding: 1rem; box-shadow: 0 4px 12px rgba(0,0,0,0.5); z-index: 1000; font-size: 0.85rem; }
.help-overlay.visible { display: block; }
.help-overlay h3 { margin-bottom: 0.5rem; color: #0066cc; }
.help-overlay code { background: #2a2a2a; padding: 0.2rem 0.4rem; border-radius: 3px; color: #4ec9b0; }
.help-overlay ul { list-style: none; margin-top: 0.5rem; }
.help-overlay li { margin: 0.5rem 0; }
</style>
</head>
<body>
<div id="app">
<header>
<div class="header-left">
<h1>Zetviel</h1>
<span class="help-icon" onclick="toggleHelp()" title="Help (?)">?</span>
</div>
<div id="status" class="status-ok"></div>
</header>
<div class="search-bar">
<input type="text" id="search" placeholder="Search (e.g., tag:inbox)" value="tag:inbox">
<button onclick="search()">Search</button>
</div>
<div class="container">
<div id="thread-list" class="thread-list"></div>
<div id="message-view" class="message-view"></div>
</div>
<div id="help-overlay" class="help-overlay">
<h3>Notmuch Search Syntax</h3>
<ul>
<li><code>tag:inbox</code> - Messages with inbox tag</li>
<li><code>from:alice</code> - From alice</li>
<li><code>to:bob</code> - To bob</li>
<li><code>subject:meeting</code> - Subject contains "meeting"</li>
<li><code>date:today</code> - Messages from today</li>
<li><code>date:yesterday</code> - Messages from yesterday</li>
<li><code>date:7d..</code> - Last 7 days</li>
<li><code>tag:unread AND from:alice</code> - Combine with AND</li>
<li><code>tag:inbox OR tag:sent</code> - Combine with OR</li>
<li><code>NOT tag:spam</code> - Exclude spam</li>
<li><code>attachment:pdf</code> - Has PDF attachment</li>
<li><code>*</code> - All messages</li>
</ul>
<h3 style="margin-top: 1rem;">Keyboard Shortcuts</h3>
<ul>
<li><code>/</code> - Focus search</li>
<li><code>j</code> - Next thread</li>
<li><code>k</code> - Previous thread</li>
<li><code>t</code> - Toggle HTML/text</li>
<li><code>?</code> - Toggle this help</li>
</ul>
</div>
</div>
<script>
let currentQuery = 'tag:inbox';
let isLoading = false;
let currentThreadIndex = -1;
let threads = [];
let currentMessageId = null;
async function api(endpoint) {
const res = await fetch(`/api/${endpoint}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
function setStatus(state) {
const status = document.getElementById('status');
status.className = state === 'loading' ? 'status-loading' : state === 'ok' ? 'status-ok' : 'status-error';
}
function setLoading(loading) {
isLoading = loading;
const btn = document.querySelector('.search-bar button');
btn.disabled = loading;
btn.textContent = loading ? 'Loading...' : 'Search';
}
async function search() {
if (isLoading) return;
const query = document.getElementById('search').value;
currentQuery = query;
history.pushState({ query }, '', `/?q=${encodeURIComponent(query)}`);
await loadThreads(query);
}
async function loadThreads(query) {
setLoading(true);
setStatus('loading');
const list = document.getElementById('thread-list');
try {
threads = await api(`query/${encodeURIComponent(query)}`);
currentThreadIndex = -1;
if (!threads || threads.length === 0) {
list.innerHTML = '<div class="loading">No threads found</div>';
} else {
list.innerHTML = threads.map((t, i) => `
<div class="thread" data-index="${i}" onclick="loadThread('${t.thread_id}', ${i})">
<div class="thread-subject">${escapeHtml(t.subject)}</div>
<div class="thread-authors">${escapeHtml(t.authors)}</div>
<div class="thread-date">${new Date(t.newest_date * 1000).toLocaleString()}</div>
</div>
`).join('');
}
setStatus('ok');
} catch (e) {
list.innerHTML = `<div class="error">Error loading threads: ${escapeHtml(e.message)}</div>`;
setStatus('error');
} finally {
setLoading(false);
}
}
async function loadThread(threadId, index) {
currentThreadIndex = index;
highlightThread(index);
setStatus('loading');
const view = document.getElementById('message-view');
view.innerHTML = '<div class="loading">Loading messages...</div>';
try {
const messages = await api(`thread/${threadId}`);
view.innerHTML = messages.map(m => `
<div class="message">
<div class="message-header">
<strong>From:</strong> ${escapeHtml(m.from || '')}<br>
<strong>To:</strong> ${escapeHtml(m.to || '')}<br>
<strong>Date:</strong> ${escapeHtml(m.date || '')}<br>
<strong>Subject:</strong> ${escapeHtml(m.subject || '')}
</div>
<div id="msg-${m.message_id}" class="message-content"><div class="loading">Loading content...</div></div>
</div>
`).join('');
setStatus('ok');
messages.forEach(m => loadMessageContent(m.message_id));
if (messages.length > 0) currentMessageId = messages[0].message_id;
} catch (e) {
view.innerHTML = `<div class="error">Error loading thread: ${escapeHtml(e.message)}</div>`;
setStatus('error');
}
}
function highlightThread(index) {
document.querySelectorAll('.thread').forEach(t => t.style.background = '');
const thread = document.querySelector(`.thread[data-index="${index}"]`);
if (thread) {
thread.style.background = '#2a2a2a';
thread.scrollIntoView({ block: 'nearest' });
}
}
async function loadMessageContent(messageId) {
const div = document.getElementById(`msg-${messageId}`);
try {
const msg = await api(`message/${messageId}`);
div.innerHTML = `
${msg.html_content ? `<button onclick="showHtml('${messageId}')">Show HTML Version</button>` : ''}
<br>
<div class="content"><pre>${escapeHtml(msg.text_content)}</pre></div>
${msg.attachments.length ? `<div class="attachments">Attachments: ${msg.attachments.map(a => escapeHtml(a.filename)).join(', ')}</div>` : ''}
`;
div.dataset.html = msg.html_content || '';
div.dataset.showingHtml = 'false';
} catch (e) {
div.innerHTML = `<div class="error">Error loading message: ${escapeHtml(e.message)}</div>`;
}
}
function showHtml(messageId) {
const div = document.getElementById(`msg-${messageId}`);
const html = div.dataset.html;
if (html) {
div.innerHTML = `
<button onclick="loadMessageContent('${messageId}')">Show Text Version</button>
<br>
<div class="content">${html}</div>
`;
div.dataset.showingHtml = 'true';
}
}
function toggleHtmlText() {
if (!currentMessageId) return;
const div = document.getElementById(`msg-${currentMessageId}`);
if (!div) return;
if (div.dataset.showingHtml === 'true') {
loadMessageContent(currentMessageId);
} else {
showHtml(currentMessageId);
}
}
function toggleHelp() {
const overlay = document.getElementById('help-overlay');
overlay.classList.toggle('visible');
}
function moveThread(direction) {
if (threads.length === 0) return;
const newIndex = currentThreadIndex + direction;
if (newIndex >= 0 && newIndex < threads.length) {
loadThread(threads[newIndex].thread_id, newIndex);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
window.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(location.search);
const query = params.get('q') || 'tag:inbox';
document.getElementById('search').value = query;
loadThreads(query);
document.getElementById('search').addEventListener('keypress', (e) => {
if (e.key === 'Enter') search();
});
document.addEventListener('keydown', (e) => {
if (document.activeElement.id === 'search') return;
if (e.key === '/') {
e.preventDefault();
document.getElementById('search').focus();
} else if (e.key === 'j') {
e.preventDefault();
moveThread(1);
} else if (e.key === 'k') {
e.preventDefault();
moveThread(-1);
} else if (e.key === 't') {
e.preventDefault();
toggleHtmlText();
} else if (e.key === '?') {
e.preventDefault();
toggleHelp();
}
});
});
window.addEventListener('popstate', (e) => {
if (e.state?.query) {
document.getElementById('search').value = e.state.query;
loadThreads(e.state.query);
}
});
</script>
</body>
</html>