Compare commits

...

4 commits

Author SHA1 Message Date
0b804a0d63
add authn
All checks were successful
Generic zig build / build (push) Successful in 52s
2025-10-15 22:41:44 -07:00
7b6a959086
add more error reporting/relax header requirements 2025-10-15 22:08:58 -07:00
7e703c4556
log error on underlying message file problem 2025-10-15 20:15:46 -07:00
9038231fee
avoid loading inbox by default/blur input on enter key 2025-10-15 20:03:41 -07:00
8 changed files with 281 additions and 36 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.zig-cache/ .zig-cache/
zig-out/ zig-out/
.direnv/ .direnv/
.zetviel_creds

View file

@ -13,6 +13,7 @@ Features
- REST API for notmuch queries - REST API for notmuch queries
- Thread and message viewing - Thread and message viewing
- Attachment handling - Attachment handling
- Basic authentication for API routes
- Security headers for safe browsing - Security headers for safe browsing
- Configurable port - Configurable port
@ -46,8 +47,30 @@ Configuration
------------- -------------
- `NOTMUCH_PATH` environment variable: Path to notmuch database (default: `mail`) - `NOTMUCH_PATH` environment variable: Path to notmuch database (default: `mail`)
- `ZETVIEL_CREDS` environment variable: Path to credentials file (default: `.zetviel_creds`)
- `--port`: HTTP server port (default: 5000) - `--port`: HTTP server port (default: 5000)
### Authentication
Zetviel requires basic authentication for all API routes. Create a credentials file with:
```sh
echo 'username' > .zetviel_creds
echo 'password' >> .zetviel_creds
```
Or set a custom path:
```sh
export ZETVIEL_CREDS=/path/to/credentials
```
The credentials file should contain two lines:
1. Username
2. Password
Static files (HTML, CSS, JS) are served without authentication.
API Endpoints API Endpoints
------------- -------------
@ -55,9 +78,15 @@ API Endpoints
- `GET /api/thread/<thread_id>` - Get messages in a thread - `GET /api/thread/<thread_id>` - Get messages in a thread
- `GET /api/message/<message_id>` - Get message details with content - `GET /api/message/<message_id>` - Get message details with content
- `GET /api/attachment/<message_id>/<num>` - Get attachment metadata - `GET /api/attachment/<message_id>/<num>` - Get attachment metadata
- `GET /api/auth/status` - Check authentication status
Security Security
-------- --------
**WARNING**: Zetviel is intended for local use only. It binds to 127.0.0.1 and should **WARNING**: Zetviel binds to 0.0.0.0 by default, making it accessible on all network interfaces.
not be exposed to the internet without additional security measures. While basic authentication is required for API routes, this is intended for local or trusted network use only.
Do not expose Zetviel directly to the internet without additional security measures such as:
- Running behind a reverse proxy with HTTPS
- Using a VPN or SSH tunnel
- Implementing additional authentication layers
- Restricting access via firewall rules

View file

