zetviel/src/main.zig
Emil Lerch 691288e134
All checks were successful
Generic zig build / build (push) Successful in 28s
attempt to detect unrecoverable notmuch state
2025-11-15 10:51:39 -08:00

420 lines
14 KiB
Zig

const std = @import("std");
const httpz = @import("httpz");
const root = @import("root.zig");
const auth = @import("auth.zig");
const version = @import("build_options").git_revision;
fn exitAfterDelay() void {
std.Thread.sleep(500 * std.time.ns_per_ms);
std.log.err("Notmuch search is in unrecoverable state: exiting", .{});
std.process.exit(1);
}
pub fn main() !u8 {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// SAFETY: buffer to be used by writer
var stdout_buffer: [1024]u8 = undefined;
var stdout_f = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_f.interface;
// SAFETY: buffer to be used by writer
var stderr_buffer: [1024]u8 = undefined;
var stderr_f = std.fs.File.stdout().writer(&stderr_buffer);
const stderr = &stderr_f.interface;
errdefer stdout.flush() catch @panic("could not flush stdout");
errdefer stderr.flush() catch @panic("could not flush stdout");
defer stdout.flush() catch @panic("could not flush stdout");
defer stderr.flush() catch @panic("could not flush stdout");
// 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")) {
try stdout.writeAll(
\\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)
\\
);
return 0;
} else if (std.mem.eql(u8, arg, "--version") or std.mem.eql(u8, arg, "-v")) {
try stdout.print("Zetviel {s}\n", .{version});
return 0;
} else if (std.mem.eql(u8, arg, "--port")) {
const port_str = args.next() orelse {
try stderr.writeAll("Error: --port requires a value\n");
return 1;
};
port = std.fmt.parseInt(u16, port_str, 10) catch {
try stderr.writeAll("Error: invalid port number\n");
return 1;
};
} else {
try stderr.print("Error: unknown argument '{s}'\n", .{arg});
try stderr.writeAll("Use --help for usage information\n");
return 1;
}
}
// Get notmuch database path from environment or use default
const db_path = std.posix.getenv("NOTMUCH_PATH") orelse "mail";
try stdout.print("Notmuch database: {s}\n", .{db_path});
// Load credentials
const creds_path = std.posix.getenv("ZETVIEL_CREDS") orelse ".zetviel_creds";
const creds = auth.loadCredentials(allocator, creds_path) catch |err| {
if (err == error.FileNotFound) {
try stderr.print("Warning: No credentials file found at {s}\n", .{creds_path});
try stderr.writeAll("API routes will be unprotected. Create a credentials file with:\n");
try stderr.writeAll(" echo 'username' > .zetviel_creds\n");
try stderr.writeAll(" echo 'password' >> .zetviel_creds\n");
} else {
try stderr.print("Error loading credentials: {s}\n", .{@errorName(err)});
return 1;
}
return 1;
};
defer {
allocator.free(creds.username);
allocator.free(creds.password);
}
// Open notmuch database
var db = try root.openNotmuchDb(
allocator,
db_path,
null,
);
defer db.close();
try stdout.print("Zetviel starting on http://0.0.0.0:{d}\n", .{port});
try stdout.flush(); // flush before we listen
// Create HTTP server
var server = try httpz.Server(*root.NotmuchDb).init(allocator, .{
.port = port,
.address = "0.0.0.0",
}, &db);
defer server.deinit();
// Security headers middleware
var security_headers = SecurityHeaders{};
const security_middleware = httpz.Middleware(*root.NotmuchDb).init(&security_headers);
// Auth middleware for API routes
var basic_auth = auth.BasicAuth{ .creds = creds };
const auth_middleware = httpz.Middleware(*root.NotmuchDb).init(&basic_auth);
// API routes with auth
var api_router = try server.router(.{ .middlewares = &.{ security_middleware, auth_middleware } });
api_router.get("/api/query/*", queryHandler, .{});
api_router.get("/api/thread/:thread_id", threadHandler, .{});
api_router.get("/api/message/:message_id", messageHandler, .{});
api_router.get("/api/attachment/:message_id/:num", attachmentHandler, .{});
api_router.get("/api/auth/status", authStatusHandler, .{});
// Static file serving (no auth)
var static_router = try server.router(.{ .middlewares = &.{security_middleware} });
static_router.get("/", indexHandler, .{});
static_router.get("/*", staticHandler, .{});
try server.listen();
return 0;
}
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 file.close();
const content = file.readToEndAlloc(db.allocator, 1024 * 1024) catch {
res.status = 500;
res.body = "Error reading index.html";
return;
};
res.header("Content-Type", "text/html");
res.header("Cache-Control", "no-cache, no-store, must-revalidate");
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 req.arena.dupe(u8, encoded_query);
const query = std.Uri.percentDecodeInPlace(query_buf);
var threads = db.search(query) catch |err| {
if (err == error.CouldNotSearchThreads) {
res.status = 503;
try res.json(.{ .@"error" = "CouldNotSearchThreads", .fatal = true }, .{});
const exit_thread = std.Thread.spawn(.{}, exitAfterDelay, .{}) catch @panic("could not spawn thread to kill process");
exit_thread.detach();
return;
}
res.status = 500;
try res.json(.{ .@"error" = @errorName(err) }, .{});
return;
};
defer threads.deinit();
// Check Accept header
const accept = req.header("accept") orelse "application/json";
if (std.mem.startsWith(u8, accept, "text/plain")) {
// Parse parameters
const has_format = std.mem.indexOf(u8, accept, "format=message-ids") != null;
const separator_param = std.mem.indexOf(u8, accept, "separator=");
const has_mutt_escape = std.mem.indexOf(u8, accept, "escape=mutt") != null;
if (!has_format) {
res.status = 400;
res.body = "Invalid Accept header: text/plain requires format=message-ids";
return;
}
// Collect message IDs
var msg_ids = std.ArrayList([]const u8){};
while (try threads.next()) |*thread| {
defer thread.deinit();
var msg_iter = try thread.thread.getMessages();
while (msg_iter.next()) |msg| {
const msg_id = msg.getMessageId();
if (has_mutt_escape) {
const escaped = try std.mem.replaceOwned(u8, res.arena, msg_id, "+", "\\+");
try msg_ids.append(res.arena, escaped);
} else {
try msg_ids.append(res.arena, msg_id);
}
}
}
// Format output
const separator: []const u8 = if (separator_param) |s| accept[s + "separator=".len .. s + "separator=".len + 1] else "\n";
const output = try std.mem.join(res.arena, separator, msg_ids.items);
res.header("Content-Type", "text/plain");
res.body = output;
} else if (std.mem.startsWith(u8, accept, "application/json") or std.mem.eql(u8, accept, "*/*")) {
try res.json(threads, .{});
} else {
res.status = 400;
res.body = "Invalid Accept header: must be application/json or text/plain; format=message-ids";
}
}
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 }, .{});
}
fn authStatusHandler(_: *root.NotmuchDb, _: *httpz.Request, res: *httpz.Response) !void {
try res.json(.{ .authenticated = true }, .{});
}
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);
}