Compare commits
22 commits
afea8dd24b
...
8a03ce4b79
Author | SHA1 | Date | |
---|---|---|---|
8a03ce4b79 | |||
39a1cb530e | |||
2941a4fc5e | |||
07a7521c52 | |||
75535c19d1 | |||
7c9d1170e2 | |||
22aad3ed8f | |||
a64e35ed75 | |||
c3c41ba080 | |||
44cd018e09 | |||
6341af1cac | |||
2c0e7850d3 | |||
463cc80c05 | |||
d2be071265 | |||
080ea81ef5 | |||
3c5edacf26 | |||
1cb86f085d | |||
1252885423 | |||
22e4a301a3 | |||
60aa457d78 | |||
b4d645bca0 | |||
3a6d7c4bea |
14 changed files with 1500 additions and 123 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
.zig-cache/
|
||||||
|
zig-out/
|
||||||
|
mail/
|
||||||
|
*.md
|
||||||
|
.mise.toml
|
||||||
|
.tool-versions
|
11
.mise.toml
Normal file
11
.mise.toml
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
[tools]
|
||||||
|
pre-commit = "latest"
|
||||||
|
"ubi:DonIsaac/zlint" = "latest"
|
||||||
|
zig = "0.15.2"
|
||||||
|
zls = "0.15.0"
|
||||||
|
|
||||||
|
[hooks]
|
||||||
|
enter = 'echo use "nix develop" if you want to build'
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
experimental = true
|
30
.pre-commit-config.yaml
Normal file
30
.pre-commit-config.yaml
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
# See https://pre-commit.com for more information
|
||||||
|
# See https://pre-commit.com/hooks.html for more hooks
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v3.2.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-added-large-files
|
||||||
|
- repo: https://github.com/batmac/pre-commit-zig
|
||||||
|
rev: v0.3.0
|
||||||
|
hooks:
|
||||||
|
- id: zig-fmt
|
||||||
|
- id: zig-build
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: smoke-test
|
||||||
|
name: Run zig build test
|
||||||
|
entry: zig
|
||||||
|
args: ["build", "--verbose", "test"]
|
||||||
|
language: system
|
||||||
|
types: [file]
|
||||||
|
pass_filenames: false
|
||||||
|
- id: zlint
|
||||||
|
name: Run zlint
|
||||||
|
entry: zlint
|
||||||
|
args: ["--deny-warnings", "--fix"]
|
||||||
|
language: system
|
||||||
|
types: [zig]
|
47
Dockerfile
Normal file
47
Dockerfile
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
# Build stage
|
||||||
|
FROM debian:bookworm-slim AS builder
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
xz-utils \
|
||||||
|
git \
|
||||||
|
pkg-config \
|
||||||
|
libnotmuch-dev \
|
||||||
|
libgmime-3.0-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Zig 0.15.2
|
||||||
|
RUN curl -fL https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz | tar -xJ -C /usr/local && \
|
||||||
|
ln -s /usr/local/zig-x86_64-linux-0.15.2/zig /usr/local/bin/zig
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build in release mode with baseline CPU features for portability
|
||||||
|
RUN zig build -Doptimize=ReleaseSafe -Dcpu=baseline
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libnotmuch5 \
|
||||||
|
libgmime-3.0-0 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy binary and static files
|
||||||
|
COPY --from=builder /build/zig-out/bin/zetviel /usr/local/bin/zetviel
|
||||||
|
COPY --from=builder /build/static /app/static
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set environment variable for notmuch database
|
||||||
|
ENV NOTMUCH_PATH=/mail
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/zetviel"]
|
79
PLAN.md
Normal file
79
PLAN.md
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
# 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
|
52
README.md
52
README.md
|
@ -1,13 +1,20 @@
|
||||||
Zetviel
|
Zetviel
|
||||||
-------
|
-------
|
||||||
|
|
||||||
As some background, I've had some issues with the very usable [netviel](https://github.com/DavidMStraub/netviel).
|
A web-based email client for [notmuch](https://notmuchmail.org/), written in Zig.
|
||||||
|
|
||||||
I wanted to address those issues, but also simplify the deployment. And, I like zig,
|
As some background, I've had some issues with the very usable [netviel](https://github.com/DavidMStraub/netviel).
|
||||||
|
I wanted to address those issues, but also simplify the deployment. And, I like Zig,
|
||||||
so I decided this was small enough I'd just re-write the thing to make my own.
|
so I decided this was small enough I'd just re-write the thing to make my own.
|
||||||
|
|
||||||
This is still very work in progress, to the point it is not yet usable. It has
|
Features
|
||||||
some basic notmuch integration and a usable build system.
|
--------
|
||||||
|
|
||||||
|
- REST API for notmuch queries
|
||||||
|
- Thread and message viewing
|
||||||
|
- Attachment handling
|
||||||
|
- Security headers for safe browsing
|
||||||
|
- Configurable port
|
||||||
|
|
||||||
Building
|
Building
|
||||||
--------
|
--------
|
||||||
|
@ -18,4 +25,39 @@ will install the necessary notmuch header/library, and the build system will
|
||||||
detect and use that. Again, `zig build` will work in that instance, but you must
|
detect and use that. Again, `zig build` will work in that instance, but you must
|
||||||
`nix develop` first.
|
`nix develop` first.
|
||||||
|
|
||||||
More to come...
|
Usage
|
||||||
|
-----
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Start server on default port (5000)
|
||||||
|
zetviel
|
||||||
|
|
||||||
|
# Start server on custom port
|
||||||
|
zetviel --port 8080
|
||||||
|
|
||||||
|
# Show help
|
||||||
|
zetviel --help
|
||||||
|
|
||||||
|
# Show version
|
||||||
|
zetviel --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
-------------
|
||||||
|
|
||||||
|
- `NOTMUCH_PATH` environment variable: Path to notmuch database (default: `mail`)
|
||||||
|
- `--port`: HTTP server port (default: 5000)
|
||||||
|
|
||||||
|
API Endpoints
|
||||||
|
-------------
|
||||||
|
|
||||||
|
- `GET /api/query/<query>` - Search threads using notmuch query syntax
|
||||||
|
- `GET /api/thread/<thread_id>` - Get messages in a thread
|
||||||
|
- `GET /api/message/<message_id>` - Get message details with content
|
||||||
|
- `GET /api/attachment/<message_id>/<num>` - Get attachment metadata
|
||||||
|
|
||||||
|
Security
|
||||||
|
--------
|
||||||
|
|
||||||
|
**WARNING**: Zetviel is intended for local use only. It binds to 127.0.0.1 and should
|
||||||
|
not be exposed to the internet without additional security measures.
|
||||||
|
|
80
build.zig
80
build.zig
|
@ -19,26 +19,44 @@ pub fn build(b: *std.Build) !void {
|
||||||
// set a preferred release mode, allowing the user to decide how to optimize.
|
// set a preferred release mode, allowing the user to decide how to optimize.
|
||||||
const optimize = b.standardOptimizeOption(.{});
|
const optimize = b.standardOptimizeOption(.{});
|
||||||
|
|
||||||
const lib = b.addStaticLibrary(.{
|
const lib_module = b.createModule(.{
|
||||||
.name = "zetviel",
|
|
||||||
// In this case the main source file is merely a path, however, in more
|
|
||||||
// complicated build scripts, this could be a generated file.
|
|
||||||
.root_source_file = b.path("src/root.zig"),
|
.root_source_file = b.path("src/root.zig"),
|
||||||
.target = target,
|
.target = target,
|
||||||
.optimize = optimize,
|
.optimize = optimize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lib = b.addLibrary(.{
|
||||||
|
.name = "zetviel",
|
||||||
|
.linkage = .static,
|
||||||
|
.root_module = lib_module,
|
||||||
|
});
|
||||||
|
|
||||||
// This declares intent for the library to be installed into the standard
|
// This declares intent for the library to be installed into the standard
|
||||||
// location when the user invokes the "install" step (the default step when
|
// location when the user invokes the "install" step (the default step when
|
||||||
// running `zig build`).
|
// running `zig build`).
|
||||||
b.installArtifact(lib);
|
b.installArtifact(lib);
|
||||||
|
|
||||||
const exe = b.addExecutable(.{
|
const httpz = b.dependency("httpz", .{
|
||||||
.name = "zetviel",
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const git_rev = b.run(&.{ "git", "describe", "--always", "--dirty=*" });
|
||||||
|
const options = b.addOptions();
|
||||||
|
options.addOption([]const u8, "git_revision", git_rev);
|
||||||
|
|
||||||
|
const exe_module = b.createModule(.{
|
||||||
.root_source_file = b.path("src/main.zig"),
|
.root_source_file = b.path("src/main.zig"),
|
||||||
.target = target,
|
.target = target,
|
||||||
.optimize = optimize,
|
.optimize = optimize,
|
||||||
});
|
});
|
||||||
|
exe_module.addImport("httpz", httpz.module("httpz"));
|
||||||
|
exe_module.addImport("build_options", options.createModule());
|
||||||
|
|
||||||
|
const exe = b.addExecutable(.{
|
||||||
|
.name = "zetviel",
|
||||||
|
.root_module = exe_module,
|
||||||
|
});
|
||||||
|
|
||||||
configure(exe, paths, reload_discovered_native_paths);
|
configure(exe, paths, reload_discovered_native_paths);
|
||||||
// This declares intent for the executable to be installed into the
|
// This declares intent for the executable to be installed into the
|
||||||
|
@ -71,20 +89,37 @@ pub fn build(b: *std.Build) !void {
|
||||||
|
|
||||||
// Creates a step for unit testing. This only builds the test executable
|
// Creates a step for unit testing. This only builds the test executable
|
||||||
// but does not run it.
|
// but does not run it.
|
||||||
const lib_unit_tests = b.addTest(.{
|
const lib_test_module = b.createModule(.{
|
||||||
.root_source_file = b.path("src/root.zig"),
|
.root_source_file = b.path("src/root.zig"),
|
||||||
.target = target,
|
.target = target,
|
||||||
.optimize = optimize,
|
.optimize = optimize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lib_unit_tests = b.addTest(.{
|
||||||
|
.root_module = lib_test_module,
|
||||||
|
});
|
||||||
configure(lib_unit_tests, paths, reload_discovered_native_paths);
|
configure(lib_unit_tests, paths, reload_discovered_native_paths);
|
||||||
|
|
||||||
const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests);
|
const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests);
|
||||||
|
|
||||||
const exe_unit_tests = b.addTest(.{
|
const exe_test_module = b.createModule(.{
|
||||||
.root_source_file = b.path("src/main.zig"),
|
.root_source_file = b.path("src/main.zig"),
|
||||||
.target = target,
|
.target = target,
|
||||||
.optimize = optimize,
|
.optimize = optimize,
|
||||||
});
|
});
|
||||||
|
exe_test_module.addImport("httpz", httpz.module("httpz"));
|
||||||
|
|
||||||
|
const exe_unit_tests = b.addTest(.{
|
||||||
|
.root_module = exe_test_module,
|
||||||
|
});
|
||||||
|
const valgrind = b.option(bool, "valgrind", "Check for leaks with valgrind") orelse false;
|
||||||
|
if (valgrind)
|
||||||
|
exe_unit_tests.setExecCmd(&[_]?[]const u8{
|
||||||
|
"valgrind",
|
||||||
|
"--leak-check=full",
|
||||||
|
"--show-leak-kinds=all",
|
||||||
|
null,
|
||||||
|
});
|
||||||
|
|
||||||
configure(exe_unit_tests, paths, reload_discovered_native_paths);
|
configure(exe_unit_tests, paths, reload_discovered_native_paths);
|
||||||
const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
|
const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
|
||||||
|
@ -100,6 +135,7 @@ pub fn build(b: *std.Build) !void {
|
||||||
fn configure(compile: *std.Build.Step.Compile, paths: std.zig.system.NativePaths, reload_paths: bool) void {
|
fn configure(compile: *std.Build.Step.Compile, paths: std.zig.system.NativePaths, reload_paths: bool) void {
|
||||||
compile.linkLibC();
|
compile.linkLibC();
|
||||||
compile.linkSystemLibrary("notmuch");
|
compile.linkSystemLibrary("notmuch");
|
||||||
|
compile.linkSystemLibrary2("gmime-3.0", .{ .use_pkg_config = .force });
|
||||||
|
|
||||||
// These are only needed if we are in nix develop shell
|
// These are only needed if we are in nix develop shell
|
||||||
if (!reload_paths) return;
|
if (!reload_paths) return;
|
||||||
|
@ -109,13 +145,11 @@ fn configure(compile: *std.Build.Step.Compile, paths: std.zig.system.NativePaths
|
||||||
compile.addIncludePath(.{ .cwd_relative = dir });
|
compile.addIncludePath(.{ .cwd_relative = dir });
|
||||||
for (paths.rpaths.items) |dir|
|
for (paths.rpaths.items) |dir|
|
||||||
compile.addRPath(.{ .cwd_relative = dir });
|
compile.addRPath(.{ .cwd_relative = dir });
|
||||||
|
|
||||||
compile.linkSystemLibrary2("gmime-3.0", .{ .use_pkg_config = .force });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn checkNix(b: *std.Build, target_query: *std.Target.Query) !std.zig.system.NativePaths {
|
fn checkNix(b: *std.Build, target_query: *std.Target.Query) !std.zig.system.NativePaths {
|
||||||
const native_result = b.resolveTargetQuery(target_query.*);
|
const native_result = b.resolveTargetQuery(target_query.*);
|
||||||
const paths = try std.zig.system.NativePaths.detect(b.allocator, native_result.result);
|
const paths = try std.zig.system.NativePaths.detect(b.allocator, &native_result.result);
|
||||||
|
|
||||||
// If we are not using nix, we can build anywhere provided the system dependencies exist
|
// If we are not using nix, we can build anywhere provided the system dependencies exist
|
||||||
if (!std.process.hasEnvVarConstant("NIX_BINTOOLS")) return paths;
|
if (!std.process.hasEnvVarConstant("NIX_BINTOOLS")) return paths;
|
||||||
|
@ -173,19 +207,19 @@ fn getDynamicLinker(elf_path: []const u8) !std.Target.DynamicLinker {
|
||||||
return error.FileNotExpectedElf;
|
return error.FileNotExpectedElf;
|
||||||
}
|
}
|
||||||
// Section header table
|
// Section header table
|
||||||
const e_shoff = std.mem.littleToNative(u64, @as(*u64, @ptrFromInt(@intFromPtr(file_contents[0x28 .. 0x29 + 8]))).*); // E8 9D 00 00 00 00 00 00
|
const e_shoff = std.mem.readInt(u64, file_contents[0x28..][0..8], .little); // E8 9D 00 00 00 00 00 00
|
||||||
// Number of sections
|
// Number of sections
|
||||||
const e_shnum = std.mem.littleToNative(u16, @as(*u16, @ptrFromInt(@intFromPtr(file_contents[0x3c .. 0x3d + 2]))).*); // 1d
|
const e_shnum = std.mem.readInt(u16, file_contents[0x3c..][0..2], .little); // 1d
|
||||||
|
|
||||||
// Index of section header that contains section header names
|
// Index of section header that contains section header names
|
||||||
const e_shstrndx = std.mem.littleToNative(u16, @as(*u16, @ptrFromInt(@intFromPtr(file_contents[0x3e .. 0x3f + 2]))).*); // 1c
|
const e_shstrndx = std.mem.readInt(u16, file_contents[0x3e..][0..2], .little); // 1c
|
||||||
// Beginning of section 0x1c (28) that contains header names
|
// Beginning of section 0x1c (28) that contains header names
|
||||||
const e_shstroff = e_shoff + (64 * e_shstrndx); // 0xa4e8
|
const e_shstroff = e_shoff + (64 * e_shstrndx); // 0xa4e8
|
||||||
const shstrtab_contents = file_contents[e_shstroff .. e_shstroff + 1 + (e_shnum * 64)];
|
const shstrtab_contents = file_contents[e_shstroff .. e_shstroff + 1 + (e_shnum * 64)];
|
||||||
// Offset for my set of null terminated strings
|
// Offset for my set of null terminated strings
|
||||||
const shstrtab_sh_offset = std.mem.littleToNative(u64, @as(*u64, @ptrFromInt(@intFromPtr(shstrtab_contents[0x18 .. 0x19 + 8]))).*); // 0x9cec
|
const shstrtab_sh_offset = std.mem.readInt(u64, shstrtab_contents[0x18..][0..8], .little); // 0x9cec
|
||||||
// Total size of section
|
// Total size of section
|
||||||
const shstrtab_sh_size = std.mem.littleToNative(u64, @as(*u64, @ptrFromInt(@intFromPtr(shstrtab_contents[0x20 .. 0x21 + 8]))).*); // 250
|
const shstrtab_sh_size = std.mem.readInt(u64, shstrtab_contents[0x20..][0..8], .little); // 250
|
||||||
// std.debug.print("e_shoff: {x}, e_shstrndx: {x}, e_shstroff: {x}, e_shnum: {x}, shstrtab_sh_offset: {x}, shstrtab_sh_size: {}\n", .{ e_shoff, e_shstrndx, e_shstroff, e_shnum, shstrtab_sh_offset, shstrtab_sh_size });
|
// std.debug.print("e_shoff: {x}, e_shstrndx: {x}, e_shstroff: {x}, e_shnum: {x}, shstrtab_sh_offset: {x}, shstrtab_sh_size: {}\n", .{ e_shoff, e_shstrndx, e_shstroff, e_shnum, shstrtab_sh_offset, shstrtab_sh_size });
|
||||||
const shstrtab_strings = file_contents[shstrtab_sh_offset .. shstrtab_sh_offset + 1 + shstrtab_sh_size];
|
const shstrtab_strings = file_contents[shstrtab_sh_offset .. shstrtab_sh_offset + 1 + shstrtab_sh_size];
|
||||||
var interp: ?[]const u8 = null;
|
var interp: ?[]const u8 = null;
|
||||||
|
@ -193,10 +227,10 @@ fn getDynamicLinker(elf_path: []const u8) !std.Target.DynamicLinker {
|
||||||
// get section offset. Look for type == SHT_PROGBITS, then go fetch name
|
// get section offset. Look for type == SHT_PROGBITS, then go fetch name
|
||||||
const sh_off = e_shoff + (64 * shndx);
|
const sh_off = e_shoff + (64 * shndx);
|
||||||
const sh_contents = file_contents[sh_off .. sh_off + 1 + 64];
|
const sh_contents = file_contents[sh_off .. sh_off + 1 + 64];
|
||||||
const sh_type = std.mem.littleToNative(u16, @as(*u16, @ptrFromInt(@intFromPtr(sh_contents[0x04 .. 0x05 + 2]))).*);
|
const sh_type = std.mem.readInt(u16, sh_contents[0x04..][0..2], .little);
|
||||||
if (sh_type != 0x01) continue;
|
if (sh_type != 0x01) continue;
|
||||||
// This is an offset to the null terminated string in our string content
|
// This is an offset to the null terminated string in our string content
|
||||||
const sh_name_offset = std.mem.littleToNative(u16, @as(*u16, @ptrFromInt(@intFromPtr(sh_contents[0x00 .. 0x01 + 2]))).*);
|
const sh_name_offset = std.mem.readInt(u16, sh_contents[0x00..][0..2], .little);
|
||||||
const sentinel = std.mem.indexOfScalar(u8, shstrtab_strings[sh_name_offset..], 0);
|
const sentinel = std.mem.indexOfScalar(u8, shstrtab_strings[sh_name_offset..], 0);
|
||||||
if (sentinel == null) {
|
if (sentinel == null) {
|
||||||
std.log.err("Invalid ELF file", .{});
|
std.log.err("Invalid ELF file", .{});
|
||||||
|
@ -206,8 +240,8 @@ fn getDynamicLinker(elf_path: []const u8) !std.Target.DynamicLinker {
|
||||||
// std.debug.print("section name: {s}\n", .{sh_name});
|
// std.debug.print("section name: {s}\n", .{sh_name});
|
||||||
if (std.mem.eql(u8, ".interp", sh_name)) {
|
if (std.mem.eql(u8, ".interp", sh_name)) {
|
||||||
// found interpreter
|
// found interpreter
|
||||||
const interp_offset = std.mem.littleToNative(u64, @as(*u64, @ptrFromInt(@intFromPtr(sh_contents[0x18 .. 0x19 + 8]))).*); // 0x9218
|
const interp_offset = std.mem.readInt(u64, sh_contents[0x18..][0..8], .little); // 0x9218
|
||||||
const interp_size = std.mem.littleToNative(u64, @as(*u64, @ptrFromInt(@intFromPtr(sh_contents[0x20 .. 0x21 + 8]))).*); // 2772
|
const interp_size = std.mem.readInt(u64, sh_contents[0x20..][0..8], .little); // 2772
|
||||||
// std.debug.print("Found interpreter at {x}, size: {}\n", .{ interp_offset, interp_size });
|
// std.debug.print("Found interpreter at {x}, size: {}\n", .{ interp_offset, interp_size });
|
||||||
interp = file_contents[interp_offset .. interp_offset + interp_size];
|
interp = file_contents[interp_offset .. interp_offset + interp_size];
|
||||||
// std.debug.print("Interp: {s}\n", .{interp});
|
// std.debug.print("Interp: {s}\n", .{interp});
|
||||||
|
@ -218,7 +252,11 @@ fn getDynamicLinker(elf_path: []const u8) !std.Target.DynamicLinker {
|
||||||
return error.CouldNotLocateInterpreter;
|
return error.CouldNotLocateInterpreter;
|
||||||
}
|
}
|
||||||
|
|
||||||
var dl = std.Target.DynamicLinker{ .buffer = undefined, .len = 0 };
|
// SAFETY: buffer is set in shortly in dl.set() call
|
||||||
|
var dl = std.Target.DynamicLinker{
|
||||||
|
.buffer = undefined,
|
||||||
|
.len = 0,
|
||||||
|
};
|
||||||
// The .interp section contains a null-terminated string, so we need to trim the null terminator
|
// The .interp section contains a null-terminated string, so we need to trim the null terminator
|
||||||
const trimmed_interp = std.mem.trimRight(u8, interp.?, &[_]u8{0});
|
const trimmed_interp = std.mem.trimRight(u8, interp.?, &[_]u8{0});
|
||||||
dl.set(trimmed_interp);
|
dl.set(trimmed_interp);
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
// 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
|
||||||
// with this value.
|
// with this value.
|
||||||
.minimum_zig_version = "0.14.0",
|
.minimum_zig_version = "0.15.2",
|
||||||
|
|
||||||
.fingerprint = 0xd4c335836acc5e4e,
|
.fingerprint = 0xd4c335836acc5e4e,
|
||||||
|
|
||||||
|
@ -17,42 +17,11 @@
|
||||||
// Once all dependencies are fetched, `zig build` no longer requires
|
// Once all dependencies are fetched, `zig build` no longer requires
|
||||||
// internet connectivity.
|
// internet connectivity.
|
||||||
.dependencies = .{
|
.dependencies = .{
|
||||||
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
|
.httpz = .{
|
||||||
//.example = .{
|
.url = "git+https://github.com/karlseguin/http.zig?ref=master#5e5ab5f82477252fd85943bcb33db483bde6de86",
|
||||||
// // When updating this field to a new URL, be sure to delete the corresponding
|
.hash = "httpz-0.0.0-PNVzrLndBgBwKYPO0v3OFD-6741_9uKdWtU27sil2-df",
|
||||||
// // `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,
|
|
||||||
//},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 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 = .{
|
.paths = .{
|
||||||
// This makes *all* files, recursively, included in this package. It is generally
|
// This makes *all* files, recursively, included in this package. It is generally
|
||||||
// better to explicitly list the files and directories instead, to insure that
|
// better to explicitly list the files and directories instead, to insure that
|
||||||
|
|
79
mail/Inbox/cur/attachmentmcattachface.msg
Normal file
79
mail/Inbox/cur/attachmentmcattachface.msg
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
Return-Path: <lobo@lerch.org>
|
||||||
|
Delivered-To: lobo@lerch.org
|
||||||
|
Received: from mail.eler.ch
|
||||||
|
by mail.eler.ch with LMTP
|
||||||
|
id mLU6K98I8GhCBwAAyA9pPg
|
||||||
|
(envelope-from <lobo@lerch.org>)
|
||||||
|
for <lobo@lerch.org>; Wed, 15 Oct 2025 20:49:35 +0000
|
||||||
|
Date: Wed, 15 Oct 2025 13:48:55 -0700
|
||||||
|
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=lerch.org; s=2023;
|
||||||
|
t=1760561375;
|
||||||
|
h=from:from:reply-to:subject:subject:date:date:message-id:message-id:
|
||||||
|
to:to:cc:mime-version:mime-version:content-type:content-type;
|
||||||
|
bh=fK/8hI6FilkYWKOrKBjauM3coZWekxJWs7kueYzsykk=;
|
||||||
|
b=ARh96zUwRhAoTOSvAwdf1758NnPijJZY1UH5umSuZvqLwQKTZpNUpgKOwqg1S84gcbVLCQ
|
||||||
|
g9+6B3FS3EFfRcVwBCQ7AOX5SezMSeMZUWh0lXOA5COVpiTSsn5ZJrDqKo/4gn9DlZMNY2
|
||||||
|
DKfCW19OcTScS5GqJ0X0cwiPhJVcC6XJccVUUPESm4eRMndu4cyRL9PesIPnUBM4bX3vZE
|
||||||
|
r84srk5AaDd8R526x1qgZTK0ModoH/UrOEbOBgon/OKklnBelOaEwmMUlQszxV8dqCscJB
|
||||||
|
bGNSYVPVFbDbzeqr47hQ4bGGpvvBqnNpvi/ZUAhMObqSUK6fm68ZTPwghm0SVA==
|
||||||
|
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=lerch.org; s=2023;
|
||||||
|
t=1760561374;
|
||||||
|
h=from:from:reply-to:subject:subject:date:date:message-id:message-id:
|
||||||
|
to:to:cc:mime-version:mime-version:content-type:content-type;
|
||||||
|
bh=fK/8hI6FilkYWKOrKBjauM3coZWekxJWs7kueYzsykk=;
|
||||||
|
b=gXz65hYYxys2vqNPzcCxCgPtKewLwSmnSkZGwnsnNIU7/AxQSMKY3w+Q5ZelzK0ApAARqn
|
||||||
|
CvLpCajueXfrYyMcM3nMY/vysCTbpFAejCMzkOxQkEZ7XkioZx/o5PChmsBcrvB7MHXqSo
|
||||||
|
mQBKxA3JpPTq4s13xmFwiAZxmxsOV0Ibddzn7OylrMTn5h8yMAtXs6bwIfyXnZLLLGiQWz
|
||||||
|
oFrzFBr+9anwKsLLgT4LGMoRLrp9sLAmgf6c5WhuLxbqR2Khlxma3t+6MeW1yliqwgs7C2
|
||||||
|
elut2+rvcLL0jVQeMD4ABjU4DeTyZHvqlZDiJRAKJ0e0wWpcWYU0/rSS5X0PuA==
|
||||||
|
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=lerch.org; s=2023;
|
||||||
|
t=1760561373;
|
||||||
|
h=from:from:reply-to:subject:subject:date:date:message-id:message-id:
|
||||||
|
to:to:cc:mime-version:mime-version:content-type:content-type;
|
||||||
|
bh=fK/8hI6FilkYWKOrKBjauM3coZWekxJWs7kueYzsykk=;
|
||||||
|
b=CkLb+hn4F61VPfutWVY6WrtNhUzLTyt0ek7eQw2uOqjvdsyctLEGSEotbaAlE2O7fygSBi
|
||||||
|
RZ0xdxbEkwvmGR4BhBXDLopVJqDwOgcE3tmyZTmSNchGne8kKZrfnffWUxMXybLfbGjRrO
|
||||||
|
W8mkDsrtGKESv2W/VrKhzb8LU0JS8LNMFBr6C4yaVxHYeljbzTLMAonjLpe4G/TSRdqJ/3
|
||||||
|
n9sVk3S3icP0KPPPqiT1Qf4eZDR5A9kfe8ck9aKdG3SAAx/6RhOSi73UVvp/5MGROW0Nm8
|
||||||
|
VWfgM/S2MwZwN327BMWPlh9lWEYGJmz6HkxZriSgzpA4g22ATWXjv8AAHPhFnA==
|
||||||
|
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=lerch.org; s=2023;
|
||||||
|
t=1760561371;
|
||||||
|
h=from:from:reply-to:subject:subject:date:date:message-id:message-id:
|
||||||
|
to:to:cc:mime-version:mime-version:content-type:content-type;
|
||||||
|
bh=fK/8hI6FilkYWKOrKBjauM3coZWekxJWs7kueYzsykk=;
|
||||||
|
b=eew4LpLzaFedigVWolli8Sc68l86F2IrsYnHfJ5INqv25iI4gkS935Zlg6kKMPzA090zNX
|
||||||
|
Ne0QnLHb0zK8AXKBLVhvb7hCIdU1fACx+p+K6UhDc6uIQrqUo3Mesdin+XISTa7hvhaTWn
|
||||||
|
vWpMsuPyu1+fBN8JMjpu/Fa0XWxhTH8eB5fDi3G/gDxoxRm/visTlSXURq8FcM8Z7wKW6O
|
||||||
|
TM6cFrk5KP2luxkt+GItWdPKU5D/fMLcu4bTc3aWXQ9WJONpUlMD7Fr9c8btat5VnX/lxh
|
||||||
|
F+3dHyiU9a+/H6H/0eFPBzCauMWU7Jl8wStOJMecxGtv+Rw0bl6qTyElTSPtbA==
|
||||||
|
From: Emil Lerch <lobo@lerch.org>
|
||||||
|
To: lobo@lerch.org
|
||||||
|
Subject: Attachment
|
||||||
|
Message-ID: <4n4wn5ogmkpcboi2ipxj3wnt5iqe6rcb5bv5jvtseya7k2yqmi@7drgod2d766o>
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/mixed; boundary="6ecwttx4c7nftwnw"
|
||||||
|
Content-Disposition: inline
|
||||||
|
|
||||||
|
|
||||||
|
--6ecwttx4c7nftwnw
|
||||||
|
Content-Type: text/plain; charset=us-ascii
|
||||||
|
Content-Disposition: inline
|
||||||
|
|
||||||
|
Attached is an attachment for attachment needs. Please see attached attachment.
|
||||||
|
|
||||||
|
--6ecwttx4c7nftwnw
|
||||||
|
Content-Type: text/plain; charset=us-ascii
|
||||||
|
Content-Disposition: attachment; filename="a.txt"
|
||||||
|
|
||||||
|
xwininfo: Window id: 0x1eb (the root window) "i3"
|
||||||
|
|
||||||
|
Root window id: 0x1eb (the root window) "i3"
|
||||||
|
Parent window id: 0x0 (none)
|
||||||
|
154 children:
|
||||||
|
0x600071b "CanvasBlocker": ("Firefox" "LibreWolf") 127x47+2378+89 +2378+89
|
||||||
|
2 children:
|
||||||
|
0x6000a12 (has no name): () 127x47+0+0 +2378+89
|
||||||
|
0x600071c (has no name): () 1x1+-1+-1 +2377+88
|
||||||
|
0x60004bf "Firefox": ("Firefox" "LibreWolf") 298x402+2113+801 +2113+801
|
||||||
|
|
||||||
|
--6ecwttx4c7nftwnw--
|
288
src/Email.zig
288
src/Email.zig
|
@ -1,5 +1,6 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const gmime = @import("c.zig").c;
|
const gmime = @import("c.zig").c;
|
||||||
|
const textTransformation = @import("textTransformation.zig");
|
||||||
|
|
||||||
const Self = @This();
|
const Self = @This();
|
||||||
|
|
||||||
|
@ -29,17 +30,17 @@ pub fn openMessage(self: *Self, filename: [:0]const u8) !Message {
|
||||||
// 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
|
||||||
return error.FileOpenFailed;
|
return error.FileOpenFailed;
|
||||||
|
defer gmime.g_object_unref(stream);
|
||||||
|
|
||||||
// Create a parser for the stream
|
// Create a parser for the stream
|
||||||
const parser = gmime.g_mime_parser_new_with_stream(stream) orelse
|
const parser = gmime.g_mime_parser_new_with_stream(stream) orelse
|
||||||
return error.ParserCreationFailed;
|
return error.ParserCreationFailed;
|
||||||
gmime.g_object_unref(stream);
|
defer gmime.g_object_unref(parser);
|
||||||
|
|
||||||
// Parse the message
|
// Parse the message
|
||||||
const message = gmime.g_mime_parser_construct_message(parser, null) orelse
|
const message = gmime.g_mime_parser_construct_message(parser, null) orelse
|
||||||
return error.MessageParsingFailed;
|
return error.MessageParsingFailed;
|
||||||
|
|
||||||
gmime.g_object_unref(parser);
|
|
||||||
return .{
|
return .{
|
||||||
.filename = filename,
|
.filename = filename,
|
||||||
.message = message,
|
.message = message,
|
||||||
|
@ -113,11 +114,13 @@ pub const Message = struct {
|
||||||
|
|
||||||
// Helper function to find HTML content in a multipart container
|
// Helper function to find HTML content in a multipart container
|
||||||
fn findHtmlInMultipart(multipart: *gmime.GMimeMultipart, allocator: std.mem.Allocator) !?[]const u8 {
|
fn findHtmlInMultipart(multipart: *gmime.GMimeMultipart, allocator: std.mem.Allocator) !?[]const u8 {
|
||||||
const count = gmime.g_mime_multipart_get_count(multipart);
|
const mpgc = gmime.g_mime_multipart_get_count(multipart);
|
||||||
|
if (mpgc == -1) return error.NoMultipartCount;
|
||||||
|
const count: usize = @intCast(mpgc);
|
||||||
|
|
||||||
|
// std.debug.print("\n\nCount: {}\n", .{count});
|
||||||
// Look for HTML part first (preferred in multipart/alternative)
|
// Look for HTML part first (preferred in multipart/alternative)
|
||||||
var i: usize = 0;
|
for (0..count) |i| {
|
||||||
while (i < count) : (i += 1) {
|
|
||||||
const part = gmime.g_mime_multipart_get_part(multipart, @intCast(i));
|
const part = gmime.g_mime_multipart_get_part(multipart, @intCast(i));
|
||||||
if (part == null) continue;
|
if (part == null) continue;
|
||||||
|
|
||||||
|
@ -126,19 +129,22 @@ pub const Message = struct {
|
||||||
|
|
||||||
const part_mime_type = gmime.g_mime_content_type_get_mime_type(part_content_type);
|
const part_mime_type = gmime.g_mime_content_type_get_mime_type(part_content_type);
|
||||||
if (part_mime_type == null) continue;
|
if (part_mime_type == null) continue;
|
||||||
|
defer gmime.g_free(part_mime_type);
|
||||||
|
// std.debug.print("Mime type: {s}\n", .{std.mem.span(part_mime_type)});
|
||||||
|
|
||||||
const part_mime_subtype = gmime.g_mime_content_type_get_media_subtype(part_content_type);
|
// subtype is "html", but mime type is "text/html", so we don't need this
|
||||||
if (part_mime_subtype == null) continue;
|
// const part_mime_subtype = gmime.g_mime_content_type_get_media_subtype(part_content_type);
|
||||||
|
// if (part_mime_subtype == null) continue;
|
||||||
|
// std.debug.print("Media subtype type: {s}\n", .{std.mem.span(part_mime_subtype)});
|
||||||
|
|
||||||
// Check if this is text/html
|
// Check if this is text/html
|
||||||
if (std.mem.eql(u8, std.mem.span(part_mime_type), "text") and
|
if (std.mem.eql(u8, std.mem.span(part_mime_type), "text/html")) {
|
||||||
std.mem.eql(u8, std.mem.span(part_mime_subtype), "html"))
|
|
||||||
{
|
|
||||||
// Try to get the text content
|
// Try to get the text content
|
||||||
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(part)), gmime.g_mime_text_part_get_type()) != 0) {
|
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(part)), gmime.g_mime_text_part_get_type()) != 0) {
|
||||||
const text_part = @as(*gmime.GMimeTextPart, @ptrCast(part));
|
const text_part: *gmime.GMimeTextPart = @ptrCast(part);
|
||||||
const text = gmime.g_mime_text_part_get_text(text_part);
|
const text = gmime.g_mime_text_part_get_text(text_part);
|
||||||
if (text != null) {
|
if (text != null) {
|
||||||
|
defer gmime.g_free(text);
|
||||||
return try allocator.dupe(u8, std.mem.span(text));
|
return try allocator.dupe(u8, std.mem.span(text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -146,19 +152,61 @@ pub const Message = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no HTML found, check for nested multiparts (like multipart/related inside multipart/alternative)
|
// If no HTML found, check for nested multiparts (like multipart/related inside multipart/alternative)
|
||||||
i = 0;
|
// TODO: Test this code path
|
||||||
while (i < count) : (i += 1) {
|
for (0..count) |i| {
|
||||||
const part = gmime.g_mime_multipart_get_part(multipart, @intCast(i));
|
const part = gmime.g_mime_multipart_get_part(multipart, @intCast(i));
|
||||||
if (part == null) continue;
|
if (part == null) continue;
|
||||||
|
|
||||||
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(part)), gmime.g_mime_multipart_get_type()) != 0) {
|
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(part)), gmime.g_mime_multipart_get_type()) != 0) {
|
||||||
const nested_multipart = @as(*gmime.GMimeMultipart, @ptrCast(part));
|
const nested_multipart: *gmime.GMimeMultipart = @ptrCast(part);
|
||||||
if (try findHtmlInMultipart(nested_multipart, allocator)) |content| {
|
if (try findHtmlInMultipart(nested_multipart, allocator)) |content|
|
||||||
return content;
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std.log.debug("No HTML Multipart found", .{});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn findTextInMultipart(multipart: *gmime.GMimeMultipart, allocator: std.mem.Allocator) !?[]const u8 {
|
||||||
|
const mpgc = gmime.g_mime_multipart_get_count(multipart);
|
||||||
|
if (mpgc == -1) return error.NoMultipartCount;
|
||||||
|
const count: usize = @intCast(mpgc);
|
||||||
|
|
||||||
|
for (0..count) |i| {
|
||||||
|
const part = gmime.g_mime_multipart_get_part(multipart, @intCast(i));
|
||||||
|
if (part == null) continue;
|
||||||
|
|
||||||
|
const part_content_type = gmime.g_mime_object_get_content_type(part);
|
||||||
|
if (part_content_type == null) continue;
|
||||||
|
|
||||||
|
const part_mime_type = gmime.g_mime_content_type_get_mime_type(part_content_type);
|
||||||
|
if (part_mime_type == null) continue;
|
||||||
|
defer gmime.g_free(part_mime_type);
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, std.mem.span(part_mime_type), "text/plain")) {
|
||||||
|
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(part)), gmime.g_mime_text_part_get_type()) != 0) {
|
||||||
|
const text_part: *gmime.GMimeTextPart = @ptrCast(part);
|
||||||
|
const text = gmime.g_mime_text_part_get_text(text_part);
|
||||||
|
if (text != null) {
|
||||||
|
defer gmime.g_free(text);
|
||||||
|
return try allocator.dupe(u8, std.mem.span(text));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (0..count) |i| {
|
||||||
|
const part = gmime.g_mime_multipart_get_part(multipart, @intCast(i));
|
||||||
|
if (part == null) continue;
|
||||||
|
|
||||||
|
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(part)), gmime.g_mime_multipart_get_type()) != 0) {
|
||||||
|
const nested_multipart: *gmime.GMimeMultipart = @ptrCast(part);
|
||||||
|
if (try findTextInMultipart(nested_multipart, allocator)) |content|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -169,22 +217,23 @@ pub const Message = struct {
|
||||||
|
|
||||||
// Check if it's a multipart message
|
// Check if it's a multipart message
|
||||||
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(body)), gmime.g_mime_multipart_get_type()) != 0) {
|
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(body)), gmime.g_mime_multipart_get_type()) != 0) {
|
||||||
const multipart = @as(*gmime.GMimeMultipart, @ptrCast(body));
|
const multipart: *gmime.GMimeMultipart = @ptrCast(body);
|
||||||
|
|
||||||
// Try to find HTML content in the multipart
|
// Try to find HTML content in the multipart
|
||||||
if (try findHtmlInMultipart(multipart, allocator)) |html_content| {
|
if (try findHtmlInMultipart(multipart, allocator)) |html_content| {
|
||||||
// Trim trailing whitespace and newlines to match expected format
|
// Trim trailing whitespace and newlines to match expected format
|
||||||
return std.mem.trimRight(u8, html_content, " \t\r\n");
|
return html_content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's not multipart or we didn't find HTML, check if it's a single text part
|
// If it's not multipart or we didn't find HTML, check if it's a single text part
|
||||||
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(body)), gmime.g_mime_text_part_get_type()) != 0) {
|
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(body)), gmime.g_mime_text_part_get_type()) != 0) {
|
||||||
const text_part = @as(*gmime.GMimeTextPart, @ptrCast(body));
|
const text_part: *gmime.GMimeTextPart = @ptrCast(body);
|
||||||
const text = gmime.g_mime_text_part_get_text(text_part);
|
const text = gmime.g_mime_text_part_get_text(text_part);
|
||||||
if (text != null) {
|
if (text != null) {
|
||||||
|
defer gmime.g_free(text);
|
||||||
const content = try allocator.dupe(u8, std.mem.span(text));
|
const content = try allocator.dupe(u8, std.mem.span(text));
|
||||||
return std.mem.trimRight(u8, content, " \t\r\n");
|
return content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -193,8 +242,171 @@ pub const Message = struct {
|
||||||
if (body_string == null) return error.BodyConversionFailed;
|
if (body_string == null) return error.BodyConversionFailed;
|
||||||
|
|
||||||
defer gmime.g_free(body_string);
|
defer gmime.g_free(body_string);
|
||||||
const content = try allocator.dupe(u8, std.mem.span(body_string));
|
return try allocator.dupe(u8, std.mem.span(body_string));
|
||||||
return std.mem.trimRight(u8, content, " \t\r\n");
|
}
|
||||||
|
|
||||||
|
pub fn getContent(self: Message, allocator: std.mem.Allocator) !struct { content: []const u8, content_type: []const u8 } {
|
||||||
|
const body = gmime.g_mime_message_get_body(self.message);
|
||||||
|
if (body == null) return error.NoMessageBody;
|
||||||
|
|
||||||
|
// Check if it's a multipart message
|
||||||
|
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(body)), gmime.g_mime_multipart_get_type()) != 0) {
|
||||||
|
const multipart: *gmime.GMimeMultipart = @ptrCast(body);
|
||||||
|
if (try findHtmlInMultipart(multipart, allocator)) |html_content| {
|
||||||
|
return .{ .content = html_content, .content_type = "text/html" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a single text part
|
||||||
|
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(body)), gmime.g_mime_text_part_get_type()) != 0) {
|
||||||
|
const text_part: *gmime.GMimeTextPart = @ptrCast(body);
|
||||||
|
const text = gmime.g_mime_text_part_get_text(text_part);
|
||||||
|
if (text != null) {
|
||||||
|
defer gmime.g_free(text);
|
||||||
|
const content = try allocator.dupe(u8, std.mem.span(text));
|
||||||
|
const content_type_obj = gmime.g_mime_object_get_content_type(body);
|
||||||
|
const mime_type = if (content_type_obj != null)
|
||||||
|
gmime.g_mime_content_type_get_mime_type(content_type_obj)
|
||||||
|
else
|
||||||
|
null;
|
||||||
|
const ct = if (mime_type != null) std.mem.span(mime_type) else "text/plain";
|
||||||
|
return .{ .content = content, .content_type = ct };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.NoTextContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn getTextAndHtmlBodyVersions(self: Message, allocator: std.mem.Allocator) !struct { text: []const u8, html: []const u8 } {
|
||||||
|
const body = gmime.g_mime_message_get_body(self.message);
|
||||||
|
if (body == null) return error.NoMessageBody;
|
||||||
|
|
||||||
|
var text_content: ?[]const u8 = null;
|
||||||
|
var html_content: ?[]const u8 = null;
|
||||||
|
|
||||||
|
// Check if it's a multipart message
|
||||||
|
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(body)), gmime.g_mime_multipart_get_type()) != 0) {
|
||||||
|
const multipart: *gmime.GMimeMultipart = @ptrCast(body);
|
||||||
|
text_content = try findTextInMultipart(multipart, allocator);
|
||||||
|
html_content = try findHtmlInMultipart(multipart, allocator);
|
||||||
|
} else if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(body)), gmime.g_mime_text_part_get_type()) != 0) {
|
||||||
|
const text_part: *gmime.GMimeTextPart = @ptrCast(body);
|
||||||
|
const text = gmime.g_mime_text_part_get_text(text_part);
|
||||||
|
if (text != null) {
|
||||||
|
defer gmime.g_free(text);
|
||||||
|
const content_type_obj = gmime.g_mime_object_get_content_type(body);
|
||||||
|
const mime_type = if (content_type_obj != null)
|
||||||
|
gmime.g_mime_content_type_get_mime_type(content_type_obj)
|
||||||
|
else
|
||||||
|
null;
|
||||||
|
const ct = if (mime_type != null) std.mem.span(mime_type) else "text/plain";
|
||||||
|
const content = try allocator.dupe(u8, std.mem.span(text));
|
||||||
|
if (std.mem.eql(u8, ct, "text/html")) {
|
||||||
|
html_content = content;
|
||||||
|
} else {
|
||||||
|
text_content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have both text and html versions
|
||||||
|
if (text_content == null and html_content != null) {
|
||||||
|
text_content = try textTransformation.htmlToText(allocator, html_content.?);
|
||||||
|
}
|
||||||
|
if (html_content == null and text_content != null) {
|
||||||
|
html_content = try std.fmt.allocPrint(allocator,
|
||||||
|
\\<html>
|
||||||
|
\\<head><title>No HTML version available</title></head>
|
||||||
|
\\<body>No HTML version available. Text is:<br><pre>{s}</pre></body>
|
||||||
|
\\</html>
|
||||||
|
, .{text_content.?});
|
||||||
|
}
|
||||||
|
|
||||||
|
var final_text = text_content orelse try allocator.dupe(u8, "no text or html versions available");
|
||||||
|
|
||||||
|
// If text is empty (e.g., HTML with only images without alt tags), provide fallback
|
||||||
|
if (final_text.len == 0) {
|
||||||
|
allocator.free(final_text);
|
||||||
|
final_text = try allocator.dupe(u8, "Message contains only image data without alt tags");
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.text = final_text,
|
||||||
|
.html = html_content orelse try allocator.dupe(u8,
|
||||||
|
\\<html>
|
||||||
|
\\<head><title>No text or HTML version available</title></head>
|
||||||
|
\\<body>No text or HTML versions available</body>
|
||||||
|
\\</html>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn getHeader(self: Message, name: []const u8) ?[]const u8 {
|
||||||
|
const name_z = std.mem.sliceTo(name, 0);
|
||||||
|
const header = gmime.g_mime_message_get_header(self.message, name_z.ptr);
|
||||||
|
if (header == null) return null;
|
||||||
|
return std.mem.span(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const AttachmentInfo = struct {
|
||||||
|
filename: []const u8,
|
||||||
|
content_type: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn getAttachments(self: Message, allocator: std.mem.Allocator) ![]AttachmentInfo {
|
||||||
|
var list = std.ArrayList(AttachmentInfo){};
|
||||||
|
defer list.deinit(allocator);
|
||||||
|
|
||||||
|
// Get the MIME part from the message (not just the body)
|
||||||
|
const mime_part = gmime.g_mime_message_get_mime_part(self.message);
|
||||||
|
if (mime_part == null) return try allocator.dupe(AttachmentInfo, &.{});
|
||||||
|
|
||||||
|
try collectAttachments(mime_part, &list, allocator);
|
||||||
|
return list.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collectAttachments(part: *gmime.GMimeObject, list: *std.ArrayList(AttachmentInfo), allocator: std.mem.Allocator) !void {
|
||||||
|
// Check if this is a multipart
|
||||||
|
if (gmime.g_type_check_instance_is_a(@as(*gmime.GTypeInstance, @ptrCast(part)), gmime.g_mime_multipart_get_type()) != 0) {
|
||||||
|
const multipart: *gmime.GMimeMultipart = @ptrCast(part);
|
||||||
|
const count_i = gmime.g_mime_multipart_get_count(multipart);
|
||||||
|
if (count_i == -1) return;
|
||||||
|
const count: usize = @intCast(count_i);
|
||||||
|
|
||||||
|
for (0..count) |i| {
|
||||||
|
const subpart = gmime.g_mime_multipart_get_part(multipart, @intCast(i));
|
||||||
|
if (subpart != null) {
|
||||||
|
try collectAttachments(subpart, list, allocator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this part is an attachment
|
||||||
|
const disposition = gmime.g_mime_object_get_content_disposition(part);
|
||||||
|
if (disposition != null) {
|
||||||
|
const disp_str = gmime.g_mime_content_disposition_get_disposition(disposition);
|
||||||
|
if (disp_str != null and (std.mem.eql(u8, std.mem.span(disp_str), "attachment") or
|
||||||
|
std.mem.eql(u8, std.mem.span(disp_str), "inline")))
|
||||||
|
{
|
||||||
|
const filename_c = gmime.g_mime_part_get_filename(@as(*gmime.GMimePart, @ptrCast(part)));
|
||||||
|
if (filename_c != null) {
|
||||||
|
const content_type_obj = gmime.g_mime_object_get_content_type(part);
|
||||||
|
const mime_type = if (content_type_obj != null)
|
||||||
|
gmime.g_mime_content_type_get_mime_type(content_type_obj)
|
||||||
|
else
|
||||||
|
null;
|
||||||
|
|
||||||
|
try list.append(allocator, .{
|
||||||
|
.filename = try allocator.dupe(u8, std.mem.span(filename_c)),
|
||||||
|
.content_type = if (mime_type != null)
|
||||||
|
try allocator.dupe(u8, std.mem.span(mime_type))
|
||||||
|
else
|
||||||
|
try allocator.dupe(u8, "application/octet-stream"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -219,7 +431,7 @@ test "read raw body of message" {
|
||||||
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
\\</head>
|
\\</head>
|
||||||
\\<body><a href="https://unmaskfauci.com/assets/images/chw.php"><img src="https://imgpx.com/dfE6oYsvHoYw.png"></a> <div><img width=1 height=1 alt="" src="https://vnevent.net/wp-content/plugins/wp-automatic/awe.php?QFYiTaVCm0ogM30sC5RNRb%2FKLO0%2FqO3iN9A89RgPbrGjPGsdVierqrtB7w8mnIqJugBVA5TZVG%2F6MFLMOrK9z4D6vgFBDRgH88%2FpEmohBbpaSFf4wx1l9S4LGJd87EK6"></div></body></html>
|
\\<body><a href="https://unmaskfauci.com/assets/images/chw.php"><img src="https://imgpx.com/dfE6oYsvHoYw.png"></a> <div><img width=1 height=1 alt="" src="https://vnevent.net/wp-content/plugins/wp-automatic/awe.php?QFYiTaVCm0ogM30sC5RNRb%2FKLO0%2FqO3iN9A89RgPbrGjPGsdVierqrtB7w8mnIqJugBVA5TZVG%2F6MFLMOrK9z4D6vgFBDRgH88%2FpEmohBbpaSFf4wx1l9S4LGJd87EK6"></div></body></html>
|
||||||
, body);
|
, std.mem.trimRight(u8, body, "\r\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "can get body from multipart/alternative html preferred" {
|
test "can get body from multipart/alternative html preferred" {
|
||||||
|
@ -232,11 +444,41 @@ test "can get body from multipart/alternative html preferred" {
|
||||||
defer msg.deinit();
|
defer msg.deinit();
|
||||||
const body = try msg.rawBody(allocator);
|
const body = try msg.rawBody(allocator);
|
||||||
defer allocator.free(body);
|
defer allocator.free(body);
|
||||||
|
const b = "hi";
|
||||||
|
_ = b;
|
||||||
try std.testing.expectEqualStrings(
|
try std.testing.expectEqualStrings(
|
||||||
\\<html>
|
\\<html>
|
||||||
\\<head>
|
\\<head>
|
||||||
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
\\</head>
|
\\</head>
|
||||||
\\<body><a href="https://unmaskfauci.com/assets/images/chw.php"><img src="https://imgpx.com/dfE6oYsvHoYw.png"></a> <div><img width=1 height=1 alt="" src="https://vnevent.net/wp-content/plugins/wp-automatic/awe.php?QFYiTaVCm0ogM30sC5RNRb%2FKLO0%2FqO3iN9A89RgPbrGjPGsdVierqrtB7w8mnIqJugBVA5TZVG%2F6MFLMOrK9z4D6vgFBDRgH88%2FpEmohBbpaSFf4wx1l9S4LGJd87EK6"></div></body></html>
|
\\<body><a href="https://unmaskfauci.com/assets/images/chw.php"><img src="https://imgpx.com/dfE6oYsvHoYw.png"></a> <div><img width=1 height=1 alt="" src="https://vnevent.net/wp-content/plugins/wp-automatic/awe.php?QFYiTaVCm0ogM30sC5RNRb%2FKLO0%2FqO3iN9A89RgPbrGjPGsdVierqrtB7w8mnIqJugBVA5TZVG%2F6MFLMOrK9z4D6vgFBDRgH88%2FpEmohBbpaSFf4wx1l9S4LGJd87EK6"></div></body></html>
|
||||||
, body);
|
, std.mem.trimRight(u8, body, "\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "can parse attachments" {
|
||||||
|
var engine = Self.init();
|
||||||
|
defer engine.deinit();
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
|
var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||||
|
const cwd = try std.fs.cwd().realpath(".", cwd_buf[0..]);
|
||||||
|
const attachment_path = try std.fs.path.joinZ(allocator, &[_][]const u8{ cwd, "mail", "Inbox", "cur", "attachmentmcattachface.msg" });
|
||||||
|
defer allocator.free(attachment_path);
|
||||||
|
|
||||||
|
const msg = try engine.openMessage(attachment_path);
|
||||||
|
defer msg.deinit();
|
||||||
|
|
||||||
|
const attachments = try msg.getAttachments(allocator);
|
||||||
|
defer {
|
||||||
|
for (attachments) |att| {
|
||||||
|
allocator.free(att.filename);
|
||||||
|
allocator.free(att.content_type);
|
||||||
|
}
|
||||||
|
allocator.free(attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have one attachment
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), attachments.len);
|
||||||
|
try std.testing.expectEqualStrings("a.txt", attachments[0].filename);
|
||||||
|
try std.testing.expectEqualStrings("text/plain", attachments[0].content_type);
|
||||||
}
|
}
|
||||||
|
|
319
src/main.zig
319
src/main.zig
|
@ -1,28 +1,315 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const httpz = @import("httpz");
|
||||||
const root = @import("root.zig");
|
const root = @import("root.zig");
|
||||||
|
|
||||||
|
const version = @import("build_options").git_revision;
|
||||||
|
|
||||||
pub fn main() !void {
|
pub fn main() !void {
|
||||||
// Prints to stderr (it's a shortcut based on `std.io.getStdErr()`)
|
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||||
std.debug.print("All your {s} are belong to us. \n", .{"codebase"});
|
defer _ = gpa.deinit();
|
||||||
|
const allocator = gpa.allocator();
|
||||||
|
|
||||||
// stdout is for the actual output of your application, for example if you
|
// Parse CLI arguments
|
||||||
// are implementing gzip, then only the compressed bytes should be sent to
|
var port: u16 = 5000;
|
||||||
// stdout, not any debugging messages.
|
var args = try std.process.argsWithAllocator(allocator);
|
||||||
const stdout_file = std.io.getStdOut().writer();
|
defer args.deinit();
|
||||||
var bw = std.io.bufferedWriter(stdout_file);
|
_ = args.skip(); // skip program name
|
||||||
const stdout = bw.writer();
|
while (args.next()) |arg| {
|
||||||
|
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
|
||||||
|
std.debug.print(
|
||||||
|
\\Zetviel - Email client for notmuch
|
||||||
|
\\
|
||||||
|
\\Usage: zetviel [OPTIONS]
|
||||||
|
\\
|
||||||
|
\\Options:
|
||||||
|
\\ --port <PORT> Port to listen on (default: 5000)
|
||||||
|
\\ --help, -h Show this help message
|
||||||
|
\\ --version, -v Show version information
|
||||||
|
\\
|
||||||
|
\\Environment:
|
||||||
|
\\ NOTMUCH_PATH Path to notmuch database (default: mail)
|
||||||
|
\\
|
||||||
|
, .{});
|
||||||
|
std.process.exit(0);
|
||||||
|
} else if (std.mem.eql(u8, arg, "--version") or std.mem.eql(u8, arg, "-v")) {
|
||||||
|
std.debug.print("Zetviel {s}\n", .{version});
|
||||||
|
std.process.exit(0);
|
||||||
|
} else if (std.mem.eql(u8, arg, "--port")) {
|
||||||
|
const port_str = args.next() orelse {
|
||||||
|
std.debug.print("Error: --port requires a value\n", .{});
|
||||||
|
std.process.exit(1);
|
||||||
|
};
|
||||||
|
port = std.fmt.parseInt(u16, port_str, 10) catch {
|
||||||
|
std.debug.print("Error: invalid port number\n", .{});
|
||||||
|
std.process.exit(1);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
std.debug.print("Error: unknown argument '{s}'\n", .{arg});
|
||||||
|
std.debug.print("Use --help for usage information\n", .{});
|
||||||
|
std.process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try stdout.print("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
|
// Open notmuch database
|
||||||
const allocator = std.heap.page_allocator;
|
var db = try root.openNotmuchDb(allocator, db_path, null);
|
||||||
var db_result = root.openNotmuchDb(allocator, "mail", null) catch |err| {
|
defer db.close();
|
||||||
std.debug.print("Failed to open notmuch database: {}\n", .{err});
|
|
||||||
|
std.debug.print("Zetviel starting on http://localhost:{d}\n", .{port});
|
||||||
|
std.debug.print("Notmuch database: {s}\n", .{db.path});
|
||||||
|
|
||||||
|
// Create HTTP server
|
||||||
|
var server = try httpz.Server(*root.NotmuchDb).init(allocator, .{
|
||||||
|
.port = port,
|
||||||
|
.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, .{});
|
||||||
|
|
||||||
|
// Static file serving
|
||||||
|
router.get("/", indexHandler, .{});
|
||||||
|
router.get("/*", staticHandler, .{});
|
||||||
|
|
||||||
|
try server.listen();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn indexHandler(db: *root.NotmuchDb, _: *httpz.Request, res: *httpz.Response) !void {
|
||||||
|
const file = std.fs.cwd().openFile("static/index.html", .{}) catch {
|
||||||
|
res.status = 500;
|
||||||
|
res.body = "Error loading index.html";
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
defer db_result.close();
|
defer file.close();
|
||||||
|
|
||||||
std.debug.print("Successfully opened notmuch database at: {s}\n", .{db_result.path});
|
const content = file.readToEndAlloc(db.allocator, 1024 * 1024) catch {
|
||||||
|
res.status = 500;
|
||||||
|
res.body = "Error reading index.html";
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
try bw.flush(); // don't forget to flush!
|
res.header("Content-Type", "text/html");
|
||||||
|
res.body = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn staticHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response) !void {
|
||||||
|
const path = req.url.path;
|
||||||
|
|
||||||
|
const file_path = if (std.mem.eql(u8, path, "/style.css"))
|
||||||
|
"static/style.css"
|
||||||
|
else if (std.mem.eql(u8, path, "/app.js"))
|
||||||
|
"static/app.js"
|
||||||
|
else {
|
||||||
|
res.status = 404;
|
||||||
|
res.body = "Not Found";
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const file = std.fs.cwd().openFile(file_path, .{}) catch {
|
||||||
|
res.status = 404;
|
||||||
|
res.body = "Not Found";
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer file.close();
|
||||||
|
|
||||||
|
const content = file.readToEndAlloc(db.allocator, 1024 * 1024) catch {
|
||||||
|
res.status = 500;
|
||||||
|
res.body = "Error reading file";
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (std.mem.endsWith(u8, file_path, ".css")) {
|
||||||
|
res.header("Content-Type", "text/css");
|
||||||
|
} else if (std.mem.endsWith(u8, file_path, ".js")) {
|
||||||
|
res.header("Content-Type", "application/javascript");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.body = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 encoded_query = req.url.path[11..]; // Skip "/api/query/"
|
||||||
|
if (encoded_query.len == 0) {
|
||||||
|
res.status = 400;
|
||||||
|
try res.json(.{ .@"error" = "Query parameter required" }, .{});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL decode the query
|
||||||
|
const query_buf = try db.allocator.dupe(u8, encoded_query);
|
||||||
|
defer db.allocator.free(query_buf);
|
||||||
|
const query = std.Uri.percentDecodeInPlace(query_buf);
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
141
src/root.zig
141
src/root.zig
|
@ -5,11 +5,16 @@ const Email = @import("Email.zig");
|
||||||
pub const Thread = struct {
|
pub const Thread = struct {
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
thread: *notmuch.Db.Thread,
|
thread: *notmuch.Db.Thread,
|
||||||
|
iterator: ?*notmuch.Db.ThreadIterator = null,
|
||||||
|
|
||||||
pub fn init(allocator: std.mem.Allocator, t: *notmuch.Db.Thread) Thread {
|
pub fn init(allocator: std.mem.Allocator, t: *notmuch.Db.Thread) Thread {
|
||||||
return .{ .allocator = allocator, .thread = t };
|
return .{ .allocator = allocator, .thread = t };
|
||||||
}
|
}
|
||||||
pub fn deinit(self: Thread) void {
|
pub fn deinit(self: Thread) void {
|
||||||
|
if (self.iterator) |iter| {
|
||||||
|
iter.deinit();
|
||||||
|
self.allocator.destroy(iter);
|
||||||
|
}
|
||||||
self.allocator.destroy(self.thread);
|
self.allocator.destroy(self.thread);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,30 +35,21 @@ pub const Thread = struct {
|
||||||
// }
|
// }
|
||||||
//]
|
//]
|
||||||
try jws.beginArray();
|
try jws.beginArray();
|
||||||
var mi = self.thread.getMessages() catch return error.OutOfMemory;
|
var mi = self.thread.getMessages() catch 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.OutOfMemory);
|
try jws.write(m.getHeader("from") catch return error.WriteFailed);
|
||||||
try jws.objectField("to");
|
try jws.objectField("to");
|
||||||
try jws.write(m.getHeader("to") catch return error.OutOfMemory);
|
try jws.write(m.getHeader("to") catch return error.WriteFailed);
|
||||||
try jws.objectField("cc");
|
try jws.objectField("cc");
|
||||||
try jws.write(m.getHeader("cc") catch return error.OutOfMemory);
|
try jws.write(m.getHeader("cc") catch return error.WriteFailed);
|
||||||
try jws.objectField("bcc");
|
try jws.objectField("bcc");
|
||||||
try jws.write(m.getHeader("bcc") catch return error.OutOfMemory);
|
try jws.write(m.getHeader("bcc") catch return error.WriteFailed);
|
||||||
try jws.objectField("date");
|
try jws.objectField("date");
|
||||||
try jws.write(m.getHeader("date") catch return error.OutOfMemory);
|
try jws.write(m.getHeader("date") catch return error.WriteFailed);
|
||||||
try jws.objectField("subject");
|
try jws.objectField("subject");
|
||||||
try jws.write(m.getHeader("subject") catch return error.OutOfMemory);
|
try jws.write(m.getHeader("subject") catch return error.WriteFailed);
|
||||||
// content, content-type, and attachments are all based on the file itself
|
|
||||||
// TODO: init shouldn't fail
|
|
||||||
// var message = try Message.init(self.allocator, m.getFilename());
|
|
||||||
// defer message.deinit();
|
|
||||||
// try message.load();
|
|
||||||
// const content_type = try message.getContentType();
|
|
||||||
// try jws.objectField("content-type");
|
|
||||||
// try jws.write(content_type);
|
|
||||||
|
|
||||||
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();
|
||||||
|
@ -146,7 +142,7 @@ pub const Threads = struct {
|
||||||
try jws.objectField("subject");
|
try jws.objectField("subject");
|
||||||
try jws.write(t.getSubject());
|
try jws.write(t.getSubject());
|
||||||
try jws.objectField("tags");
|
try jws.objectField("tags");
|
||||||
var tags = t.getTags() catch return error.OutOfMemory;
|
var tags = t.getTags() catch return error.WriteFailed;
|
||||||
try tags.jsonStringify(jws);
|
try tags.jsonStringify(jws);
|
||||||
try jws.objectField("thread_id");
|
try jws.objectField("thread_id");
|
||||||
try jws.write(t.getThreadId());
|
try jws.write(t.getThreadId());
|
||||||
|
@ -187,17 +183,94 @@ pub const NotmuchDb = struct {
|
||||||
pub fn getThread(self: *NotmuchDb, thread_id: []const u8) !Thread {
|
pub fn getThread(self: *NotmuchDb, thread_id: []const u8) !Thread {
|
||||||
var query_buf: [1024:0]u8 = undefined;
|
var query_buf: [1024:0]u8 = undefined;
|
||||||
const query_z = try std.fmt.bufPrintZ(&query_buf, "thread:{s}", .{thread_id});
|
const query_z = try std.fmt.bufPrintZ(&query_buf, "thread:{s}", .{thread_id});
|
||||||
var thread_iter = try self.db.searchThreads(query_z);
|
const iter_ptr = try self.allocator.create(notmuch.Db.ThreadIterator);
|
||||||
defer thread_iter.deinit();
|
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| {
|
if (thread) |t| {
|
||||||
const tptr = try self.allocator.create(notmuch.Db.Thread);
|
const tptr = try self.allocator.create(notmuch.Db.Thread);
|
||||||
tptr.* = t;
|
tptr.* = t;
|
||||||
return Thread.init(self.allocator, tptr);
|
var result = Thread.init(self.allocator, tptr);
|
||||||
|
result.iterator = iter_ptr;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
return error.ThreadNotFound;
|
return error.ThreadNotFound;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const MessageDetail = struct {
|
||||||
|
from: ?[]const u8,
|
||||||
|
to: ?[]const u8,
|
||||||
|
cc: ?[]const u8,
|
||||||
|
bcc: ?[]const u8,
|
||||||
|
date: ?[]const u8,
|
||||||
|
subject: ?[]const u8,
|
||||||
|
text_content: []const u8,
|
||||||
|
html_content: []const u8,
|
||||||
|
attachments: []Email.Message.AttachmentInfo,
|
||||||
|
message_id: []const u8,
|
||||||
|
|
||||||
|
pub fn deinit(self: MessageDetail, allocator: std.mem.Allocator) void {
|
||||||
|
if (self.from) |f| allocator.free(f);
|
||||||
|
if (self.to) |t| allocator.free(t);
|
||||||
|
if (self.cc) |c| allocator.free(c);
|
||||||
|
if (self.bcc) |b| allocator.free(b);
|
||||||
|
if (self.date) |d| allocator.free(d);
|
||||||
|
if (self.subject) |s| allocator.free(s);
|
||||||
|
allocator.free(self.text_content);
|
||||||
|
allocator.free(self.html_content);
|
||||||
|
for (self.attachments) |att| {
|
||||||
|
allocator.free(att.filename);
|
||||||
|
allocator.free(att.content_type);
|
||||||
|
}
|
||||||
|
allocator.free(self.attachments);
|
||||||
|
allocator.free(self.message_id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn getMessage(self: *NotmuchDb, message_id: []const u8) !MessageDetail {
|
||||||
|
var query_buf: [1024:0]u8 = undefined;
|
||||||
|
const query_z = try std.fmt.bufPrintZ(&query_buf, "mid:{s}", .{message_id});
|
||||||
|
var thread_iter = try self.db.searchThreads(query_z);
|
||||||
|
defer thread_iter.deinit();
|
||||||
|
|
||||||
|
const thread = thread_iter.next() orelse return error.MessageNotFound;
|
||||||
|
defer thread.deinit();
|
||||||
|
|
||||||
|
var msg_iter = try thread.getMessages();
|
||||||
|
const notmuch_msg = msg_iter.next() orelse return error.MessageNotFound;
|
||||||
|
|
||||||
|
const filename_z = try self.allocator.dupeZ(u8, notmuch_msg.getFilename());
|
||||||
|
defer self.allocator.free(filename_z);
|
||||||
|
|
||||||
|
const email_msg = try self.email.openMessage(filename_z);
|
||||||
|
defer email_msg.deinit();
|
||||||
|
|
||||||
|
const content_info = try email_msg.getTextAndHtmlBodyVersions(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 to = if (notmuch_msg.getHeader("to") catch null) |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") catch null) |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") catch null) |h| try self.allocator.dupe(u8, h) else null;
|
||||||
|
const msg_id = try self.allocator.dupe(u8, notmuch_msg.getMessageId());
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.from = from,
|
||||||
|
.to = to,
|
||||||
|
.cc = cc,
|
||||||
|
.bcc = bcc,
|
||||||
|
.date = date,
|
||||||
|
.subject = subject,
|
||||||
|
.text_content = content_info.text,
|
||||||
|
.html_content = content_info.html,
|
||||||
|
.attachments = attachments,
|
||||||
|
.message_id = msg_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Opens a notmuch database at the specified path
|
/// Opens a notmuch database at the specified path
|
||||||
|
@ -254,7 +327,7 @@ test "can stringify general queries" {
|
||||||
defer db.close();
|
defer db.close();
|
||||||
var threads = try db.search("Tablets");
|
var threads = try db.search("Tablets");
|
||||||
defer threads.deinit();
|
defer threads.deinit();
|
||||||
const actual = try std.json.stringifyAlloc(allocator, threads, .{ .whitespace = .indent_2 });
|
const actual = try std.fmt.allocPrint(allocator, "{f}", .{std.json.fmt(threads, .{ .whitespace = .indent_2 })});
|
||||||
defer allocator.free(actual);
|
defer allocator.free(actual);
|
||||||
try std.testing.expectEqualStrings(
|
try std.testing.expectEqualStrings(
|
||||||
\\[
|
\\[
|
||||||
|
@ -306,3 +379,27 @@ test "can stringify specific threads" {
|
||||||
\\]
|
\\]
|
||||||
, actual);
|
, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "can get message details with content" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
var db = try openNotmuchDb(allocator, "mail", null);
|
||||||
|
defer db.close();
|
||||||
|
|
||||||
|
// Get a message by its ID
|
||||||
|
const message_id = "8afeb74dca321817e44e07ac4a2e040962e86e@youpharm.co";
|
||||||
|
const msg_detail = try db.getMessage(message_id);
|
||||||
|
defer msg_detail.deinit(allocator);
|
||||||
|
|
||||||
|
// Verify headers
|
||||||
|
try std.testing.expect(msg_detail.from != null);
|
||||||
|
try std.testing.expect(msg_detail.subject != null);
|
||||||
|
|
||||||
|
// Verify content was extracted - we should always have both text and html
|
||||||
|
try std.testing.expect(msg_detail.text_content.len >= 0);
|
||||||
|
try std.testing.expect(msg_detail.html_content.len > 0);
|
||||||
|
|
||||||
|
// This message has no attachments
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), msg_detail.attachments.len);
|
||||||
|
|
||||||
|
// TODO: Add test with attachment once we have a sample email with attachments
|
||||||
|
}
|
||||||
|
|
154
src/textTransformation.zig
Normal file
154
src/textTransformation.zig
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub fn htmlToText(allocator: std.mem.Allocator, html: []const u8) ![]const u8 {
|
||||||
|
var result = std.ArrayList(u8){};
|
||||||
|
errdefer result.deinit(allocator);
|
||||||
|
|
||||||
|
var i: usize = 0;
|
||||||
|
var in_tag = false;
|
||||||
|
var in_script = false;
|
||||||
|
var in_style = false;
|
||||||
|
|
||||||
|
while (i < html.len) {
|
||||||
|
if (html[i] == '<') {
|
||||||
|
in_tag = true;
|
||||||
|
if (i + 7 <= html.len and std.mem.eql(u8, html[i .. i + 7], "<script")) {
|
||||||
|
in_script = true;
|
||||||
|
} else if (i + 6 <= html.len and std.mem.eql(u8, html[i .. i + 6], "<style")) {
|
||||||
|
in_style = true;
|
||||||
|
} else if (i + 9 <= html.len and std.mem.eql(u8, html[i .. i + 9], "</script>")) {
|
||||||
|
in_script = false;
|
||||||
|
i += 8;
|
||||||
|
} else if (i + 8 <= html.len and std.mem.eql(u8, html[i .. i + 8], "</style>")) {
|
||||||
|
in_style = false;
|
||||||
|
i += 7;
|
||||||
|
} else if (i + 4 <= html.len and std.mem.eql(u8, html[i .. i + 4], "<img")) {
|
||||||
|
// Extract alt attribute from img tags
|
||||||
|
const tag_end = std.mem.indexOfScalarPos(u8, html, i, '>') orelse html.len;
|
||||||
|
const tag_content = html[i..tag_end];
|
||||||
|
if (std.mem.indexOf(u8, tag_content, "alt=\"")) |alt_start| {
|
||||||
|
const alt_value_start = alt_start + 5;
|
||||||
|
if (std.mem.indexOfScalarPos(u8, tag_content, alt_value_start, '"')) |alt_end| {
|
||||||
|
const alt_text = tag_content[alt_value_start..alt_end];
|
||||||
|
if (alt_text.len > 0) {
|
||||||
|
try result.append(allocator, '[');
|
||||||
|
for (alt_text) |c| {
|
||||||
|
try result.append(allocator, c);
|
||||||
|
}
|
||||||
|
try result.append(allocator, ']');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = tag_end;
|
||||||
|
in_tag = false;
|
||||||
|
} else if ((i + 3 <= html.len and std.mem.eql(u8, html[i .. i + 3], "<br")) or
|
||||||
|
(i + 3 <= html.len and std.mem.eql(u8, html[i .. i + 3], "<p>")) or
|
||||||
|
(i + 4 <= html.len and std.mem.eql(u8, html[i .. i + 4], "<div")))
|
||||||
|
{
|
||||||
|
try result.append(allocator, '\n');
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
} else if (html[i] == '>') {
|
||||||
|
in_tag = false;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_tag and !in_script and !in_style) {
|
||||||
|
try result.append(allocator, html[i]);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "htmlToText - simple text" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const html = "<p>Hello World</p>";
|
||||||
|
const text = try htmlToText(allocator, html);
|
||||||
|
defer allocator.free(text);
|
||||||
|
try std.testing.expectEqualStrings("\nHello World", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "htmlToText - strips script tags" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const html = "<p>Before</p><script>alert('test');</script><p>After</p>";
|
||||||
|
const text = try htmlToText(allocator, html);
|
||||||
|
defer allocator.free(text);
|
||||||
|
try std.testing.expectEqualStrings("\nBefore\nAfter", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "htmlToText - strips style tags" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const html = "<style>body { color: red; }</style><p>Content</p>";
|
||||||
|
const text = try htmlToText(allocator, html);
|
||||||
|
defer allocator.free(text);
|
||||||
|
try std.testing.expectEqualStrings("\nContent", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "htmlToText - handles br tags" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const html = "Line 1<br>Line 2<br/>Line 3";
|
||||||
|
const text = try htmlToText(allocator, html);
|
||||||
|
defer allocator.free(text);
|
||||||
|
try std.testing.expectEqualStrings("Line 1\nLine 2\nLine 3", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "htmlToText - handles div tags" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const html = "<div>First</div><div class='test'>Second</div>";
|
||||||
|
const text = try htmlToText(allocator, html);
|
||||||
|
defer allocator.free(text);
|
||||||
|
try std.testing.expectEqualStrings("\nFirst\nSecond", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "htmlToText - complex html" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const html =
|
||||||
|
\\<html>
|
||||||
|
\\<head><style>body { margin: 0; }</style></head>
|
||||||
|
\\<body>
|
||||||
|
\\<p>Hello</p>
|
||||||
|
\\<script>console.log('test');</script>
|
||||||
|
\\<div>World</div>
|
||||||
|
\\</body>
|
||||||
|
\\</html>
|
||||||
|
;
|
||||||
|
const text = try htmlToText(allocator, html);
|
||||||
|
defer allocator.free(text);
|
||||||
|
try std.testing.expectEqualStrings("\n\n\n\nHello\n\nWorld\n\n", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "htmlToText - empty string" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const html = "";
|
||||||
|
const text = try htmlToText(allocator, html);
|
||||||
|
defer allocator.free(text);
|
||||||
|
try std.testing.expectEqualStrings("", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "htmlToText - plain text" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const html = "Just plain text";
|
||||||
|
const text = try htmlToText(allocator, html);
|
||||||
|
defer allocator.free(text);
|
||||||
|
try std.testing.expectEqualStrings("Just plain text", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "htmlToText - extracts img alt attributes" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const html = "<img src=\"test.jpg\" alt=\"Test Image\"><img alt=\"Another\" src=\"x.png\">";
|
||||||
|
const text = try htmlToText(allocator, html);
|
||||||
|
defer allocator.free(text);
|
||||||
|
try std.testing.expectEqualStrings("[Test Image][Another]", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "htmlToText - img without alt" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const html = "<img src=\"test.jpg\">";
|
||||||
|
const text = try htmlToText(allocator, html);
|
||||||
|
defer allocator.free(text);
|
||||||
|
try std.testing.expectEqualStrings("", text);
|
||||||
|
}
|
296
static/index.html
Normal file
296
static/index.html
Normal file
|
@ -0,0 +1,296 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Zetviel</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.5; background: #1e1e1e; color: #e0e0e0; }
|
||||||
|
header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid #333; background: #252525; }
|
||||||
|
h1 { font-size: 1.5rem; }
|
||||||
|
.header-left { display: flex; align-items: center; gap: 1rem; }
|
||||||
|
.help-icon { cursor: pointer; font-size: 1.2rem; color: #0066cc; }
|
||||||
|
.help-icon:hover { color: #0052a3; }
|
||||||
|
.status-ok { color: #0f0; }
|
||||||
|
.status-error { color: #f00; }
|
||||||
|
.status-loading { color: #ff0; }
|
||||||
|
.search-bar { padding: 1rem; border-bottom: 1px solid #333; background: #252525; }
|
||||||
|
#search { width: 70%; padding: 0.5rem; border: 1px solid #444; border-radius: 4px; background: #2a2a2a; color: #e0e0e0; }
|
||||||
|
button { padding: 0.5rem 1rem; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||||
|
button:hover { background: #0052a3; }
|
||||||
|
button:disabled { background: #444; cursor: not-allowed; }
|
||||||
|
.container { display: flex; height: calc(100vh - 140px); }
|
||||||
|
.thread-list { width: 40%; overflow-y: auto; border-right: 1px solid #333; }
|
||||||
|
.thread { padding: 1rem; border-bottom: 1px solid #2a2a2a; cursor: pointer; }
|
||||||
|
.thread:hover { background: #2a2a2a; }
|
||||||
|
.thread-subject { font-weight: bold; margin-bottom: 0.25rem; }
|
||||||
|
.thread-authors { color: #999; font-size: 0.9rem; }
|
||||||
|
.thread-date { color: #666; font-size: 0.85rem; }
|
||||||
|
.message-view { width: 60%; overflow-y: auto; padding: 1rem; }
|
||||||
|
.message { margin-bottom: 2rem; padding: 1rem; border: 1px solid #333; border-radius: 4px; background: #252525; }
|
||||||
|
.message-header { margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #333; }
|
||||||
|
.message-content { margin-top: 1rem; }
|
||||||
|
.content { padding: 1rem; background: #2a2a2a; border-radius: 4px; }
|
||||||
|
.content pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||||
|
.attachments { margin-top: 1rem; padding: 0.5rem; background: #3a3a00; border-radius: 4px; }
|
||||||
|
.loading { padding: 1rem; text-align: center; color: #999; }
|
||||||
|
.error { padding: 1rem; background: #3a0000; border: 1px solid #600; border-radius: 4px; margin: 1rem; }
|
||||||
|
.help-overlay { display: none; position: fixed; bottom: 1rem; right: 1rem; width: 425px; max-height: 500px; overflow-y: auto; background: #252525; border: 1px solid #444; border-radius: 8px; padding: 1rem; box-shadow: 0 4px 12px rgba(0,0,0,0.5); z-index: 1000; font-size: 0.85rem; }
|
||||||
|
.help-overlay.visible { display: block; }
|
||||||
|
.help-overlay h3 { margin-bottom: 0.5rem; color: #0066cc; }
|
||||||
|
.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 li { margin: 0.5rem 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>Zetviel</h1>
|
||||||
|
<span class="help-icon" onclick="toggleHelp()" title="Help (?)">?</span>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status-ok">●</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" id="search" placeholder="Search (e.g., tag:inbox)" value="tag:inbox">
|
||||||
|
<button onclick="search()">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div id="thread-list" class="thread-list"></div>
|
||||||
|
<div id="message-view" class="message-view"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="help-overlay" class="help-overlay">
|
||||||
|
<h3>Notmuch Search Syntax</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>tag:inbox</code> - Messages with inbox tag</li>
|
||||||
|
<li><code>from:alice</code> - From alice</li>
|
||||||
|
<li><code>to:bob</code> - To bob</li>
|
||||||
|
<li><code>subject:meeting</code> - Subject contains "meeting"</li>
|
||||||
|
<li><code>date:today</code> - Messages from today</li>
|
||||||
|
<li><code>date:yesterday</code> - Messages from yesterday</li>
|
||||||
|
<li><code>date:7d..</code> - Last 7 days</li>
|
||||||
|
<li><code>tag:unread AND from:alice</code> - Combine with AND</li>
|
||||||
|
<li><code>tag:inbox OR tag:sent</code> - Combine with OR</li>
|
||||||
|
<li><code>NOT tag:spam</code> - Exclude spam</li>
|
||||||
|
<li><code>attachment:pdf</code> - Has PDF attachment</li>
|
||||||
|
<li><code>*</code> - All messages</li>
|
||||||
|
</ul>
|
||||||
|
<h3 style="margin-top: 1rem;">Keyboard Shortcuts</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>/</code> - Focus search</li>
|
||||||
|
<li><code>j</code> - Next thread</li>
|
||||||
|
<li><code>k</code> - Previous thread</li>
|
||||||
|
<li><code>t</code> - Toggle HTML/text</li>
|
||||||
|
<li><code>?</code> - Toggle this help</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let currentQuery = 'tag:inbox';
|
||||||
|
let isLoading = false;
|
||||||
|
let currentThreadIndex = -1;
|
||||||
|
let threads = [];
|
||||||
|
let currentMessageId = null;
|
||||||
|
|
||||||
|
async function api(endpoint) {
|
||||||
|
const res = await fetch(`/api/${endpoint}`);
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(state) {
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
status.className = state === 'loading' ? 'status-loading' : state === 'ok' ? 'status-ok' : 'status-error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(loading) {
|
||||||
|
isLoading = loading;
|
||||||
|
const btn = document.querySelector('.search-bar button');
|
||||||
|
btn.disabled = loading;
|
||||||
|
btn.textContent = loading ? 'Loading...' : 'Search';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
if (isLoading) return;
|
||||||
|
const query = document.getElementById('search').value;
|
||||||
|
currentQuery = query;
|
||||||
|
history.pushState({ query }, '', `/?q=${encodeURIComponent(query)}`);
|
||||||
|
await loadThreads(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadThreads(query) {
|
||||||
|
setLoading(true);
|
||||||
|
setStatus('loading');
|
||||||
|
const list = document.getElementById('thread-list');
|
||||||
|
|
||||||
|
try {
|
||||||
|
threads = await api(`query/${encodeURIComponent(query)}`);
|
||||||
|
currentThreadIndex = -1;
|
||||||
|
if (!threads || threads.length === 0) {
|
||||||
|
list.innerHTML = '<div class="loading">No threads found</div>';
|
||||||
|
} else {
|
||||||
|
list.innerHTML = threads.map((t, i) => `
|
||||||
|
<div class="thread" data-index="${i}" onclick="loadThread('${t.thread_id}', ${i})">
|
||||||
|
<div class="thread-subject">${escapeHtml(t.subject)}</div>
|
||||||
|
<div class="thread-authors">${escapeHtml(t.authors)}</div>
|
||||||
|
<div class="thread-date">${new Date(t.newest_date * 1000).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
setStatus('ok');
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<div class="error">Error loading threads: ${escapeHtml(e.message)}</div>`;
|
||||||
|
setStatus('error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadThread(threadId, index) {
|
||||||
|
currentThreadIndex = index;
|
||||||
|
highlightThread(index);
|
||||||
|
setStatus('loading');
|
||||||
|
const view = document.getElementById('message-view');
|
||||||
|
view.innerHTML = '<div class="loading">Loading messages...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = await api(`thread/${threadId}`);
|
||||||
|
view.innerHTML = messages.map(m => `
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-header">
|
||||||
|
<strong>From:</strong> ${escapeHtml(m.from || '')}<br>
|
||||||
|
<strong>To:</strong> ${escapeHtml(m.to || '')}<br>
|
||||||
|
<strong>Date:</strong> ${escapeHtml(m.date || '')}<br>
|
||||||
|
<strong>Subject:</strong> ${escapeHtml(m.subject || '')}
|
||||||
|
</div>
|
||||||
|
<div id="msg-${m.message_id}" class="message-content"><div class="loading">Loading content...</div></div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
setStatus('ok');
|
||||||
|
|
||||||
|
messages.forEach(m => loadMessageContent(m.message_id));
|
||||||
|
if (messages.length > 0) currentMessageId = messages[0].message_id;
|
||||||
|
} catch (e) {
|
||||||
|
view.innerHTML = `<div class="error">Error loading thread: ${escapeHtml(e.message)}</div>`;
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightThread(index) {
|
||||||
|
document.querySelectorAll('.thread').forEach(t => t.style.background = '');
|
||||||
|
const thread = document.querySelector(`.thread[data-index="${index}"]`);
|
||||||
|
if (thread) {
|
||||||
|
thread.style.background = '#2a2a2a';
|
||||||
|
thread.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessageContent(messageId) {
|
||||||
|
const div = document.getElementById(`msg-${messageId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg = await api(`message/${messageId}`);
|
||||||
|
div.innerHTML = `
|
||||||
|
${msg.html_content ? `<button onclick="showHtml('${messageId}')">Show HTML Version</button>` : ''}
|
||||||
|
<br>
|
||||||
|
<div class="content"><pre>${escapeHtml(msg.text_content)}</pre></div>
|
||||||
|
${msg.attachments.length ? `<div class="attachments">Attachments: ${msg.attachments.map(a => escapeHtml(a.filename)).join(', ')}</div>` : ''}
|
||||||
|
`;
|
||||||
|
div.dataset.html = msg.html_content || '';
|
||||||
|
div.dataset.showingHtml = 'false';
|
||||||
|
} catch (e) {
|
||||||
|
div.innerHTML = `<div class="error">Error loading message: ${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHtml(messageId) {
|
||||||
|
const div = document.getElementById(`msg-${messageId}`);
|
||||||
|
const html = div.dataset.html;
|
||||||
|
if (html) {
|
||||||
|
div.innerHTML = `
|
||||||
|
<button onclick="loadMessageContent('${messageId}')">Show Text Version</button>
|
||||||
|
<br>
|
||||||
|
<div class="content">${html}</div>
|
||||||
|
`;
|
||||||
|
div.dataset.showingHtml = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHtmlText() {
|
||||||
|
if (!currentMessageId) return;
|
||||||
|
const div = document.getElementById(`msg-${currentMessageId}`);
|
||||||
|
if (!div) return;
|
||||||
|
|
||||||
|
if (div.dataset.showingHtml === 'true') {
|
||||||
|
loadMessageContent(currentMessageId);
|
||||||
|
} else {
|
||||||
|
showHtml(currentMessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHelp() {
|
||||||
|
const overlay = document.getElementById('help-overlay');
|
||||||
|
overlay.classList.toggle('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveThread(direction) {
|
||||||
|
if (threads.length === 0) return;
|
||||||
|
|
||||||
|
const newIndex = currentThreadIndex + direction;
|
||||||
|
if (newIndex >= 0 && newIndex < threads.length) {
|
||||||
|
loadThread(threads[newIndex].thread_id, newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const query = params.get('q') || 'tag:inbox';
|
||||||
|
document.getElementById('search').value = query;
|
||||||
|
loadThreads(query);
|
||||||
|
|
||||||
|
document.getElementById('search').addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') search();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (document.activeElement.id === 'search') return;
|
||||||
|
|
||||||
|
if (e.key === '/') {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('search').focus();
|
||||||
|
} else if (e.key === 'j') {
|
||||||
|
e.preventDefault();
|
||||||
|
moveThread(1);
|
||||||
|
} else if (e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
moveThread(-1);
|
||||||
|
} else if (e.key === 't') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleHtmlText();
|
||||||
|
} else if (e.key === '?') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleHelp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('popstate', (e) => {
|
||||||
|
if (e.state?.query) {
|
||||||
|
document.getElementById('search').value = e.state.query;
|
||||||
|
loadThreads(e.state.query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Add table
Reference in a new issue