zetviel/src/main.zig

212 lines
6.4 KiB
Zig

const std = @import("std");
const httpz = @import("httpz");
const root = @import("root.zig");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Get notmuch database path from environment or use default
const db_path = std.posix.getenv("NOTMUCH_PATH") orelse "mail";
// Open notmuch database
var db = try root.openNotmuchDb(allocator, db_path, null);
defer db.close();
std.debug.print("Zetviel starting on http://localhost:5000\n", .{});
std.debug.print("Notmuch database: {s}\n", .{db.path});
// Create HTTP server
var server = try httpz.Server(*root.NotmuchDb).init(allocator, .{
.port = 5000,
.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, .{});
// TODO: Static file serving for frontend
try server.listen();
}
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 query = req.url.path[11..]; // Skip "/api/query/"
if (query.len == 0) {
res.status = 400;
try res.json(.{ .@"error" = "Query parameter required" }, .{});
return;
}
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);
}