Compare commits

...

3 commits

Author SHA1 Message Date
df741c69ae
add mutt compatible processing to the query api
All checks were successful
Generic zig build / build (push) Successful in 46s
2025-10-16 11:19:44 -07:00
52affcd587
add a second message to the test notmuch db 2025-10-16 11:19:17 -07:00
5e4ae198a1
remove plan 2025-10-16 10:30:18 -07:00
9 changed files with 133 additions and 83 deletions

79
PLAN.md
View file

@ -1,79 +0,0 @@
# 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

@ -2,7 +2,7 @@
.name = .zetviel, .name = .zetviel,
// This is a [Semantic Version](https://semver.org/). // This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication. // In a future version of Zig it will be used for package deduplication.
.version = "0.0.0", .version = "0.9.0",
// This field is optional. // This field is optional.
// This is currently advisory only; Zig does not yet do anything // This is currently advisory only; Zig does not yet do anything

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,77 @@
Return-Path: <mail@youpharm.co>
Delivered-To: lobo@lerch.org
Received: from mail.eler.ch
by mail.eler.ch with LMTP
id BJ9kLnLDm2aYDgQAyA9pPg
(envelope-from <mail@youpharm.co>)
for <lobo@lerch.org>; Sat, 20 Jul 2024 14:02:26 +0000
Received: from youpharm.co (youpharm.co [5.101.65.218])
by mail.eler.ch (Postfix) with ESMTP id E3C9F467AB
for <emil@lerch.org>; Sat, 20 Jul 2024 14:02:25 +0000 (UTC)
DKIM-Signature: v=1; a=rsa-sha1; c=relaxed/relaxed; s=s1; d=youpharm.co;
h=Message-ID:From:To:Subject:Date:MIME-Version:Content-Type; i=abuse@youpharm.co;
bh=Lnj6s7dL4V4gz92hab9GJ0HxEL8=;
b=MLiLXLaBVkYaJuabi+DsOFUkjmqsYJ0hsfHW5JKX61Fal+1j2iFjFWrggCv+m0zruA+j6W+iJ7CV
nxcDpT5mZe0+e2bOu1f8YEGNj7DPVpkYjeB8esR4qo/LSot0TIOU7YojSk8HP/hQVYEwpC58f21C
sXgqEyMn1bV4+UHE1QnhoyZRP/lyadba4SCCSeyG5VQMZ4cIZtlcBFA+yd6I03lZ2f/Lh7tinFXj
HrOyPjQLpk4VNbVsbbpsI+sKOEGlmgpRVIatV+Hcwk8ZuhFsubdF/cSc1p3jFbUdhBa3TMcqFVS2
L1UO4e13PWJVjHzAUXlGDF66PGIR2pHnBd1pew==
DomainKey-Signature: a=rsa-sha1; c=nofws; q=dns; s=s1; d=youpharm.co;
b=fFAj5RzuUx0++wASk/u6T0GlBurb2y6h/1WEls22gWKfoEKHzChyXYJNyknfho2r/3Cw0DNrWXFI
nRIivnoNX4rOvc4hsCieljl9lt0fOaYzLgHKS083D8JIYLLySX0Qwj7xydC3nB3WmHhOlrz6eM7d
lPIOT14K1e5LxQTLox8PaUqknSUNrsBZ8tREcVLqb7Ud9SVvdHjyccjampV70XPOeKMd9NLt4a/H
sEeS184PGBo7/uAuHojS2y2LDkY6nRdZPmjPvA9ghNU8udr+biG3NEX8V2v2ZJy7w9H6FfJfCb2/
MrxZmGPWgcYJ7cQ/pNMKcHM1QoAKYKMEG76V8g==;
Message-ID: <8afeb74dca321817e44e07ac4a2e040962e86e@youpharm.co>
From: Top Medications <mail@youpharm.co>
To: emil@lerch.org
Subject: ***SPAM*** Tablets without a prescription
Date: Sat, 20 Jul 2024 16:02:18 +0200
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="08044db730c8e2ed1eb4fd56b0d4fef3d9e86e"
X-Rspamd-Queue-Id: E3C9F467AB
X-Rspamd-Server: mail.eler.ch
X-Spamd-Result: default: False [10.00 / 11.00];
ABUSE_SURBL(5.50)[youpharm.co:helo,youpharm.co:dkim,youpharm.co:rdns];
RSPAMD_URIBL(4.50)[unmaskfauci.com:url];
BAD_REP_POLICIES(0.10)[];
MIME_GOOD(-0.10)[multipart/alternative,text/plain];
DKIM_TRACE(0.00)[youpharm.co:+];
ARC_NA(0.00)[];
DMARC_POLICY_ALLOW(0.00)[youpharm.co,none];
RCVD_COUNT_ZERO(0.00)[0];
MIME_TRACE(0.00)[0:+,1:+,2:~];
FROM_EQ_ENVFROM(0.00)[];
RCPT_COUNT_ONE(0.00)[1];
TO_DN_NONE(0.00)[];
R_SPF_ALLOW(0.00)[+a];
R_DKIM_ALLOW(0.00)[youpharm.co:s=s1];
ASN(0.00)[asn:34665, ipnet:5.101.65.0/24, country:RU];
FROM_HAS_DN(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
DWL_DNSWL_BLOCKED(0.00)[youpharm.co:dkim];
MID_RHS_MATCH_FROM(0.00)[]
X-Rspamd-Action: rewrite subject
Content-Length: 789
--08044db730c8e2ed1eb4fd56b0d4fef3d9e86e
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
--08044db730c8e2ed1eb4fd56b0d4fef3d9e86e
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
<html>
<head>
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dutf-8">
</head>
<body><a href=3D"https://unmaskfauci.com/assets/images/chw.php"><img src=3D=
"https://imgpx.com/dfE6oYsvHoYw.png"></a> <div><img width=3D1 height=3D1 =
alt=3D"" src=3D"https://vnevent.net/wp-content/plugins/wp-automatic/awe.p=
hp?QFYiTaVCm0ogM30sC5RNRb%2FKLO0%2FqO3iN9A89RgPbrGjPGsdVierqrtB7w8mnIqJug=
BVA5TZVG%2F6MFLMOrK9z4D6vgFBDRgH88%2FpEmohBbpaSFf4wx1l9S4LGJd87EK6"></div=
></body></html>
--08044db730c8e2ed1eb4fd56b0d4fef3d9e86e--

12
notmuch-config Normal file
View file

@ -0,0 +1,12 @@
[database]
path=/home/lobo/shared/zetviel/mail
[user]
name=Test User
primary_email=test@example.com
[new]
tags=unread;inbox;
ignore=
[search]
exclude_tags=deleted;spam;
[maildir]
synchronize_flags=true

View file

@ -204,8 +204,7 @@ fn queryHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response)
} }
// URL decode the query // URL decode the query
const query_buf = try db.allocator.dupe(u8, encoded_query); const query_buf = try req.arena.dupe(u8, encoded_query);
defer db.allocator.free(query_buf);
const query = std.Uri.percentDecodeInPlace(query_buf); const query = std.Uri.percentDecodeInPlace(query_buf);
var threads = db.search(query) catch |err| { var threads = db.search(query) catch |err| {
@ -215,7 +214,48 @@ fn queryHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response)
}; };
defer threads.deinit(); defer threads.deinit();
try res.json(threads, .{}); // 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 { fn threadHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response) !void {