Compare commits

..

No commits in common. "0b804a0d631a55caef540cc1953ad580a160471c" and "777f0a101629108f7fa760edad21fb3d8fcf32da" have entirely different histories.

8 changed files with 36 additions and 281 deletions

1
.gitignore vendored
View file

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

View file

@ -13,7 +13,6 @@ 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
@ -47,30 +46,8 @@ 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
------------- -------------
@ -78,15 +55,9 @@ 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 binds to 0.0.0.0 by default, making it accessible on all network interfaces. **WARNING**: Zetviel is intended for local use only. It binds to 127.0.0.1 and should
While basic authentication is required for API routes, this is intended for local or trusted network use only. not be exposed to the internet without additional security measures.
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,10 +28,8 @@ 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

View file

@ -1,69 +0,0 @@
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,7 +1,6 @@
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;
@ -68,25 +67,6 @@ 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,
@ -105,26 +85,18 @@ pub fn main() !u8 {
}, &db); }, &db);
defer server.deinit(); defer server.deinit();
// Security headers middleware // API routes
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, .{});
// Auth middleware for API routes // Static file serving
var basic_auth = auth.BasicAuth{ .creds = creds }; router.get("/", indexHandler, .{});
const auth_middleware = httpz.Middleware(*root.NotmuchDb).init(&basic_auth); router.get("/*", staticHandler, .{});
// 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;
@ -145,7 +117,6 @@ 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;
} }
@ -293,10 +264,6 @@ 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,7 +124,6 @@ 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;
@ -156,13 +155,10 @@ 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 { const val = c.notmuch_message_get_header(self.message_handle, header) orelse return error.ErrorGettingHeader; // null is an error
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; if (ziggy_val.len == 0) return null; // empty string indicates message does not contain the header
return ziggy_val; return ziggy_val;
} }
pub fn getMessageId(self: Message) []const u8 { pub fn getMessageId(self: Message) []const u8 {

View file

@ -35,24 +35,21 @@ pub const Thread = struct {
// } // }
//] //]
try jws.beginArray(); try jws.beginArray();
var mi = self.thread.getMessages() catch |err| { var mi = self.thread.getMessages() catch return error.WriteFailed;
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")); try jws.write(m.getHeader("from") catch return error.WriteFailed);
try jws.objectField("to"); try jws.objectField("to");
try jws.write(m.getHeader("to")); try jws.write(m.getHeader("to") catch return error.WriteFailed);
try jws.objectField("cc"); try jws.objectField("cc");
try jws.write(m.getHeader("cc")); try jws.write(m.getHeader("cc") catch return error.WriteFailed);
try jws.objectField("bcc"); try jws.objectField("bcc");
try jws.write(m.getHeader("bcc")); try jws.write(m.getHeader("bcc") catch return error.WriteFailed);
try jws.objectField("date"); try jws.objectField("date");
try jws.write(m.getHeader("date")); try jws.write(m.getHeader("date") catch return error.WriteFailed);
try jws.objectField("subject"); try jws.objectField("subject");
try jws.write(m.getHeader("subject")); try jws.write(m.getHeader("subject") catch return error.WriteFailed);
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();
@ -253,12 +250,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")) |h| try self.allocator.dupe(u8, h) else null; const from = if (notmuch_msg.getHeader("from") 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 to = if (notmuch_msg.getHeader("to") 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 cc = if (notmuch_msg.getHeader("cc") 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 bcc = if (notmuch_msg.getHeader("bcc") 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 date = if (notmuch_msg.getHeader("date") 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 subject = if (notmuch_msg.getHeader("subject") catch null) |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,12 +42,6 @@ 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>
@ -57,14 +51,11 @@ 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 style="display: flex; align-items: center; gap: 1rem;"> <div id="status" class="status-ok"></div>
<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)"> <input type="text" id="search" placeholder="Search (e.g., tag:inbox)" value="tag:inbox">
<button onclick="search()">Search</button> <button onclick="search()">Search</button>
</div> </div>
@ -98,15 +89,6 @@ 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';
@ -114,77 +96,13 @@ 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 headers = {}; const res = await fetch(`/api/${endpoint}`);
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';
@ -335,40 +253,18 @@ function escapeHtml(text) {
return div.innerHTML; return div.innerHTML;
} }
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', () => {
// 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'); const query = params.get('q') || 'tag:inbox';
if (query) { document.getElementById('search').value = query;
document.getElementById('search').value = query; loadThreads(query);
if (authCredentials) {
loadThreads(query);
}
}
document.getElementById('search').addEventListener('keypress', (e) => { document.getElementById('search').addEventListener('keypress', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') search();
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();