diff --git a/build.zig b/build.zig index 52aeedc..9e0a8ce 100644 --- a/build.zig +++ b/build.zig @@ -36,11 +36,17 @@ pub fn build(b: *std.Build) !void { // running `zig build`). b.installArtifact(lib); + const httpz = b.dependency("httpz", .{ + .target = target, + .optimize = optimize, + }); + const exe_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }); + exe_module.addImport("httpz", httpz.module("httpz")); const exe = b.addExecutable(.{ .name = "zetviel", @@ -96,6 +102,7 @@ pub fn build(b: *std.Build) !void { .target = target, .optimize = optimize, }); + exe_test_module.addImport("httpz", httpz.module("httpz")); const exe_unit_tests = b.addTest(.{ .root_module = exe_test_module, diff --git a/build.zig.zon b/build.zig.zon index 66fd346..d701df7 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -17,42 +17,11 @@ // Once all dependencies are fetched, `zig build` no longer requires // internet connectivity. .dependencies = .{ - // See `zig fetch --save ` 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 diff --git a/src/main.zig b/src/main.zig index 0ba585c..c9eb6dc 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,23 +1,199 @@ const std = @import("std"); +const httpz = @import("httpz"); const root = @import("root.zig"); 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.fs.File{ .handle = std.posix.STDOUT_FILENO }; - try stdout_file.writeAll("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", null) 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: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 router = try server.router(.{}); + 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(); +} + +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 db_result.close(); + defer threads.deinit(); - std.debug.print("Successfully opened notmuch database at: {s}\n", .{db_result.path}); + 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); } diff --git a/src/root.zig b/src/root.zig index 0239e9b..b69a006 100644 --- a/src/root.zig +++ b/src/root.zig @@ -5,11 +5,16 @@ const Email = @import("Email.zig"); 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); } @@ -178,14 +183,18 @@ 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; }