@ -28,8 +28,10 @@ pub fn openMessage(self: *Self, filename: [:0]const u8) !Message {
// TODO: remove the :0 // TODO: remove the :0
self.gmimeInit(); self.gmimeInit();
// Open the file as a GMime stream // Open the file as a GMime stream
const stream = gmime.g_mime_stream_fs_open(filename.ptr, gmime.O_RDONLY, 0o0644, null) orelse const stream = gmime.g_mime_stream_fs_open(filename.ptr, gmime.O_RDONLY, 0o0644, null) orelse {
std.log.err("Failed to open message file: {s}", .{filename});
return error.FileOpenFailed; return error.FileOpenFailed;
};
defer gmime.g_object_unref(stream); defer gmime.g_object_unref(stream);
// Create a parser for the stream // Create a parser for the stream

69
src/auth.zig Normal file
View file

@ -0,0 +1,69 @@
const std = @import("std");
const httpz = @import("httpz");
pub const Credentials = struct {
username: []const u8,
password: []const u8,
};
pub fn loadCredentials(allocator: std.mem.Allocator, path: []const u8) !Credentials {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const content = try file.readToEndAlloc(allocator, 1024);
defer allocator.free(content);
var lines = std.mem.splitScalar(u8, content, '\n');
const username = std.mem.trim(u8, lines.next() orelse return error.InvalidCredentials, &std.ascii.whitespace);
const password = std.mem.trim(u8, lines.next() orelse return error.InvalidCredentials, &std.ascii.whitespace);
return .{
.username = try allocator.dupe(u8, username),
.password = try allocator.dupe(u8, password),
};
}
pub const BasicAuth = struct {
creds: Credentials,
pub fn execute(self: *BasicAuth, req: *httpz.Request, res: *httpz.Response, executor: anytype) !void {
const auth_header = req.header("authorization") orelse {
res.status = 401;
return;
};
if (!std.mem.startsWith(u8, auth_header, "Basic ")) {
res.status = 401;
return;
}
const encoded = auth_header[6..];
var decoded_buf: [256]u8 = undefined;
const decoded_len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch {
res.status = 401;
return;
};
_ = std.base64.standard.Decoder.decode(&decoded_buf, encoded) catch {
res.status = 401;
return;
};
const decoded = decoded_buf[0..decoded_len];
var parts = std.mem.splitScalar(u8, decoded, ':');
const username = parts.next() orelse {
res.status = 401;
return;
};
const password = parts.next() orelse {
res.status = 401;
return;
};
if (!std.mem.eql(u8, username, self.creds.username) or !std.mem.eql(u8, password, self.creds.password)) {
res.status = 401;
return;
}
return executor.next();
}
};

View file

@ -1,6 +1,7 @@
const std = @import("std"); const std = @import("std");
const httpz = @import("httpz"); const httpz = @import("httpz");
const root = @import("root.zig"); const root = @import("root.zig");
const auth = @import("auth.zig");
const version = @import("build_options").git_revision; const version = @import("build_options").git_revision;
@ -67,6 +68,25 @@ pub fn main() !u8 {
const db_path = std.posix.getenv("NOTMUCH_PATH") orelse "mail"; const db_path = std.posix.getenv("NOTMUCH_PATH") orelse "mail";
try stdout.print("Notmuch database: {s}\n", .{db_path}); 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 // Open notmuch database
var db = try root.openNotmuchDb( var db = try root.openNotmuchDb(
allocator, allocator,
@ -85,18 +105,26 @@ pub fn main() !u8 {
}, &db); }, &db);
defer server.deinit(); defer server.deinit();
// API routes // Security headers middleware
var security_headers = SecurityHeaders{}; var security_headers = SecurityHeaders{};
const security_middleware = httpz.Middleware(*root.NotmuchDb).init(&security_headers); 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, .{});
// Static file serving // Auth middleware for API routes
router.get("/", indexHandler, .{}); var basic_auth = auth.BasicAuth{ .creds = creds };
router.get("/*", staticHandler, .{}); 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(); try server.listen();
return 0; return 0;
@ -117,6 +145,7 @@ fn indexHandler(db: *root.NotmuchDb, _: *httpz.Request, res: *httpz.Response) !v
}; };
res.header("Content-Type", "text/html"); res.header("Content-Type", "text/html");
res.header("Cache-Control", "no-cache, no-store, must-revalidate");
res.body = content; res.body = content;
} }
@ -264,6 +293,10 @@ fn attachmentHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Respo
try res.json(.{ .filename = att.filename, .content_type = att.content_type }, .{}); 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" { test "queryHandler with valid query" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
var db = try root.openNotmuchDb(allocator, "mail", null); var db = try root.openNotmuchDb(allocator, "mail", null);

View file

