420 lines
14 KiB
Zig
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);
|
|
}
|