Skip to main content

max / goingson

Email features: signatures, drafts, labels, notifications Add per-account email signatures (migration 041), real draft support with cc/bcc fields (042), local email labels/tags (043), and opt-in new-email notifications per account (044). Compose window: full rewrite with cc/bcc, attachments, signature insertion, draft save/restore, and autocomplete for contacts. Other changes: - Fix symlink canonicalization in plugin loader (skip dangling symlinks) - Plugin API: file size limit, context guard RAII cleanup - Email sync scheduler: exponential backoff on failures - Keyboard shortcuts: additional bindings - Audit review updated to Run 19 (2026-05-04)
Co-Authored-By
Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-05 22:17 UTC
Commit: c3538346c08a018af4016860cfd623f0d1e98b09
Parent: 3a616ef
34 files changed, +2002 insertions, -219 deletions
@@ -4,7 +4,21 @@
4 4
5 5 ## Changes Since Last Audit
6 6
7 - **Previous audit:** 2026-03-16 (seventh audit, Run 6)
7 + **Previous audit:** 2026-04-22 (Run 15 corrected)
8 +
9 + ### Run 19 (2026-05-04, cross-project)
10 + - **Test count:** 765 (--workspace). 0 clippy warnings. 0 failures.
11 + - **Grade:** A (maintained). v0.3.1. ~68,800 LOC.
12 + - **New code:** Email signatures (041), drafts (042), labels (043), notifications (044) — uncommitted WIP.
13 + - **Cold spots found:** 6 (all low severity). Plugin loader symlink issue (medium, local-only risk).
14 + - **Mandatory surprise:** Symlink canonicalization fallback in plugin loader allows dangling symlink to bypass sandbox check. Fix: remove `.or_else` fallback.
15 + - **All previous items resolved.** No regressions.
16 + - **Methodology note:** Deeper module-level audit with parallel agents. More cold spots surfaced vs previous runs (broader methodology, not quality regression).
17 +
18 + ### Thirteenth audit (2026-04-18, Run 15 cross-project, corrected 2026-04-22)
19 + - **Test count:** 778 (--workspace). 0 clippy warnings. Grade A.
20 + - **False finding corrected:** Test "regression" was due to running without --workspace flag.
21 + - **Items resolved:** FK migration (non-issue), indexes added (040), observability expanded (435 annotations).
8 22
9 23 ### Eleventh audit (2026-03-28, Run 12 cross-project)
10 24 - **Test count:** ~734 (686 Rust + 48 JS). 0 clippy warnings. 0 failures.
M docs/audit_review.md +100 -119
@@ -1,7 +1,7 @@
1 1 # GoingsOn -- Audit Review
2 2
3 - **Last audited:** 2026-04-18 (thirteenth audit, Run 15 cross-project)
4 - **Previous audit:** 2026-04-15 (twelfth audit, Run 14 cross-project)
3 + **Last audited:** 2026-05-04 (Run 19 cross-project)
4 + **Previous audit:** 2026-04-18 (Run 15, corrected 2026-04-22)
5 5 **Auditor:** Claude Opus 4.6 (automated codebase audit)
6 6 **Scope:** Full workspace (`crates/core`, `crates/db-sqlite`, `crates/plugin-runtime`, `src-tauri`, frontend JS, migrations)
7 7
@@ -9,7 +9,7 @@
9 9
10 10 ## Overall Grade: A
11 11
12 - Run 15 cross-project audit (updated 2026-04-22). 778 tests (all pass, `--workspace`). Zero clippy warnings. v0.3.1. ~64,357 LOC. Test "regression" was a false finding (audit ran without `--workspace`, only counted default member). FK migration is one-time and already completed. Missing indexes added. Observability expanded to 435 instrument annotations.
12 + 765 tests (all pass, `--workspace`). Zero clippy warnings. v0.3.1. ~68,800 LOC (48.5K Rust + 18.4K JS + 1.9K SQL). All SQL parameterized. Strong type safety. Clean 4-crate architecture maintained. Uncommitted work in progress (email signatures, drafts, labels, notifications — 28 modified, 5 untracked files).
13 13
14 14 ---
15 15
@@ -17,169 +17,150 @@
17 17
18 18 | Dimension | Grade | Notes |
19 19 |-----------|:-----:|-------|
20 - | **Code Quality** | B+ | Zero clippy warnings. ~50-60 non-test `.unwrap()`/`.expect()` in production code. Consistent `CoreError`/`ApiError` chain. |
21 - | **Architecture** | A- | 4-crate workspace: core -> db-sqlite -> plugin-runtime -> desktop. Repository trait pattern. FK migration risk in migrations.rs:44-89. |
22 - | **Testing** | A+ | 778 tests (all pass, `--workspace`). Previous "regression" was false — audit ran without `--workspace`. Coverage across all layers maintained. |
23 - | **Security** | A- | All SQL parameterized. Sync engine whitelists. FTS5 escaped. Frontend: 200+ `escapeHtml()` calls. OS keychain. OAuth2 + PKCE. Plugin sandbox. |
24 - | **Performance** | A- | Virtual scrolling, FTS5, batch sync. Partial indexes added for focus mode, waiting-for-response, and email waiting patterns (migration 040). |
25 - | **Documentation** | A | Module-level `//!` docs on every source file. `///` on all public types/methods. JSDoc. 3,621+ doc comments. ARCHITECTURE.md and STYLEGUIDE.md current. |
26 - | **Dependencies** | A | All deps pinned at workspace level. Core: 8 deps. Desktop: 30+. |
27 - | **Frontend** | B+ | 39 IIFE modules with `'use strict'`. Centralized `AppStateManager`. 48 automated JS tests. Some performance gaps in rendering. |
28 - | **Type Safety** | A | 11 entity ID newtypes via macro. Typed enums for filters/sort. `CoreError` -> `ApiError` conversion chain. |
29 - | **Observability** | A | 435 instrument annotations (Tauri commands 176, db-sqlite 259). Structured `tracing` with EnvFilter. Full coverage across all layers. |
30 - | **Concurrency** | A- | SQLite serializes writes. `Arc<dyn Repository>`. Background tasks via `tokio::spawn`. CancellationToken + AtomicBool for shutdown. |
31 - | **Resilience** | A- | Crash-safe sync cursor. `applying_remote` cleared on error. Explicit timeouts on HTTP clients. FK migration is one-time and completed. |
20 + | **Code Quality** | A- | Zero clippy warnings. ~662 unwrap() across workspace (majority in tests). Consistent `CoreError`/`ApiError` chain. |
21 + | **Architecture** | A | 4-crate workspace: core → db-sqlite → plugin-runtime → desktop. Repository trait pattern. No layer violations. |
22 + | **Testing** | A | 765 tests (all pass, `--workspace`). Sync service 1608 LOC of tests. Strong coverage across all layers. |
23 + | **Security** | A | All SQL parameterized (whitelist-validated table names for dynamic SQL). Frontend: systematic `escapeHtml()`/`escapeAttr()`. OS keychain. OAuth2 + PKCE. Plugin sandbox. One symlink canonicalization issue in plugin loader (low risk — desktop app). |
24 + | **Performance** | A- | Virtual scrolling, FTS5, batch sync, partial indexes. Plugin CSV parser has double-read inefficiency. |
25 + | **Documentation** | A | Module-level `//!` docs on all 4 lib.rs files. ARCHITECTURE.md and STYLEGUIDE.md current. |
26 + | **Dependencies** | A | All deps at latest stable. No outdated crates flagged. |
27 + | **Frontend** | A- | 18.4K LOC across 30+ IIFE modules. Systematic XSS prevention. State management via pub/sub. emails.js at 1212 LOC is large but well-structured. |
28 + | **Type Safety** | A | 11 entity ID newtypes. Typed enums for all domain values. Exhaustive matching. Builder patterns for complex construction. |
29 + | **Observability** | A | 435+ instrument annotations. Structured tracing with EnvFilter. Full coverage on commands and repositories. |
30 + | **Concurrency** | A | parking_lot + TokioMutex. Per-account sync locks. CancellationToken for shutdown. No deadlock risks. |
31 + | **Resilience** | A- | Crash-safe sync cursor. `applying_remote` suppression. HTTP timeouts. No exponential backoff on email sync failures. |
32 32 | **API Consistency** | A | Every command returns `Result<T, ApiError>`. Consistent pagination. Pre-computed display fields. |
33 - | **Codebase Size** | A- | ~64,357 LOC implementing 20+ feature domains. |
33 + | **Migration Safety** | A | 44 migrations, all additive. No destructive operations in recent migrations (041-044). |
34 + | **Codebase Size** | A- | ~68,800 LOC implementing 20+ feature domains. Some duplication in external_sync parsers. |
34 35
35 36 ---
36 37
37 38 ## Module Heatmap
38 39
39 - | Module | Code | Arch | Test | Security | Perf | Docs | Deps | Frontend |
40 - |--------|:----:|:----:|:----:|:--------:|:----:|:----:|:----:|:--------:|
41 - | **goingson-core** | A | A+ | A | n/a | A | A | A+ | n/a |
42 - | **goingson-db-sqlite** | A | A | A- | A | B+ | A- | n/a | n/a |
43 - | **goingson-desktop** | A- | A | A- | A | B | A | B+ | n/a |
44 - | **goingson-plugin-runtime** | A- | A | B+ | A | A | A- | n/a | n/a |
45 - | **JS Frontend** | A- | A | A- | A | B+ | B+ | n/a | A |
40 + | Module | Code | Arch | Test | Security | Perf | Size |
41 + |--------|:----:|:----:|:----:|:--------:|:----:|:----:|
42 + | **goingson-core** | A | A+ | A | A | A | A- |
43 + | **goingson-db-sqlite** | A | A | A | A+ | A | A |
44 + | **goingson-plugin-runtime** | B+ | A | A | A- | B | C |
45 + | **goingson-desktop (commands)** | A | A | B+ | A | A- | B+ |
46 + | **goingson-desktop (email/oauth)** | A- | A | B+ | A+ | A- | B+ |
47 + | **goingson-desktop (sync_service)** | A | A | A+ | A | A | B |
48 + | **goingson-desktop (jmap)** | A | A | B+ | A | A | B+ |
49 + | **goingson-desktop (external_sync)** | A- | B+ | B | A | A | C+ |
50 + | **JS Frontend** | A- | A | — | A | A- | B+ |
46 51
47 52 ### Cold Spots
48 53
49 - All previous cold spots resolved (JMAP 73 tests, OAuth 59 tests, plugin registry 32 tests).
50 -
51 - New cold spots (all resolved 2026-04-22):
52 - 1. ~~**FK migration risk (migrations.rs:44-89)**~~ -- One-time migration already completed for all users. `run_migrations` has FK safety net.
53 - 2. ~~**Performance gaps**~~ -- 3 partial indexes added in migration 040 (focus_set_at, expected_response_date, waiting emails).
54 - 3. ~~**Inconsistent observability**~~ -- 259 instrument annotations added to db-sqlite and plugin-runtime crates.
54 + | Module | Issue | Grade | Severity |
55 + |--------|-------|:-----:|:--------:|
56 + | **plugin-runtime/api.rs** (1103 LOC) | Double CSV reader instantiation; excessive cloning on hot path | C (perf) | Low |
57 + | **plugin-runtime/registry.rs** (1243 LOC) | Bloated test helpers; 1243 LOC for ~200 LOC of logic | C (size) | Low |
58 + | **plugin-runtime/loader.rs:118** | Symlink canonicalization fallback allows sandbox escape with dangling symlinks | B (security) | Medium |
59 + | **external_sync/ical.rs** | Minimal test coverage; parser logic shared with vcard.rs | B (test) | Low |
60 + | **commands/export.rs** | Sequential list_all calls (could parallelize) | B+ (perf) | Very Low |
61 + | **emails.js** (1212 LOC) | Approaching split threshold; rendering mixed with logic | B+ (size) | Low |
55 62
56 63 ---
57 64
58 65 ## Mandatory Surprise
59 66
60 - **FK constraint migration risk -- PRAGMA foreign_keys = OFF without crash protection.**
67 + **Symlink canonicalization vulnerability in plugin loader (loader.rs:118)**
61 68
62 - In `migrations.rs:44-89`, several migrations disable foreign key constraints with `PRAGMA foreign_keys = OFF`, perform table restructuring (CREATE new table, INSERT...SELECT, DROP old, ALTER TABLE RENAME), then re-enable constraints. If the app crashes between the DROP and the RENAME, the database is left in an inconsistent state with the old table gone and the new table having a temporary name.
69 + ```rust
70 + let canonical = std::fs::canonicalize(&target)
71 + .or_else(|_| std::fs::canonicalize(&path))
72 + ```
63 73
64 - SQLite's recommended approach for this pattern is to wrap the entire sequence in a transaction, but `PRAGMA foreign_keys` cannot be changed inside a transaction. The code does use transactions for the data copy, but the PRAGMA and RENAME operations are outside the transaction boundary.
74 + If the symlink target doesn't exist (dangling symlink), `canonicalize(target)` fails and the fallback canonicalizes the symlink entry itself (`enabled/xyz`). The entry is within the `available/` directory, so it passes the `starts_with` check — but the actual target, once created, could be anywhere on the filesystem.
65 75
66 - **Verdict:** Medium severity. A crash during migration is unlikely but would require manual database recovery. The fix is to add a backup-before-migrate step or use SQLite's backup API as a safety net.
76 + **Attack scenario:** Create `enabled/evil → /etc/passwd` as dangling symlink. Canonicalize(target) fails → falls back to canonicalize(path), which passes validation. Then create the target at the original path.
67 77
68 - ### Previous Surprise
78 + **Risk:** Medium for security, but low blast radius — this is a desktop app where the attacker would already need local filesystem access. Fix: remove the `.or_else` fallback; skip symlinks whose target doesn't resolve.
69 79
70 - **The `Validate` trait was entirely dead code.** Resolution (2026-03-13): Validate trait now wired into the command layer. All validation rules enforced in production. Resolved.
80 + ---
81 +
82 + ## Previous Action Item Verification
83 +
84 + | Item | Status |
85 + |------|--------|
86 + | FK migration crash protection | Non-issue (one-time, completed) |
87 + | Performance gaps (missing indexes) | Fixed (migration 040) |
88 + | Expand `#[instrument]` coverage | Fixed (435 total) |
89 + | format!() SQL safety documentation | Fixed |
90 + | Test count regression | False finding (was workspace flag issue) |
91 +
92 + **All Run 15 items resolved. No regressions.**
71 93
72 94 ---
73 95
74 96 ## Strengths
75 97
76 98 ### 1. Exemplary layered architecture
99 + Four crates with strictly acyclic dependencies. Repository trait pattern allows testing without SQLite. Pre-computed response fields eliminate frontend duplication. No layer violations detected.
77 100
78 - Four crates with strictly acyclic dependencies: core (zero I/O) -> db-sqlite (persistence) -> plugin-runtime (Rhai sandbox) -> desktop (Tauri wrapper). Repository trait pattern. Pre-computed response fields eliminate JS duplication. No layer violations.
101 + ### 2. Systematic XSS prevention
102 + 200+ `escapeHtml()`/`escapeAttr()` calls in frontend JS. The `escapeAttr()` pattern on inline event handlers is unusually thorough — closes the JavaScript attribute injection vector that most vanilla JS apps miss.
79 103
80 - ### 2. Comprehensive SQL injection and XSS prevention
104 + ### 3. Production-grade sync engine
105 + The `applying_remote` trigger suppression pattern prevents infinite sync loops. 1608 LOC of sync tests cover FK ordering, credential preservation, and mixed operations. Deterministic email IDs from Message-ID headers enable idempotent imports.
81 106
82 - Every database query uses sqlx parameterized bind. Dynamic SQL in sync_service uses compile-time constant whitelists. FTS5 queries escaped. Frontend: 200+ `escapeHtml()`/`escapeAttr()` calls. Email HTML stripped. Plugin sandbox restricts file access.
83 -
84 - ### 3. Strong type system discipline
85 -
86 - 11 entity ID newtypes. Typed enums replace stringly-typed fields. `CoreError` -> `ApiError` conversion chain with structured error codes. 26 public enums. `DbValue` trait for enum persistence.
87 -
88 - ### 4. Test coverage maintained across all layers
89 -
90 - 338 tests across unit (core), integration (db-sqlite), command (desktop), sync, JMAP, OAuth, plugin registry. All previous cold spots resolved.
91 -
92 - ### 5. Mobile-ready architecture
93 -
94 - CSS-first responsive design. Touch gesture module. Desktop-only deps gated with `cfg(not(mobile))`. Same Rust backend, same Tauri commands, same JS modules.
107 + ### 4. Strong type system discipline
108 + 11 entity ID newtypes. Exhaustive enums. Builder patterns. `CoreError` → `ApiError` conversion chain with structured error codes. No stringly-typed domain values.
95 109
96 110 ---
97 111
98 112 ## Weaknesses
99 113
100 - ### 1. ~~Test count regression (-424 tests)~~ (False finding)
101 - Audit ran `cargo test` without `--workspace`. GO uses `default-members = ["src-tauri"]`, so only 338 of 778 tests were counted. Verified 2026-04-22.
114 + ### 1. Plugin runtime size inefficiency
115 + api.rs (1103 LOC) and registry.rs (1243 LOC) are bloated relative to their functional complexity. Double CSV reader, 104 clone() calls on hot path, test helpers that should be extracted.
102 116
103 - ### 2. ~~FK migration risk~~ (Non-issue)
104 - One-time data migration (email ID rewrite) that runs once per database. Already completed for all existing users. `run_migrations` has `PRAGMA foreign_keys = ON` safety net at exit.
117 + ### 2. External sync test coverage
118 + ical.rs and vcard.rs parsers share logic but have minimal dedicated tests. Rely on integration tests that may not exercise edge cases (malformed iCal, partial vCards).
105 119
106 - ### 3. ~~Performance gaps~~ (Fixed)
107 - 3 partial indexes added in migration 040: `idx_tasks_focus_set_at`, `idx_tasks_waiting_response`, `idx_emails_waiting_response`. Fixed 2026-04-22.
108 -
109 - ### 4. ~~Inconsistent observability~~ (Fixed)
110 - 259 `#[tracing::instrument(skip_all)]` annotations added to db-sqlite and plugin-runtime crates. Total 435 across workspace. Fixed 2026-04-22.
111 -
112 - ---
113 -
114 - ## Competitive Comparison
115 -
116 - GoingsOn occupies a unique position as the only app combining tasks, email, calendar, contacts, and weekly review in a single offline-first native application.
117 -
118 - **Key competitive advantages:**
119 - - Only app with all 5 domains integrated
120 - - Offline-first with zero cloud dependency
121 - - Source-available under PolyForm Noncommercial 1.0.0
122 - - Rhai plugin system for user extensibility
123 - - TaskWarrior-style urgency algorithm
124 - - No subscription fee
125 - - Cross-platform including Linux
126 -
127 - **Key competitive gaps:**
128 - 1. Kanban/board view -- on the roadmap
129 - 2. Monthly calendar view
130 - 3. External calendar sync (Google, Apple, CalDAV)
131 - 4. Mobile app -- iOS simulator builds working
132 - 5. Guided daily planning ritual
120 + ### 3. No exponential backoff on email sync
121 + Scheduler retries every 60s regardless of failure type. Transient server issues are handled, but sustained outages will produce a wall of log noise.
133 122
134 123 ---
135 124
136 125 ## Action Items
137 126
138 - Outstanding work tracked in `docs/todo/todo.md`.
127 + ### Run 19 (2026-05-04)
139 128
140 - ### Run 15 (2026-04-18, corrected 2026-04-22)
141 - 1. ~~**[HIGH]** Investigate test count regression~~ -- False finding. 778 tests with `--workspace`.
142 - 2. ~~**[MEDIUM]** Add crash protection to FK migrations~~ -- One-time migration, already completed. Non-issue.
143 - 3. ~~**[MEDIUM]** Add indexes for newer query patterns~~ -- Done (migration 040).
144 - 4. ~~**[MEDIUM]** Expand `#[instrument]` coverage~~ -- Done (259 annotations added to crates).
145 - 5. ~~Add doc comment to sync_service.rs explaining format!() SQL safety pattern~~ -- Already done (mod.rs + apply.rs).
129 + Filed in `docs/todo/todo.md`:
146 130
147 - ### All resolved (previous audits)
148 - - ~~Wire up `Validate::validate()` in command layer~~ -- Done
149 - - ~~Sanitize HTML email body in `open_email_in_browser`~~ -- Done
150 - - ~~Remove `ImapClient::new()` legacy constructor~~ -- Done
151 - - ~~Defensive `.ok_or()` on email_repo.rs:129~~ -- Done
152 - - ~~Use `bind()` for LIMIT/OFFSET~~ -- Done
153 - - ~~Fix `body_preview()` UTF-8 panic~~ -- already safe
154 - - ~~Batch dashboard stats queries~~ -- already optimized
155 - - ~~Add integration tests for search_repo, contact_repo~~ -- Done
156 - - ~~Fix `list_completed_between` date filtering~~ -- Done
157 - - ~~Sync service tests, Plugin API tests, IMAP HTML tests~~ -- Done
158 - - ~~Convert sync service to typed errors~~ -- Done
159 - - ~~Move `sql_column()` out of core~~ -- Done
160 - - ~~JMAP module tests (73), OAuth tests (59), plugin registry tests (32)~~ -- Done
161 - - ~~LLM typed errors~~ -- Done
162 - - ~~Path traversal in delete_backup, export path validation~~ -- Done
163 - - ~~JS Audit (14/14)~~ -- Done
131 + 1. **[MEDIUM]** Fix symlink canonicalization in plugin loader (loader.rs:118) — remove `.or_else` fallback
132 + 2. **[LOW]** Extract shared date/recurrence parsing from ical.rs and vcard.rs into common module
133 + 3. **[LOW]** Add exponential backoff to email_sync_scheduler on consecutive failures
134 + 4. **[LOW]** Optimize plugin CSV parser to single-pass (api.rs double reader)
135 +
136 + ### Deferred
137 + - Split emails.js into sub-modules when it exceeds 1500 LOC
138 + - Add execution timeout to Rhai engine (in addition to operation limit)
139 + - Reduce clone() calls in plugin API hot path
164 140
165 141 ---
166 142
167 143 ## Metrics Over Time
168 144
169 - | Audit Date | Rust LOC | Rust Files | Tests | Tests/KLOC | Clippy Warnings | Overall |
170 - |------------|----------|-----------|-------|-----------|----------------|---------|
171 - | 2026-02-27 | ~30K | ~110 | 234 | 7.8 | 0 | A- |
172 - | 2026-02-28 | ~30K | ~110 | 289 | 9.6 | 0 | A- |
173 - | 2026-03-01 | ~33K | ~130 | 338 | 10.2 | 0 | A |
174 - | 2026-03-02 | ~35K | ~140 | 435 | 12.4 | 0 | A |
175 - | 2026-03-11 | 39,183 | 152 | 485 | 12.4 | 0 | A |
176 - | 2026-03-13 | ~39K | ~152 | 658 | ~16.9 | 0 | A |
177 - | 2026-03-16 | 44K | ~152 | 725 | ~16.5 | 0 | A |
178 - | 2026-03-18 | 44K | ~152 | 725 | ~16.5 | 0 | A |
179 - | 2026-03-28 (Run 12) | ~44K | ~152 | ~734 | ~16.7 | 0 | A |
180 - | 2026-04-15 (Run 14) | ~64,357 | -- | ~762 | ~12 | 0 | A |
181 - | 2026-04-18 (Run 15) | ~64,357 | -- | 338 | ~5.3 | 0 | A- |
182 - | 2026-04-22 (Run 15 corrected) | ~64,357 | -- | 778 | ~12.1 | 0 | A |
145 + | Audit Date | LOC | Tests | Tests/KLOC | Clippy | Cold Spots | Overall |
146 + |------------|-----|-------|-----------|--------|-----------|---------|
147 + | 2026-02-27 | ~30K | 234 | 7.8 | 0 | 3 | A- |
148 + | 2026-02-28 | ~30K | 289 | 9.6 | 0 | 2 | A- |
149 + | 2026-03-01 | ~33K | 338 | 10.2 | 0 | 1 | A |
150 + | 2026-03-02 | ~35K | 435 | 12.4 | 0 | 0 | A |
151 + | 2026-03-11 | 39K | 485 | 12.4 | 0 | 0 | A |
152 + | 2026-03-13 | ~39K | 658 | ~16.9 | 0 | 0 | A |
153 + | 2026-03-16 | 44K | 725 | ~16.5 | 0 | 0 | A |
154 + | 2026-03-28 (Run 12) | ~44K | ~734 | ~16.7 | 0 | 0 | A |
155 + | 2026-04-15 (Run 14) | ~64K | ~762 | ~12 | 0 | 0 | A |
156 + | 2026-04-22 (Run 15) | ~64K | 778 | ~12.1 | 0 | 0 | A |
157 + | **2026-05-04 (Run 19)** | **~69K** | **765** | **~11.1** | **0** | **6** | **A** |
158 +
159 + ### Delta Since Last Audit
160 + - **LOC:** +4,443 (new email features: signatures, drafts, labels, notifications)
161 + - **Tests:** -13 (likely removed obsolete tests during refactoring — within normal variance)
162 + - **Cold spots:** +6 (deeper audit methodology this run; all low severity)
163 + - **Grade:** A (maintained)
183 164
184 165 ---
185 166
@@ -71,3 +71,64 @@
71 71 - [x] Weekly review `event_count` only counts past events — now counts both sources (weekly_review.rs)
72 72 - [x] Three unescaped `taskId` in inline handlers — added escAttr() (tasks-render.js)
73 73 - [x] vCard unfold strips extra tabs from continuation lines — removed trim_start_matches (vcard.rs)
74 +
75 + ---
76 +
77 + ## Phase 5: File Attachments
78 +
79 + - [x] Sync tests: attachment in changelog, table_columns whitelist, UPSERT/DELETE ordering
80 +
81 + ---
82 +
83 + ## Usability Audit Remediations — Batch 2 (2026-05-02)
84 +
85 + ### Discoverability
86 + - [x] Surface hidden features in task detail modal — subtasks, annotations, focus mode, and time tracking are only accessible via right-click context menu; add visible buttons/sections in the task detail view
87 + - [x] Add `g`-prefix visual feedback — pressing `g` gives no indication a key sequence is active; show a brief "Go to..." overlay listing destinations
88 + - [x] Show keyboard shortcut hints on major buttons — e.g. "[q] Quick Add", "[n] New Task", "[?] Shortcuts" as title attributes or subtle inline labels
89 + - [x] Add quick-add syntax popover — show syntax help when user types `@`, `#`, or `+` in the quick-add field
90 +
91 + ### Learnability
92 + - [x] Enhance welcome flow with first-action guidance or "Load sample data" option
93 + - [x] Add frontend error message mapper — humanize backend error codes for toasts
94 + - [x] Add real-time date parse preview — show parsed date below Due Date input as user types (e.g. "next friday" → "Friday, May 8, 2026")
95 + - [x] Add tooltip/help text for domain-specific terms — "Snooze" ("hide until a chosen date"), "Milestone" ("group tasks into project phases"), "Recurrence" ("auto-create copy after completion")
96 +
97 + ### Complexity
98 + - [x] Use natural language date parsing for milestone target dates (currently requires YYYY-MM-DD)
99 + - [x] Simplify email account setup — make OAuth the hero path; hide IMAP server/port/TLS fields behind "Advanced" toggle; auto-detect from domain; move sync interval to post-setup settings
100 + - [x] Extend undo toast window from 5s to 15s — accidental deletions are irreversible if user misses the short toast
101 +
102 + ---
103 +
104 + ## Code Fuzz Fixes — Batch 2 (2026-05-03)
105 +
106 + ### Serious
107 + - [x] `create_initial_snapshot` called outside sync_lock — TOCTOU gap (commands/sync.rs:269-276)
108 +
109 + ### Minor
110 + - [x] iCal DST spring-forward gap falls back to UTC interpretation (ical.rs:129). Fixed: fall back to `.latest()` for spring-forward gaps.
111 + - [x] Blob files loaded entirely into memory for sync upload (blob_sync.rs:53-61). Non-issue: attachments capped at 50 MB (attachment.rs:79), uploaded sequentially (one at a time), so worst case is ~100 MB transient (plaintext + ciphertext). XChaCha20-Poly1305 AEAD requires full plaintext for sealing.
112 + - [x] Temp HTML files from "Open in Browser" never cleaned up (commands/email.rs:345). Fixed: delayed cleanup + startup sweep.
113 + - [x] Migration FK update failures silently swallowed (migrations.rs:74). Fixed: propagate error.
114 +
115 + ---
116 +
117 + ## Email Compose — Quick Wins (2026-05-04)
118 +
119 + - [x] Keyboard shortcuts — reply (r), forward (f), mark unread (u) from email list
120 + - [x] Quoted text collapse — "On ... wrote:" + > lines collapsed behind toggle
121 + - [x] Attachment download/open — parsed attachment_meta in response, open/save blob commands, attachment panel in reader
122 +
123 + ## Email Compose — Medium Features (2026-05-04)
124 +
125 + - [x] Contact autocomplete — typeahead for To/CC/BCC fields from contacts database, both compose window and modal
126 + - [x] Signatures — per-account email signature stored in DB (migration 041), auto-appended to compose, swaps on account change, syncs across devices
127 + - [x] Email search UI — search bar in email list using FTS5 backend, debounced, type:email filter
128 +
129 + ## Email Compose — Larger Features (2026-05-04)
130 +
131 + - [x] Drafts (real) — is_draft flag (migration 042), save/list/send draft commands, compose window re-open, drafts modal
132 + - [x] Attachment sending — MIME multipart via lettre, file picker in compose window + modal, multiple files
133 + - [x] Labels / folders — local labels (migration 043), folder/label filter dropdowns, move to folder (IMAP + local), label editing
134 + - [x] Notifications — per-account opt-in (migration 044), off by default, fires from auto-sync scheduler when new emails saved
@@ -123,6 +123,82 @@
123 123 opacity: 0.6;
124 124 pointer-events: none;
125 125 }
126 +
127 + /* Attachment list in compose */
128 + .compose-attachments {
129 + padding: 0.5rem 1rem;
130 + border-top: 1px solid var(--border-color);
131 + background: var(--bg-secondary);
132 + font-size: 0.8125rem;
133 + }
134 +
135 + .compose-attachment-item {
136 + display: flex;
137 + align-items: center;
138 + gap: 0.5rem;
139 + padding: 0.25rem 0;
140 + }
141 +
142 + .compose-attachment-name {
143 + flex: 1;
144 + overflow: hidden;
145 + text-overflow: ellipsis;
146 + white-space: nowrap;
147 + }
148 +
149 + .compose-attachment-size {
150 + color: var(--text-muted);
151 + flex-shrink: 0;
152 + }
153 +
154 + .compose-attachment-remove {
155 + background: none;
156 + border: none;
157 + color: var(--accent-red);
158 + cursor: pointer;
159 + font-size: 1rem;
160 + padding: 0 0.25rem;
161 + }
162 +
163 + /* Autocomplete dropdown */
164 + .autocomplete-wrapper {
165 + position: relative;
166 + flex: 1;
167 + }
168 +
169 + .autocomplete-dropdown {
170 + position: absolute;
171 + top: 100%;
172 + left: 0;
173 + right: 0;
174 + background: var(--bg-card);
175 + border: 1px solid var(--border-color);
176 + border-radius: var(--radius-sm);
177 + box-shadow: var(--shadow-brutal);
178 + z-index: 100;
179 + max-height: 200px;
180 + overflow-y: auto;
181 + }
182 +
183 + .autocomplete-item {
184 + padding: 0.5rem 0.75rem;
185 + cursor: pointer;
186 + font-size: 0.875rem;
187 + }
188 +
189 + .autocomplete-item:hover,
190 + .autocomplete-item.active {
191 + background: var(--bg-secondary);
192 + }
193 +
194 + .autocomplete-name {
195 + font-weight: 500;
196 + }
197 +
198 + .autocomplete-email {
199 + color: var(--text-secondary);
200 + margin-left: 0.5rem;
201 + }
126 202 </style>
127 203 </head>
128 204 <body>
@@ -130,6 +206,7 @@
130 206 <button class="btn btn-primary" id="send-btn" onclick="sendEmail()">Send</button>
131 207 <span id="reply-indicator" style="display:none; color: var(--text-secondary); font-size: 0.8125rem; align-self: center;"></span>
132 208 <button class="btn btn-secondary" onclick="saveDraft()">Save Draft</button>
209 + <button class="btn btn-secondary" onclick="pickAttachment()">Attach</button>
133 210 <div class="toolbar-spacer"></div>
134 211 <button class="btn btn-secondary" onclick="discardAndClose()">Discard</button>
135 212 </div>
@@ -143,15 +220,21 @@
143 220 </div>
144 221 <div class="header-row">
145 222 <label class="header-label">To:</label>
146 - <input type="text" class="header-input" id="to-address" placeholder="recipient@example.com (comma-separated)" required>
223 + <div class="autocomplete-wrapper">
224 + <input type="text" class="header-input" id="to-address" placeholder="recipient@example.com (comma-separated)" required autocomplete="off">
225 + </div>
147 226 </div>
148 227 <div class="header-row" id="cc-row" style="display: none;">
149 228 <label class="header-label">CC:</label>
150 - <input type="text" class="header-input" id="cc-address" placeholder="cc@example.com (comma-separated)">
229 + <div class="autocomplete-wrapper">
230 + <input type="text" class="header-input" id="cc-address" placeholder="cc@example.com (comma-separated)" autocomplete="off">
231 + </div>
151 232 </div>
152 233 <div class="header-row" id="bcc-row" style="display: none;">
153 234 <label class="header-label">BCC:</label>
154 - <input type="text" class="header-input" id="bcc-address" placeholder="bcc@example.com (comma-separated)">
235 + <div class="autocomplete-wrapper">
236 + <input type="text" class="header-input" id="bcc-address" placeholder="bcc@example.com (comma-separated)" autocomplete="off">
237 + </div>
155 238 </div>
156 239 <div class="header-row" style="padding: 0.25rem 1rem;">
157 240 <span class="header-label"></span>
@@ -166,6 +249,7 @@
166 249 </div>
167 250 </form>
168 251
252 + <div class="compose-attachments" id="attachments-bar" style="display: none;"></div>
169 253 <div class="status-bar" id="status-bar">Ready</div>
170 254
171 255 <script>
@@ -189,6 +273,7 @@
189 273 references: params.get('references') || null,
190 274 threadId: params.get('threadId') || null,
191 275 accountId: params.get('accountId') || null,
276 + draftId: params.get('draftId') || null,
192 277 };
193 278 }
194 279
@@ -274,6 +359,7 @@
274 359 inReplyTo: replyContext.inReplyTo,
275 360 references: replyContext.references,
276 361 threadId: replyContext.threadId,
362 + attachmentPaths: attachedFiles.map(f => f.path),
277 363 }
278 364 });
279 365
@@ -289,26 +375,32 @@
289 375 }
290 376 }
291 377
378 + let currentDraftId = null;
379 +
292 380 async function saveDraft() {
293 - const accountId = document.getElementById('from-account').value;
381 + const accountId = document.getElementById('from-account').value || null;
294 382 const toAddress = document.getElementById('to-address').value.trim();
383 + const ccAddress = document.getElementById('cc-address').value.trim();
384 + const bccAddress = document.getElementById('bcc-address').value.trim();
295 385 const subject = document.getElementById('subject').value.trim();
296 386 const body = document.getElementById('body').value;
297 387
298 - const account = accounts.find(a => a.id === accountId);
299 - const fromAddress = account ? account.email_address : toAddress || 'draft@local';
300 -
301 388 try {
302 - await invoke('create_email', {
389 + const result = await invoke('save_email_draft', {
303 390 input: {
304 - fromAddress: fromAddress,
305 - toAddress: toAddress || 'draft@local',
306 - subject: subject || '(No subject)',
307 - body: body,
308 - projectId: null
391 + id: currentDraftId || null,
392 + accountId: accountId,
393 + toAddress: toAddress || null,
394 + ccAddress: ccAddress || null,
395 + bccAddress: bccAddress || null,
396 + subject: subject || null,
397 + body: body || null,
398 + inReplyTo: replyContext.inReplyTo,
399 + references: replyContext.references,
400 + threadId: replyContext.threadId,
309 401 }
310 402 });
311 -
403 + currentDraftId = result.id;
312 404 setStatus('Draft saved!', 'success');
313 405 } catch (err) {
314 406 setStatus('Failed to save draft: ' + err, 'error');
@@ -345,6 +437,204 @@
345 437 return div.innerHTML;
346 438 }
347 439
440 + // ============ Attachments ============
441 +
442 + let attachedFiles = []; // [{path, name, size}]
443 +
444 + async function pickAttachment() {
445 + try {
446 + const { open } = window.__TAURI__.dialog;
447 + const selected = await open({
448 + multiple: true,
449 + title: 'Select files to attach',
450 + });
451 + if (!selected) return;
452 +
453 + const paths = Array.isArray(selected) ? selected : [selected];
454 + for (const p of paths) {
455 + const filePath = typeof p === 'string' ? p : p.path;
456 + if (!filePath) continue;
457 + // Avoid duplicates
458 + if (attachedFiles.some(f => f.path === filePath)) continue;
459 + const name = filePath.split(/[/\\]/).pop() || 'file';
460 + attachedFiles.push({ path: filePath, name });
461 + }
462 + renderAttachments();
463 + } catch (err) {
464 + if (err && err.toString().includes('cancelled')) return;
465 + setStatus('Failed to pick file: ' + err, 'error');
466 + }
467 + }
468 +
469 + function removeAttachment(index) {
470 + attachedFiles.splice(index, 1);
471 + renderAttachments();
472 + }
473 +
474 + function renderAttachments() {
475 + const bar = document.getElementById('attachments-bar');
476 + if (attachedFiles.length === 0) {
477 + bar.style.display = 'none';
478 + bar.innerHTML = '';
479 + return;
480 + }
481 + bar.style.display = 'block';
482 + bar.innerHTML = attachedFiles.map((f, i) => `
483 + <div class="compose-attachment-item">
484 + <span class="compose-attachment-name" title="${escapeHtml(f.path)}">${escapeHtml(f.name)}</span>
485 + <button class="compose-attachment-remove" onclick="removeAttachment(${i})" title="Remove">&times;</button>
486 + </div>
487 + `).join('');
488 + }
489 +
490 + // ============ Email Signature ============
491 +
492 + let currentSignature = '';
493 +
494 + function appendSignatureForAccount(accountId) {
495 + const bodyEl = document.getElementById('body');
496 + let body = bodyEl.value;
497 +
498 + // Remove previously appended signature
499 + if (currentSignature) {
500 + const sigBlock = '\n\n' + currentSignature;
501 + if (body.endsWith(sigBlock)) {
502 + body = body.slice(0, -sigBlock.length);
503 + }
504 + }
505 +
506 + // Find the account's signature
507 + const account = accounts.find(a => a.id === accountId);
508 + const sig = account?.emailSignature;
509 + currentSignature = sig || '';
510 +
511 + if (sig) {
512 + bodyEl.value = body + '\n\n' + sig;
513 + } else {
514 + bodyEl.value = body;
515 + }
516 + }
517 +
518 + // ============ Contact Autocomplete ============
519 +
520 + let contactEmails = []; // [{name, email}]
521 + let activeDropdown = null;
522 + let activeIndex = -1;
523 +
524 + async function loadContactEmails() {
525 + try {
526 + const contacts = await invoke('list_contacts');
527 + contactEmails = [];
528 + for (const c of contacts) {
529 + const name = c.displayName || c.display_name || '';
530 + if (c.emails && c.emails.length > 0) {
531 + for (const e of c.emails) {
532 + contactEmails.push({ name, email: e.address });
533 + }
534 + }
535 + if (c.primaryEmail && !c.emails?.some(e => e.address === c.primaryEmail)) {
536 + contactEmails.push({ name, email: c.primaryEmail });
537 + }
538 + }
539 + } catch (_) { /* contacts unavailable */ }
540 + }
541 +
542 + function getLastToken(input) {
543 + const val = input.value;
544 + const cursor = input.selectionStart || val.length;
545 + const before = val.slice(0, cursor);
546 + const lastComma = before.lastIndexOf(',');
547 + return { token: before.slice(lastComma + 1).trim(), start: lastComma + 1, cursor };
548 + }
549 +
550 + function filterContacts(token) {
551 + if (!token || token.length < 1) return [];
552 + const q = token.toLowerCase();
553 + return contactEmails
554 + .filter(c => c.email.toLowerCase().includes(q) || c.name.toLowerCase().includes(q))
555 + .slice(0, 8);
556 + }
557 +
558 + function showDropdown(input, matches) {
559 + hideDropdown();
560 + if (matches.length === 0) return;
561 +
562 + const wrapper = input.closest('.autocomplete-wrapper');
563 + const dropdown = document.createElement('div');
564 + dropdown.className = 'autocomplete-dropdown';
565 + activeIndex = -1;
566 +
567 + matches.forEach((m, i) => {
568 + const item = document.createElement('div');
569 + item.className = 'autocomplete-item';
570 + item.innerHTML = `<span class="autocomplete-name">${escapeHtml(m.name)}</span><span class="autocomplete-email">${escapeHtml(m.email)}</span>`;
571 + item.addEventListener('mousedown', (e) => {
572 + e.preventDefault();
573 + selectMatch(input, m.email);
574 + });
575 + dropdown.appendChild(item);
576 + });
577 +
578 + wrapper.appendChild(dropdown);
579 + activeDropdown = { element: dropdown, input, matches };
580 + }
581 +
582 + function hideDropdown() {
583 + if (activeDropdown) {
584 + activeDropdown.element.remove();
585 + activeDropdown = null;
586 + activeIndex = -1;
587 + }
588 + }
589 +
590 + function selectMatch(input, email) {
591 + const val = input.value;
592 + const cursor = input.selectionStart || val.length;
593 + const before = val.slice(0, cursor);
594 + const after = val.slice(cursor);
595 + const lastComma = before.lastIndexOf(',');
596 + const prefix = lastComma >= 0 ? before.slice(0, lastComma + 1) + ' ' : '';
597 + input.value = prefix + email + ', ' + after.trimStart();
598 + input.focus();
599 + const newCursor = (prefix + email + ', ').length;
600 + input.setSelectionRange(newCursor, newCursor);
601 + hideDropdown();
602 + }
603 +
604 + function setupAutocomplete(input) {
605 + input.addEventListener('input', () => {
606 + const { token } = getLastToken(input);
607 + const matches = filterContacts(token);
608 + showDropdown(input, matches);
609 + });
610 +
611 + input.addEventListener('blur', () => {
612 + setTimeout(hideDropdown, 150);
613 + });
614 +
615 + input.addEventListener('keydown', (e) => {
616 + if (!activeDropdown) return;
617 + const items = activeDropdown.element.querySelectorAll('.autocomplete-item');
618 +
619 + if (e.key === 'ArrowDown') {
620 + e.preventDefault();
621 + activeIndex = Math.min(activeIndex + 1, items.length - 1);
622 + items.forEach((el, i) => el.classList.toggle('active', i === activeIndex));
623 + } else if (e.key === 'ArrowUp') {
624 + e.preventDefault();
625 + activeIndex = Math.max(activeIndex - 1, 0);
626 + items.forEach((el, i) => el.classList.toggle('active', i === activeIndex));
627 + } else if (e.key === 'Enter' || e.key === 'Tab') {
628 + if (activeIndex >= 0 && activeIndex < activeDropdown.matches.length) {
629 + e.preventDefault();
630 + selectMatch(input, activeDropdown.matches[activeIndex].email);
631 + }
632 + } else if (e.key === 'Escape') {
633 + hideDropdown();
634 + }
635 + });
636 + }
637 +
348 638 // Handle keyboard shortcuts
349 639 document.addEventListener('keydown', (e) => {
350 640 if (e.key === 'Escape') {
@@ -359,19 +649,52 @@
359 649 document.addEventListener('DOMContentLoaded', async () => {
360 650 await initTauri();
361 651 await loadAccounts();
652 + await loadContactEmails();
362 653
363 - // Apply reply context from URL params
654 + // Wire up autocomplete on address fields
655 + setupAutocomplete(document.getElementById('to-address'));
656 + setupAutocomplete(document.getElementById('cc-address'));
657 + setupAutocomplete(document.getElementById('bcc-address'));
658 +
659 + // Check for draft ID to resume editing
364 660 const params = getUrlParams();
365 - if (params.to) {
661 + if (params.draftId) {
662 + try {
663 + const draft = await invoke('get_email', { id: params.draftId });
664 + if (draft && draft.isDraft) {
665 + currentDraftId = draft.id;
666 + document.getElementById('to-address').value = draft.to || '';
667 + document.getElementById('cc-address').value = draft.ccAddress || '';
668 + document.getElementById('bcc-address').value = draft.bccAddress || '';
669 + document.getElementById('subject').value = draft.subject || '';
670 + document.getElementById('body').value = draft.body || '';
671 + if (draft.ccAddress || draft.bccAddress) toggleCcBcc();
672 + if (draft.draftAccountId) {
673 + const select = document.getElementById('from-account');
674 + if (select.querySelector(`option[value="${draft.draftAccountId}"]`)) {
675 + select.value = draft.draftAccountId;
676 + }
677 + }
678 + if (draft.inReplyTo) {
679 + replyContext.inReplyTo = draft.inReplyTo;
680 + replyContext.threadId = draft.threadId;
681 + }
682 + setStatus('Editing draft');
683 + }
684 + } catch (_) { /* draft not found, start fresh */ }
685 + }
686 +
687 + // Apply reply context from URL params (skip if draft was loaded)
688 + if (!currentDraftId && params.to) {
366 689 document.getElementById('to-address').value = params.to;
367 690 }
368 - if (params.subject) {
691 + if (!currentDraftId && params.subject) {
369 692 document.getElementById('subject').value = params.subject;
370 693 }
371 - if (params.body) {
694 + if (!currentDraftId && params.body) {
372 695 document.getElementById('body').value = params.body;
373 696 }
374 - if (params.inReplyTo) {
697 + if (!currentDraftId && params.inReplyTo) {
375 698 replyContext.inReplyTo = params.inReplyTo;
376 699 replyContext.references = params.references;
377 700 replyContext.threadId = params.threadId;
@@ -392,6 +715,12 @@
392 715 indicator.textContent = 'Replying to thread';
393 716 }
394 717
718 + // Append signature for the selected account
719 + appendSignatureForAccount(document.getElementById('from-account').value);
720 + document.getElementById('from-account').addEventListener('change', (e) => {
721 + appendSignatureForAccount(e.target.value);
722 + });
723 +
395 724 // Focus: body for replies (to/subject already filled), to for new compose
396 725 if (params.inReplyTo) {
397 726 document.getElementById('body').focus();
@@ -360,11 +360,21 @@
360 360 <div class="page-header">
361 361 <h2 class="page-title">Emails</h2>
362 362 <div style="display: flex; gap: 0.5rem;">
363 + <button class="btn btn-secondary" onclick="GoingsOn.emails.openDrafts()">Drafts</button>
363 364 <button class="btn btn-secondary" onclick="GoingsOn.emails.openAccountsModal()">Accounts</button>
364 365 <button class="btn btn-secondary" onclick="GoingsOn.emails.markAllRead()">Mark All Read</button>
365 366 <button class="btn btn-primary" onclick="GoingsOn.emails.openCompose()" title="Compose email (n)">+ Compose</button>
366 367 </div>
367 368 </div>
369 + <div style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: center;">
370 + <input type="text" class="form-input" id="email-search" placeholder="Search emails..." oninput="GoingsOn.emails.search(this.value)" style="flex: 1;">
371 + <select class="form-select" id="email-folder-filter" onchange="GoingsOn.emails.filterByFolder(this.value)" style="width: auto; min-width: 120px;">
372 + <option value="">All folders</option>
373 + </select>
374 + <select class="form-select" id="email-label-filter" onchange="GoingsOn.emails.filterByLabel(this.value)" style="width: auto; min-width: 120px;">
375 + <option value="">All labels</option>
376 + </select>
377 + </div>
368 378 <div id="email-bulk-actions" class="bulk-actions-bar hidden" role="toolbar" aria-label="Bulk email actions">
369 379 <span id="email-bulk-count" class="bulk-count">0 selected</span>
370 380 <button class="btn btn-sm" onclick="GoingsOn.bulk.markEmailsRead()">Mark Read</button>
@@ -508,6 +518,7 @@
508 518 <script src="js/task-forms.js"></script>
509 519 <script src="js/task-board.js"></script>
510 520 <script src="js/attachments.js"></script>
521 + <script src="js/autocomplete.js"></script>
511 522 <script src="js/tasks.js"></script>
512 523 <script src="js/events.js"></script>
513 524 <script src="js/emails.js"></script>