@ -124,6 +124,7 @@ pub const Db = struct {
if (nm_query == null) return error.CouldNotCreateQuery; if (nm_query == null) return error.CouldNotCreateQuery;
const handle = nm_query.?; const handle = nm_query.?;
errdefer c.notmuch_query_destroy(handle); errdefer c.notmuch_query_destroy(handle);
// SAFETY: out paramter in notmuch_query_search_threads
var threads: ?*c.notmuch_threads_t = undefined; var threads: ?*c.notmuch_threads_t = undefined;
const status = c.notmuch_query_search_threads(handle, &threads); const status = c.notmuch_query_search_threads(handle, &threads);
if (status != c.NOTMUCH_STATUS_SUCCESS) return error.CouldNotSearchThreads; if (status != c.NOTMUCH_STATUS_SUCCESS) return error.CouldNotSearchThreads;
@ -155,10 +156,13 @@ pub const Db = struct {
pub const Message = struct { pub const Message = struct {
message_handle: *c.notmuch_message_t, message_handle: *c.notmuch_message_t,
pub fn getHeader(self: Message, header: [:0]const u8) !?[]const u8 { pub fn getHeader(self: Message, header: [:0]const u8) ?[]const u8 {
const val = c.notmuch_message_get_header(self.message_handle, header) orelse return error.ErrorGettingHeader; // null is an error const val = c.notmuch_message_get_header(self.message_handle, header) orelse {
std.log.err("notmuch returned null for header '{s}' on message {s} (file: {s})", .{ header, self.getMessageId(), self.getFilename() });
return null;
};
const ziggy_val = std.mem.span(val); const ziggy_val = std.mem.span(val);
if (ziggy_val.len == 0) return null; // empty string indicates message does not contain the header if (ziggy_val.len == 0) return null;
return ziggy_val; return ziggy_val;
} }
pub fn getMessageId(self: Message) []const u8 { pub fn getMessageId(self: Message) []const u8 {

View file

@ -35,21 +35,24 @@ pub const Thread = struct {
// } // }
//] //]
try jws.beginArray(); try jws.beginArray();
var mi = self.thread.getMessages() catch return error.WriteFailed; var mi = self.thread.getMessages() catch |err| {
std.log.err("Failed to get messages for thread {s}: {s}", .{ self.thread.getThreadId(), @errorName(err) });
return error.WriteFailed;
};
while (mi.next()) |m| { while (mi.next()) |m| {
try jws.beginObject(); try jws.beginObject();
try jws.objectField("from"); try jws.objectField("from");
try jws.write(m.getHeader("from") catch return error.WriteFailed); try jws.write(m.getHeader("from"));
try jws.objectField("to"); try jws.objectField("to");
try jws.write(m.getHeader("to") catch return error.WriteFailed); try jws.write(m.getHeader("to"));
try jws.objectField("cc"); try jws.objectField("cc");
try jws.write(m.getHeader("cc") catch return error.WriteFailed); try jws.write(m.getHeader("cc"));
try jws.objectField("bcc"); try jws.objectField("bcc");
try jws.write(m.getHeader("bcc") catch return error.WriteFailed); try jws.write(m.getHeader("bcc"));
try jws.objectField("date"); try jws.objectField("date");
try jws.write(m.getHeader("date") catch return error.WriteFailed); try jws.write(m.getHeader("date"));
try jws.objectField("subject"); try jws.objectField("subject");
try jws.write(m.getHeader("subject") catch return error.WriteFailed); try jws.write(m.getHeader("subject"));
try jws.objectField("message_id"); try jws.objectField("message_id");
try jws.write(m.getMessageId()); try jws.write(m.getMessageId());
try jws.endObject(); try jws.endObject();
@ -250,12 +253,12 @@ pub const NotmuchDb = struct {
const content_info = try email_msg.getTextAndHtmlBodyVersions(self.allocator); const content_info = try email_msg.getTextAndHtmlBodyVersions(self.allocator);
const attachments = try email_msg.getAttachments(self.allocator); const attachments = try email_msg.getAttachments(self.allocator);
const from = if (notmuch_msg.getHeader("from") catch null) |h| try self.allocator.dupe(u8, h) else null; const from = if (notmuch_msg.getHeader("from")) |h| try self.allocator.dupe(u8, h) else null;
const to = if (notmuch_msg.getHeader("to") catch null) |h| try self.allocator.dupe(u8, h) else null; const to = if (notmuch_msg.getHeader("to")) |h| try self.allocator.dupe(u8, h) else null;
const cc = if (notmuch_msg.getHeader("cc") catch null) |h| try self.allocator.dupe(u8, h) else null; const cc = if (notmuch_msg.getHeader("cc")) |h| try self.allocator.dupe(u8, h) else null;
const bcc = if (notmuch_msg.getHeader("bcc") catch null) |h| try self.allocator.dupe(u8, h) else null; const bcc = if (notmuch_msg.getHeader("bcc")) |h| try self.allocator.dupe(u8, h) else null;
const date = if (notmuch_msg.getHeader("date") catch null) |h| try self.allocator.dupe(u8, h) else null; const date = if (notmuch_msg.getHeader("date")) |h| try self.allocator.dupe(u8, h) else null;
const subject = if (notmuch_msg.getHeader("subject") catch null) |h| try self.allocator.dupe(u8, h) else null; const subject = if (notmuch_msg.getHeader("subject")) |h| try self.allocator.dupe(u8, h) else null;
const msg_id = try self.allocator.dupe(u8, notmuch_msg.getMessageId()); const msg_id = try self.allocator.dupe(u8, notmuch_msg.getMessageId());
return .{ return .{

View file

@ -42,6 +42,12 @@ button:disabled { background: #444; cursor: not-allowed; }
.help-overlay code { background: #2a2a2a; padding: 0.2rem 0.4rem; border-radius: 3px; color: #4ec9b0; } .help-overlay code { background: #2a2a2a; padding: 0.2rem 0.4rem; border-radius: 3px; color: #4ec9b0; }
.help-overlay ul { list-style: none; margin-top: 0.5rem; } .help-overlay ul { list-style: none; margin-top: 0.5rem; }
.help-overlay li { margin: 0.5rem 0; } .help-overlay li { margin: 0.5rem 0; }
.login-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 2000; align-items: center; justify-content: center; }
.login-overlay.visible { display: flex; }
.login-box { background: #252525; border: 1px solid #444; border-radius: 8px; padding: 2rem; width: 300px; }
.login-box h2 { margin-bottom: 1rem; }
.login-box input { width: 100%; padding: 0.5rem; margin-bottom: 1rem; border: 1px solid #444; border-radius: 4px; background: #2a2a2a; color: #e0e0e0; }
.login-box button { width: 100%; }
</style> </style>
</head> </head>
<body> <body>
@ -51,11 +57,14 @@ button:disabled { background: #444; cursor: not-allowed; }
<h1>Zetviel</h1> <h1>Zetviel</h1>
<span class="help-icon" onclick="toggleHelp()" title="Help (?)">?</span> <span class="help-icon" onclick="toggleHelp()" title="Help (?)">?</span>
</div> </div>
<div id="status" class="status-ok"></div> <div style="display: flex; align-items: center; gap: 1rem;">
<span id="auth-status" style="font-size: 0.9rem; color: #999;"></span>
<div id="status" class="status-ok"></div>
</div>
</header> </header>
<div class="search-bar"> <div class="search-bar">
<input type="text" id="search" placeholder="Search (e.g., tag:inbox)" value="tag:inbox"> <input type="text" id="search" placeholder="Search (e.g., tag:inbox)">
<button onclick="search()">Search</button> <button onclick="search()">Search</button>
</div> </div>
@ -89,6 +98,15 @@ button:disabled { background: #444; cursor: not-allowed; }
<li><code>?</code> - Toggle this help</li> <li><code>?</code> - Toggle this help</li>
</ul> </ul>
</div> </div>
<div id="login-overlay" class="login-overlay">
<div class="login-box">
<h2>Login Required</h2>
<input type="text" id="login-username" placeholder="Username" autocomplete="username">
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password">
<button onclick="submitLogin()">Login</button>
</div>
</div>
</div> </div>
<script> <script>
let currentQuery = 'tag:inbox'; let currentQuery = 'tag:inbox';
@ -96,13 +114,77 @@ let isLoading = false;
let currentThreadIndex = -1; let currentThreadIndex = -1;
let threads = []; let threads = [];
let currentMessageId = null; let currentMessageId = null;
let authCredentials = null;
async function api(endpoint) { async function api(endpoint) {
const res = await fetch(`/api/${endpoint}`); const headers = {};
if (authCredentials) {
headers['Authorization'] = `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`;
}
const res = await fetch(`/api/${endpoint}`, { headers });
if (res.status === 401) {
const success = await promptLogin();
if (!success) throw new Error('Authentication required');
headers['Authorization'] = `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`;
const retryRes = await fetch(`/api/${endpoint}`, { headers });
if (!retryRes.ok) throw new Error(`API error: ${retryRes.status}`);
return retryRes.json();
}
if (!res.ok) throw new Error(`API error: ${res.status}`); if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json(); return res.json();
} }
async function checkAuthStatus() {
try {
await api('auth/status');
document.getElementById('auth-status').textContent = 'Logged in';
document.getElementById('auth-status').style.color = '#0f0';
return true;
} catch (e) {
document.getElementById('auth-status').textContent = '';
return false;
}
}
let loginResolve = null;
function promptLogin() {
return new Promise((resolve) => {
loginResolve = resolve;
document.getElementById('login-overlay').classList.add('visible');
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
document.getElementById('login-username').focus();
});
}
async function submitLogin() {
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
if (!username || !password) return;
authCredentials = { username, password };
try {
await fetch('/api/auth/status', {
headers: { 'Authorization': `Basic ${btoa(`${username}:${password}`)}` }
}).then(r => {
if (!r.ok) throw new Error('Invalid credentials');
});
sessionStorage.setItem('authCredentials', JSON.stringify(authCredentials));
document.getElementById('auth-status').textContent = 'Logged in';
document.getElementById('auth-status').style.color = '#0f0';
document.getElementById('login-overlay').classList.remove('visible');
if (loginResolve) loginResolve(true);
} catch (e) {
authCredentials = null;
alert('Login failed');
if (loginResolve) loginResolve(false);
}
}
function setStatus(state) { function setStatus(state) {
const status = document.getElementById('status'); const status = document.getElementById('status');
status.className = state === 'loading' ? 'status-loading' : state === 'ok' ? 'status-ok' : 'status-error'; status.className = state === 'loading' ? 'status-loading' : state === 'ok' ? 'status-ok' : 'status-error';
@ -253,18 +335,40 @@ function escapeHtml(text) {
return div.innerHTML; return div.innerHTML;
} }
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', async () => {
// Restore credentials from sessionStorage
const stored = sessionStorage.getItem('authCredentials');
if (stored) {
authCredentials = JSON.parse(stored);
document.getElementById('auth-status').textContent = 'Logged in';
document.getElementById('auth-status').style.color = '#0f0';
}
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const query = params.get('q') || 'tag:inbox'; const query = params.get('q');
document.getElementById('search').value = query; if (query) {
loadThreads(query); document.getElementById('search').value = query;
if (authCredentials) {
loadThreads(query);
}
}
document.getElementById('search').addEventListener('keypress', (e) => { document.getElementById('search').addEventListener('keypress', (e) => {
if (e.key === 'Enter') search(); if (e.key === 'Enter') {
search();
e.target.blur();
}
}); });
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (document.activeElement.id === 'search') return; if (document.activeElement.id === 'search') return;
if (document.getElementById('login-overlay').classList.contains('visible')) {
if (e.key === 'Enter') {
e.preventDefault();
submitLogin();
}
return;
}
if (e.key === '/') { if (e.key === '/') {
e.preventDefault(); e.preventDefault();