Promote rebuilt engine-first foundation (dev -> main) #166
No files matched your search
+5
-1
@@ -2,8 +2,12 @@
|
||||
# Development quality enforcement aliases
|
||||
check-all = "clippy --all-targets --workspace -- -D warnings"
|
||||
test-all = "test --workspace"
|
||||
cov = "llvm-cov --workspace --fail-under-lines 100"
|
||||
cov = "llvm-cov --workspace --ignore-filename-regex main\\.rs --summary-only"
|
||||
verify = ["fmt --all -- --check", "check-all", "test-all"]
|
||||
# Dogfooding: build the runnable daemon + reference CLI in release mode.
|
||||
build-release = ["build", "--release", "-p", "nunciod", "-p", "nuncio-cli"]
|
||||
# Remove the entire workspace target directory (all crates, all profiles).
|
||||
clean-all = "clean"
|
||||
|
||||
[build]
|
||||
# Treat warnings as errors and forbid unsafe code at local build time
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing to Nuncio
|
||||
|
||||
Thank you for contributing to Nuncio ([nuncio.mx](https://nuncio.mx))!
|
||||
Thank you for contributing to Nuncio!
|
||||
|
||||
## Semantic Versioning 2.0.0 & Tagging
|
||||
|
||||
@@ -9,7 +9,10 @@ Nuncio follows **Semantic Versioning 2.0.0** (`vMAJOR.MINOR.PATCH`):
|
||||
- `MINOR`: Incremented for new functionality added in a backward-compatible manner.
|
||||
- `PATCH`: Incremented for backward-compatible bug fixes.
|
||||
|
||||
Tagging a commit with `vX.Y.Z` triggers [.github/workflows/release.yml](file:///R:/Git.Local/KofTwentyTwo/nuncio/.github/workflows/release.yml) to automatically compile cross-platform release artifacts (Windows `.zip`/`.msi`, macOS `.tar.gz`/`.dmg`, Linux `.tar.gz`/`.AppImage`), generate release notes from Conventional Commits, compute SHA256 checksums, and publish an official GitHub Release.
|
||||
Tagging a commit with `vX.Y.Z` is intended to drive the release workflow under
|
||||
`.github/workflows/`. **Pre-alpha note:** the release pipeline is being rebuilt and
|
||||
**no functional release exists yet** — see roadmap milestone M7 (issue #209). Do not
|
||||
tag releases until the backend is declared client-ready.
|
||||
|
||||
## Architecture Decoupling Rules
|
||||
|
||||
|
||||
+7
-3
@@ -1,13 +1,17 @@
|
||||
# Security Policy
|
||||
|
||||
Nuncio ([nuncio.mx](https://nuncio.mx)) takes security and data privacy seriously. Because Nuncio handles sensitive mail payloads, calendar schedules, and authentication credentials across Windows, macOS, and Linux, we maintain strict security disclosures and cryptographic standards.
|
||||
Nuncio handles sensitive mail, calendar, and contact data plus authentication
|
||||
credentials, so security is a first-class concern. **Status: pre-alpha.** Several
|
||||
hardening items — at-rest encryption correctness, key zeroization, a fail-closed
|
||||
signature-verifying updater, and transport/egress hardening — are tracked under
|
||||
roadmap milestone M6 and are not all complete. **Do not use Nuncio with real
|
||||
accounts or sensitive data yet.**
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| :--- | :--- |
|
||||
| `0.1.x` | Supported |
|
||||
| `< 0.1.0` | Unsupported |
|
||||
| all pre-1.0 | **Not supported** — non-functional pre-alpha, under active reconstruction |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-D warnings"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
fmt:
|
||||
name: Code Formatting Check
|
||||
@@ -36,8 +39,8 @@ jobs:
|
||||
sudo apt-get install -y libglib2.0-dev libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev build-essential curl wget file libssl-dev
|
||||
- name: Cache Cargo Dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Run Clippy Linter
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
- name: Run Clippy Linter (all targets, matches local `cargo check-all`)
|
||||
run: cargo check-all
|
||||
|
||||
test:
|
||||
name: Test Suite Execution
|
||||
@@ -68,8 +71,12 @@ jobs:
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
- name: Cache Cargo Dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Measure & Enforce Code Coverage Threshold
|
||||
run: cargo llvm-cov --workspace --ignore-filename-regex "main\.rs" --fail-under-lines 100
|
||||
# Coverage is informational for now. The former `--fail-under-lines 100`
|
||||
# gate excluded all main.rs (the integration wiring) and incentivized
|
||||
# stub+matching-test theater. A realistic threshold plus a required
|
||||
# mock-server E2E gate lands with Phase 1.C.6 (GH #143).
|
||||
- name: Measure Code Coverage (informational)
|
||||
run: cargo llvm-cov --workspace --ignore-filename-regex "main\.rs" --summary-only
|
||||
|
||||
build-matrix:
|
||||
name: Cross-Platform Build Matrix (${{ matrix.os }})
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
name: Nuncio Desktop GUI Release Pipeline (MSI, DMG, AppImage, Deb)
|
||||
|
||||
on:
|
||||
# Desktop installers are built ONLY from version tags (per the release policy).
|
||||
# Branch-push triggers were removed during the pre-alpha rebuild: they ran on
|
||||
# every dev push and failed (the Tauri GUI is archived under _reference/).
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- rc
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
name: Nuncio Nightly & Snapshot Release Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # Daily Nightly Build at 02:00 UTC
|
||||
# Auto-nightly is DISABLED during the pre-alpha rebuild: no public releases
|
||||
# until V3, and this pipeline builds the presentation GUI now archived under
|
||||
# _reference/. Manual-only until the release path is restored in Phase 4.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
name: Nuncio Multi-Platform Release Pipeline (Dev, RC, Prod)
|
||||
|
||||
on:
|
||||
# Releases are cut ONLY from version tags (vMAJOR.MINOR.PATCH), per the release
|
||||
# policy (no public release until V3). Branch-push and nightly-cron triggers were
|
||||
# removed during the pre-alpha rebuild: they ran on every dev push and failed
|
||||
# (they build the presentation GUI now archived under _reference/).
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- rc
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Daily Nightly Build at 00:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -10,6 +10,9 @@ on:
|
||||
- '.github/workflows/wiki-sync.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
sync-wiki:
|
||||
name: Sync docs/ to GitHub Wiki
|
||||
@@ -27,15 +30,18 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: wiki-repo
|
||||
|
||||
- name: Sync docs and assets to Wiki
|
||||
- name: Sync docs and assets to Wiki (mirror with delete)
|
||||
run: |
|
||||
# Mirror docs/ -> wiki so that files removed from docs/ are also removed
|
||||
# from the wiki. Preserve only the wiki's .git directory.
|
||||
find wiki-repo -mindepth 1 -maxdepth 1 -not -name '.git' -exec rm -rf {} +
|
||||
mkdir -p wiki-repo/assets
|
||||
cp -r docs/* wiki-repo/ 2>/dev/null || true
|
||||
cp -r assets/* wiki-repo/assets/ 2>/dev/null || true
|
||||
cd wiki-repo
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add .
|
||||
git add -A
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit to Wiki."
|
||||
else
|
||||
|
||||
@@ -28,3 +28,10 @@ Thumbs.db
|
||||
**/build/
|
||||
package-lock.json
|
||||
|
||||
|
||||
# Agent scratch + regenerable build artifacts (keep out of the repo)
|
||||
.superpowers/
|
||||
excalidraw.log
|
||||
_reference/**/gen/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
Generated
+2
-3
@@ -1,13 +1,12 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Run nuncio-gui" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="command" value="run -p nuncio-gui" />
|
||||
<configuration default="false" name="Cargo Build Release (daemon + CLI)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="command" value="build-release" />
|
||||
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||
<option name="emulateTerminal" value="true" />
|
||||
<option name="channel" value="DEFAULT" />
|
||||
<option name="requiredFeatures" value="true" />
|
||||
<option name="allFeatures" value="false" />
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="buildTarget" value="REMOTE" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs />
|
||||
<method v="2">
|
||||
-1
@@ -7,7 +7,6 @@
|
||||
<option name="requiredFeatures" value="true" />
|
||||
<option name="allFeatures" value="false" />
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="buildTarget" value="REMOTE" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs />
|
||||
<method v="2">
|
||||
|
||||
Generated
+14
@@ -0,0 +1,14 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Cargo Clean" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="command" value="clean" />
|
||||
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||
<option name="emulateTerminal" value="true" />
|
||||
<option name="channel" value="DEFAULT" />
|
||||
<option name="requiredFeatures" value="true" />
|
||||
<option name="allFeatures" value="false" />
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
-1
@@ -7,7 +7,6 @@
|
||||
<option name="requiredFeatures" value="true" />
|
||||
<option name="allFeatures" value="false" />
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="buildTarget" value="REMOTE" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs />
|
||||
<method v="2">
|
||||
|
||||
-1
@@ -7,7 +7,6 @@
|
||||
<option name="requiredFeatures" value="true" />
|
||||
<option name="allFeatures" value="false" />
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="buildTarget" value="REMOTE" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs />
|
||||
<method v="2">
|
||||
|
||||
+2
-3
@@ -1,13 +1,12 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Run nuncio-tui" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="command" value="run -p nuncio-tui" />
|
||||
<configuration default="false" name="Cargo Verify (fmt + clippy + tests)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="command" value="verify" />
|
||||
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||
<option name="emulateTerminal" value="true" />
|
||||
<option name="channel" value="DEFAULT" />
|
||||
<option name="requiredFeatures" value="true" />
|
||||
<option name="allFeatures" value="false" />
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="buildTarget" value="REMOTE" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs />
|
||||
<method v="2">
|
||||
@@ -0,0 +1,19 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Run nuncio-cli (system status)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="command" value="run -p nuncio-cli -- system status" />
|
||||
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||
<envs />
|
||||
<option name="emulateTerminal" value="true" />
|
||||
<option name="channel" value="DEFAULT" />
|
||||
<option name="requiredFeatures" value="true" />
|
||||
<option name="allFeatures" value="false" />
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="buildTarget" value="REMOTE" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<option name="isRedirectInput" value="false" />
|
||||
<option name="redirectInputPath" value="" />
|
||||
<method v="2">
|
||||
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
.idea/runConfigurations/Run_nuncio_cli.xml → .idea/runConfigurations/Run_nuncio_cli_account_list.xml
Generated
+2
-3
@@ -1,13 +1,12 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Run nuncio-cli (status)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="command" value="run -p nuncio-cli -- status" />
|
||||
<configuration default="false" name="Run nuncio-cli (account list)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="command" value="run -p nuncio-cli -- account list" />
|
||||
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||
<option name="emulateTerminal" value="true" />
|
||||
<option name="channel" value="DEFAULT" />
|
||||
<option name="requiredFeatures" value="true" />
|
||||
<option name="allFeatures" value="false" />
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="buildTarget" value="REMOTE" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs />
|
||||
<method v="2">
|
||||
@@ -0,0 +1,16 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Run nuncio-cli (mail list)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="command" value="run -p nuncio-cli -- mail list" />
|
||||
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||
<option name="emulateTerminal" value="true" />
|
||||
<option name="channel" value="DEFAULT" />
|
||||
<option name="requiredFeatures" value="true" />
|
||||
<option name="allFeatures" value="false" />
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs />
|
||||
<method v="2">
|
||||
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,16 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Run nuncio-cli (mail sync)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="command" value="run -p nuncio-cli -- mail sync" />
|
||||
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||
<option name="emulateTerminal" value="true" />
|
||||
<option name="channel" value="DEFAULT" />
|
||||
<option name="requiredFeatures" value="true" />
|
||||
<option name="allFeatures" value="false" />
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs />
|
||||
<method v="2">
|
||||
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Run nunciod (daemon)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="command" value="run -p nunciod" />
|
||||
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||
<option name="emulateTerminal" value="true" />
|
||||
<option name="channel" value="DEFAULT" />
|
||||
<option name="requiredFeatures" value="true" />
|
||||
<option name="allFeatures" value="false" />
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs />
|
||||
<method v="2">
|
||||
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -2,61 +2,143 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Nuncio ([nuncio.mx](https://nuncio.mx)) is a cross-platform (Linux/macOS/Windows) mail, calendar, and contacts suite written in Rust: a central daemon plus four presentation shells (CLI, TUI, GUI, MCP).
|
||||
## What this repo is
|
||||
|
||||
## Commands
|
||||
Nuncio is a **local-first mail, calendar, and contacts engine** written in Rust
|
||||
(edition 2021, Cargo workspace). It is **pre-alpha and under active
|
||||
reconstruction** after a 2026-07-26 forensic assessment found the original
|
||||
AI-generated proof of concept was largely non-functional and its documentation
|
||||
substantially fabricated. **Trust the code and this file over any older prose.**
|
||||
|
||||
Cargo aliases are defined in `.cargo/config.toml`:
|
||||
Read these before substantive work:
|
||||
- [`docs/ROADMAP.md`](docs/ROADMAP.md) — the authoritative plan and target architecture (rendered page: <https://koftwentytwo.github.io/nuncio/roadmap/>)
|
||||
- [`docs/STORY-WORKFLOW.md`](docs/STORY-WORKFLOW.md) — how to execute a roadmap story (gates, patterns to mirror, branch/PR/review flow)
|
||||
- [`docs/HANDOFF.md`](docs/HANDOFF.md) — current state, branches, and the M1–M7 story index for picking up on a new machine
|
||||
- [`docs/adr/0001-engine-first-grpc-architecture.md`](docs/adr/0001-engine-first-grpc-architecture.md) — the governing architecture decision
|
||||
- [`docs/BACKLOG.md`](docs/BACKLOG.md) — engineering-ready Phase 0 / Phase 1 stories (Phase 2+ is in `docs/ROADMAP.md`)
|
||||
|
||||
## Architecture (target)
|
||||
|
||||
The daemon is the product; UIs are thin clients over a published contract.
|
||||
|
||||
- **`nunciod`** owns all state (SQLite/WAL), credentials (OS keyring), protocol
|
||||
sync logic, the filter engine, and all business logic. The `nuncio-*` library
|
||||
crates are composed by the daemon.
|
||||
- The daemon **publishes a versioned gRPC API** (`proto/nuncio/v1/*.proto`, being
|
||||
introduced in roadmap Phase 1) over **loopback TCP with a bearer-token
|
||||
handshake** (token in the OS keyring). **Server-streaming RPCs carry push**
|
||||
(new mail, sync progress, events).
|
||||
- The `.proto` files are the single source of truth for the contract. **Feature
|
||||
parity across clients is guaranteed by the contract, not by discipline.**
|
||||
- **`nuncio-cli` is the in-repo reference client** and E2E driver. All other UIs
|
||||
(native macOS/Windows GUIs, TUI, MCP bridge) become separate repositories.
|
||||
|
||||
### Current vs target state
|
||||
|
||||
The presentation shells (`nuncio-tui`, `nuncio-gui`, `nuncio-mcp`) have been moved
|
||||
to `_reference/` (out of the workspace) and will be rebuilt as separate native
|
||||
client repos. The daemon serves a single transport — the **gRPC API on loopback
|
||||
`127.0.0.1:9420`**; the old hand-rolled JSON-RPC IPC has been removed.
|
||||
|
||||
### Library-first boundary
|
||||
|
||||
Business logic, protocol clients, storage, search, and key management live in the
|
||||
headless crates (`nuncio-core`, `nuncio-store`, `nuncio-mail`, `nuncio-cal`,
|
||||
`nuncio-contacts`, `nuncio-filter`). Presentation code must remain a thin client
|
||||
of the daemon's API and hold no business logic or private data store.
|
||||
|
||||
## Build, test, and quality gates
|
||||
|
||||
Warnings are hard errors and `unsafe_code` is forbidden (see
|
||||
[`.cargo/config.toml`](.cargo/config.toml) and workspace lints in
|
||||
[`Cargo.toml`](Cargo.toml): `unwrap_used`, `expect_used`, `panic`, `todo` are all
|
||||
`deny`).
|
||||
|
||||
Cargo aliases (defined in `.cargo/config.toml`):
|
||||
|
||||
```bash
|
||||
cargo check-all # clippy --all-targets --workspace -- -D warnings
|
||||
cargo test-all # cargo test --workspace
|
||||
cargo cov # cargo llvm-cov --workspace --fail-under-lines 100 (requires cargo-llvm-cov)
|
||||
cargo verify # fmt --check + check-all + test-all (run before every push/PR)
|
||||
cargo check-all # = clippy --all-targets --workspace -- -D warnings (the lint gate)
|
||||
cargo test-all # = test --workspace
|
||||
cargo cov # = llvm-cov --workspace --ignore-filename-regex main.rs --summary-only (informational; needs cargo-llvm-cov)
|
||||
cargo verify # = fmt --all --check + check-all + test-all
|
||||
```
|
||||
|
||||
- Run a single test: `cargo test -p nuncio-store test_name` (or `cargo test --test integration_matrix` for root-level integration tests in `tests/`).
|
||||
- CI's coverage gate excludes binary entry points: `cargo llvm-cov --workspace --ignore-filename-regex "main\.rs" --fail-under-lines 100`.
|
||||
- The pre-commit hook (`.githooks/pre-commit`) runs fmt check, clippy, and the full test suite; a commit that hasn't passed `cargo verify` will fail it.
|
||||
- GUI frontend (React 18 + Vite + TypeScript) lives in `crates/nuncio-gui/ui/`: `npm run dev` / `npm run build` from that directory. The Tauri shell (`crates/nuncio-gui/src-tauri/`) builds the frontend automatically via its `beforeBuildCommand`.
|
||||
- Run shells: `cargo run -p nuncio-cli -- status`, `cargo run -p nuncio-tui`, `cargo run -p nuncio-gui`, `cargo run -p nunciod` (daemon).
|
||||
Common commands:
|
||||
|
||||
Warnings are hard errors everywhere: `.cargo/config.toml` sets `rustflags = ["-D", "warnings", "-F", "unsafe_code", "-D", "unused_must_use"]`, and workspace lints in `Cargo.toml` deny `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::todo`, `clippy::unimplemented`, and `clippy::unreachable`. `unwrap()`/`expect()` are permitted only in tests. Use `thiserror` error enums in library crates.
|
||||
```bash
|
||||
cargo build --workspace
|
||||
cargo test -p nuncio-store # one crate
|
||||
cargo test -p nuncio-filter parser:: # one module's tests
|
||||
cargo test -p nunciod --test daemon_e2e_test # one integration suite
|
||||
cargo run -p nunciod # start the daemon (gRPC on 127.0.0.1:9420)
|
||||
cargo run -p nuncio-cli -- system status # drive it via the CLI (daemon must be running)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
> **Gate status:** the workspace is green under the pinned toolchain
|
||||
> (`rust-toolchain.toml` = 1.97.1), so the local pre-commit hook runs the exact
|
||||
> `fmt`/`clippy --all-targets`/tests that CI does — "green locally" is CI-equivalent.
|
||||
> CI itself is paused until the GitHub Actions minutes reset (releases are tag-only).
|
||||
> Coverage is informational, not a 100% gate.
|
||||
|
||||
**Hybrid daemon-first model.** All state, storage, credentials, and protocol sync live in a standalone background daemon (`nunciod`). The four UIs are thin clients that talk to it over OS-native IPC — a UNIX domain socket (`~/.nuncio/nuncio.sock`) on POSIX, a named pipe (`\\.\pipe\nuncio-ipc`) on Windows — using JSON-RPC 2.0 framing. The client side (`IpcClient`, in `crates/nuncio-core/src/ipc/`) auto-spawns the daemon and retries; the server side (`IpcDaemonServer`) fans events out to all connected shells via the `nuncio-core` `EventBus` (`CoreCommand` in, `CoreEvent`/`AppState` snapshots out).
|
||||
## RustRover / IDE
|
||||
|
||||
**Headless engine crates** (all business logic MUST live here, never in shells):
|
||||
A shared RustRover setup lives in `.idea/` (the useful parts are tracked;
|
||||
`workspace.xml`/`*.iml` and other local churn are gitignored):
|
||||
- **Cargo project** auto-resolves from the root `Cargo.toml`; after pulling changes
|
||||
that add or move crates, run **Reload Cargo Project** (RustRover usually prompts).
|
||||
- **Toolchain** comes from `rust-toolchain.toml` (1.97.1) automatically.
|
||||
- **rustfmt on save** is enabled (`.idea/codeStyles/Project.xml`).
|
||||
- **Shared run configurations** (`.idea/runConfigurations/`):
|
||||
- *Run nunciod (daemon)* — start the daemon.
|
||||
- *Run nuncio-cli (system status)* — drive it (start the daemon first).
|
||||
- *Cargo Build Release (daemon + CLI)* — `cargo build-release`.
|
||||
- *Cargo Check All* · *Cargo Test All* · *Cargo Verify (fmt + clippy + tests)* — the gates.
|
||||
- *Cargo Coverage* — informational `llvm-cov` (needs `cargo-llvm-cov` installed).
|
||||
- Tip: set Clippy as the external linter (Settings → Rust → External Linters) so the
|
||||
editor mirrors the `-D warnings` gate.
|
||||
- **Full walkthrough:** [`docs/DEVELOPING-IN-RUSTROVER.md`](docs/DEVELOPING-IN-RUSTROVER.md)
|
||||
— setup, running the daemon + CLI, running/debugging tests, gates, and the dev loop.
|
||||
|
||||
- `nuncio-core` — domain models (`Email`, `CalendarEvent`, `Contact`), `EventBus`, IPC client/server + framing, config, E2EE (OpenPGP/S-MIME), WASM plugins, `McpAgentPolicy` RBAC, WORM audit, self-update.
|
||||
- `nuncio-mail` — IMAP4rev1, JMAP (RFC 8620/8621), SMTP (`lettre`), MIME parsing.
|
||||
- `nuncio-cal` — CalDAV (RFC 4791), iCalendar (RFC 5545), `rrule` recurrence, NLP scheduler.
|
||||
- `nuncio-contacts` — CardDAV sync (RFC 6352), vCard 4.0, email contact harvester.
|
||||
- `nuncio-store` — SQLite WAL (`sqlx`), FTS5 trigram search, AES-256-GCM + `age` encryption at rest, OS keyring vault, WORM audit ledger.
|
||||
- `nuncio-filter` — NSQL declarative filter language (parsed with `sqlparser`), AST actions, validator, dry-run, HMAC webhooks. Spec: `docs/NSQL-Filter-Language-Specification.md`.
|
||||
## Conventions
|
||||
|
||||
**Presentation shells** (thin UI layers over `IpcClient` only): `nuncio-cli` (Noun+Verb subcommands, `--json` output), `nuncio-tui` (Ratatui, Vim keys), `nuncio-gui` (Rust view/sandbox layer + `src-tauri` Tauri v2 shell + `ui/` React frontend — two workspace crates plus an npm package), `nuncio-mcp` (MCP stdio server exposing tools/resources/prompts to LLM agents), `nunciod` (the daemon binary itself).
|
||||
- **Rust edition 2021**; `cargo fmt` enforced (no unformatted code).
|
||||
- **Error handling:** `thiserror` in library crates; `unwrap()`/`expect()` are
|
||||
forbidden in production code (tests only).
|
||||
- **Security:** never store plaintext credentials/keys in source or SQLite; route
|
||||
secrets through the OS keyring; encrypt attachments at rest; sandbox untrusted
|
||||
HTML email in an `<iframe sandbox>` with JS disabled. (Several of these are
|
||||
aspirational in the current tree — see the assessment; Phase 0.E hardens them.)
|
||||
- **Commits:** Conventional Commits (`feat(scope): …`, `fix(scope): …`), imperative,
|
||||
<72-char subjects, no AI attribution.
|
||||
- **Comments:** explain the code's *intent, constraints, and rationale*. Do NOT
|
||||
put backlog-story numbers (`1.C.2`), GH issue references (`#156`, `GH-156`,
|
||||
`Refs #`), or `Phase N` breadcrumbs in source comments (`.rs`/`.proto`) — that
|
||||
traceability belongs in git history and `docs/`, and it rots in the code.
|
||||
Keep genuine technical rationale (RFC citations, algorithm notes, why-not-X).
|
||||
(Commit messages may still cite `Refs #NNN`.)
|
||||
- **Branches:** feature branches; never commit directly to `main`. Branch flow is
|
||||
`dev` → `rc` → `main`.
|
||||
- **A capability is "done" only when engine + proto + CLI command + offline E2E
|
||||
test all exist and CI is green.** No fabricated success output — an unimplemented
|
||||
path returns an honest error, never fake data.
|
||||
- **Roadmap is dual-format — keep it in sync.** `docs/ROADMAP.md` is the source of
|
||||
truth; `docs/roadmap/index.html` is its rendered GitHub Pages page
|
||||
(<https://koftwentytwo.github.io/nuncio/roadmap/>, linked from `README.md`).
|
||||
Any change to milestones, status, or scope MUST update **both** in the same
|
||||
commit — never let the rendered page drift from the markdown, and keep the
|
||||
`README.md` link valid.
|
||||
|
||||
**Standing rule — 100% four-shell feature parity.** Any feature exposed in one shell MUST be implemented in all four: CLI subcommand (with `--json`), TUI keybinding/modal, GUI React component, and MCP tool handler.
|
||||
## Testing standards
|
||||
|
||||
## Testing
|
||||
- 100% of `cargo test-all` must pass before committing.
|
||||
- Integration tests use ephemeral SQLite (`tempfile` / `:memory:`); no state
|
||||
leakage between tests.
|
||||
- **All external protocols (IMAP/JMAP/SMTP/CalDAV/CardDAV) must be mocked**
|
||||
(`wiremock` or the `Mock*Backend` traits); the OS keyring is mocked via
|
||||
`MockKeyring`. **Live network calls in tests are forbidden.**
|
||||
|
||||
- 100% line coverage is enforced (`cargo cov`); all workspace tests must pass locally before committing.
|
||||
- Live network calls in tests are forbidden. Mock all protocols (IMAP/JMAP/SMTP/CalDAV/CardDAV) with `wiremock` or mock traits (`MockMailBackend`, `MockCalendarBackend`), and the OS vault with the in-memory `MockKeyring`.
|
||||
- Integration/E2E tests use isolated ephemeral SQLite databases (`tempfile` or `:memory:`, e.g. `DatabaseEngine::connect_ephemeral()`); no state leakage between tests. Root-level `tests/` holds the cross-crate E2E matrix (`integration_matrix.rs`, `ephemeral_harness.rs`).
|
||||
## Release policy
|
||||
|
||||
## Security
|
||||
|
||||
- Never store plain-text passwords, tokens, or keys in source or SQLite — credentials go to OS vaults via `keyring`; attachments encrypted at rest via `age`; credential fields use `ZeroizeOnDrop`.
|
||||
- Untrusted HTML email renders only inside `<iframe sandbox>` with JS disabled.
|
||||
|
||||
## Commits, Branches & Releases
|
||||
|
||||
- Conventional Commits (`feat(scope): description`), subject under 72 chars, imperative mood, zero AI attribution.
|
||||
- All work on feature branches (`feature/GH-123-description`); never commit directly to `main`.
|
||||
- SemVer 2.0.0; tags `vMAJOR.MINOR.PATCH`. **Unified V3 release mandate:** no public release tag or binary until Phase V3 is 100% complete and verified across all four shells on Linux, macOS, and Windows. Pushing a `v*.*.*` tag triggers `.github/workflows/release.yml` (packages + SHA256 checksums for `.zip`/`.msi`, `.tar.gz`/`.dmg`, `.tar.gz`/`.AppImage`).
|
||||
|
||||
## Planning & Task Tracking
|
||||
|
||||
Roadmap and issue state live on GitHub (Project board #5, milestones, issues) and in `docs/` (`PLAN-production-roadmap-100-plus.md`, `TODO.md`, `SESSION-STATE.md`). In the multi-agent split, Claude Code owns the headless engine crates (`nuncio-core`, `nuncio-mail`, `nuncio-cal`, `nuncio-store`, and by extension `nuncio-contacts`/`nuncio-filter`): protocol parsers, FTS5 migrations, `rrule` math, encryption, and coverage. CLI work is assigned to Codex; TUI/GUI/CI to Antigravity workers.
|
||||
SemVer 2.0.0; release tags are `vMAJOR.MINOR.PATCH`. **No release should be
|
||||
presented as functional until the engine and its API are solid (roadmap Phase 4).**
|
||||
Existing public pre-1.0 releases are non-functional and are being marked pre-alpha.
|
||||
Generated
+330
-3408
File diff suppressed because it is too large.
Load diff
+7
-4
@@ -5,14 +5,11 @@ members = [
|
||||
"crates/nuncio-mail",
|
||||
"crates/nuncio-cal",
|
||||
"crates/nuncio-store",
|
||||
"crates/nuncio-tui",
|
||||
"crates/nuncio-gui",
|
||||
"crates/nuncio-gui/src-tauri",
|
||||
"crates/nuncio-cli",
|
||||
"crates/nuncio-mcp",
|
||||
"crates/nunciod",
|
||||
"crates/nuncio-filter",
|
||||
"crates/nuncio-contacts",
|
||||
"crates/nuncio-proto",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -81,6 +78,12 @@ hex = "0.4"
|
||||
flate2 = "1.0"
|
||||
tar = "0.4"
|
||||
zip = { version = "8.6", default-features = false, features = ["deflate"] }
|
||||
tonic = "0.14"
|
||||
tonic-prost = "0.14"
|
||||
prost = "0.14"
|
||||
tonic-prost-build = "0.14"
|
||||
protoc-bin-vendored = "3.2"
|
||||
subtle = "2.6"
|
||||
|
||||
|
||||
|
||||
@@ -1,176 +1,73 @@
|
||||
# Nuncio
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/nuncio_app_icon.jpg" alt="Nuncio Application Icon" width="128" style="border-radius: 24px;" />
|
||||
</p>
|
||||
**A local-first mail, calendar, and contacts engine written in Rust.**
|
||||
|
||||
<h3 align="center">Nuncio Mail & Calendar Suite</h3>
|
||||
> **Status: pre-alpha, under active reconstruction (2026-07-26).**
|
||||
> Nuncio began as an AI-generated proof of concept. A forensic assessment found
|
||||
> that while several library-layer components are genuinely well-built, the
|
||||
> end-to-end product did not work and much of the prior documentation was
|
||||
> inaccurate. The project is being rebuilt engine-first. **It is not usable yet,
|
||||
> and published pre-1.0 releases are non-functional — do not install them.**
|
||||
> See the **[live roadmap](https://koftwentytwo.github.io/nuncio/roadmap/)**
|
||||
> (source: [`docs/ROADMAP.md`](docs/ROADMAP.md)) for the plan and
|
||||
> [`docs/adr/0001-engine-first-grpc-architecture.md`](docs/adr/0001-engine-first-grpc-architecture.md)
|
||||
> for the architecture decision.
|
||||
|
||||
<p align="center">
|
||||
<b>High-Performance Library-First Messenger and Calendar Courier for Developers, Power Users, and AI Agents</b>
|
||||
</p>
|
||||
## What Nuncio is
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nuncio.mx">Official Site: nuncio.mx</a> •
|
||||
<a href="https://github.com/KofTwentyTwo/nuncio">GitHub Repository</a>
|
||||
</p>
|
||||
A background daemon (`nunciod`) owns all state, credentials, and protocol logic
|
||||
(IMAP/SMTP, then JMAP, CalDAV, CardDAV), and **publishes a versioned gRPC API over
|
||||
loopback**. User interfaces are separate native client projects that consume that
|
||||
API — they hold no business logic and no data of their own. This makes feature
|
||||
parity across clients a property of the published contract rather than a matter of
|
||||
discipline.
|
||||
|
||||
> **Etymology**: Derived from the Latin verb ***nūntiō*** ("I announce", "I declare", "I deliver a message") and noun ***nūntius*** ("messenger", "courier", "bearer of tidings"). Nuncio is built as the ultimate cross-platform messenger and calendar courier across Linux, macOS, and Windows.
|
||||
The only client in this repository is `nuncio-cli`, which serves as the reference
|
||||
API consumer and the driver for end-to-end tests. Native GUIs (macOS/Windows), a
|
||||
TUI, and an MCP bridge will live in separate repositories once the engine and its
|
||||
API are solid.
|
||||
|
||||
---
|
||||
## Workspace layout
|
||||
|
||||
## Visual Application Preview
|
||||
Engine libraries composed by the daemon:
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 4 Great Interfaces + Central Daemon Topology
|
||||
|
||||
Nuncio operates on a **Hybrid Daemon-First Architecture**. Centralized state management, SQLite WAL persistence, credential security enclaves, and protocol synchronizers reside inside a standalone background daemon (`nunciod`). Four decoupled interfaces communicate with `nunciod` over native IPC socket streams:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Four Great Interfaces
|
||||
CLI["nuncio-cli (POSIX CLI)"]
|
||||
TUI["nuncio-tui (Ratatui TUI)"]
|
||||
GUI["nuncio-gui (Tauri v2 Desktop GUI)"]
|
||||
MCP["nuncio-mcp (Native MCP LLM Agent Interface)"]
|
||||
end
|
||||
|
||||
subgraph IPC Client Engine
|
||||
Client["IpcClient (Auto-Spawn + Retry Loop + JSON-RPC 2.0 Framing)"]
|
||||
end
|
||||
|
||||
subgraph Native OS Sockets
|
||||
UnixSocket["POSIX: UNIX Domain Socket (~/.nuncio/nuncio.sock)"]
|
||||
WinPipe["Windows: Named Pipe (\\\\.\\pipe\\nuncio-ipc)"]
|
||||
end
|
||||
|
||||
subgraph Central Background Daemon
|
||||
DaemonServer["IpcDaemonServer (Security Enclave)"]
|
||||
DaemonProcess["nunciod Daemon Process"]
|
||||
EventBus["nuncio-core EventBus"]
|
||||
end
|
||||
|
||||
subgraph Core Engine & Protocol Libraries
|
||||
MailEngine["nuncio-mail (IMAP / SMTP / JMAP)"]
|
||||
CalEngine["nuncio-cal (CalDAV / iCal / rrule)"]
|
||||
StoreEngine["nuncio-store (SQLite WAL / FTS5 Trigram / Age Cipher)"]
|
||||
end
|
||||
|
||||
CLI --> Client
|
||||
TUI --> Client
|
||||
GUI --> Client
|
||||
MCP --> Client
|
||||
|
||||
Client --> UnixSocket
|
||||
Client --> WinPipe
|
||||
|
||||
UnixSocket --> DaemonServer
|
||||
WinPipe --> DaemonServer
|
||||
|
||||
DaemonServer --> DaemonProcess
|
||||
DaemonProcess --> EventBus
|
||||
|
||||
EventBus --> MailEngine
|
||||
EventBus --> CalEngine
|
||||
EventBus --> StoreEngine
|
||||
```
|
||||
|
||||
1. **`nuncio-cli` (POSIX CLI)**: Scriptable `<Noun> <Verb>` commands with deterministic `--json` output and offline-resilient local SQLite reads.
|
||||
2. **`nuncio-tui` (Terminal TUI)**: Keyboard-first 3-pane split view built in Ratatui with Vim navigation (`j`/`k`/`h`/`l`) and live `CoreEvent` push updates.
|
||||
3. **`nuncio-gui` (Tauri v2 Desktop GUI)**: Cross-platform native window host with React 18 + Vite + TypeScript frontend, glassmorphic styling, and strict HTML `<iframe sandbox>` CSP email rendering.
|
||||
4. **`nuncio-mcp` (Native LLM Agent UI)**: Model Context Protocol (MCP) JSON-RPC 2.0 stdio server exposing tools (`nuncio_mail_send`, `nuncio_cal_create_event`), resources (`nuncio://mail/inbox`), and prompts for local AI agents (Claude, Cursor, Antigravity).
|
||||
5. **`nunciod` (Central Daemon Binary)**: Standalone background process owning SQLite WAL storage, security enclaves, background protocol sync loops, and multi-client IPC socket streams.
|
||||
|
||||
---
|
||||
|
||||
## Workspace Crate Architecture
|
||||
|
||||
The Nuncio Cargo workspace is modularized into 11 crates with strict domain boundaries:
|
||||
|
||||
| Crate Path | Architectural Role & Description |
|
||||
| Crate | Role |
|
||||
| :--- | :--- |
|
||||
| `crates/nuncio-core` | Core domain models (`Email`, `CalendarEvent`, `Contact`), `EventBus`, OpenPGP/S/MIME E2EE, WASM Plugins, Local LLM Summarization, and `IpcClient`/`IpcDaemonServer` framing. |
|
||||
| `crates/nuncio-mail` | IMAP4rev1, JMAP (RFC 8620/8621), SMTP transport engines, and MIME stream parser. |
|
||||
| `crates/nuncio-cal` | CalDAV (RFC 4791) client, iCalendar (RFC 5545) parser, `rrule` recurrence engine, and Natural Language NLP scheduler. |
|
||||
| `crates/nuncio-contacts` | Sovereign SQLite contacts engine, CardDAV client sync (RFC 6352), email contact harvester, and vCard 4.0 generator. |
|
||||
| `crates/nuncio-store` | SQLite WAL persistence (`DatabaseEngine`), FTS5 trigram search index (<10ms across 100k+ emails), AES-256-GCM cipher, `age` stream cipher, OS Keyring vault, and WORM audit ledger. |
|
||||
| `crates/nuncio-filter` | Declarative NSQL filter engine (`sqlparser 0.54`), AST actions, 6-pass validator, dry-run tester, and HMAC webhooks. |
|
||||
| `crates/nuncio-cli` | Pure Noun + Verb CLI runner and Unix pipe scripting engine. |
|
||||
| `crates/nuncio-tui` | Terminal user interface powered by Ratatui with Vim motion velocity (`j`/`k`/`h`/`l`/`g i`/`e`/`s`). |
|
||||
| `crates/nuncio-gui` | Native desktop GUI application shell powered by Tauri v2 and React. |
|
||||
| `crates/nuncio-mcp` | MCP stdio server providing LLM agents direct read/write access with `McpAgentPolicy` RBAC. |
|
||||
| `crates/nunciod` | Standalone background daemon binary server. |
|
||||
| `crates/nuncio-core` | Domain models, event bus, IPC/API layer, cross-cutting services |
|
||||
| `crates/nuncio-store` | SQLite (WAL) persistence, FTS search, ciphers, corruption recovery, keyring vault |
|
||||
| `crates/nuncio-mail` | IMAP, JMAP, SMTP protocol engines and MIME handling |
|
||||
| `crates/nuncio-cal` | iCalendar, CalDAV, CardDAV, recurrence, scheduling |
|
||||
| `crates/nuncio-contacts` | Contacts store, CardDAV sync, vCard |
|
||||
| `crates/nuncio-filter` | NSQL declarative filter language (parser, validator, engine) |
|
||||
| `crates/nunciod` | The daemon binary — owns state and serves the API |
|
||||
| `crates/nuncio-cli` | Reference API client + E2E driver |
|
||||
|
||||
---
|
||||
The former `nuncio-tui`, `nuncio-gui`, and `nuncio-mcp` shells have been moved to
|
||||
`_reference/` (out of the workspace) and will be rebuilt as separate client
|
||||
repositories (roadmap Phase 5).
|
||||
|
||||
## CLI Command Usage (Pure Noun + Verb Syntax)
|
||||
|
||||
Nuncio CLI enforces standardized `<Noun> <Verb> [Flags]` syntax with optional `--json` output envelopes:
|
||||
## Build & test
|
||||
|
||||
```bash
|
||||
# Account Management
|
||||
nuncio account add --email james.maes@kof22.com --imap-host mail.kof22.com --imap-port 993
|
||||
nuncio account list --json
|
||||
|
||||
# Mail Operations
|
||||
nuncio mail sync [--account <id>]
|
||||
nuncio mail list --folder INBOX --limit 50
|
||||
nuncio mail read --id msg_123
|
||||
nuncio mail search --query "roadmap"
|
||||
nuncio mail send --to alice@nuncio.mx --subject "Release Update" --body "Message body"
|
||||
|
||||
# Calendar Operations
|
||||
nuncio cal list --calendar work
|
||||
nuncio cal create --summary "Team Standup" --start 1774348800 --end 1774352400
|
||||
|
||||
# System Status
|
||||
nuncio system status --json
|
||||
nuncio daemon
|
||||
cargo build --workspace # build everything
|
||||
cargo check-all # clippy, all targets, warnings-as-errors (the gate)
|
||||
cargo test-all # run the whole test suite
|
||||
cargo verify # fmt --check + check-all + test-all
|
||||
cargo test -p nuncio-store # test a single crate
|
||||
```
|
||||
|
||||
---
|
||||
Cargo aliases are defined in [`.cargo/config.toml`](.cargo/config.toml).
|
||||
|
||||
## Quality Gates & Verification Commands
|
||||
## Documentation
|
||||
|
||||
Build configurations enforce strict zero-warning policy (`-D warnings -F unsafe_code -D unused_must_use`):
|
||||
- **[Roadmap — live rendered page](https://koftwentytwo.github.io/nuncio/roadmap/)** — the visual plan to a client-ready backend (GitHub Pages)
|
||||
- [`docs/ROADMAP.md`](docs/ROADMAP.md) — authoritative roadmap and target architecture (source for the page above)
|
||||
- [`docs/STORY-WORKFLOW.md`](docs/STORY-WORKFLOW.md) — how contributors execute a roadmap story (gates, patterns, review flow)
|
||||
- [`docs/HANDOFF.md`](docs/HANDOFF.md) — development handoff: current state, branches, and the M1–M7 story index
|
||||
- [`docs/BACKLOG.md`](docs/BACKLOG.md) — engineering-ready Phase 0 / Phase 1 stories
|
||||
- [`docs/adr/`](docs/adr/) — architecture decision records
|
||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — technical architecture
|
||||
|
||||
```bash
|
||||
# 1. Compiler & Linter Quality Gate (Zero warnings allowed)
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace -- -D warnings
|
||||
## License
|
||||
|
||||
# 2. Run Full Integration Test Suite (100 tests across 9 crates)
|
||||
cargo test --workspace
|
||||
|
||||
# 3. Formatter Gate
|
||||
cargo fmt --all --check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Master Architectural Documentation & Roadmaps
|
||||
|
||||
- **[Enhanced Production Roadmap (V1, V2, V3)](docs/PLAN-enhanced-roadmap-v1-v2-v3.md)**: Deep commercial feature specification benchmarked against Superhuman, Mimestream, Apple Mail, Outlook, Hey, Cron, and Fantastical.
|
||||
- **[Hybrid Daemon Architecture Blueprint](docs/PLAN-hybrid-daemon-architecture.md)**: Sockets, framing, auto-spawning, and security enclave design.
|
||||
- **[Executive Audit Review Report](docs/EXECUTIVE-AUDIT-REVIEW.md)**: Authoritative technical audit scorecard across architecture, security, code quality, UI/UX, and performance.
|
||||
- **[Architecture Specification](docs/ARCHITECTURE.md)**: Domain encapsulation, Hexagonal Ports & Adapters model, and IPC streaming contracts.
|
||||
|
||||
---
|
||||
|
||||
## Single Source of Truth for Execution
|
||||
|
||||
Project planning, issue tracking, atomic micro-milestones, and subagent assignments are authoritatively managed directly on GitHub:
|
||||
|
||||
- **GitHub Project Board**: [Nuncio Roadmap Project #5](https://github.com/users/KofTwentyTwo/projects/5)
|
||||
- **GitHub Milestones**: [KofTwentyTwo/nuncio/milestones](https://github.com/KofTwentyTwo/nuncio/milestones) (`v0.1.0` through `v1.0.0`)
|
||||
- **GitHub Issues**: [KofTwentyTwo/nuncio/issues](https://github.com/KofTwentyTwo/nuncio/issues)
|
||||
|
||||
---
|
||||
|
||||
## License & Open Source Acknowledgments
|
||||
|
||||
- **Nuncio License**: MIT OR Apache-2.0
|
||||
- **Third-Party Open Source Libraries**: See **[THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md)** for full credits and license terms (Tokio, Ratatui, Tauri v2, SQLx, Lettre, async-imap, RustCrypto AES-GCM, age, Zeroize, Keyring, React, Lucide, Vite).
|
||||
MIT OR Apache-2.0.
|
||||
@@ -0,0 +1,40 @@
|
||||
# `_reference/` — archived pre-gRPC presentation shells
|
||||
|
||||
This directory holds three presentation shells that were removed from the
|
||||
Cargo workspace (backlog **0.D.2**, GH-145) as part of shrinking the
|
||||
workspace to just the engine, the daemon, and the reference CLI:
|
||||
|
||||
- `nuncio-tui` — the Ratatui terminal UI shell
|
||||
- `nuncio-gui` — the Rust GUI core plus its `src-tauri` Tauri desktop shell
|
||||
and its `ui/` React/Vite frontend
|
||||
- `nuncio-mcp` — the MCP (Model Context Protocol) server shell for LLM tool
|
||||
access
|
||||
|
||||
They were built directly against the in-process `nuncio-core`/`nuncio-mail`/
|
||||
`nuncio-cal`/`nuncio-store`/`nuncio-filter`/`nuncio-contacts` library APIs,
|
||||
from the pre-gRPC phase of the project. Per the target architecture in
|
||||
[`docs/ROADMAP.md`](../docs/ROADMAP.md), `nunciod` is moving to publish a
|
||||
versioned gRPC API over loopback, and every presentation shell other than
|
||||
the in-repo `nuncio-cli` reference client is planned to live in its own
|
||||
separate native client repository (**Phase 5 — Client projects**):
|
||||
`nuncio-tui`, `nuncio-gui-macos`, `nuncio-gui-windows`, `nuncio-mcp`, etc.
|
||||
|
||||
**This code is retained intact, not deleted**, so it can be mined for UI/UX
|
||||
ideas, keybindings, view layout, and MCP tool-surface design when those
|
||||
Phase 5 client repos are built against the new gRPC API. It is **not**
|
||||
built, checked, or tested by the root Cargo workspace — none of these
|
||||
directories are members of the workspace `Cargo.toml`, so
|
||||
`cargo build --workspace`, `cargo check-all`, and `cargo test --workspace`
|
||||
never touch them.
|
||||
|
||||
Each crate's internal `path = "../..."` dependencies on the engine crates
|
||||
have been updated to point at `../../crates/<name>` (e.g.
|
||||
`_reference/nuncio-tui/Cargo.toml` now points at `../../crates/nuncio-core`)
|
||||
so the crate sources still resolve correctly relative to their new location.
|
||||
They are **not guaranteed to build standalone** as-is: they still declare
|
||||
`*.workspace = true` fields (`version`, `edition`, `lints`, and workspace
|
||||
`[dependencies]` aliases) that only resolve inside a Cargo workspace, and
|
||||
they are not their own workspace root. Restoring a standalone build (for
|
||||
mining or resurrection) will require either vendoring those workspace
|
||||
values directly into each `Cargo.toml`, or giving `_reference/` its own
|
||||
`[workspace]` root.
|
||||
@@ -10,10 +10,10 @@ repository.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nuncio-core = { path = "../nuncio-core" }
|
||||
nuncio-mail = { path = "../nuncio-mail" }
|
||||
nuncio-cal = { path = "../nuncio-cal" }
|
||||
nuncio-store = { path = "../nuncio-store" }
|
||||
nuncio-core = { path = "../../crates/nuncio-core" }
|
||||
nuncio-mail = { path = "../../crates/nuncio-mail" }
|
||||
nuncio-cal = { path = "../../crates/nuncio-cal" }
|
||||
nuncio-store = { path = "../../crates/nuncio-store" }
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -10,7 +10,7 @@ repository.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nuncio-core = { path = "../../nuncio-core" }
|
||||
nuncio-core = { path = "../../../crates/nuncio-core" }
|
||||
nuncio-gui = { path = ".." }
|
||||
tauri = { version = "2.0", features = [] }
|
||||
tauri-plugin-updater = "2.0"
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,7 @@
|
||||
//! Nuncio GUI Desktop Library.
|
||||
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
|
||||
|
||||
pub mod ipc;
|
||||
pub mod sandbox;
|
||||
pub mod view;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
@@ -1,5 +1,7 @@
|
||||
//! E2E System Test Suite for nuncio-gui.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use nuncio_core::{EngineStatus, EventBus};
|
||||
use nuncio_gui::{GuiViewState, HtmlSanitizer, IpcBridge, IpcCommandPayload};
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -10,12 +10,12 @@ repository.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nuncio-core = { path = "../nuncio-core" }
|
||||
nuncio-mail = { path = "../nuncio-mail" }
|
||||
nuncio-cal = { path = "../nuncio-cal" }
|
||||
nuncio-store = { path = "../nuncio-store" }
|
||||
nuncio-filter = { path = "../nuncio-filter" }
|
||||
nuncio-contacts = { path = "../nuncio-contacts" }
|
||||
nuncio-core = { path = "../../crates/nuncio-core" }
|
||||
nuncio-mail = { path = "../../crates/nuncio-mail" }
|
||||
nuncio-cal = { path = "../../crates/nuncio-cal" }
|
||||
nuncio-store = { path = "../../crates/nuncio-store" }
|
||||
nuncio-filter = { path = "../../crates/nuncio-filter" }
|
||||
nuncio-contacts = { path = "../../crates/nuncio-contacts" }
|
||||
|
||||
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
@@ -2,6 +2,8 @@
|
||||
//! Exposes a 4th presentation shell ("Native LLM Agent UI") allowing AI models
|
||||
//! full access to email, calendar, search, and storage features.
|
||||
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
|
||||
|
||||
pub mod resources;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
File renamed without changes.
@@ -48,13 +48,15 @@ impl McpResourceHandler {
|
||||
McpResourceDefinition {
|
||||
uri: "nuncio://filters".to_string(),
|
||||
name: "NSQL Filter Rules".to_string(),
|
||||
description: "Active server-side NSQL filter rules stored in Nuncio database.".to_string(),
|
||||
description: "Active server-side NSQL filter rules stored in Nuncio database."
|
||||
.to_string(),
|
||||
mime_type: "application/json".to_string(),
|
||||
},
|
||||
McpResourceDefinition {
|
||||
uri: "nuncio://system/status".to_string(),
|
||||
name: "System Health & Recovery Diagnostic Status".to_string(),
|
||||
description: "Database health probe and self-healing recovery diagnostic status.".to_string(),
|
||||
description: "Database health probe and self-healing recovery diagnostic status."
|
||||
.to_string(),
|
||||
mime_type: "application/json".to_string(),
|
||||
},
|
||||
]
|
||||
@@ -76,7 +78,11 @@ impl McpResourceHandler {
|
||||
Ok(json!({ "uri": uri, "content": accounts }))
|
||||
}
|
||||
"nuncio://filters" => {
|
||||
let rules = self.db.list_filter_rules().await.map_err(|e| e.to_string())?;
|
||||
let rules = self
|
||||
.db
|
||||
.list_filter_rules()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "uri": uri, "content": rules }))
|
||||
}
|
||||
"nuncio://system/status" => {
|
||||
@@ -21,7 +21,10 @@ impl McpServer {
|
||||
let (db_engine, dir) = DatabaseEngine::connect_ephemeral().await?;
|
||||
let db = Arc::new(db_engine);
|
||||
Ok(Self {
|
||||
tools: McpToolHandler::with_policy(db.clone(), nuncio_core::McpAgentPolicy::unrestricted("stdio-agent")),
|
||||
tools: McpToolHandler::with_policy(
|
||||
db.clone(),
|
||||
nuncio_core::McpAgentPolicy::unrestricted("stdio-agent"),
|
||||
),
|
||||
resources: McpResourceHandler::new(db),
|
||||
_tempdir: Some(Arc::new(dir)),
|
||||
})
|
||||
@@ -30,7 +33,10 @@ impl McpServer {
|
||||
/// Create a new `McpServer` wrapping shared `DatabaseEngine`.
|
||||
pub fn new(db: Arc<DatabaseEngine>) -> Self {
|
||||
Self {
|
||||
tools: McpToolHandler::with_policy(db.clone(), nuncio_core::McpAgentPolicy::unrestricted("stdio-agent")),
|
||||
tools: McpToolHandler::with_policy(
|
||||
db.clone(),
|
||||
nuncio_core::McpAgentPolicy::unrestricted("stdio-agent"),
|
||||
),
|
||||
resources: McpResourceHandler::new(db),
|
||||
_tempdir: None,
|
||||
}
|
||||
@@ -299,13 +299,24 @@ impl McpToolHandler {
|
||||
pub async fn call_tool(&self, name: &str, args: Value) -> Result<Value, String> {
|
||||
match name {
|
||||
"nuncio_mail_list" => {
|
||||
if !self.policy.is_data_type_allowed(DataType::Mail) || !self.policy.permissions.read_mail {
|
||||
return Err(format!("403 Forbidden: Agent '{}' lacks 'read_mail' permission", self.policy.agent_id));
|
||||
if !self.policy.is_data_type_allowed(DataType::Mail)
|
||||
|| !self.policy.permissions.read_mail
|
||||
{
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' lacks 'read_mail' permission",
|
||||
self.policy.agent_id
|
||||
));
|
||||
}
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
||||
let folder_id = args.get("folder_id").and_then(|v| v.as_str()).unwrap_or("INBOX");
|
||||
let folder_id = args
|
||||
.get("folder_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("INBOX");
|
||||
if !self.policy.is_folder_allowed(folder_id) {
|
||||
return Err(format!("403 Forbidden: Agent '{}' is restricted from folder '{}'", self.policy.agent_id, folder_id));
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' is restricted from folder '{}'",
|
||||
self.policy.agent_id, folder_id
|
||||
));
|
||||
}
|
||||
let messages = self
|
||||
.db
|
||||
@@ -330,16 +341,36 @@ impl McpToolHandler {
|
||||
Ok(json!({ "messages": filtered }))
|
||||
}
|
||||
"nuncio_mail_send" => {
|
||||
if !self.policy.is_data_type_allowed(DataType::Mail) || !self.policy.permissions.send_mail {
|
||||
return Err(format!("403 Forbidden: Agent '{}' lacks 'send_mail' permission", self.policy.agent_id));
|
||||
if !self.policy.is_data_type_allowed(DataType::Mail)
|
||||
|| !self.policy.permissions.send_mail
|
||||
{
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' lacks 'send_mail' permission",
|
||||
self.policy.agent_id
|
||||
));
|
||||
}
|
||||
let account_id = args.get("account_id").and_then(|v| v.as_str()).ok_or("missing account_id")?;
|
||||
let account_id = args
|
||||
.get("account_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing account_id")?;
|
||||
if !self.policy.is_account_allowed(account_id) {
|
||||
return Err(format!("403 Forbidden: Agent '{}' is restricted from account '{}'", self.policy.agent_id, account_id));
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' is restricted from account '{}'",
|
||||
self.policy.agent_id, account_id
|
||||
));
|
||||
}
|
||||
let recipient = args.get("recipient").and_then(|v| v.as_str()).ok_or("missing recipient")?;
|
||||
let subject = args.get("subject").and_then(|v| v.as_str()).ok_or("missing subject")?;
|
||||
let body = args.get("body").and_then(|v| v.as_str()).ok_or("missing body")?;
|
||||
let recipient = args
|
||||
.get("recipient")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing recipient")?;
|
||||
let subject = args
|
||||
.get("subject")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing subject")?;
|
||||
let body = args
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing body")?;
|
||||
|
||||
let email = Email {
|
||||
id: format!("mcp-outbound-{}", chrono::Utc::now().timestamp_millis()),
|
||||
@@ -365,13 +396,24 @@ impl McpToolHandler {
|
||||
Ok(json!({ "status": "queued_and_saved", "email_id": email.id }))
|
||||
}
|
||||
"nuncio_mail_search" => {
|
||||
if !self.policy.is_data_type_allowed(DataType::Mail) || !self.policy.permissions.read_mail {
|
||||
return Err(format!("403 Forbidden: Agent '{}' lacks 'read_mail' permission", self.policy.agent_id));
|
||||
if !self.policy.is_data_type_allowed(DataType::Mail)
|
||||
|| !self.policy.permissions.read_mail
|
||||
{
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' lacks 'read_mail' permission",
|
||||
self.policy.agent_id
|
||||
));
|
||||
}
|
||||
let query = args.get("query").and_then(|v| v.as_str()).ok_or("missing query")?;
|
||||
let query = args
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing query")?;
|
||||
let search_engine = SearchEngine::new(&self.db);
|
||||
let _ = search_engine.setup_fts_tables().await;
|
||||
let results = search_engine.search_messages(query).await.map_err(|e| e.to_string())?;
|
||||
let results = search_engine
|
||||
.search_messages(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let sanitized: Vec<_> = results
|
||||
.into_iter()
|
||||
@@ -384,10 +426,18 @@ impl McpToolHandler {
|
||||
Ok(json!({ "results": sanitized }))
|
||||
}
|
||||
"nuncio_cal_list_events" => {
|
||||
if !self.policy.is_data_type_allowed(DataType::Calendar) || !self.policy.permissions.read_calendar {
|
||||
return Err(format!("403 Forbidden: Agent '{}' lacks 'read_calendar' permission", self.policy.agent_id));
|
||||
if !self.policy.is_data_type_allowed(DataType::Calendar)
|
||||
|| !self.policy.permissions.read_calendar
|
||||
{
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' lacks 'read_calendar' permission",
|
||||
self.policy.agent_id
|
||||
));
|
||||
}
|
||||
let calendar_id = args.get("calendar_id").and_then(|v| v.as_str()).unwrap_or("work");
|
||||
let calendar_id = args
|
||||
.get("calendar_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("work");
|
||||
#[allow(clippy::type_complexity)]
|
||||
let rows: Vec<(String, String, String, String, i64, i64, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, account_id, calendar_id, summary, start_time, end_time, location FROM calendar_events WHERE calendar_id = ?"
|
||||
@@ -405,17 +455,40 @@ impl McpToolHandler {
|
||||
Ok(json!({ "events": filtered }))
|
||||
}
|
||||
"nuncio_cal_create_event" => {
|
||||
if !self.policy.is_data_type_allowed(DataType::Calendar) || !self.policy.permissions.write_calendar {
|
||||
return Err(format!("403 Forbidden: Agent '{}' lacks 'write_calendar' permission", self.policy.agent_id));
|
||||
if !self.policy.is_data_type_allowed(DataType::Calendar)
|
||||
|| !self.policy.permissions.write_calendar
|
||||
{
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' lacks 'write_calendar' permission",
|
||||
self.policy.agent_id
|
||||
));
|
||||
}
|
||||
let account_id = args.get("account_id").and_then(|v| v.as_str()).unwrap_or("acct-1");
|
||||
let account_id = args
|
||||
.get("account_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("acct-1");
|
||||
if !self.policy.is_account_allowed(account_id) {
|
||||
return Err(format!("403 Forbidden: Agent '{}' is restricted from account '{}'", self.policy.agent_id, account_id));
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' is restricted from account '{}'",
|
||||
self.policy.agent_id, account_id
|
||||
));
|
||||
}
|
||||
let calendar_id = args.get("calendar_id").and_then(|v| v.as_str()).ok_or("missing calendar_id")?;
|
||||
let summary = args.get("summary").and_then(|v| v.as_str()).ok_or("missing summary")?;
|
||||
let start_time = args.get("start_time").and_then(|v| v.as_i64()).ok_or("missing start_time")?;
|
||||
let end_time = args.get("end_time").and_then(|v| v.as_i64()).ok_or("missing end_time")?;
|
||||
let calendar_id = args
|
||||
.get("calendar_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing calendar_id")?;
|
||||
let summary = args
|
||||
.get("summary")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing summary")?;
|
||||
let start_time = args
|
||||
.get("start_time")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or("missing start_time")?;
|
||||
let end_time = args
|
||||
.get("end_time")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or("missing end_time")?;
|
||||
|
||||
let event = CalendarEvent {
|
||||
id: format!("mcp-evt-{}", chrono::Utc::now().timestamp_millis()),
|
||||
@@ -448,7 +521,10 @@ impl McpToolHandler {
|
||||
}
|
||||
"nuncio_account_list" => {
|
||||
if !self.policy.is_data_type_allowed(DataType::Mail) {
|
||||
return Err(format!("403 Forbidden: Agent '{}' lacks access to account list", self.policy.agent_id));
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' lacks access to account list",
|
||||
self.policy.agent_id
|
||||
));
|
||||
}
|
||||
let accounts = self.db.list_accounts().await.map_err(|e| e.to_string())?;
|
||||
let filtered: Vec<_> = accounts
|
||||
@@ -458,9 +534,18 @@ impl McpToolHandler {
|
||||
Ok(json!({ "accounts": filtered }))
|
||||
}
|
||||
"nuncio_account_add" => {
|
||||
let email = args.get("email").and_then(|v| v.as_str()).ok_or("missing email")?;
|
||||
let imap_host = args.get("imap_host").and_then(|v| v.as_str()).ok_or("missing imap_host")?;
|
||||
let imap_port = args.get("imap_port").and_then(|v| v.as_u64()).unwrap_or(993) as u16;
|
||||
let email = args
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing email")?;
|
||||
let imap_host = args
|
||||
.get("imap_host")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing imap_host")?;
|
||||
let imap_port = args
|
||||
.get("imap_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(993) as u16;
|
||||
|
||||
let acct = nuncio_core::AccountConfig {
|
||||
id: format!("acct-{}", chrono::Utc::now().timestamp_millis()),
|
||||
@@ -480,80 +565,147 @@ impl McpToolHandler {
|
||||
Ok(json!({ "status": "created", "account": acct }))
|
||||
}
|
||||
"nuncio_account_edit" => {
|
||||
let account_id = args.get("account_id").and_then(|v| v.as_str()).ok_or("missing account_id")?;
|
||||
let account_id = args
|
||||
.get("account_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing account_id")?;
|
||||
let email = args.get("email").and_then(|v| v.as_str());
|
||||
Ok(json!({ "status": "updated", "account_id": account_id, "updated_email": email }))
|
||||
}
|
||||
"nuncio_account_delete" => {
|
||||
let account_id = args.get("account_id").and_then(|v| v.as_str()).ok_or("missing account_id")?;
|
||||
let account_id = args
|
||||
.get("account_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing account_id")?;
|
||||
Ok(json!({ "status": "deleted", "account_id": account_id }))
|
||||
}
|
||||
"nuncio_account_test" => {
|
||||
let account_id = args.get("account_id").and_then(|v| v.as_str()).ok_or("missing account_id")?;
|
||||
let account_id = args
|
||||
.get("account_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing account_id")?;
|
||||
Ok(json!({ "status": "ok", "account_id": account_id, "latency_ms": 24 }))
|
||||
}
|
||||
"nuncio_licenses" => {
|
||||
Ok(json!({
|
||||
"licenses": [
|
||||
{ "name": "tokio", "license": "MIT", "description": "Asynchronous runtime engine" },
|
||||
{ "name": "ratatui", "license": "MIT", "description": "Terminal UI rendering engine" },
|
||||
{ "name": "tauri", "license": "MIT/Apache-2.0", "description": "Desktop WebView shell" },
|
||||
{ "name": "sqlx", "license": "MIT/Apache-2.0", "description": "Async SQLite database driver" },
|
||||
{ "name": "lettre", "license": "MIT", "description": "SMTP transport client" },
|
||||
{ "name": "async-imap", "license": "MIT/Apache-2.0", "description": "Async IMAP protocol client" },
|
||||
{ "name": "aes-gcm", "license": "MIT/Apache-2.0", "description": "AES-256-GCM authenticated encryption" },
|
||||
{ "name": "age", "license": "MIT/Apache-2.0", "description": "Attachment stream encryption cipher" },
|
||||
{ "name": "zeroize", "license": "MIT/Apache-2.0", "description": "Secure memory wiping" },
|
||||
{ "name": "keyring", "license": "MIT/Apache-2.0", "description": "OS key store integration" }
|
||||
]
|
||||
}))
|
||||
}
|
||||
"nuncio_licenses" => Ok(json!({
|
||||
"licenses": [
|
||||
{ "name": "tokio", "license": "MIT", "description": "Asynchronous runtime engine" },
|
||||
{ "name": "ratatui", "license": "MIT", "description": "Terminal UI rendering engine" },
|
||||
{ "name": "tauri", "license": "MIT/Apache-2.0", "description": "Desktop WebView shell" },
|
||||
{ "name": "sqlx", "license": "MIT/Apache-2.0", "description": "Async SQLite database driver" },
|
||||
{ "name": "lettre", "license": "MIT", "description": "SMTP transport client" },
|
||||
{ "name": "async-imap", "license": "MIT/Apache-2.0", "description": "Async IMAP protocol client" },
|
||||
{ "name": "aes-gcm", "license": "MIT/Apache-2.0", "description": "AES-256-GCM authenticated encryption" },
|
||||
{ "name": "age", "license": "MIT/Apache-2.0", "description": "Attachment stream encryption cipher" },
|
||||
{ "name": "zeroize", "license": "MIT/Apache-2.0", "description": "Secure memory wiping" },
|
||||
{ "name": "keyring", "license": "MIT/Apache-2.0", "description": "OS key store integration" }
|
||||
]
|
||||
})),
|
||||
"nuncio_filter_list" => {
|
||||
if !self.policy.is_data_type_allowed(DataType::FilterRules) || !self.policy.permissions.manage_filters {
|
||||
return Err(format!("403 Forbidden: Agent '{}' lacks 'manage_filters' permission", self.policy.agent_id));
|
||||
if !self.policy.is_data_type_allowed(DataType::FilterRules)
|
||||
|| !self.policy.permissions.manage_filters
|
||||
{
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' lacks 'manage_filters' permission",
|
||||
self.policy.agent_id
|
||||
));
|
||||
}
|
||||
let rules = self.db.list_filter_rules().await.map_err(|e| e.to_string())?;
|
||||
let rules = self
|
||||
.db
|
||||
.list_filter_rules()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "rules": rules }))
|
||||
}
|
||||
"nuncio_filter_create" => {
|
||||
if !self.policy.is_data_type_allowed(DataType::FilterRules) || !self.policy.permissions.manage_filters {
|
||||
return Err(format!("403 Forbidden: Agent '{}' lacks 'manage_filters' permission", self.policy.agent_id));
|
||||
if !self.policy.is_data_type_allowed(DataType::FilterRules)
|
||||
|| !self.policy.permissions.manage_filters
|
||||
{
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' lacks 'manage_filters' permission",
|
||||
self.policy.agent_id
|
||||
));
|
||||
}
|
||||
let name = args.get("name").and_then(|v| v.as_str()).ok_or("missing name")?;
|
||||
let sql = args.get("sql").and_then(|v| v.as_str()).ok_or("missing sql")?;
|
||||
let name = args
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing name")?;
|
||||
let sql = args
|
||||
.get("sql")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing sql")?;
|
||||
let priority = args.get("priority").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
|
||||
let rule = nuncio_filter::NsqlParser::parse_rule(name, priority, sql).map_err(|e| e.to_string())?;
|
||||
let rule = nuncio_filter::NsqlParser::parse_rule(name, priority, sql)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let opts = nuncio_filter::ValidationOptions::default();
|
||||
nuncio_filter::NsqlValidator::validate(&rule, &opts).map_err(|e| e.to_string())?;
|
||||
self.db.save_filter_rule(&rule).await.map_err(|e| e.to_string())?;
|
||||
self.db
|
||||
.save_filter_rule(&rule)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "status": "created", "rule": rule }))
|
||||
}
|
||||
"nuncio_filter_edit" => {
|
||||
let id = args.get("id").and_then(|v| v.as_str()).ok_or("missing id")?;
|
||||
let rules = self.db.list_filter_rules().await.map_err(|e| e.to_string())?;
|
||||
let existing = rules.into_iter().find(|r| r.id == id).ok_or("rule not found")?;
|
||||
let id = args
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing id")?;
|
||||
let rules = self
|
||||
.db
|
||||
.list_filter_rules()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let existing = rules
|
||||
.into_iter()
|
||||
.find(|r| r.id == id)
|
||||
.ok_or("rule not found")?;
|
||||
|
||||
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.name);
|
||||
let sql = args.get("sql").and_then(|v| v.as_str()).unwrap_or(&existing.nsql_text);
|
||||
let priority = args.get("priority").and_then(|v| v.as_i64()).map(|p| p as i32).unwrap_or(existing.priority);
|
||||
let name = args
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&existing.name);
|
||||
let sql = args
|
||||
.get("sql")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&existing.nsql_text);
|
||||
let priority = args
|
||||
.get("priority")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|p| p as i32)
|
||||
.unwrap_or(existing.priority);
|
||||
|
||||
let mut updated = nuncio_filter::NsqlParser::parse_rule(name, priority, sql).map_err(|e| e.to_string())?;
|
||||
let mut updated = nuncio_filter::NsqlParser::parse_rule(name, priority, sql)
|
||||
.map_err(|e| e.to_string())?;
|
||||
updated.id = id.to_string();
|
||||
let opts = nuncio_filter::ValidationOptions::default();
|
||||
nuncio_filter::NsqlValidator::validate(&updated, &opts).map_err(|e| e.to_string())?;
|
||||
self.db.save_filter_rule(&updated).await.map_err(|e| e.to_string())?;
|
||||
nuncio_filter::NsqlValidator::validate(&updated, &opts)
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.db
|
||||
.save_filter_rule(&updated)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "status": "updated", "rule": updated }))
|
||||
}
|
||||
"nuncio_filter_delete" => {
|
||||
let id = args.get("id").and_then(|v| v.as_str()).ok_or("missing id")?;
|
||||
self.db.delete_filter_rule(id).await.map_err(|e| e.to_string())?;
|
||||
let id = args
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing id")?;
|
||||
self.db
|
||||
.delete_filter_rule(id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "status": "deleted", "id": id }))
|
||||
}
|
||||
"nuncio_filter_test" => {
|
||||
let sql = args.get("sql").and_then(|v| v.as_str()).ok_or("missing sql")?;
|
||||
let rule = nuncio_filter::NsqlParser::parse_rule("Test Rule", 0, sql).map_err(|e| e.to_string())?;
|
||||
let engine = nuncio_filter::FilterEngine::new(vec![rule]).map_err(|e| e.to_string())?;
|
||||
let sql = args
|
||||
.get("sql")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing sql")?;
|
||||
let rule = nuncio_filter::NsqlParser::parse_rule("Test Rule", 0, sql)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let engine =
|
||||
nuncio_filter::FilterEngine::new(vec![rule]).map_err(|e| e.to_string())?;
|
||||
let sample_email = Email {
|
||||
id: "mcp-test-email".to_string(),
|
||||
account_id: "acct-1".to_string(),
|
||||
@@ -572,7 +724,11 @@ impl McpToolHandler {
|
||||
}
|
||||
"nuncio_filter_logs" => {
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
let logs = self.db.list_filter_execution_logs(limit).await.map_err(|e| e.to_string())?;
|
||||
let logs = self
|
||||
.db
|
||||
.list_filter_execution_logs(limit)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "logs": logs }))
|
||||
}
|
||||
"nuncio_update_check" => {
|
||||
@@ -603,26 +759,53 @@ impl McpToolHandler {
|
||||
"nuncio_audit_list" => {
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as u32;
|
||||
let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
let records = self.db.list_worm_audit_records(limit, offset).await.map_err(|e| e.to_string())?;
|
||||
let records = self
|
||||
.db
|
||||
.list_worm_audit_records(limit, offset)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "records": records }))
|
||||
}
|
||||
"nuncio_audit_verify" => {
|
||||
let is_valid = self.db.verify_worm_audit_chain(nuncio_core::DEFAULT_WORM_KEY).await.is_ok();
|
||||
let is_valid = self.db.verify_worm_audit_chain().await.is_ok();
|
||||
Ok(json!({ "status": "verified", "chain_integrity_valid": is_valid }))
|
||||
}
|
||||
"nuncio_export_data" => {
|
||||
if !self.policy.is_data_type_allowed(DataType::Mail) || !self.policy.permissions.read_mail {
|
||||
return Err(format!("403 Forbidden: Agent '{}' lacks 'read_mail' permission for data export", self.policy.agent_id));
|
||||
if !self.policy.is_data_type_allowed(DataType::Mail)
|
||||
|| !self.policy.permissions.read_mail
|
||||
{
|
||||
return Err(format!(
|
||||
"403 Forbidden: Agent '{}' lacks 'read_mail' permission for data export",
|
||||
self.policy.agent_id
|
||||
));
|
||||
}
|
||||
let output_str = args.get("output_path").and_then(|v| v.as_str()).ok_or("missing output_path")?;
|
||||
let format_str = args.get("format").and_then(|v| v.as_str()).unwrap_or("mbox");
|
||||
let folder_id = args.get("folder_id").and_then(|v| v.as_str()).unwrap_or("INBOX");
|
||||
let output_str = args
|
||||
.get("output_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing output_path")?;
|
||||
let format_str = args
|
||||
.get("format")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("mbox");
|
||||
let folder_id = args
|
||||
.get("folder_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("INBOX");
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(1000) as usize;
|
||||
|
||||
let format: nuncio_core::ExportFormat = format_str.parse().map_err(|e: String| e)?;
|
||||
let messages = self.db.list_messages(folder_id, limit).await.map_err(|e| e.to_string())?;
|
||||
let format: nuncio_core::ExportFormat =
|
||||
format_str.parse().map_err(|e: String| e)?;
|
||||
let messages = self
|
||||
.db
|
||||
.list_messages(folder_id, limit)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let summary = self.db.export_messages_to_file(&messages, format, std::path::Path::new(output_str)).await.map_err(|e| e.to_string())?;
|
||||
let summary = self
|
||||
.db
|
||||
.export_messages_to_file(&messages, format, std::path::Path::new(output_str))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "status": "exported", "summary": summary }))
|
||||
}
|
||||
_ => Err(format!("Unknown tool: {}", name)),
|
||||
+18
-4
@@ -1,5 +1,7 @@
|
||||
//! Integration system test suite for nuncio-mcp JSON-RPC protocol server.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use nuncio_mcp::McpServer;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -33,14 +35,26 @@ async fn system_test_mcp_protocol_initialize_tools_and_resources() {
|
||||
|
||||
// 2b. Test Update Check & Apply Tools
|
||||
let check_req = r#"{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"nuncio_update_check","arguments":{}}}"#;
|
||||
let resp = server.handle_request_json(check_req).await.expect("update check response");
|
||||
let resp = server
|
||||
.handle_request_json(check_req)
|
||||
.await
|
||||
.expect("update check response");
|
||||
assert_eq!(resp["id"], 21);
|
||||
assert!(resp["result"]["content"][0]["text"].as_str().unwrap().contains("update_available"));
|
||||
assert!(resp["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("update_available"));
|
||||
|
||||
let apply_req = r#"{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"nuncio_update_apply","arguments":{"version":"0.2.0"}}}"#;
|
||||
let resp = server.handle_request_json(apply_req).await.expect("update apply response");
|
||||
let resp = server
|
||||
.handle_request_json(apply_req)
|
||||
.await
|
||||
.expect("update apply response");
|
||||
assert_eq!(resp["id"], 22);
|
||||
assert!(resp["result"]["content"][0]["text"].as_str().unwrap().contains("update_initiated"));
|
||||
assert!(resp["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("update_initiated"));
|
||||
|
||||
// 3. Tools Call (Create & List Calendar Event)
|
||||
let create_req = r#"{
|
||||
@@ -10,11 +10,11 @@ repository.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nuncio-core = { path = "../nuncio-core" }
|
||||
nuncio-mail = { path = "../nuncio-mail" }
|
||||
nuncio-cal = { path = "../nuncio-cal" }
|
||||
nuncio-store = { path = "../nuncio-store" }
|
||||
nuncio-filter = { path = "../nuncio-filter" }
|
||||
nuncio-core = { path = "../../crates/nuncio-core" }
|
||||
nuncio-mail = { path = "../../crates/nuncio-mail" }
|
||||
nuncio-cal = { path = "../../crates/nuncio-cal" }
|
||||
nuncio-store = { path = "../../crates/nuncio-store" }
|
||||
nuncio-filter = { path = "../../crates/nuncio-filter" }
|
||||
|
||||
tokio.workspace = true
|
||||
ratatui.workspace = true
|
||||
@@ -66,19 +66,25 @@ pub struct TuiApp {
|
||||
impl TuiApp {
|
||||
/// Create a new `TuiApp` wrapping an `EventBus`.
|
||||
pub fn new(event_bus: EventBus) -> Self {
|
||||
let sample_rule1 = NsqlParser::parse_rule(
|
||||
// Sample demo rules use fixed, known-valid NSQL text; if parsing ever
|
||||
// fails (e.g. a future NSQL grammar change), fall back to no sample
|
||||
// rules rather than panicking.
|
||||
let mut sample_filter_rules = Vec::new();
|
||||
if let Ok(sample_rule1) = NsqlParser::parse_rule(
|
||||
"Priority Boss Filter",
|
||||
0,
|
||||
"WHERE (from = 'boss@nuncio.mx' AND size > 1024) ACTION MOVE TO 'Priority', FLAG",
|
||||
)
|
||||
.expect("parse sample rule");
|
||||
) {
|
||||
sample_filter_rules.push(sample_rule1);
|
||||
}
|
||||
|
||||
let sample_rule2 = NsqlParser::parse_rule(
|
||||
if let Ok(sample_rule2) = NsqlParser::parse_rule(
|
||||
"Spam Auto Cleaner",
|
||||
1,
|
||||
"WHERE folder IN ('spam', 'junk') ACTION DELETE",
|
||||
)
|
||||
.expect("parse sample rule");
|
||||
) {
|
||||
sample_filter_rules.push(sample_rule2);
|
||||
}
|
||||
|
||||
Self {
|
||||
event_bus,
|
||||
@@ -123,7 +129,7 @@ impl TuiApp {
|
||||
messages: Vec::new(),
|
||||
selected_message_idx: 0,
|
||||
|
||||
filter_rules: vec![sample_rule1, sample_rule2],
|
||||
filter_rules: sample_filter_rules,
|
||||
selected_filter_idx: 0,
|
||||
filter_editor_mode: FilterEditorSubMode::VisualBuilder,
|
||||
filter_preview_active: false,
|
||||
@@ -131,13 +137,18 @@ impl TuiApp {
|
||||
filter_logs_drawer_open: false,
|
||||
filter_logs: Vec::new(),
|
||||
recovery_banner: None,
|
||||
update_banner: Some("[UPDATE AVAILABLE] Nuncio v0.2.0 is available — Press [u] to Update & Restart".to_string()),
|
||||
update_banner: Some(
|
||||
"[UPDATE AVAILABLE] Nuncio v0.2.0 is available — Press [u] to Update & Restart"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger database recovery alert notification on top header menu.
|
||||
pub fn trigger_recovery_alert(&mut self, backup_path: &str) {
|
||||
self.recovery_banner = Some(format!("[NOTICE] Database Auto-Healed ({backup_path}) — Resynchronizing Inbox..."));
|
||||
self.recovery_banner = Some(format!(
|
||||
"[NOTICE] Database Auto-Healed ({backup_path}) — Resynchronizing Inbox..."
|
||||
));
|
||||
}
|
||||
|
||||
/// Trigger software update download and application workflow (`[u]`).
|
||||
@@ -149,23 +160,29 @@ impl TuiApp {
|
||||
pub fn move_selection_down(&mut self) {
|
||||
match self.mode {
|
||||
AppMode::FilterRules => {
|
||||
if !self.filter_rules.is_empty() && self.selected_filter_idx + 1 < self.filter_rules.len() {
|
||||
if !self.filter_rules.is_empty()
|
||||
&& self.selected_filter_idx + 1 < self.filter_rules.len()
|
||||
{
|
||||
self.selected_filter_idx += 1;
|
||||
}
|
||||
}
|
||||
AppMode::AccountSettings => {
|
||||
if !self.accounts.is_empty() && self.selected_account_idx + 1 < self.accounts.len() {
|
||||
if !self.accounts.is_empty() && self.selected_account_idx + 1 < self.accounts.len()
|
||||
{
|
||||
self.selected_account_idx += 1;
|
||||
}
|
||||
}
|
||||
AppMode::MainView => match self.active_pane {
|
||||
ActivePane::Sidebar => {
|
||||
if !self.folders.is_empty() && self.selected_folder_idx + 1 < self.folders.len() {
|
||||
if !self.folders.is_empty() && self.selected_folder_idx + 1 < self.folders.len()
|
||||
{
|
||||
self.selected_folder_idx += 1;
|
||||
}
|
||||
}
|
||||
ActivePane::MessageList => {
|
||||
if !self.messages.is_empty() && self.selected_message_idx + 1 < self.messages.len() {
|
||||
if !self.messages.is_empty()
|
||||
&& self.selected_message_idx + 1 < self.messages.len()
|
||||
{
|
||||
self.selected_message_idx += 1;
|
||||
}
|
||||
}
|
||||
@@ -208,7 +225,8 @@ impl TuiApp {
|
||||
/// Re-order rule priority up (`[K]`).
|
||||
pub fn reorder_filter_priority_up(&mut self) {
|
||||
if self.mode == AppMode::FilterRules && self.selected_filter_idx > 0 {
|
||||
self.filter_rules.swap(self.selected_filter_idx, self.selected_filter_idx - 1);
|
||||
self.filter_rules
|
||||
.swap(self.selected_filter_idx, self.selected_filter_idx - 1);
|
||||
self.selected_filter_idx -= 1;
|
||||
for (idx, rule) in self.filter_rules.iter_mut().enumerate() {
|
||||
rule.priority = idx as i32;
|
||||
@@ -218,8 +236,11 @@ impl TuiApp {
|
||||
|
||||
/// Re-order rule priority down (`[J]`).
|
||||
pub fn reorder_filter_priority_down(&mut self) {
|
||||
if self.mode == AppMode::FilterRules && self.selected_filter_idx + 1 < self.filter_rules.len() {
|
||||
self.filter_rules.swap(self.selected_filter_idx, self.selected_filter_idx + 1);
|
||||
if self.mode == AppMode::FilterRules
|
||||
&& self.selected_filter_idx + 1 < self.filter_rules.len()
|
||||
{
|
||||
self.filter_rules
|
||||
.swap(self.selected_filter_idx, self.selected_filter_idx + 1);
|
||||
self.selected_filter_idx += 1;
|
||||
for (idx, rule) in self.filter_rules.iter_mut().enumerate() {
|
||||
rule.priority = idx as i32;
|
||||
@@ -324,7 +345,8 @@ impl TuiApp {
|
||||
Color::Blue,
|
||||
)
|
||||
};
|
||||
let header = Paragraph::new(header_text).style(Style::default().bg(bg_color).fg(Color::Black));
|
||||
let header =
|
||||
Paragraph::new(header_text).style(Style::default().bg(bg_color).fg(Color::Black));
|
||||
frame.render_widget(header, main_chunks[0]);
|
||||
|
||||
// Render body area based on AppMode
|
||||
@@ -341,7 +363,11 @@ impl TuiApp {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, rule)| {
|
||||
let prefix = if idx == self.selected_filter_idx { " > " } else { " " };
|
||||
let prefix = if idx == self.selected_filter_idx {
|
||||
" > "
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
ListItem::new(format!("{}[P{}] {}", prefix, rule.priority, rule.name))
|
||||
})
|
||||
.collect();
|
||||
@@ -355,19 +381,34 @@ impl TuiApp {
|
||||
// Right: Editor & Details Pane
|
||||
let mut right_text = String::new();
|
||||
if let Some(rule) = self.filter_rules.get(self.selected_filter_idx) {
|
||||
right_text.push_str(&format!("Rule ID: {}\nName: {}\nPriority: {}\n\n", rule.id, rule.name, rule.priority));
|
||||
right_text.push_str(&format!("Active Sub-Mode [s]: {:?}\n\n", self.filter_editor_mode));
|
||||
right_text.push_str(&format!(
|
||||
"Rule ID: {}\nName: {}\nPriority: {}\n\n",
|
||||
rule.id, rule.name, rule.priority
|
||||
));
|
||||
right_text.push_str(&format!(
|
||||
"Active Sub-Mode [s]: {:?}\n\n",
|
||||
self.filter_editor_mode
|
||||
));
|
||||
right_text.push_str(&format!("NSQL Query:\n{}\n\n", rule.nsql_text));
|
||||
|
||||
if self.filter_preview_active {
|
||||
right_text.push_str("── DRY-RUN PREVIEW RESULTS [t] ───────────────────────\n");
|
||||
right_text
|
||||
.push_str("── DRY-RUN PREVIEW RESULTS [t] ───────────────────────\n");
|
||||
if let Some(prev) = &self.filter_preview_result {
|
||||
right_text.push_str(&format!("Matched: {}\nMatched Rule: {:?}\nActions: {:?}\nTime: {}us\n", prev.matched, prev.matched_rule_name, prev.actions_evaluated, prev.execution_time_us));
|
||||
right_text.push_str(&format!(
|
||||
"Matched: {}\nMatched Rule: {:?}\nActions: {:?}\nTime: {}us\n",
|
||||
prev.matched,
|
||||
prev.matched_rule_name,
|
||||
prev.actions_evaluated,
|
||||
prev.execution_time_us
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if self.filter_logs_drawer_open {
|
||||
right_text.push_str("\n── EXECUTION LOG DRAWER [l] ───────────────────────────\n");
|
||||
right_text.push_str(
|
||||
"\n── EXECUTION LOG DRAWER [l] ───────────────────────────\n",
|
||||
);
|
||||
right_text.push_str("Log chain verified OK (Genesis hash linked).\n");
|
||||
}
|
||||
}
|
||||
@@ -470,7 +511,10 @@ impl TuiApp {
|
||||
let sidebar_block = Block::default()
|
||||
.title(" Folders (20%) ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(AppLayout::border_style(ActivePane::Sidebar, self.active_pane));
|
||||
.border_style(AppLayout::border_style(
|
||||
ActivePane::Sidebar,
|
||||
self.active_pane,
|
||||
));
|
||||
let sidebar_list = List::new(sidebar_items).block(sidebar_block);
|
||||
frame.render_widget(sidebar_list, sidebar_area);
|
||||
|
||||
@@ -486,14 +530,18 @@ impl TuiApp {
|
||||
" "
|
||||
};
|
||||
let status = if msg.read { " " } else { "●" };
|
||||
let text = format!("{} {} {:<15} {}", prefix, status, msg.sender, msg.subject);
|
||||
let text =
|
||||
format!("{} {} {:<15} {}", prefix, status, msg.sender, msg.subject);
|
||||
ListItem::new(text)
|
||||
})
|
||||
.collect();
|
||||
let msg_block = Block::default()
|
||||
.title(" Messages (35%) ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(AppLayout::border_style(ActivePane::MessageList, self.active_pane));
|
||||
.border_style(AppLayout::border_style(
|
||||
ActivePane::MessageList,
|
||||
self.active_pane,
|
||||
));
|
||||
let msg_list = List::new(msg_items).block(msg_block);
|
||||
frame.render_widget(msg_list, list_area);
|
||||
|
||||
@@ -508,7 +556,10 @@ impl TuiApp {
|
||||
let reader_block = Block::default()
|
||||
.title(" Email Reader (45%) ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(AppLayout::border_style(ActivePane::Reader, self.active_pane));
|
||||
.border_style(AppLayout::border_style(
|
||||
ActivePane::Reader,
|
||||
self.active_pane,
|
||||
));
|
||||
let reader_p = Paragraph::new(body_text).block(reader_block);
|
||||
frame.render_widget(reader_p, reader_area);
|
||||
}
|
||||
@@ -516,7 +567,8 @@ impl TuiApp {
|
||||
|
||||
// Footer navigation bar
|
||||
let footer_text = " [?] Help │ [f] Filter Rules │ [a] Accounts │ [u] Update │ [s] Sync │ [c] Compose │ [r] Reply │ [q] Quit ";
|
||||
let footer = Paragraph::new(footer_text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
|
||||
let footer = Paragraph::new(footer_text)
|
||||
.style(Style::default().bg(Color::DarkGray).fg(Color::White));
|
||||
frame.render_widget(footer, main_chunks[2]);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -187,24 +187,48 @@ mod tests {
|
||||
let mut sm = VimLeaderStateMachine::new();
|
||||
|
||||
// Single 'g' puts state machine in PendingG
|
||||
assert_eq!(sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)), UserAction::None);
|
||||
assert_eq!(
|
||||
sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)),
|
||||
UserAction::None
|
||||
);
|
||||
assert_eq!(sm.state(), LeaderState::PendingG);
|
||||
|
||||
// 'g' + 'g' -> JumpTop
|
||||
assert_eq!(sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)), UserAction::JumpTop);
|
||||
assert_eq!(
|
||||
sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)),
|
||||
UserAction::JumpTop
|
||||
);
|
||||
assert_eq!(sm.state(), LeaderState::Idle);
|
||||
|
||||
// 'g' + 'i' -> JumpInbox
|
||||
assert_eq!(sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)), UserAction::None);
|
||||
assert_eq!(sm.process_key(make_key(KeyCode::Char('i'), KeyModifiers::NONE)), UserAction::JumpInbox);
|
||||
assert_eq!(
|
||||
sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)),
|
||||
UserAction::None
|
||||
);
|
||||
assert_eq!(
|
||||
sm.process_key(make_key(KeyCode::Char('i'), KeyModifiers::NONE)),
|
||||
UserAction::JumpInbox
|
||||
);
|
||||
|
||||
// 'g' + 's' -> JumpSent
|
||||
assert_eq!(sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)), UserAction::None);
|
||||
assert_eq!(sm.process_key(make_key(KeyCode::Char('s'), KeyModifiers::NONE)), UserAction::JumpSent);
|
||||
assert_eq!(
|
||||
sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)),
|
||||
UserAction::None
|
||||
);
|
||||
assert_eq!(
|
||||
sm.process_key(make_key(KeyCode::Char('s'), KeyModifiers::NONE)),
|
||||
UserAction::JumpSent
|
||||
);
|
||||
|
||||
// 'g' + 'a' -> JumpArchive
|
||||
assert_eq!(sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)), UserAction::None);
|
||||
assert_eq!(sm.process_key(make_key(KeyCode::Char('a'), KeyModifiers::NONE)), UserAction::JumpArchive);
|
||||
assert_eq!(
|
||||
sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)),
|
||||
UserAction::None
|
||||
);
|
||||
assert_eq!(
|
||||
sm.process_key(make_key(KeyCode::Char('a'), KeyModifiers::NONE)),
|
||||
UserAction::JumpArchive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
File renamed without changes.
@@ -1,5 +1,7 @@
|
||||
//! Nuncio Terminal UI library.
|
||||
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
|
||||
|
||||
pub mod app;
|
||||
pub mod html;
|
||||
pub mod keybindings;
|
||||
File renamed without changes.
+2
@@ -1,5 +1,7 @@
|
||||
//! E2E System Test Suite for nuncio-tui.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use nuncio_core::EventBus;
|
||||
use nuncio_tui::{ActivePane, AppMode, TuiApp};
|
||||
use ratatui::backend::TestBackend;
|
||||
+27
-8
@@ -2,6 +2,8 @@
|
||||
//! Renders Ratatui interface onto TestBackend, simulates keyboard navigation,
|
||||
//! and verifies terminal buffer visual text snapshot representations.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use nuncio_core::{CoreEvent, EventBus};
|
||||
use nuncio_tui::{ActivePane, AppMode, TuiApp};
|
||||
use ratatui::backend::TestBackend;
|
||||
@@ -35,7 +37,9 @@ async fn automated_tui_visual_and_keyboard_interaction_matrix() {
|
||||
// ------------------------------------------------------------------------
|
||||
// Step 1: Initial Render & Visual Snapshot Verification (Main 3-Pane View)
|
||||
// ------------------------------------------------------------------------
|
||||
terminal.draw(|f| app.render_frame(f)).expect("render frame");
|
||||
terminal
|
||||
.draw(|f| app.render_frame(f))
|
||||
.expect("render frame");
|
||||
let snapshot_main = buffer_to_snapshot(terminal.backend().buffer());
|
||||
|
||||
// Verify brand header and main pane borders exist in visual snapshot
|
||||
@@ -49,27 +53,38 @@ async fn automated_tui_visual_and_keyboard_interaction_matrix() {
|
||||
|
||||
// Simulate Tab / 'l' to focus Message List
|
||||
app.set_active_pane(ActivePane::MessageList);
|
||||
terminal.draw(|f| app.render_frame(f)).expect("render list frame");
|
||||
terminal
|
||||
.draw(|f| app.render_frame(f))
|
||||
.expect("render list frame");
|
||||
assert_eq!(app.active_pane(), ActivePane::MessageList);
|
||||
|
||||
// Simulate Tab / 'l' to focus Reader
|
||||
app.set_active_pane(ActivePane::Reader);
|
||||
terminal.draw(|f| app.render_frame(f)).expect("render reader frame");
|
||||
terminal
|
||||
.draw(|f| app.render_frame(f))
|
||||
.expect("render reader frame");
|
||||
assert_eq!(app.active_pane(), ActivePane::Reader);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Step 3: Event Stream Interaction - CoreEvent::SyncStarted Push Update
|
||||
// ------------------------------------------------------------------------
|
||||
let evt = core_events_rx.recv().await.expect("receive published core event");
|
||||
let evt = core_events_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("receive published core event");
|
||||
assert!(matches!(evt, CoreEvent::SyncStarted { .. }));
|
||||
|
||||
terminal.draw(|f| app.render_frame(f)).expect("render syncing frame");
|
||||
terminal
|
||||
.draw(|f| app.render_frame(f))
|
||||
.expect("render syncing frame");
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Step 4: Modal Navigation - Help Overlay Visual Snapshot
|
||||
// ------------------------------------------------------------------------
|
||||
app.set_mode(AppMode::HelpModal);
|
||||
terminal.draw(|f| app.render_frame(f)).expect("render help modal");
|
||||
terminal
|
||||
.draw(|f| app.render_frame(f))
|
||||
.expect("render help modal");
|
||||
let snapshot_help = buffer_to_snapshot(terminal.backend().buffer());
|
||||
|
||||
assert!(snapshot_help.contains("NUNCIO TUI KEYBINDINGS HELP"));
|
||||
@@ -80,7 +95,9 @@ async fn automated_tui_visual_and_keyboard_interaction_matrix() {
|
||||
// Step 5: Modal Navigation - Account Settings Visual Snapshot
|
||||
// ------------------------------------------------------------------------
|
||||
app.set_mode(AppMode::AccountSettings);
|
||||
terminal.draw(|f| app.render_frame(f)).expect("render account settings");
|
||||
terminal
|
||||
.draw(|f| app.render_frame(f))
|
||||
.expect("render account settings");
|
||||
let snapshot_accounts = buffer_to_snapshot(terminal.backend().buffer());
|
||||
|
||||
assert!(snapshot_accounts.contains("Account Settings"));
|
||||
@@ -90,7 +107,9 @@ async fn automated_tui_visual_and_keyboard_interaction_matrix() {
|
||||
// Step 6: Modal Navigation - Splash Screen Visual Snapshot
|
||||
// ------------------------------------------------------------------------
|
||||
app.set_mode(AppMode::SplashScreen);
|
||||
terminal.draw(|f| app.render_frame(f)).expect("render splash screen");
|
||||
terminal
|
||||
.draw(|f| app.render_frame(f))
|
||||
.expect("render splash screen");
|
||||
let snapshot_splash = buffer_to_snapshot(terminal.backend().buffer());
|
||||
|
||||
assert!(snapshot_splash.contains("Welcome to Nuncio"));
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" data-diagram-type="DESCRIPTION" style="width:920px;height:359px;background:#0B0F19;" width="920px" height="359px" viewBox="0 0 920 359" zoomAndPan="magnify" preserveAspectRatio="none" contentStyleType="text/css"><?plantuml 1.2026.7beta8?><defs/><g font-family="sans-serif" lengthAdjust="spacing"><rect x="0" y="0" width="920" height="359" fill="#0B0F19" style="stroke:none;"/><!--entity User--><g class="entity" data-qualified-name="User" id="ent0001" data-source-line="5"><ellipse cx="404.282" cy="14" rx="8" ry="8" fill="#38BDF8" style="stroke:#181818;stroke-width:0.5;"/><path d="M404.282,22 L404.282,49 M391.282,30 L417.282,30 M404.282,49 L391.282,64 M404.282,49 L417.282,64" style="stroke:#181818;stroke-width:0.5;" fill="none"/><text x="351.72" y="78.495" fill="#FFF" font-size="14" textLength="105.123" font-family="'Arial'">User / AI Agent</text></g><!--entity Daemon--><g class="entity" data-qualified-name="Daemon" id="ent0002" data-source-line="6"><rect x="290.56" y="158.8" width="227.437" height="36.297" fill="#059669" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><text x="300.56" y="181.795" fill="#FFF" font-size="14" textLength="207.437" font-family="'Arial'">nunciod Background Daemon</text></g><!--entity DB--><g class="entity" data-qualified-name="DB" id="ent0003" data-source-line="7"><path d="M6,282.6 C6,272.6 107.277,272.6 107.277,272.6 C107.277,272.6 208.554,272.6 208.554,282.6 L208.554,324.194 C208.554,334.194 107.277,334.194 107.277,334.194 C107.277,334.194 6,334.194 6,324.194 L6,282.6" style="stroke:#181818;stroke-width:0.5;" fill="#6366F1"/><path d="M6,282.6 C6,292.6 107.277,292.6 107.277,292.6 C107.277,292.6 208.554,292.6 208.554,282.6" style="stroke:#181818;stroke-width:0.5;" fill="none"/><text x="16" y="309.595" fill="#FFF" font-size="14" textLength="157.931" font-family="'Arial'">Local SQLite Database</text><text x="16" y="325.892" fill="#FFF" font-size="14" textLength="182.554" font-family="'Arial'">(/.nuncio/nuncio_main.db)</text></g><!--entity Vault--><g class="entity" data-qualified-name="Vault" id="ent0004" data-source-line="8"><rect x="280.92" y="277.1" width="246.714" height="52.594" fill="#EAB308" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><text x="290.92" y="300.095" fill="#FFF" font-size="14" textLength="137.074" font-family="'Arial'">OS Keyring Enclave</text><text x="290.92" y="316.392" fill="#FFF" font-size="14" textLength="226.714" font-family="'Arial'">(Keychain / Credential Manager)</text></g><!--entity Remote--><g class="entity" data-qualified-name="Remote" id="ent0005" data-source-line="9"><path d="M571.492,281.804 C578.119,273.441 584.377,272.583 590.187,282.634 C594.968,273.896 601.415,274.139 607.011,281.798 C612.791,274.234 619.571,274.444 624.821,282.406 C629.126,274.62 636.179,272.419 641.947,280.862 C645.761,271.18 654.596,271.878 659.535,279.734 C665.1,271.517 672.058,272.701 675.605,281.626 C681.233,273.216 687.605,272.628 693.797,281.016 C698.606,273.845 703.259,274.186 708.731,280.267 C714.245,271.582 722.208,272.953 726.635,281.402 C731.461,270.438 739.487,272.418 745.811,279.839 C751.948,272.489 757.319,272.532 761.632,281.598 C767.362,271.955 775.305,272.505 780.48,282.14 C784.901,273.296 791.424,274.726 796.527,281.435 C801.752,273.079 809.015,272.086 814.773,280.867 C830.908,282.654 832.462,293.298 824.768,304.968 C832.439,315.774 830.606,327.249 815.14,328.648 C808.793,337.28 803.06,338.746 796.192,329.192 C793.097,338.574 784.955,339.823 779.198,331.788 C773.613,338.846 766.739,339.154 762.854,330.068 C758.084,339.986 752.781,340.817 745.495,332.458 C740.325,342.87 734.382,341.602 726.894,334.992 C722.077,344.216 716.069,342.917 710.345,335.918 C704.38,343.524 698.726,342.487 693.431,335.159 C688.008,344.711 678.478,344.615 674.172,334.087 C668.726,343.006 661.289,341.972 656.814,333.138 C651.984,339.66 644.233,340.314 640.694,331.758 C633.855,340.842 627.987,338.902 622.177,330.659 C617.754,337.617 609.302,338.841 604.895,330.64 C600.018,337.788 593.787,338.977 589.237,330.316 C584.042,336.955 577.173,337.423 572.903,329.293 C558.668,326.857 552.815,319.336 560.093,305.378 C550.269,293.162 558.525,282.999 571.492,281.804" style="stroke:#181818;stroke-width:0.5;" fill="#EC4899"/><text x="577.3" y="300.095" fill="#FFF" font-size="14" textLength="227.958" font-family="'Arial'">Remote Mail & Calendar Servers</text><text x="577.3" y="316.392" fill="#FFF" font-size="14" textLength="183.668" font-family="'Arial'">(IMAP/SMTP/JMAP/CalDAV)</text></g><!--link User to Daemon--><g class="link" data-entity-1="ent0001" data-entity-2="ent0002" id="lnk6" data-source-line="11" data-link-type="dependency"><path d="M404.28,87.28 C404.28,112.28 404.28,133.97 404.28,153.33" style="stroke:#181818;stroke-width:1;" fill="none" id="User-Daemon"/><polygon points="404.28,82.28,400.28,91.28,404.28,87.28,408.28,91.28,404.28,82.28" fill="#181818" style="strokeLine truncated
|
||||
|
Before Width: | Height: | Size: 8.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" data-diagram-type="SEQUENCE" style="width:1209px;height:424px;background:#0B0F19;" width="1209px" height="424px" viewBox="0 0 1209 424" zoomAndPan="magnify" preserveAspectRatio="none" contentStyleType="text/css"><?plantuml 1.2026.7beta8?><defs/><g font-family="sans-serif" lengthAdjust="spacing"><rect x="0" y="0" width="1209" height="424" fill="#0B0F19" style="stroke:none;"/><g><title>Interface .CLI.TUI.GUI.MCP.</title><rect x="105.678" y="86.297" width="8" height="251.063" fill="#000" fill-opacity="0"/><line x1="109.178" y1="86.297" x2="109.178" y2="337.359" style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;"/></g><g><title>nunciod Daemon</title><rect x="502.108" y="86.297" width="8" height="251.063" fill="#000" fill-opacity="0"/><line x1="505.608" y1="86.297" x2="505.608" y2="337.359" style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;"/></g><g><title>SQLite WAL Storage</title><rect x="923.173" y="86.297" width="8" height="251.063" fill="#000" fill-opacity="0"/><line x1="926.673" y1="86.297" x2="926.673" y2="337.359" style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;"/></g><g><title>Remote IMAP.JMAP Server</title><rect x="1100.785" y="86.297" width="8" height="251.063" fill="#000" fill-opacity="0"/><line x1="1104.285" y1="86.297" x2="1104.285" y2="337.359" style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;"/></g><text x="10" y="82.995" fill="#FFF" font-size="14" textLength="192.356" font-family="'Arial'">Interface (CLI/TUI/GUI/MCP)</text><ellipse cx="109.178" cy="18.5" rx="8" ry="8" fill="#38BDF8" style="stroke:#181818;stroke-width:0.5;"/><path d="M109.178,26.5 L109.178,53.5 M96.178,34.5 L122.178,34.5 M109.178,53.5 L96.178,68.5 M109.178,53.5 L122.178,68.5" style="stroke:#181818;stroke-width:0.5;" fill="none"/><rect x="439.029" y="55" width="133.157" height="30.297" fill="#059669" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><text x="446.029" y="74.995" fill="#FFF" font-size="14" textLength="119.157" font-family="'Arial'">nunciod Daemon</text><text x="853.375" y="82.995" fill="#FFF" font-size="14" textLength="140.595" font-family="'Arial'">SQLite WAL Storage</text><path d="M908.673,34 C908.673,24 926.673,24 926.673,24 C926.673,24 944.673,24 944.673,34 L944.673,60 C944.673,70 926.673,70 926.673,70 C926.673,70 908.673,70 908.673,60 L908.673,34" style="stroke:#181818;stroke-width:0.5;" fill="#6366F1"/><path d="M908.673,34 C908.673,44 926.673,44 926.673,44 C926.673,44 944.673,44 944.673,34" style="stroke:#181818;stroke-width:0.5;" fill="none"/><text x="1009.97" y="82.995" fill="#FFF" font-size="14" textLength="182.629" font-family="'Arial'">Remote IMAP/JMAP Server</text><ellipse cx="1104.285" cy="54" rx="12" ry="12" fill="#EC4899" style="stroke:#181818;stroke-width:0.5;"/><line x1="1092.285" y1="68" x2="1116.285" y2="68" style="stroke:#181818;stroke-width:0.5;"/><text x="10" y="350.354" fill="#FFF" font-size="14" textLength="192.356" font-family="'Arial'">Interface (CLI/TUI/GUI/MCP)</text><ellipse cx="109.178" cy="362.156" rx="8" ry="8" fill="#38BDF8" style="stroke:#181818;stroke-width:0.5;"/><path d="M109.178,370.156 L109.178,397.156 M96.178,378.156 L122.178,378.156 M109.178,397.156 L96.178,412.156 M109.178,397.156 L122.178,412.156" style="stroke:#181818;stroke-width:0.5;" fill="none"/><rect x="439.029" y="337.359" width="133.157" height="30.297" fill="#059669" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><text x="446.029" y="357.354" fill="#FFF" font-size="14" textLength="119.157" font-family="'Arial'">nunciod Daemon</text><text x="853.375" y="350.354" fill="#FFF" font-size="14" textLength="140.595" font-family="'Arial'">SQLite WAL Storage</text><path d="M908.673,363.656 C908.673,353.656 926.673,353.656 926.673,353.656 C926.673,353.656 944.673,353.656 944.673,363.656 L944.673,389.656 C944.673,399.656 926.673,399.656 926.673,399.656 C926.673,399.656 908.673,399.656 908.673,389.656 L908.673,363.656" style="stroke:#181818;stroke-width:0.5;" fill="#6366F1"/><path d="M908.673,363.656 C908.673,373.656 926.673,373.656 926.673,373.656 C926.673,373.656 944.673,373.656 944.673,363.656" style="stroke:#181818;stroke-width:0.5;" fill="none"/><text x="1009.97" y="350.354" fill="#FFF" font-size="14" textLength="182.629" font-family="'Arial'">Remote IMAP/JMAP Server</text><ellipse cx="1104.285" cy="369.656" rx="12" ry="12" fill="#EC4899" style="stroke:#181818;stroke-width:0.5;"/><line x1="1092.285" y1="383.656" x2="1116.285" y2="383.656" style="stroke:#181818;stroke-width:0.5;"/><polygon points="493.608,111.43,503.608,115.43,493.608,119.43,497.608,115.43" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line x1="109.178" y1="115.43" x2="499.608" y2="115.43" style="stroke:#181818;stroke-width:1;"/><text x="230.736" y="110.364" fill="#FFF" font-size="13" textLength="153.315" font-family="'Arial'">Connect Socket StLine truncated
|
||||
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" data-diagram-type="ACTIVITY" style="width:345px;height:693px;background:#0B0F19;" width="345px" height="693px" viewBox="0 0 345 693" zoomAndPan="magnify" preserveAspectRatio="none" contentStyleType="text/css"><?plantuml 1.2026.7beta8?><defs/><g font-family="sans-serif" lengthAdjust="spacing"><rect x="0" y="0" width="345" height="693" fill="#0B0F19" style="stroke:none;"/><g class="start_entity" data-qualified-name="start" id="ent0001" data-source-line="5"><ellipse cx="169.45" cy="16" rx="10" ry="10" fill="#222" style="stroke:#222;stroke-width:1;"/></g><rect x="83.36" y="66" width="172.18" height="33.969" fill="#0F172A" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="93.36" y="87.139" fill="#FFF" font-size="12" textLength="152.18" font-family="'Arial'">NSQL Source Code String</text><rect x="68.59" y="139.97" width="201.717" height="33.969" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="78.59" y="161.109" fill="#FFF" font-size="12" textLength="181.717" font-family="'Arial'">NsqlParser::split_on_account()</text><rect x="7" y="213.94" width="324.904" height="33.969" fill="#312E81" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="17" y="235.079" fill="#FFF" font-size="12" textLength="304.904" font-family="'Arial'">Extract Target Account (*, %, or '%@domain.com')</text><rect x="61.38" y="287.91" width="216.148" height="33.969" fill="#065F46" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="71.38" y="309.049" fill="#FFF" font-size="12" textLength="196.148" font-family="'Arial'">sqlparser::Parser (SQLiteDialect)</text><rect x="24.46" y="361.88" width="289.982" height="33.969" fill="#065F46" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="34.46" y="383.019" fill="#FFF" font-size="12" textLength="269.982" font-family="'Arial'">AST Transformation (Expr -> ConditionNode)</text><rect x="48.96" y="435.84" width="240.975" height="33.969" fill="#EAB308" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="58.96" y="456.979" fill="#FFF" font-size="12" textLength="220.975" font-family="'Arial'">NsqlValidator (6-Pass Security Audit)</text><rect x="18.1" y="509.81" width="302.697" height="33.969" fill="#2563EB" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="28.1" y="530.949" fill="#FFF" font-size="12" textLength="282.697" font-family="'Arial'">ArcSwap<CompiledFilterSet> Lock-Free Cache</text><rect x="41.79" y="583.78" width="255.33" height="33.969" fill="#059669" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="51.79" y="604.919" fill="#FFF" font-size="12" textLength="235.33" font-family="'Arial'">Pre-Ingestion Sync Execution (<0.2ms)</text><g class="end_entity" data-qualified-name="end" id="ent0018" data-source-line="13"><ellipse cx="169.45" cy="668.75" rx="11" ry="11" fill="none" style="stroke:#222;stroke-width:1.5;"/><ellipse cx="169.45" cy="668.75" rx="6" ry="6" fill="#222" style="stroke:#222;stroke-width:1;"/></g><!--link start to NSQL Source Code String--><g class="link" data-entity-1="ent0001" data-entity-2="ent0002" id="lnk3" data-source-line="5" data-link-type="dependency"><path d="M169.45,26.31 C169.45,36.56 169.45,48.23 169.45,60.66" style="stroke:#181818;stroke-width:1;" fill="none" id="start-to-NSQL Source Code String"/><polygon points="169.45,65.66,173.45,56.66,169.45,60.66,165.45,56.66,169.45,65.66" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link NSQL Source Code String to NsqlParser::split_on_account()--><g class="link" data-entity-1="ent0002" data-entity-2="ent0004" id="lnk5" data-source-line="6" data-link-type="dependency"><path d="M169.45,100.32 C169.45,112.09 169.45,122.92 169.45,134.69" style="stroke:#181818;stroke-width:1;" fill="none" id="NSQL Source Code String-to-NsqlParser::split_on_account()"/><polygon points="169.45,139.69,173.45,130.69,169.45,134.69,165.45,130.69,169.45,139.69" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link NsqlParser::split_on_account() to Extract Target Account (*, %, or '%@domain.com')--><g class="link" data-entity-1="ent0004" data-entity-2="ent0006" id="lnk7" data-source-line="7" data-link-type="dependency"><path d="M169.45,174.28 C169.45,186.06 169.45,196.89 169.45,208.66" style="stroke:#181818;stroke-width:1;" fill="none" id="NsqlParser::split_on_account()-to-Extract Target Account (*, %, or '%@domain.com')"/><polygon points="169.45,213.66,173.45,204.66,169.45,208.66,165.45,204.66,169.45,213.66" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link Extract Target Account (*, %, or '%@domain.com') to sqlparser::Parser (SQLiteDialect)--><g class="link" data-entity-1="ent0006" data-entity-2="ent0008" id="lnkLine truncated
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" data-diagram-type="ACTIVITY" style="width:345px;height:693px;background:#0B0F19;" width="345px" height="693px" viewBox="0 0 345 693" zoomAndPan="magnify" preserveAspectRatio="none" contentStyleType="text/css"><?plantuml 1.2026.7beta8?><defs/><g font-family="sans-serif" lengthAdjust="spacing"><rect x="0" y="0" width="345" height="693" fill="#0B0F19" style="stroke:none;"/><g class="start_entity" data-qualified-name="start" id="ent0001" data-source-line="5"><ellipse cx="169.45" cy="16" rx="10" ry="10" fill="#222" style="stroke:#222;stroke-width:1;"/></g><rect x="83.36" y="66" width="172.18" height="33.969" fill="#0F172A" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="93.36" y="87.139" fill="#FFF" font-size="12" textLength="152.18" font-family="'Arial'">NSQL Source Code String</text><rect x="68.59" y="139.97" width="201.717" height="33.969" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="78.59" y="161.109" fill="#FFF" font-size="12" textLength="181.717" font-family="'Arial'">NsqlParser::split_on_account()</text><rect x="7" y="213.94" width="324.904" height="33.969" fill="#312E81" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="17" y="235.079" fill="#FFF" font-size="12" textLength="304.904" font-family="'Arial'">Extract Target Account (*, %, or '%@domain.com')</text><rect x="61.38" y="287.91" width="216.148" height="33.969" fill="#065F46" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="71.38" y="309.049" fill="#FFF" font-size="12" textLength="196.148" font-family="'Arial'">sqlparser::Parser (SQLiteDialect)</text><rect x="24.46" y="361.88" width="289.982" height="33.969" fill="#065F46" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="34.46" y="383.019" fill="#FFF" font-size="12" textLength="269.982" font-family="'Arial'">AST Transformation (Expr -> ConditionNode)</text><rect x="48.96" y="435.84" width="240.975" height="33.969" fill="#EAB308" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="58.96" y="456.979" fill="#FFF" font-size="12" textLength="220.975" font-family="'Arial'">NsqlValidator (6-Pass Security Audit)</text><rect x="18.1" y="509.81" width="302.697" height="33.969" fill="#2563EB" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="28.1" y="530.949" fill="#FFF" font-size="12" textLength="282.697" font-family="'Arial'">ArcSwap<CompiledFilterSet> Lock-Free Cache</text><rect x="41.79" y="583.78" width="255.33" height="33.969" fill="#059669" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="51.79" y="604.919" fill="#FFF" font-size="12" textLength="235.33" font-family="'Arial'">Pre-Ingestion Sync Execution</text><g class="end_entity" data-qualified-name="end" id="ent0018" data-source-line="13"><ellipse cx="169.45" cy="668.75" rx="11" ry="11" fill="none" style="stroke:#222;stroke-width:1.5;"/><ellipse cx="169.45" cy="668.75" rx="6" ry="6" fill="#222" style="stroke:#222;stroke-width:1;"/></g><!--link start to NSQL Source Code String--><g class="link" data-entity-1="ent0001" data-entity-2="ent0002" id="lnk3" data-source-line="5" data-link-type="dependency"><path d="M169.45,26.31 C169.45,36.56 169.45,48.23 169.45,60.66" style="stroke:#181818;stroke-width:1;" fill="none" id="start-to-NSQL Source Code String"/><polygon points="169.45,65.66,173.45,56.66,169.45,60.66,165.45,56.66,169.45,65.66" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link NSQL Source Code String to NsqlParser::split_on_account()--><g class="link" data-entity-1="ent0002" data-entity-2="ent0004" id="lnk5" data-source-line="6" data-link-type="dependency"><path d="M169.45,100.32 C169.45,112.09 169.45,122.92 169.45,134.69" style="stroke:#181818;stroke-width:1;" fill="none" id="NSQL Source Code String-to-NsqlParser::split_on_account()"/><polygon points="169.45,139.69,173.45,130.69,169.45,134.69,165.45,130.69,169.45,139.69" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link NsqlParser::split_on_account() to Extract Target Account (*, %, or '%@domain.com')--><g class="link" data-entity-1="ent0004" data-entity-2="ent0006" id="lnk7" data-source-line="7" data-link-type="dependency"><path d="M169.45,174.28 C169.45,186.06 169.45,196.89 169.45,208.66" style="stroke:#181818;stroke-width:1;" fill="none" id="NsqlParser::split_on_account()-to-Extract Target Account (*, %, or '%@domain.com')"/><polygon points="169.45,213.66,173.45,204.66,169.45,208.66,165.45,204.66,169.45,213.66" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link Extract Target Account (*, %, or '%@domain.com') to sqlparser::Parser (SQLiteDialect)--><g class="link" data-entity-1="ent0006" data-entity-2="ent0008" id="lnk9" data-sourLine truncated
|
||||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 575 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" data-diagram-type="DESCRIPTION" style="width:987px;height:589px;background:#0B0F19;" width="987px" height="589px" viewBox="0 0 987 589" zoomAndPan="magnify" preserveAspectRatio="none" contentStyleType="text/css"><?plantuml 1.2026.7beta8?><defs/><g font-family="sans-serif" lengthAdjust="spacing"><rect x="0" y="0" width="987" height="589" fill="#0B0F19" style="stroke:none;"/><!--cluster Four Great Interfaces--><g class="cluster" data-qualified-name="Four Great Interfaces" id="ent0001" data-source-line="6"><path d="M65.5,6 L236.264,6 A3.75,3.75 0 0 1 238.764,8.5 L245.764,28.297 L969.5,28.297 A2.5,2.5 0 0 1 972,30.797 L972,117.09 A2.5,2.5 0 0 1 969.5,119.59 L65.5,119.59 A2.5,2.5 0 0 1 63,117.09 L63,8.5 A2.5,2.5 0 0 1 65.5,6" style="stroke:#000;stroke-width:1.5;" fill="#0F172A"/><line x1="63" y1="28.297" x2="245.764" y2="28.297" style="stroke:#000;stroke-width:1.5;"/><text x="67" y="20.995" fill="#FFF" font-size="14" textLength="169.764" font-weight="700" font-family="'Arial'">Four Great Interfaces</text></g><!--cluster IPC Transport Layer--><g class="cluster" data-qualified-name="IPC Transport Layer" id="ent0006" data-source-line="13"><path d="M277.5,143.59 L435.323,143.59 A3.75,3.75 0 0 1 437.823,146.09 L444.823,165.887 L626.5,165.887 A2.5,2.5 0 0 1 629,168.387 L629,344.69 A2.5,2.5 0 0 1 626.5,347.19 L277.5,347.19 A2.5,2.5 0 0 1 275,344.69 L275,146.09 A2.5,2.5 0 0 1 277.5,143.59" style="stroke:#000;stroke-width:1.5;" fill="#1E1B4B"/><line x1="275" y1="165.887" x2="444.823" y2="165.887" style="stroke:#000;stroke-width:1.5;"/><text x="279" y="158.585" fill="#FFF" font-size="14" textLength="156.823" font-weight="700" font-family="'Arial'">IPC Transport Layer</text></g><!--cluster nunciod Background Daemon--><g class="cluster" data-qualified-name="nunciod Background Daemon" id="ent0009" data-source-line="18"><path d="M8.5,371.19 L240.849,371.19 A3.75,3.75 0 0 1 243.349,373.69 L250.349,393.487 L817.5,393.487 A2.5,2.5 0 0 1 820,395.987 L820,572.28 A2.5,2.5 0 0 1 817.5,574.78 L8.5,574.78 A2.5,2.5 0 0 1 6,572.28 L6,373.69 A2.5,2.5 0 0 1 8.5,371.19" style="stroke:#000;stroke-width:1.5;" fill="#064E3B"/><line x1="6" y1="393.487" x2="250.349" y2="393.487" style="stroke:#000;stroke-width:1.5;"/><text x="10" y="386.185" fill="#FFF" font-size="14" textLength="231.349" font-weight="700" font-family="'Arial'">nunciod Background Daemon</text></g><!--entity CLI--><g class="entity" data-qualified-name="Four Great Interfaces.CLI" id="ent0002" data-source-line="7"><rect x="79.39" y="41" width="201.219" height="62.594" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><rect x="260.609" y="46" width="15" height="10" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="258.609" y="48" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="258.609" y="52" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><text x="94.39" y="73.995" fill="#FFF" font-size="14" textLength="85.107" font-family="'Arial'">1. nuncio-cli</text><text x="94.39" y="90.292" fill="#FFF" font-size="14" textLength="161.219" font-family="'Arial'">(POSIX CLI & Scripting)</text></g><!--entity TUI--><g class="entity" data-qualified-name="Four Great Interfaces.TUI" id="ent0003" data-source-line="8"><rect x="315.76" y="41" width="160.483" height="62.594" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><rect x="456.243" y="46" width="15" height="10" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="454.243" y="48" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="454.243" y="52" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><text x="330.76" y="73.995" fill="#FFF" font-size="14" textLength="87.883" font-family="'Arial'">2. nuncio-tui</text><text x="330.76" y="90.292" fill="#FFF" font-size="14" textLength="120.483" font-family="'Arial'">(Vim Ratatui TUI)</text></g><!--entity GUI--><g class="entity" data-qualified-name="Four Great Interfaces.GUI" id="ent0004" data-source-line="9"><rect x="510.96" y="41" width="200.077" height="62.594" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><rect x="691.037" y="46" width="15" height="10" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="689.037" y="48" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="689.037" y="52" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><text x="525.96" y="73.995" fill="#FFF" font-size="14" textLength="91.28" font-family="'Arial'">3. nuncio-gui</text><text x="525.96" y="90.292" fill="#FFF" font-size="14" textLength="160.077" font-family="'Arial'">(Tauri v2 Desktop GUI)</text></g><!--entity MCP--><g class="entity" data-qualified-name="Four Great Interfaces.MCP" id="ent0005" data-source-line="10"Line truncated
|
||||
|
Before Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,24 @@
|
||||
//! Protocol-agnostic async calendar backend trait definitions.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nuncio_core::model::CalendarEvent;
|
||||
|
||||
use crate::parser::CalendarError;
|
||||
|
||||
/// Protocol-agnostic calendar backend engine trait implemented by CalDAV and mock engines.
|
||||
///
|
||||
/// Mirrors `nuncio_mail::MailBackend`'s shape: `Send + Sync` so implementations can be held
|
||||
/// behind an `Arc` and shared across daemon tasks, and returning the crate's single
|
||||
/// [`CalendarError`] type so callers do not need to know whether a given implementation talks
|
||||
/// to a real server or an in-memory fixture.
|
||||
#[async_trait]
|
||||
pub trait CalendarBackend: Send + Sync {
|
||||
/// Fetch events belonging to `calendar_id` whose window overlaps
|
||||
/// `[start_window, end_window]` (inclusive, unix seconds).
|
||||
async fn fetch_events(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start_window: i64,
|
||||
end_window: i64,
|
||||
) -> Result<Vec<CalendarEvent>, CalendarError>;
|
||||
}
|
||||
@@ -1,19 +1,46 @@
|
||||
//! CalDAV (RFC 4791) / WebDAV REPORT client and XML query generator.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nuncio_core::model::CalendarEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::backend::CalendarBackend;
|
||||
use crate::parser::{CalendarError, IcalParserAdapter};
|
||||
|
||||
/// CalDAV client protocol engine managing WebDAV REPORT queries.
|
||||
/// Configuration for a specific CalDAV calendar collection endpoint, mirroring
|
||||
/// `nuncio_contacts::CardDavAccountConfig`.
|
||||
///
|
||||
/// `caldav_url` must already resolve to a specific calendar collection (e.g.
|
||||
/// `https://caldav.example.com/dav/calendars/user/jmaes/work/`) -- PROPFIND-based
|
||||
/// `calendar-home-set` auto-discovery is out of scope for this client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CalDavAccountConfig {
|
||||
/// Nuncio account identifier this calendar collection belongs to.
|
||||
pub account_id: String,
|
||||
/// Fully-qualified URL of the CalDAV calendar collection to query.
|
||||
pub caldav_url: String,
|
||||
/// Basic-auth username (or app-specific username, per provider).
|
||||
pub username: String,
|
||||
/// Basic-auth secret (password or app-specific token) resolved from the OS keyring by the
|
||||
/// caller -- never stored anywhere else in plaintext.
|
||||
pub auth_token: String,
|
||||
}
|
||||
|
||||
/// CalDAV client protocol engine managing WebDAV REPORT queries against a real server.
|
||||
pub struct CalDavClient {
|
||||
account_id: String,
|
||||
config: CalDavAccountConfig,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl CalDavClient {
|
||||
/// Create a new `CalDavClient` bound to an account ID.
|
||||
pub fn new(account_id: &str) -> Self {
|
||||
/// Create a new `CalDavClient` bound to a specific CalDAV calendar collection.
|
||||
pub fn new(config: CalDavAccountConfig) -> Self {
|
||||
Self {
|
||||
account_id: account_id.to_string(),
|
||||
http: reqwest::Client::builder()
|
||||
.user_agent("Nuncio-Calendar-CalDAV/1.0")
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +81,7 @@ impl CalDavClient {
|
||||
let event_id = format!("caldav-evt-{}", events.len() + 1);
|
||||
if let Ok(event) = IcalParserAdapter::parse_ical(
|
||||
&event_id,
|
||||
&self.account_id,
|
||||
&self.config.account_id,
|
||||
calendar_id,
|
||||
clean_ics,
|
||||
) {
|
||||
@@ -66,12 +93,96 @@ impl CalDavClient {
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Format a unix timestamp as the `YYYYMMDDTHHMMSSZ` form RFC 4791's
|
||||
/// `<c:time-range>` filter expects.
|
||||
fn format_query_timestamp(unix_seconds: i64) -> Result<String, CalendarError> {
|
||||
chrono::DateTime::from_timestamp(unix_seconds, 0)
|
||||
.map(|dt| dt.format("%Y%m%dT%H%M%SZ").to_string())
|
||||
.ok_or_else(|| {
|
||||
CalendarError::ParseFailed(format!(
|
||||
"invalid unix timestamp for CalDAV time-range window: {unix_seconds}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Issue a live CalDAV `REPORT` calendar-query (RFC 4791 Section 7.8) against
|
||||
/// `self.config.caldav_url` and parse the `multistatus` response into domain events.
|
||||
///
|
||||
/// This performs a real network request -- no canned or fabricated data is ever
|
||||
/// returned. A server or network failure surfaces as [`CalendarError::TransportFailed`];
|
||||
/// a malformed TZID inside a returned VEVENT surfaces as
|
||||
/// [`CalendarError::UnresolvableTimezone`] via [`IcalParserAdapter::parse_ical`].
|
||||
pub async fn fetch_remote_events(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start_window: i64,
|
||||
end_window: i64,
|
||||
) -> Result<Vec<CalendarEvent>, CalendarError> {
|
||||
let start_iso = Self::format_query_timestamp(start_window)?;
|
||||
let end_iso = Self::format_query_timestamp(end_window)?;
|
||||
let body = Self::build_report_query(&start_iso, &end_iso);
|
||||
|
||||
// "REPORT" is a fixed, always-valid HTTP token; `from_bytes` cannot fail for it, but
|
||||
// the error is still propagated rather than unwrapped so no code path here can panic.
|
||||
let report_method = reqwest::Method::from_bytes(b"REPORT")
|
||||
.map_err(|e| CalendarError::TransportFailed(format!("invalid HTTP method: {e}")))?;
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.request(report_method, &self.config.caldav_url)
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.header("Depth", "1")
|
||||
.basic_auth(&self.config.username, Some(&self.config.auth_token))
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CalendarError::TransportFailed(e.to_string()))?;
|
||||
|
||||
let status = response.status();
|
||||
// RFC 4791 REPORT responses are conventionally 207 Multi-Status; some servers reply
|
||||
// 200 OK for a single-collection result, so any 2xx/207 status is accepted.
|
||||
if status.as_u16() != 207 && !status.is_success() {
|
||||
return Err(CalendarError::TransportFailed(format!(
|
||||
"CalDAV server returned unexpected status {status}"
|
||||
)));
|
||||
}
|
||||
|
||||
let raw_xml = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| CalendarError::TransportFailed(e.to_string()))?;
|
||||
|
||||
self.parse_multistatus_response(calendar_id, &raw_xml)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CalendarBackend for CalDavClient {
|
||||
async fn fetch_events(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start_window: i64,
|
||||
end_window: i64,
|
||||
) -> Result<Vec<CalendarEvent>, CalendarError> {
|
||||
self.fetch_remote_events(calendar_id, start_window, end_window)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_config() -> CalDavAccountConfig {
|
||||
CalDavAccountConfig {
|
||||
account_id: "acct-1".to_string(),
|
||||
caldav_url: "https://caldav.example.com/calendars/work/".to_string(),
|
||||
username: "jmaes".to_string(),
|
||||
auth_token: "app-token-secret".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_report_query_format() {
|
||||
let query = CalDavClient::build_report_query("20240101T000000Z", "20240201T000000Z");
|
||||
@@ -82,7 +193,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_multistatus_response_extracts_events() {
|
||||
let client = CalDavClient::new("acct-1");
|
||||
let client = CalDavClient::new(test_config());
|
||||
let xml_response = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response>
|
||||
@@ -109,4 +220,11 @@ END:VCALENDAR</c:calendar-data>
|
||||
assert_eq!(events[0].summary, "Product Planning");
|
||||
assert_eq!(events[0].location, Some("Boardroom".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_query_timestamp_produces_ical_utc_form() {
|
||||
let formatted =
|
||||
CalDavClient::format_query_timestamp(1_704_067_200).expect("valid timestamp");
|
||||
assert_eq!(formatted, "20240101T000000Z");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! CardDAV (vCard RFC 6350) contact sync and XML query parser.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::parser::CalendarError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Contact entity representation synced over CardDAV.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -34,7 +34,8 @@ impl CardDavClient {
|
||||
<d:getetag />
|
||||
<card:address-data />
|
||||
</d:prop>
|
||||
</card:addressbook-query>"#.to_string()
|
||||
</card:addressbook-query>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse raw vCard string into domain [`Contact`] entity.
|
||||
@@ -66,7 +67,9 @@ impl CardDavClient {
|
||||
}
|
||||
|
||||
if name.is_empty() && email.is_empty() {
|
||||
return Err(CalendarError::ParseFailed("invalid or empty vCard data".to_string()));
|
||||
return Err(CalendarError::ParseFailed(
|
||||
"invalid or empty vCard data".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if name.is_empty() {
|
||||
@@ -136,7 +139,9 @@ END:VCARD</card:address-data>
|
||||
</d:response>
|
||||
</d:multistatus>"#;
|
||||
|
||||
let contacts = client.parse_multistatus_response(xml).expect("parse multistatus");
|
||||
let contacts = client
|
||||
.parse_multistatus_response(xml)
|
||||
.expect("parse multistatus");
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].name, "Alice Dev");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! iCalendar (RFC 5545), CalDAV (RFC 4791), and recurrence engine for Nuncio.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
|
||||
|
||||
pub mod backend;
|
||||
pub mod caldav;
|
||||
pub mod carddav;
|
||||
pub mod mock;
|
||||
@@ -10,7 +12,8 @@ pub mod parser;
|
||||
pub mod rrule;
|
||||
pub mod scheduling;
|
||||
|
||||
pub use caldav::CalDavClient;
|
||||
pub use backend::CalendarBackend;
|
||||
pub use caldav::{CalDavAccountConfig, CalDavClient};
|
||||
pub use carddav::{CardDavClient, Contact};
|
||||
pub use mock::MockCalendarBackend;
|
||||
pub use nlp::{NaturalLanguageScheduler, NlpError, ParsedSchedulingIntent};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Deterministic mock calendar backend for offline testing and integration verification.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nuncio_core::model::CalendarEvent;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::backend::CalendarBackend;
|
||||
use crate::parser::CalendarError;
|
||||
|
||||
/// Thread-safe mock calendar backend for offline testing.
|
||||
@@ -56,9 +58,14 @@ impl MockCalendarBackend {
|
||||
let matches = guard
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
// Interval-overlap (inclusive): an event is in the window if it
|
||||
// starts before the window ends and ends after the window
|
||||
// begins. Matches the CalendarBackend contract and the store's
|
||||
// list_calendar_events query, so mock and persisted results
|
||||
// agree for the same window.
|
||||
e.calendar_id == calendar_id
|
||||
&& e.start_time >= start_window
|
||||
&& e.end_time <= end_window
|
||||
&& e.start_time <= end_window
|
||||
&& e.end_time >= start_window
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
@@ -67,6 +74,18 @@ impl MockCalendarBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CalendarBackend for MockCalendarBackend {
|
||||
async fn fetch_events(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start_window: i64,
|
||||
end_window: i64,
|
||||
) -> Result<Vec<CalendarEvent>, CalendarError> {
|
||||
self.list_events(calendar_id, start_window, end_window)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -100,6 +119,31 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
/// Proves `MockCalendarBackend` is usable behind the `CalendarBackend` trait object, the
|
||||
/// same way the daemon will inject either it or the real CalDAV client.
|
||||
#[tokio::test]
|
||||
async fn mock_calendar_backend_is_usable_as_a_trait_object() {
|
||||
let mock = MockCalendarBackend::new();
|
||||
mock.add_event(CalendarEvent {
|
||||
id: "evt-trait-1".to_string(),
|
||||
account_id: "acct-1".to_string(),
|
||||
calendar_id: "cal-work".to_string(),
|
||||
summary: "Trait Object Planning".to_string(),
|
||||
start_time: 1700000000,
|
||||
end_time: 1700003600,
|
||||
rrule: None,
|
||||
location: None,
|
||||
});
|
||||
|
||||
let backend: Arc<dyn CalendarBackend> = Arc::new(mock);
|
||||
let events = backend
|
||||
.fetch_events("cal-work", 1699999000, 1700004000)
|
||||
.await
|
||||
.expect("fetch events succeeds");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].id, "evt-trait-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wiremock_caldav_report_simulation() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
+182
-26
@@ -1,7 +1,10 @@
|
||||
//! iCalendar (RFC 5545) and VCard (RFC 6350) parser adapter wrapping `icalendar`.
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use icalendar::{Component, EventLike};
|
||||
use nuncio_core::model::CalendarEvent;
|
||||
use std::str::FromStr;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors returned by the iCalendar parsing engine.
|
||||
@@ -10,6 +13,62 @@ pub enum CalendarError {
|
||||
/// Failed to parse RFC 5545 iCalendar payload.
|
||||
#[error("failed to parse iCalendar payload: {0}")]
|
||||
ParseFailed(String),
|
||||
|
||||
/// A `DTSTART`/`DTEND` carried a `TZID` parameter (RFC 5545 3.3.5 Form #3) that could not
|
||||
/// be resolved to a single unambiguous UTC instant: either the TZID string is not a
|
||||
/// recognized IANA zone name, or the local wall-clock value falls inside a DST transition
|
||||
/// gap/overlap with no (or two) valid UTC equivalents. Returned instead of ever silently
|
||||
/// substituting a zeroed or fixed-offset timestamp.
|
||||
#[error("unresolvable TZID timezone reference: {0}")]
|
||||
UnresolvableTimezone(String),
|
||||
|
||||
/// CalDAV network/transport-layer failure (connection, TLS, or an unexpected HTTP status)
|
||||
/// distinct from a payload that connected fine but failed to parse.
|
||||
#[error("CalDAV transport failure: {0}")]
|
||||
TransportFailed(String),
|
||||
}
|
||||
|
||||
/// Resolve an iCalendar `DATE`-or-`DATE-TIME` value to a UTC unix timestamp.
|
||||
///
|
||||
/// `date_only_time` supplies the wall-clock time assumed for a bare `DATE` value (no time
|
||||
/// component at all): start-of-day for `DTSTART`, end-of-day for `DTEND`, matching this
|
||||
/// parser's pre-existing whole-day-event convention.
|
||||
fn resolve_timestamp(
|
||||
value: icalendar::DatePerhapsTime,
|
||||
date_only_time: (u32, u32, u32),
|
||||
) -> Result<i64, CalendarError> {
|
||||
match value {
|
||||
icalendar::DatePerhapsTime::Date(date) => {
|
||||
let (hour, min, sec) = date_only_time;
|
||||
date.and_hms_opt(hour, min, sec)
|
||||
.map(|naive| naive.and_utc().timestamp())
|
||||
.ok_or_else(|| {
|
||||
CalendarError::ParseFailed(
|
||||
"invalid DATE value in iCalendar payload".to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
icalendar::DatePerhapsTime::DateTime(dt) => match dt {
|
||||
icalendar::CalendarDateTime::Utc(dt) => Ok(dt.timestamp()),
|
||||
// Floating times (RFC 5545 3.3.5 Form #1) carry no zone information at all -- there
|
||||
// is no correct universal interpretation, only a documented convention. Treating
|
||||
// the naive wall-clock value as if it were UTC is the pre-existing behavior here
|
||||
// and is left unchanged: fixing it would require guessing a zone, which is exactly
|
||||
// the kind of fabrication this parser must not do.
|
||||
icalendar::CalendarDateTime::Floating(dt) => Ok(dt.and_utc().timestamp()),
|
||||
icalendar::CalendarDateTime::WithTimezone { date_time, tzid } => {
|
||||
let tz = Tz::from_str(&tzid)
|
||||
.map_err(|_| CalendarError::UnresolvableTimezone(tzid.clone()))?;
|
||||
// `.single()` rejects ambiguous local times -- those that fall inside a DST
|
||||
// "spring forward" gap (no valid instant) or "fall back" overlap (two valid
|
||||
// instants) -- rather than guessing which offset the source server intended.
|
||||
tz.from_local_datetime(&date_time)
|
||||
.single()
|
||||
.map(|resolved| resolved.with_timezone(&Utc).timestamp())
|
||||
.ok_or(CalendarError::UnresolvableTimezone(tzid))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// iCalendar parser adapter converting raw `.ics` payloads into Nuncio [`CalendarEvent`] domain entities.
|
||||
@@ -45,33 +104,15 @@ impl IcalParserAdapter {
|
||||
.get("RRULE")
|
||||
.map(|p| p.value().to_string());
|
||||
|
||||
let start_time = event
|
||||
.get_start()
|
||||
.map(|d| match d {
|
||||
icalendar::DatePerhapsTime::Date(date) => date
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.map_or(0, |dt| dt.and_utc().timestamp()),
|
||||
icalendar::DatePerhapsTime::DateTime(dt) => match dt {
|
||||
icalendar::CalendarDateTime::Utc(dt) => dt.timestamp(),
|
||||
icalendar::CalendarDateTime::Floating(dt) => dt.and_utc().timestamp(),
|
||||
_ => 0,
|
||||
},
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let start_time = match event.get_start() {
|
||||
Some(value) => resolve_timestamp(value, (0, 0, 0))?,
|
||||
None => 0,
|
||||
};
|
||||
|
||||
let end_time = event
|
||||
.get_end()
|
||||
.map(|d| match d {
|
||||
icalendar::DatePerhapsTime::Date(date) => date
|
||||
.and_hms_opt(23, 59, 59)
|
||||
.map_or(0, |dt| dt.and_utc().timestamp()),
|
||||
icalendar::DatePerhapsTime::DateTime(dt) => match dt {
|
||||
icalendar::CalendarDateTime::Utc(dt) => dt.timestamp(),
|
||||
icalendar::CalendarDateTime::Floating(dt) => dt.and_utc().timestamp(),
|
||||
_ => 0,
|
||||
},
|
||||
})
|
||||
.unwrap_or(start_time + 3600);
|
||||
let end_time = match event.get_end() {
|
||||
Some(value) => resolve_timestamp(value, (23, 59, 59))?,
|
||||
None => start_time + 3600,
|
||||
};
|
||||
|
||||
return Ok(CalendarEvent {
|
||||
id: id.to_string(),
|
||||
@@ -142,4 +183,119 @@ mod tests {
|
||||
.to_string()
|
||||
.contains("exceeds maximum allowed limit of 25MB"));
|
||||
}
|
||||
|
||||
/// Regression for the TZID bug: `DTSTART;TZID=America/New_York:...` during Eastern
|
||||
/// Daylight Time (UTC-4) must resolve to the correct UTC instant, not fall through the old
|
||||
/// catch-all that zeroed the timestamp.
|
||||
#[test]
|
||||
fn parse_tzid_dtstart_during_edt_resolves_correct_utc_epoch() {
|
||||
let ics = "BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
SUMMARY:Standup EDT\r\n\
|
||||
DTSTART;TZID=America/New_York:20240715T090000\r\n\
|
||||
DTEND;TZID=America/New_York:20240715T100000\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR";
|
||||
|
||||
let event = IcalParserAdapter::parse_ical("evt-tz-edt", "acct-1", "cal-1", ics)
|
||||
.expect("parse succeeds");
|
||||
|
||||
assert_ne!(
|
||||
event.start_time, 0,
|
||||
"TZID timestamp must never be silently zeroed"
|
||||
);
|
||||
// 2024-07-15 09:00 America/New_York is EDT (UTC-4) -> 2024-07-15T13:00:00Z.
|
||||
assert_eq!(event.start_time, 1_721_048_400);
|
||||
// 2024-07-15 10:00 America/New_York (EDT) -> 2024-07-15T14:00:00Z.
|
||||
assert_eq!(event.end_time, 1_721_052_000);
|
||||
}
|
||||
|
||||
/// Same wall-clock hour as the EDT fixture above but on the winter side of the DST
|
||||
/// boundary: America/New_York is EST (UTC-5) there, so the resolved UTC instant must
|
||||
/// differ by an hour from the summer fixture -- proving the fix performs a genuine
|
||||
/// chrono_tz calendar-aware lookup rather than assuming a fixed UTC offset.
|
||||
#[test]
|
||||
fn parse_tzid_dtstart_during_est_resolves_correct_utc_epoch_across_dst_boundary() {
|
||||
let ics = "BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
SUMMARY:Standup EST\r\n\
|
||||
DTSTART;TZID=America/New_York:20240115T090000\r\n\
|
||||
DTEND;TZID=America/New_York:20240115T100000\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR";
|
||||
|
||||
let event = IcalParserAdapter::parse_ical("evt-tz-est", "acct-1", "cal-1", ics)
|
||||
.expect("parse succeeds");
|
||||
|
||||
assert_ne!(
|
||||
event.start_time, 0,
|
||||
"TZID timestamp must never be silently zeroed"
|
||||
);
|
||||
// 2024-01-15 09:00 America/New_York is EST (UTC-5) -> 2024-01-15T14:00:00Z.
|
||||
assert_eq!(event.start_time, 1_705_327_200);
|
||||
// 2024-01-15 10:00 America/New_York (EST) -> 2024-01-15T15:00:00Z.
|
||||
assert_eq!(event.end_time, 1_705_330_800);
|
||||
}
|
||||
|
||||
/// Plain `Z`-suffixed UTC datetimes must keep working exactly as before the TZID fix.
|
||||
#[test]
|
||||
fn parse_plain_utc_z_dtstart_has_no_regression() {
|
||||
let ics = "BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
SUMMARY:UTC Event\r\n\
|
||||
DTSTART:20240301T120000Z\r\n\
|
||||
DTEND:20240301T130000Z\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR";
|
||||
|
||||
let event = IcalParserAdapter::parse_ical("evt-utc", "acct-1", "cal-1", ics)
|
||||
.expect("parse succeeds");
|
||||
|
||||
assert_eq!(event.start_time, 1_709_294_400);
|
||||
assert_eq!(event.end_time, 1_709_298_000);
|
||||
}
|
||||
|
||||
/// An unresolvable TZID (not a real IANA zone) must surface as a genuine error, never a
|
||||
/// fabricated zero timestamp.
|
||||
#[test]
|
||||
fn parse_unknown_tzid_returns_error_not_a_fabricated_zero() {
|
||||
let ics = "BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
SUMMARY:Bogus TZID Event\r\n\
|
||||
DTSTART;TZID=Not/A_Real_Zone:20240115T090000\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR";
|
||||
|
||||
let err = IcalParserAdapter::parse_ical("evt-bad-tz", "acct-1", "cal-1", ics)
|
||||
.expect_err("unresolvable TZID must be a real error, not a zeroed timestamp");
|
||||
assert!(
|
||||
matches!(err, CalendarError::UnresolvableTimezone(ref tz) if tz == "Not/A_Real_Zone")
|
||||
);
|
||||
}
|
||||
|
||||
/// A local time inside a DST "spring forward" gap has no valid instant, so
|
||||
/// `from_local_datetime(..).single()` yields `None`. That must surface as an
|
||||
/// error rather than a silently substituted timestamp. In America/New_York,
|
||||
/// 2024-03-10 02:30 never occurs (clocks jump 02:00 -> 03:00).
|
||||
#[test]
|
||||
fn parse_tzid_in_dst_gap_returns_error_not_a_fabricated_timestamp() {
|
||||
let ics = "BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
SUMMARY:Nonexistent Local Time\r\n\
|
||||
DTSTART;TZID=America/New_York:20240310T023000\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR";
|
||||
|
||||
let err = IcalParserAdapter::parse_ical("evt-gap", "acct-1", "cal-1", ics).expect_err(
|
||||
"a nonexistent DST-gap local time must be an error, not a fabricated instant",
|
||||
);
|
||||
assert!(
|
||||
matches!(err, CalendarError::UnresolvableTimezone(ref tz) if tz == "America/New_York")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -67,17 +67,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn generate_1on1_link_format() {
|
||||
let link = SchedulingLinkGenerator::generate_1on1_link("james.maes@kof22.com", 30, "Architecture Review");
|
||||
let link = SchedulingLinkGenerator::generate_1on1_link(
|
||||
"james.maes@kof22.com",
|
||||
30,
|
||||
"Architecture Review",
|
||||
);
|
||||
assert_eq!(link.host_email, "james.maes@kof22.com");
|
||||
assert_eq!(link.duration_mins, 30);
|
||||
assert_eq!(link.booking_url, "https://nuncio.mx/meet/james.maes@kof22.com/architecture-review?dur=30");
|
||||
assert_eq!(
|
||||
link.booking_url,
|
||||
"https://nuncio.mx/meet/james.maes@kof22.com/architecture-review?dur=30"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duration_minutes_variations() {
|
||||
assert_eq!(SchedulingLinkGenerator::parse_duration_minutes("30m"), 30);
|
||||
assert_eq!(SchedulingLinkGenerator::parse_duration_minutes("1h"), 60);
|
||||
assert_eq!(SchedulingLinkGenerator::parse_duration_minutes("45 mins"), 45);
|
||||
assert_eq!(SchedulingLinkGenerator::parse_duration_minutes("2 hours"), 120);
|
||||
assert_eq!(
|
||||
SchedulingLinkGenerator::parse_duration_minutes("45 mins"),
|
||||
45
|
||||
);
|
||||
assert_eq!(
|
||||
SchedulingLinkGenerator::parse_duration_minutes("2 hours"),
|
||||
120
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! WireMock integration test suite for the real CalDAV `REPORT` transport.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use nuncio_cal::{CalDavAccountConfig, CalDavClient, CalendarBackend, CalendarError};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// A canned CalDAV `multistatus` response containing one TZID-qualified VEVENT and one plain
|
||||
/// UTC VEVENT, exercising both the real HTTP transport and the TZID resolution fix together.
|
||||
fn multistatus_body() -> String {
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response>
|
||||
<d:href>/calendars/work/standup.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<c:calendar-data>BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Wiremock CalDAV Standup
|
||||
LOCATION:Conference Room B
|
||||
DTSTART;TZID=America/New_York:20240715T090000
|
||||
DTEND;TZID=America/New_York:20240715T100000
|
||||
END:VEVENT
|
||||
END:VCALENDAR</c:calendar-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/calendars/work/utc-review.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<c:calendar-data>BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Wiremock UTC Review
|
||||
DTSTART:20240301T120000Z
|
||||
DTEND:20240301T130000Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR</c:calendar-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wiremock_caldav_report_fetches_and_resolves_tzid_events() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/calendars/work/"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(multistatus_body()))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = CalDavAccountConfig {
|
||||
account_id: "acct-wm-1".to_string(),
|
||||
caldav_url: format!("{}/calendars/work/", mock_server.uri()),
|
||||
username: "james.maes".to_string(),
|
||||
auth_token: "wiremock-app-token".to_string(),
|
||||
};
|
||||
let client = CalDavClient::new(config);
|
||||
|
||||
// Drive the real client through the `CalendarBackend` trait, exactly how the daemon will
|
||||
// invoke either this real client or `MockCalendarBackend` behind the same seam.
|
||||
let events: Vec<_> =
|
||||
CalendarBackend::fetch_events(&client, "cal-work", 1_700_000_000, 1_800_000_000)
|
||||
.await
|
||||
.expect("fetch_events succeeds against the wiremock server");
|
||||
|
||||
assert_eq!(events.len(), 2);
|
||||
|
||||
let tzid_event = events
|
||||
.iter()
|
||||
.find(|e| e.summary == "Wiremock CalDAV Standup")
|
||||
.expect("TZID event present");
|
||||
assert_eq!(tzid_event.account_id, "acct-wm-1");
|
||||
assert_eq!(tzid_event.calendar_id, "cal-work");
|
||||
assert_eq!(tzid_event.location, Some("Conference Room B".to_string()));
|
||||
// 2024-07-15 09:00 America/New_York is EDT (UTC-4) -> 2024-07-15T13:00:00Z.
|
||||
assert_eq!(tzid_event.start_time, 1_721_048_400);
|
||||
assert_eq!(tzid_event.end_time, 1_721_052_000);
|
||||
|
||||
let utc_event = events
|
||||
.iter()
|
||||
.find(|e| e.summary == "Wiremock UTC Review")
|
||||
.expect("UTC event present");
|
||||
assert_eq!(utc_event.start_time, 1_709_294_400);
|
||||
assert_eq!(utc_event.end_time, 1_709_298_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wiremock_caldav_report_server_error_surfaces_as_transport_failure() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/calendars/broken/"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = CalDavAccountConfig {
|
||||
account_id: "acct-wm-2".to_string(),
|
||||
caldav_url: format!("{}/calendars/broken/", mock_server.uri()),
|
||||
username: "james.maes".to_string(),
|
||||
auth_token: "wiremock-app-token".to_string(),
|
||||
};
|
||||
let client = CalDavClient::new(config);
|
||||
|
||||
let err = client
|
||||
.fetch_remote_events("cal-broken", 1_700_000_000, 1_800_000_000)
|
||||
.await
|
||||
.expect_err("a 500 response must be a real error, not fabricated empty success");
|
||||
|
||||
assert!(matches!(err, CalendarError::TransportFailed(_)));
|
||||
}
|
||||
@@ -16,9 +16,11 @@ nuncio-cal = { path = "../nuncio-cal" }
|
||||
nuncio-store = { path = "../nuncio-store" }
|
||||
nuncio-filter = { path = "../nuncio-filter" }
|
||||
nuncio-contacts = { path = "../nuncio-contacts" }
|
||||
nuncio-proto = { path = "../nuncio-proto" }
|
||||
|
||||
tokio.workspace = true
|
||||
sqlx.workspace = true
|
||||
tonic.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -26,5 +28,9 @@ thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
rpassword.workspace = true
|
||||
chrono.workspace = true
|
||||
hex.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-stream = { workspace = true, features = ["net"] }
|
||||
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
//! Command-line argument hierarchy and Clap subcommand parsing.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// Carries an account password credential from an interactive `rpassword`
|
||||
/// prompt (see `nuncio-cli`'s `main`) through to `Accounts/AddAccount`
|
||||
/// without ever being reachable as a Clap CLI flag, printed by `Debug`, or
|
||||
/// carried by value through `Serialize`/`Deserialize`.
|
||||
///
|
||||
/// `AccountSubcommand::Add`'s `password` field is `#[arg(skip)]`, so Clap
|
||||
/// never parses it from argv; `main` populates it exactly once, right after
|
||||
/// `Cli::parse()`, by prompting without echo. `Debug`, `Serialize` are
|
||||
/// hand-implemented below to redact the value on principle -- defense in
|
||||
/// depth in case a future caller logs or serializes a `Commands` value --
|
||||
/// and `Deserialize` always yields an empty password, since a password
|
||||
/// must never round-trip through a serialized command payload.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PasswordArg(pub String);
|
||||
|
||||
impl std::fmt::Debug for PasswordArg {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("PasswordArg(\"[REDACTED]\")")
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for PasswordArg {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0 == other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for PasswordArg {}
|
||||
|
||||
impl Serialize for PasswordArg {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str("[REDACTED]")
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PasswordArg {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
// Discard whatever was serialized (see the redacting `Serialize`
|
||||
// impl above) and always yield an empty password rather than ever
|
||||
// reconstructing a real credential from a deserialized payload.
|
||||
let _ = String::deserialize(deserializer);
|
||||
Ok(PasswordArg(String::new()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Nuncio CLI — Pure Noun + Verb Developer-First Mail & Calendar Automation.
|
||||
#[derive(Parser, Debug, Clone, PartialEq, Eq)]
|
||||
@@ -87,12 +132,6 @@ pub enum Commands {
|
||||
#[command(subcommand)]
|
||||
action: ContactSubcommand,
|
||||
},
|
||||
/// Launch centralized local background server daemon (`nuncio daemon`).
|
||||
Daemon {
|
||||
/// TCP port to bind IPC daemon server to (default: 9422).
|
||||
#[arg(long, default_value = "9422")]
|
||||
port: u16,
|
||||
},
|
||||
}
|
||||
|
||||
/// Contact subcommands (`nuncio contact <verb>`).
|
||||
@@ -131,7 +170,6 @@ pub enum UpdateSubcommand {
|
||||
Apply,
|
||||
}
|
||||
|
||||
|
||||
/// Account subcommands (`nuncio account <verb>`).
|
||||
#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
@@ -161,6 +199,12 @@ pub enum AccountSubcommand {
|
||||
/// SMTP connection transport mode (implicit_tls, start_tls, plain).
|
||||
#[arg(long, default_value = "implicit_tls", help = "SMTP transport mode")]
|
||||
smtp_mode: String,
|
||||
/// Account password credential. Never a CLI flag -- always read
|
||||
/// interactively without echo (via `rpassword`) in `main` and
|
||||
/// injected here immediately before dispatch to the daemon's
|
||||
/// `Accounts/AddAccount` RPC. See [`PasswordArg`].
|
||||
#[arg(skip)]
|
||||
password: PasswordArg,
|
||||
},
|
||||
/// Display details for a specific configured account.
|
||||
Show {
|
||||
@@ -239,6 +283,47 @@ pub enum MailSubcommand {
|
||||
#[arg(short, long, help = "Search query string")]
|
||||
query: String,
|
||||
},
|
||||
/// Mark a message read or unread.
|
||||
Mark {
|
||||
/// Unique message identifier.
|
||||
#[arg(short, long, help = "Unique message identifier")]
|
||||
id: String,
|
||||
/// Mark the message as read.
|
||||
#[arg(long, help = "Mark the message as read", conflicts_with = "unread")]
|
||||
read: bool,
|
||||
/// Mark the message as unread.
|
||||
#[arg(long, help = "Mark the message as unread", conflicts_with = "read")]
|
||||
unread: bool,
|
||||
},
|
||||
/// Export mailbox messages to a portable file format: MBOX, an EML
|
||||
/// zip archive, JSON, or JSON Lines.
|
||||
Export {
|
||||
/// Export format (mbox, eml, json, or jsonl).
|
||||
#[arg(
|
||||
short = 'f',
|
||||
long,
|
||||
default_value = "mbox",
|
||||
help = "Export format (mbox, eml, json, jsonl)"
|
||||
)]
|
||||
format: String,
|
||||
/// Destination output file path, on the daemon's host filesystem.
|
||||
#[arg(short = 'o', long, help = "Destination output file path")]
|
||||
out: String,
|
||||
/// Restrict the export to a single account's messages.
|
||||
#[arg(
|
||||
long,
|
||||
help = "Restrict export to a single account ID",
|
||||
conflicts_with = "folder"
|
||||
)]
|
||||
account: Option<String>,
|
||||
/// Restrict the export to a single folder's messages.
|
||||
#[arg(
|
||||
long,
|
||||
help = "Restrict export to a single folder ID",
|
||||
conflicts_with = "account"
|
||||
)]
|
||||
folder: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Folder subcommands (`nuncio folder <verb>`).
|
||||
@@ -265,6 +350,30 @@ pub enum CalSubcommand {
|
||||
pub enum SystemSubcommand {
|
||||
/// Display system, daemon, and event bus status.
|
||||
Status,
|
||||
/// WORM (Write Once, Read Many) tamper-evident audit ledger operations
|
||||
/// (`nuncio system audit <verb>`).
|
||||
Audit {
|
||||
#[command(subcommand)]
|
||||
action: AuditSubcommand,
|
||||
},
|
||||
}
|
||||
|
||||
/// Audit subcommands (`nuncio system audit <verb>`).
|
||||
#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AuditSubcommand {
|
||||
/// List persisted WORM audit ledger records, sequence ascending.
|
||||
List {
|
||||
/// Max records to fetch (0 = server default).
|
||||
#[arg(short, long, default_value_t = 50, help = "Max records to fetch")]
|
||||
limit: u32,
|
||||
/// Pagination offset.
|
||||
#[arg(short, long, default_value_t = 0, help = "Pagination offset")]
|
||||
offset: u32,
|
||||
},
|
||||
/// Verify the WORM audit ledger's cryptographic HMAC hash-chain
|
||||
/// integrity.
|
||||
Verify,
|
||||
}
|
||||
|
||||
/// Filter subcommands (`nuncio filter <verb>`).
|
||||
@@ -306,6 +415,12 @@ pub enum FilterSubcommand {
|
||||
#[arg(short, long, help = "Rule identifier")]
|
||||
id: String,
|
||||
},
|
||||
/// Validate an NSQL query's syntax and semantics without persisting it.
|
||||
Validate {
|
||||
/// NSQL query string to validate.
|
||||
#[arg(short, long, help = "NSQL query string")]
|
||||
sql: String,
|
||||
},
|
||||
/// Test / dry-run NSQL query against an email.
|
||||
Test {
|
||||
/// NSQL query string to test.
|
||||
@@ -369,6 +484,7 @@ mod tests {
|
||||
smtp_port: 465,
|
||||
imap_mode: "implicit_tls".to_string(),
|
||||
smtp_mode: "implicit_tls".to_string(),
|
||||
password: PasswordArg::default(),
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -395,6 +511,142 @@ mod tests {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let cli_mark_read =
|
||||
Cli::parse_from(["nuncio", "mail", "mark", "--id", "msg-123", "--read"]);
|
||||
assert_eq!(
|
||||
cli_mark_read.command,
|
||||
Commands::Mail {
|
||||
action: MailSubcommand::Mark {
|
||||
id: "msg-123".to_string(),
|
||||
read: true,
|
||||
unread: false,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let cli_mark_unread =
|
||||
Cli::parse_from(["nuncio", "mail", "mark", "--id", "msg-123", "--unread"]);
|
||||
assert_eq!(
|
||||
cli_mark_unread.command,
|
||||
Commands::Mail {
|
||||
action: MailSubcommand::Mark {
|
||||
id: "msg-123".to_string(),
|
||||
read: false,
|
||||
unread: true,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// `--read` and `--unread` are mutually exclusive at the Clap level.
|
||||
let conflict = Cli::try_parse_from([
|
||||
"nuncio", "mail", "mark", "--id", "msg-123", "--read", "--unread",
|
||||
]);
|
||||
assert!(conflict.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pure_noun_verb_mail_export_command() {
|
||||
let cli_export = Cli::parse_from([
|
||||
"nuncio",
|
||||
"mail",
|
||||
"export",
|
||||
"--format",
|
||||
"json",
|
||||
"--out",
|
||||
"/tmp/out.json",
|
||||
]);
|
||||
assert_eq!(
|
||||
cli_export.command,
|
||||
Commands::Mail {
|
||||
action: MailSubcommand::Export {
|
||||
format: "json".to_string(),
|
||||
out: "/tmp/out.json".to_string(),
|
||||
account: None,
|
||||
folder: None,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let cli_export_scoped = Cli::parse_from([
|
||||
"nuncio",
|
||||
"mail",
|
||||
"export",
|
||||
"--format",
|
||||
"jsonl",
|
||||
"--out",
|
||||
"/tmp/scoped.jsonl",
|
||||
"--account",
|
||||
"acct-1",
|
||||
]);
|
||||
assert_eq!(
|
||||
cli_export_scoped.command,
|
||||
Commands::Mail {
|
||||
action: MailSubcommand::Export {
|
||||
format: "jsonl".to_string(),
|
||||
out: "/tmp/scoped.jsonl".to_string(),
|
||||
account: Some("acct-1".to_string()),
|
||||
folder: None,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// `--account` and `--folder` are mutually exclusive at the Clap
|
||||
// level -- an export scope is either "one account", "one folder",
|
||||
// or (neither flag) "everything".
|
||||
let conflict = Cli::try_parse_from([
|
||||
"nuncio",
|
||||
"mail",
|
||||
"export",
|
||||
"--out",
|
||||
"/tmp/out.json",
|
||||
"--account",
|
||||
"acct-1",
|
||||
"--folder",
|
||||
"inbox",
|
||||
]);
|
||||
assert!(conflict.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pure_noun_verb_system_audit_commands() {
|
||||
let cli_list = Cli::parse_from(["nuncio", "system", "audit", "list"]);
|
||||
assert_eq!(
|
||||
cli_list.command,
|
||||
Commands::System {
|
||||
action: SystemSubcommand::Audit {
|
||||
action: AuditSubcommand::List {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let cli_list_paged = Cli::parse_from([
|
||||
"nuncio", "system", "audit", "list", "--limit", "10", "--offset", "5",
|
||||
]);
|
||||
assert_eq!(
|
||||
cli_list_paged.command,
|
||||
Commands::System {
|
||||
action: SystemSubcommand::Audit {
|
||||
action: AuditSubcommand::List {
|
||||
limit: 10,
|
||||
offset: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let cli_verify = Cli::parse_from(["nuncio", "system", "audit", "verify"]);
|
||||
assert_eq!(
|
||||
cli_verify.command,
|
||||
Commands::System {
|
||||
action: SystemSubcommand::Audit {
|
||||
action: AuditSubcommand::Verify
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -415,5 +667,53 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_add_password_is_never_parsed_from_argv() {
|
||||
// `--password` is deliberately not a recognized flag: the password
|
||||
// MUST always come from an interactive, non-echoing prompt in
|
||||
// `main`, never from argv (which would land it in shell history and
|
||||
// process listings).
|
||||
let cli_add = Cli::parse_from([
|
||||
"nuncio",
|
||||
"account",
|
||||
"add",
|
||||
"--email",
|
||||
"a@b.com",
|
||||
"--imap-host",
|
||||
"imap.b.com",
|
||||
]);
|
||||
match cli_add.command {
|
||||
Commands::Account {
|
||||
action: AccountSubcommand::Add { password, .. },
|
||||
} => assert_eq!(password, PasswordArg::default()),
|
||||
other => panic!("expected Account::Add, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_arg_debug_and_serialize_always_redact_the_value() {
|
||||
let secret = PasswordArg("super-secret-value".to_string());
|
||||
assert_eq!(format!("{secret:?}"), "PasswordArg(\"[REDACTED]\")");
|
||||
|
||||
let json = serde_json::to_string(&secret).expect("serializes");
|
||||
assert_eq!(json, "\"[REDACTED]\"");
|
||||
assert!(!json.contains("super-secret-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_arg_deserialize_never_reconstructs_a_real_credential() {
|
||||
let deserialized: PasswordArg =
|
||||
serde_json::from_str("\"super-secret-value\"").expect("deserializes");
|
||||
assert_eq!(deserialized, PasswordArg::default());
|
||||
assert_eq!(deserialized.0, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_arg_equality_and_default() {
|
||||
assert_eq!(PasswordArg::default(), PasswordArg(String::new()));
|
||||
assert_ne!(PasswordArg("a".to_string()), PasswordArg("b".to_string()));
|
||||
let cloned = PasswordArg("clone-me".to_string()).clone();
|
||||
assert_eq!(cloned.0, "clone-me");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
//! Nuncio CLI library.
|
||||
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
|
||||
|
||||
pub mod args;
|
||||
pub mod output;
|
||||
pub mod runner;
|
||||
|
||||
pub use args::{
|
||||
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, MailSubcommand, SystemSubcommand,
|
||||
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, MailSubcommand, PasswordArg,
|
||||
SystemSubcommand,
|
||||
};
|
||||
pub use output::JsonResponse;
|
||||
pub use runner::HeadlessRunner;
|
||||
@@ -1,9 +1,25 @@
|
||||
use clap::Parser;
|
||||
use nuncio_cli::{args::Cli, HeadlessRunner};
|
||||
use nuncio_cli::{args::Cli, AccountSubcommand, Commands, HeadlessRunner, PasswordArg};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = Cli::parse();
|
||||
let mut cli = Cli::parse();
|
||||
|
||||
// `account add`'s password is deliberately never a Clap-parsed CLI
|
||||
// flag (see `PasswordArg`'s doc comment): read it here, interactively
|
||||
// and without echo, immediately after parsing argv and before any
|
||||
// other work, then inject it into the already-parsed command. This is
|
||||
// the ONLY place in the whole `nuncio-cli` binary that ever reads a raw
|
||||
// password from the terminal.
|
||||
if let Commands::Account {
|
||||
action: AccountSubcommand::Add { password, .. },
|
||||
} = &mut cli.command
|
||||
{
|
||||
let entered = rpassword::prompt_password("Account password: ")
|
||||
.map_err(|e| format!("failed to read password: {e}"))?;
|
||||
*password = PasswordArg(entered);
|
||||
}
|
||||
|
||||
let runner = HeadlessRunner::ephemeral().await?;
|
||||
|
||||
let output_str = runner.execute_command(&cli.command, cli.json).await;
|
||||
|
||||
+2621
-408
File diff suppressed because it is too large.
Load diff
@@ -1,26 +1,24 @@
|
||||
//! E2E System Test Suite for nuncio-cli.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use nuncio_cli::{
|
||||
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, HeadlessRunner, MailSubcommand,
|
||||
SystemSubcommand,
|
||||
PasswordArg, SystemSubcommand,
|
||||
};
|
||||
use nuncio_store::vault::SecretManager;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_test_cli_noun_verb_execution_matrix() {
|
||||
let runner = HeadlessRunner::ephemeral().await.expect("runner init");
|
||||
|
||||
// 1. System status
|
||||
let out: String = runner
|
||||
.execute_command(
|
||||
&Commands::System {
|
||||
action: SystemSubcommand::Status,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "ok");
|
||||
// 1. System status is a real gRPC client of the `nunciod` daemon, so it
|
||||
// is exercised separately below via `ephemeral_with` + `SecretManager::mock()`
|
||||
// rather than through this `ephemeral()`-constructed runner (which holds
|
||||
// the production `SecretManager` and must never touch the real OS
|
||||
// keyring in a test).
|
||||
|
||||
// 1b. Banner output
|
||||
let out: String = runner.execute_command(&Commands::Banner, true).await;
|
||||
@@ -33,75 +31,16 @@ async fn system_test_cli_noun_verb_execution_matrix() {
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "ok");
|
||||
|
||||
// 2. Account list & add
|
||||
let out: String = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::List,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "ok");
|
||||
// 2. Account list & add are real gRPC clients of the `nunciod` daemon's
|
||||
// `Accounts` API, so -- exactly like `system status` above -- they are
|
||||
// exercised separately below via `ephemeral_with` + `SecretManager::mock()`
|
||||
// rather than through this `ephemeral()`-constructed runner.
|
||||
|
||||
let out: String = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "test@nuncio.mx".to_string(),
|
||||
imap_host: "mail.nuncio.mx".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "mail.nuncio.mx".to_string(),
|
||||
smtp_port: 465,
|
||||
imap_mode: "implicit_tls".to_string(),
|
||||
smtp_mode: "implicit_tls".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "ok");
|
||||
|
||||
// 3. Folder list
|
||||
let out: String = runner
|
||||
.execute_command(
|
||||
&Commands::Folder {
|
||||
action: FolderSubcommand::List,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "ok");
|
||||
|
||||
// 4. Mail list & search & read
|
||||
let out: String = runner
|
||||
.execute_command(
|
||||
&Commands::Mail {
|
||||
action: MailSubcommand::List {
|
||||
folder: "INBOX".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "ok");
|
||||
|
||||
let out: String = runner
|
||||
.execute_command(
|
||||
&Commands::Mail {
|
||||
action: MailSubcommand::Search {
|
||||
query: "Architecture".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "ok");
|
||||
// 3. Folder list & 4. Mail list/search/read/mark are real gRPC clients
|
||||
// of the `nunciod` daemon's `Mail` API, so -- exactly like `system
|
||||
// status` and `account add`/`list` above -- they are exercised
|
||||
// separately below via `ephemeral_with` + `SecretManager::mock()`
|
||||
// rather than through this `ephemeral()`-constructed runner.
|
||||
|
||||
// 5. Calendar list & sync
|
||||
let out: String = runner
|
||||
@@ -115,3 +54,183 @@ async fn system_test_cli_noun_verb_execution_matrix() {
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "ok");
|
||||
}
|
||||
|
||||
/// `system status` is a real gRPC client of the `nunciod` daemon. With no
|
||||
/// daemon reachable at an address nothing is listening on, it must report
|
||||
/// a clear, honest error rather than fabricating a status.
|
||||
#[tokio::test]
|
||||
async fn system_status_reports_honest_error_when_daemon_unreachable() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
drop(listener); // free the port; nothing is listening on it now
|
||||
|
||||
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("runner init");
|
||||
|
||||
let out: String = runner
|
||||
.execute_command(
|
||||
&Commands::System {
|
||||
action: SystemSubcommand::Status,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "error");
|
||||
assert!(json["error"]
|
||||
.as_str()
|
||||
.expect("error message present")
|
||||
.contains("unreachable"));
|
||||
}
|
||||
|
||||
/// `account add` / `account list` are real gRPC clients of the `nunciod`
|
||||
/// daemon's `Accounts` API. With no daemon reachable, both must report a
|
||||
/// clear, honest error rather than fabricating success or silently
|
||||
/// falling back to any local state.
|
||||
#[tokio::test]
|
||||
async fn account_add_and_list_report_honest_errors_when_daemon_unreachable() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
drop(listener); // free the port; nothing is listening on it now
|
||||
|
||||
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("runner init");
|
||||
|
||||
let list_out: String = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::List,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let list_json: Value = serde_json::from_str(&list_out).expect("valid json");
|
||||
assert_eq!(list_json["status"], "error");
|
||||
assert!(list_json["error"]
|
||||
.as_str()
|
||||
.expect("error message present")
|
||||
.contains("unreachable"));
|
||||
|
||||
let add_out: String = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "test@nuncio.mx".to_string(),
|
||||
imap_host: "mail.nuncio.mx".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "mail.nuncio.mx".to_string(),
|
||||
smtp_port: 465,
|
||||
imap_mode: "implicit_tls".to_string(),
|
||||
smtp_mode: "implicit_tls".to_string(),
|
||||
password: PasswordArg("irrelevant-unreachable-daemon".to_string()),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let add_json: Value = serde_json::from_str(&add_out).expect("valid json");
|
||||
assert_eq!(add_json["status"], "error");
|
||||
assert!(add_json["error"]
|
||||
.as_str()
|
||||
.expect("error message present")
|
||||
.contains("unreachable"));
|
||||
// The password must never leak into an error message either.
|
||||
assert!(!add_out.contains("irrelevant-unreachable-daemon"));
|
||||
}
|
||||
|
||||
/// `folder list`, `mail list`, `mail read`, `mail search`, and `mail mark`
|
||||
/// are real gRPC clients of the `nunciod` daemon's `Mail` API. With no
|
||||
/// daemon reachable, all of them must report a clear, honest error rather
|
||||
/// than fabricating an empty result.
|
||||
#[tokio::test]
|
||||
async fn mail_and_folder_report_honest_errors_when_daemon_unreachable() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
drop(listener); // free the port; nothing is listening on it now
|
||||
|
||||
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("runner init");
|
||||
|
||||
let assert_honest_error = |out: String| {
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "error");
|
||||
assert!(json["error"]
|
||||
.as_str()
|
||||
.expect("error message present")
|
||||
.contains("unreachable"));
|
||||
};
|
||||
|
||||
assert_honest_error(
|
||||
runner
|
||||
.execute_command(
|
||||
&Commands::Folder {
|
||||
action: FolderSubcommand::List,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
assert_honest_error(
|
||||
runner
|
||||
.execute_command(
|
||||
&Commands::Mail {
|
||||
action: MailSubcommand::List {
|
||||
folder: "INBOX".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
assert_honest_error(
|
||||
runner
|
||||
.execute_command(
|
||||
&Commands::Mail {
|
||||
action: MailSubcommand::Read {
|
||||
id: "msg-1".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
assert_honest_error(
|
||||
runner
|
||||
.execute_command(
|
||||
&Commands::Mail {
|
||||
action: MailSubcommand::Search {
|
||||
query: "Architecture".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
assert_honest_error(
|
||||
runner
|
||||
.execute_command(
|
||||
&Commands::Mail {
|
||||
action: MailSubcommand::Mark {
|
||||
id: "msg-1".to_string(),
|
||||
read: true,
|
||||
unread: false,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,10 @@ impl CardDavClient {
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_vcards(&self) -> Result<Vec<Contact>, CardDavError> {
|
||||
info!("Initiating CardDAV PROPFIND fetch from {}", self.config.carddav_url);
|
||||
info!(
|
||||
"Initiating CardDAV PROPFIND fetch from {}",
|
||||
self.config.carddav_url
|
||||
);
|
||||
// Returns mock or parsed remote vcards for CardDAV endpoint
|
||||
let sample_contact = Contact::new("Google CardDAV Sync Contact", "carddav.sync@nuncio.mx");
|
||||
Ok(vec![sample_contact])
|
||||
|
||||
@@ -63,7 +63,7 @@ impl ContactsDatabase {
|
||||
emails_json,
|
||||
tokenize='trigram'
|
||||
);
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -73,8 +73,10 @@ impl ContactsDatabase {
|
||||
}
|
||||
|
||||
pub async fn save_contact(&self, contact: &Contact) -> Result<(), ContactsStoreError> {
|
||||
let emails_json = serde_json::to_string(&contact.emails).unwrap_or_else(|_| "[]".to_string());
|
||||
let phones_json = serde_json::to_string(&contact.phones).unwrap_or_else(|_| "[]".to_string());
|
||||
let emails_json =
|
||||
serde_json::to_string(&contact.emails).unwrap_or_else(|_| "[]".to_string());
|
||||
let phones_json =
|
||||
serde_json::to_string(&contact.phones).unwrap_or_else(|_| "[]".to_string());
|
||||
let created_str = contact.created_at.to_rfc3339();
|
||||
let updated_str = contact.updated_at.to_rfc3339();
|
||||
let last_interacted_str = contact.last_interacted_at.map(|t| t.to_rfc3339());
|
||||
@@ -95,7 +97,7 @@ impl ContactsDatabase {
|
||||
interaction_count = excluded.interaction_count,
|
||||
last_interacted_at = excluded.last_interacted_at,
|
||||
updated_at = excluded.updated_at
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.bind(&contact.id)
|
||||
.bind(&contact.account_id)
|
||||
@@ -144,7 +146,7 @@ impl ContactsDatabase {
|
||||
WHERE display_name LIKE ? OR organization LIKE ? OR emails_json LIKE ?
|
||||
ORDER BY interaction_count DESC, display_name ASC
|
||||
LIMIT 50
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(&pattern)
|
||||
@@ -188,14 +190,22 @@ impl ContactsDatabase {
|
||||
self.search_contacts("").await
|
||||
}
|
||||
|
||||
pub async fn harvest_email_address(&self, display_name: &str, email: &str) -> Result<(), ContactsStoreError> {
|
||||
pub async fn harvest_email_address(
|
||||
&self,
|
||||
display_name: &str,
|
||||
email: &str,
|
||||
) -> Result<(), ContactsStoreError> {
|
||||
if email.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let existing = self.search_contacts(email).await?;
|
||||
if existing.is_empty() {
|
||||
let name = if display_name.trim().is_empty() { email } else { display_name };
|
||||
let name = if display_name.trim().is_empty() {
|
||||
email
|
||||
} else {
|
||||
display_name
|
||||
};
|
||||
let mut contact = Contact::new(name, email);
|
||||
contact.interaction_count = 1;
|
||||
contact.last_interacted_at = Some(chrono::Utc::now());
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
|
||||
|
||||
pub mod carddav;
|
||||
pub mod db;
|
||||
pub mod models;
|
||||
@@ -12,21 +14,33 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_contacts_database_crud_and_fts() {
|
||||
let db = ContactsDatabase::in_memory().await.expect("Failed to create in-memory db");
|
||||
let db = ContactsDatabase::in_memory()
|
||||
.await
|
||||
.expect("Failed to create in-memory db");
|
||||
|
||||
let mut contact = Contact::new("James Maes", "james.maes@kof22.com");
|
||||
contact.organization = Some("KofTwentyTwo".to_string());
|
||||
db.save_contact(&contact).await.expect("Failed to save contact");
|
||||
db.save_contact(&contact)
|
||||
.await
|
||||
.expect("Failed to save contact");
|
||||
|
||||
let results = db.search_contacts("James").await.expect("Failed to search contacts");
|
||||
let results = db
|
||||
.search_contacts("James")
|
||||
.await
|
||||
.expect("Failed to search contacts");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].display_name, "James Maes");
|
||||
assert_eq!(results[0].emails[0].email, "james.maes@kof22.com");
|
||||
|
||||
let harvested = db.harvest_email_address("Alice Smith", "alice@kof22.com").await;
|
||||
let harvested = db
|
||||
.harvest_email_address("Alice Smith", "alice@kof22.com")
|
||||
.await;
|
||||
assert!(harvested.is_ok());
|
||||
|
||||
let results2 = db.search_contacts("alice").await.expect("Failed to search harvested");
|
||||
let results2 = db
|
||||
.search_contacts("alice")
|
||||
.await
|
||||
.expect("Failed to search harvested");
|
||||
assert_eq!(results2.len(), 1);
|
||||
assert_eq!(results2[0].display_name, "Alice Smith");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Contact {
|
||||
@@ -86,10 +86,18 @@ impl Contact {
|
||||
vcard.push_str(&format!("TITLE:{}\r\n", title));
|
||||
}
|
||||
for email in &self.emails {
|
||||
vcard.push_str(&format!("EMAIL;TYPE={}:{}\r\n", email.label.to_uppercase(), email.email));
|
||||
vcard.push_str(&format!(
|
||||
"EMAIL;TYPE={}:{}\r\n",
|
||||
email.label.to_uppercase(),
|
||||
email.email
|
||||
));
|
||||
}
|
||||
for phone in &self.phones {
|
||||
vcard.push_str(&format!("TEL;TYPE={}:{}\r\n", phone.label.to_uppercase(), phone.phone));
|
||||
vcard.push_str(&format!(
|
||||
"TEL;TYPE={}:{}\r\n",
|
||||
phone.label.to_uppercase(),
|
||||
phone.phone
|
||||
));
|
||||
}
|
||||
vcard.push_str("END:VCARD\r\n");
|
||||
vcard
|
||||
|
||||
@@ -119,7 +119,9 @@ mod tests {
|
||||
#[test]
|
||||
fn summarize_thread_returns_valid_structure() {
|
||||
let engine = LocalAiEngine::default_local();
|
||||
let summary = engine.summarize_thread("Meeting notes for Q3 project...").unwrap();
|
||||
let summary = engine
|
||||
.summarize_thread("Meeting notes for Q3 project...")
|
||||
.unwrap();
|
||||
assert_eq!(summary.bullets.len(), 3);
|
||||
assert!(!summary.sentiment.is_empty());
|
||||
}
|
||||
@@ -127,7 +129,9 @@ mod tests {
|
||||
#[test]
|
||||
fn extract_action_items_returns_items() {
|
||||
let engine = LocalAiEngine::default_local();
|
||||
let items = engine.extract_action_items("Please review the audit by Friday.").unwrap();
|
||||
let items = engine
|
||||
.extract_action_items("Please review the audit by Friday.")
|
||||
.unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].task_description, "Review security audit report");
|
||||
}
|
||||
|
||||
@@ -56,10 +56,16 @@ pub struct AccountConfig {
|
||||
pub email_address: String,
|
||||
/// Protocol engine type.
|
||||
pub protocol: AccountProtocol,
|
||||
/// Server hostname or IP address.
|
||||
/// Server hostname or IP address (IMAP/JMAP).
|
||||
pub server_host: String,
|
||||
/// Server port number.
|
||||
/// Server port number (IMAP/JMAP).
|
||||
pub server_port: u16,
|
||||
/// SMTP server hostname or IP address, used for outbound mail. Distinct
|
||||
/// from `server_host` because a mail provider's inbound (IMAP/JMAP) and
|
||||
/// outbound (SMTP) endpoints commonly differ.
|
||||
pub smtp_host: String,
|
||||
/// SMTP server port number, used for outbound mail.
|
||||
pub smtp_port: u16,
|
||||
/// Whether TLS connection encryption is enabled.
|
||||
pub use_tls: bool,
|
||||
/// IMAP connection security transport mode.
|
||||
@@ -105,6 +111,12 @@ impl AccountConfig {
|
||||
if self.server_port == 0 {
|
||||
return Err(ConfigError::InvalidPort);
|
||||
}
|
||||
if self.smtp_host.trim().is_empty() {
|
||||
return Err(ConfigError::EmptyField { field: "smtp_host" });
|
||||
}
|
||||
if self.smtp_port == 0 {
|
||||
return Err(ConfigError::InvalidPort);
|
||||
}
|
||||
if self.keyring_secret_key.trim().is_empty() {
|
||||
return Err(ConfigError::EmptyField {
|
||||
field: "keyring_secret_key",
|
||||
@@ -130,6 +142,8 @@ mod tests {
|
||||
protocol: AccountProtocol::Jmap,
|
||||
server_host: "jmap.nuncio.mx".to_string(),
|
||||
server_port: 443,
|
||||
smtp_host: "smtp.nuncio.mx".to_string(),
|
||||
smtp_port: 465,
|
||||
use_tls: true,
|
||||
imap_tls_mode: TlsMode::ImplicitTls,
|
||||
smtp_tls_mode: TlsMode::ImplicitTls,
|
||||
@@ -211,6 +225,23 @@ mod tests {
|
||||
assert_eq!(config.validate().unwrap_err(), ConfigError::InvalidPort);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_smtp_host_fails_validation() {
|
||||
let mut config = valid_account();
|
||||
config.smtp_host = " ".to_string();
|
||||
assert_eq!(
|
||||
config.validate().unwrap_err(),
|
||||
ConfigError::EmptyField { field: "smtp_host" }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_smtp_port_fails_validation() {
|
||||
let mut config = valid_account();
|
||||
config.smtp_port = 0;
|
||||
assert_eq!(config.validate().unwrap_err(), ConfigError::InvalidPort);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_keyring_key_fails_validation() {
|
||||
let mut config = valid_account();
|
||||
|
||||
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user