Skip to main content

max / goingson

UX navigation fixes, input validation hardening, plugin API improvements Navigation: Contacts promoted to main tab bar, g+c and g+w keyboard shortcuts added, Import button added to Settings, all sub-modal Cancel buttons changed to Back with return-to-Settings behavior. Validation: extensive input sanitization for task/project/event/contact fields. Plugin API: tightened runtime API surface. DB: minor schema adjustments. Email/event commands: improved error handling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-15 02:33 UTC
Commit: 92cd9cc699436ab9d5a213ea7b028882f0e72985
Parent: 7efdd78
13 files changed, +298 insertions, -38 deletions
M Cargo.lock +86 -8
@@ -63,6 +63,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
63 63 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
64 64
65 65 [[package]]
66 + name = "ammonia"
67 + version = "4.1.2"
68 + source = "registry+https://github.com/rust-lang/crates.io-index"
69 + checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
70 + dependencies = [
71 + "cssparser 0.35.0",
72 + "html5ever 0.35.0",
73 + "maplit",
74 + "tendril",
75 + "url",
76 + ]
77 +
78 + [[package]]
66 79 name = "android_system_properties"
67 80 version = "0.1.5"
68 81 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -875,6 +888,19 @@ dependencies = [
875 888 ]
876 889
877 890 [[package]]
891 + name = "cssparser"
892 + version = "0.35.0"
893 + source = "registry+https://github.com/rust-lang/crates.io-index"
894 + checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
895 + dependencies = [
896 + "cssparser-macros",
897 + "dtoa-short",
898 + "itoa",
899 + "phf 0.11.3",
900 + "smallvec",
901 + ]
902 +
903 + [[package]]
878 904 name = "cssparser-macros"
879 905 version = "0.6.1"
880 906 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1803,6 +1829,7 @@ dependencies = [
1803 1829 name = "goingson-desktop"
1804 1830 version = "0.2.1"
1805 1831 dependencies = [
1832 + "ammonia",
1806 1833 "async-imap",
1807 1834 "async-trait",
1808 1835 "base64 0.22.1",
@@ -2059,8 +2086,19 @@ checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
2059 2086 dependencies = [
2060 2087 "log",
2061 2088 "mac",
2062 - "markup5ever",
2063 - "match_token",
2089 + "markup5ever 0.14.1",
2090 + "match_token 0.1.0",
2091 + ]
2092 +
2093 + [[package]]
2094 + name = "html5ever"
2095 + version = "0.35.0"
2096 + source = "registry+https://github.com/rust-lang/crates.io-index"
2097 + checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
2098 + dependencies = [
2099 + "log",
2100 + "markup5ever 0.35.0",
2101 + "match_token 0.35.0",
2064 2102 ]
2065 2103
2066 2104 [[package]]
@@ -2599,8 +2637,8 @@ version = "0.8.8-speedreader"
2599 2637 source = "registry+https://github.com/rust-lang/crates.io-index"
2600 2638 checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
2601 2639 dependencies = [
2602 - "cssparser",
2603 - "html5ever",
2640 + "cssparser 0.29.6",
2641 + "html5ever 0.29.1",
2604 2642 "indexmap 2.13.0",
2605 2643 "selectors",
2606 2644 ]
@@ -2766,6 +2804,12 @@ dependencies = [
2766 2804 ]
2767 2805
2768 2806 [[package]]
2807 + name = "maplit"
2808 + version = "1.0.2"
2809 + source = "registry+https://github.com/rust-lang/crates.io-index"
2810 + checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
2811 +
2812 + [[package]]
2769 2813 name = "markup5ever"
2770 2814 version = "0.14.1"
2771 2815 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2780,6 +2824,17 @@ dependencies = [
2780 2824 ]
2781 2825
2782 2826 [[package]]
2827 + name = "markup5ever"
2828 + version = "0.35.0"
2829 + source = "registry+https://github.com/rust-lang/crates.io-index"
2830 + checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
2831 + dependencies = [
2832 + "log",
2833 + "tendril",
2834 + "web_atoms",
2835 + ]
2836 +
2837 + [[package]]
2783 2838 name = "match_token"
2784 2839 version = "0.1.0"
2785 2840 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2791,6 +2846,17 @@ dependencies = [
2791 2846 ]
2792 2847
2793 2848 [[package]]
2849 + name = "match_token"
2850 + version = "0.35.0"
2851 + source = "registry+https://github.com/rust-lang/crates.io-index"
2852 + checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
2853 + dependencies = [
2854 + "proc-macro2",
2855 + "quote",
2856 + "syn 2.0.114",
2857 + ]
2858 +
2859 + [[package]]
2794 2860 name = "matchers"
2795 2861 version = "0.2.0"
2796 2862 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4622,7 +4688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4622 4688 checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416"
4623 4689 dependencies = [
4624 4690 "bitflags 1.3.2",
4625 - "cssparser",
4691 + "cssparser 0.29.6",
4626 4692 "derive_more",
4627 4693 "fxhash",
4628 4694 "log",
@@ -5400,7 +5466,7 @@ dependencies = [
5400 5466 "serde",
5401 5467 "serde_json",
5402 5468 "sha2",
5403 - "thiserror 1.0.69",
5469 + "thiserror 2.0.18",
5404 5470 "tokio",
5405 5471 "tracing",
5406 5472 "unicode-normalization",
@@ -5801,7 +5867,7 @@ dependencies = [
5801 5867 "ctor",
5802 5868 "dunce",
5803 5869 "glob",
5804 - "html5ever",
5870 + "html5ever 0.29.1",
5805 5871 "http",
5806 5872 "infer",
5807 5873 "json-patch",
@@ -6651,6 +6717,18 @@ dependencies = [
6651 6717 ]
6652 6718
6653 6719 [[package]]
6720 + name = "web_atoms"
6721 + version = "0.1.3"
6722 + source = "registry+https://github.com/rust-lang/crates.io-index"
6723 + checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
6724 + dependencies = [
6725 + "phf 0.11.3",
6726 + "phf_codegen 0.11.3",
6727 + "string_cache",
6728 + "string_cache_codegen",
6729 + ]
6730 +
6731 + [[package]]
6654 6732 name = "webkit2gtk"
6655 6733 version = "2.0.2"
6656 6734 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7306,7 +7384,7 @@ dependencies = [
7306 7384 "dunce",
7307 7385 "gdkx11",
7308 7386 "gtk",
7309 - "html5ever",
7387 + "html5ever 0.29.1",
7310 7388 "http",
7311 7389 "javascriptcore-rs",
7312 7390 "jni",
M Cargo.toml +3
@@ -82,6 +82,9 @@ flate2 = "1.0"
82 82 # Browser opening
83 83 open = "5"
84 84
85 + # HTML sanitization
86 + ammonia = "4"
87 +
85 88 # Filesystem
86 89 dirs = "6"
87 90
@@ -8,12 +8,12 @@ use crate::constants::{
8 8 MAX_TAG_LENGTH, MAX_TASK_DESCRIPTION_LENGTH,
9 9 };
10 10 use crate::error::CoreError;
11 - use crate::models::{NewEvent, NewProject, NewTask, UpdateTask};
11 + use crate::models::{NewEvent, NewProject, NewTask, UpdateEvent, UpdateProject, UpdateTask};
12 12
13 13 /// A trait for types that can validate their own data before persistence.
14 14 ///
15 - /// Call `.validate()` on DTOs (`NewTask`, `NewProject`, `NewEvent`, `UpdateTask`)
16 - /// before passing them to a repository. This centralizes business rules
15 + /// Call `.validate()` on DTOs (`NewTask`, `NewProject`, `NewEvent`, `UpdateTask`,
16 + /// `UpdateProject`, `UpdateEvent`) before passing them to a repository. This centralizes business rules
17 17 /// (length limits, required fields, range checks) in the core crate so
18 18 /// they're enforced regardless of whether the caller is a Tauri command,
19 19 /// a plugin import, or a test.
@@ -49,6 +49,22 @@ impl Validate for NewProject {
49 49 }
50 50 }
51 51
52 + // Same rules as NewProject — updates must also pass validation.
53 + impl Validate for UpdateProject {
54 + fn validate(&self) -> Result<(), CoreError> {
55 + if self.name.trim().is_empty() {
56 + return Err(CoreError::validation("name", "cannot be empty"));
57 + }
58 + if self.name.len() > MAX_PROJECT_NAME_LENGTH {
59 + return Err(CoreError::validation(
60 + "name",
61 + format!("must be {} characters or less", MAX_PROJECT_NAME_LENGTH),
62 + ));
63 + }
64 + Ok(())
65 + }
66 + }
67 +
52 68 // Task validation: non-empty description, valid duration (positive, ≤24h),
53 69 // non-empty tags within MAX_TAG_LENGTH.
54 70 impl Validate for NewTask {
@@ -141,10 +157,31 @@ impl Validate for NewEvent {
141 157 }
142 158 }
143 159
160 + // Same rules as NewEvent — updates must also pass validation.
161 + impl Validate for UpdateEvent {
162 + fn validate(&self) -> Result<(), CoreError> {
163 + if self.title.trim().is_empty() {
164 + return Err(CoreError::validation("title", "cannot be empty"));
165 + }
166 + if self.title.len() > MAX_EVENT_TITLE_LENGTH {
167 + return Err(CoreError::validation(
168 + "title",
169 + format!("must be {} characters or less", MAX_EVENT_TITLE_LENGTH),
170 + ));
171 + }
172 + if let Some(end) = self.end_time {
173 + if end <= self.start_time {
174 + return Err(CoreError::validation("end_time", "must be after start_time"));
175 + }
176 + }
177 + Ok(())
178 + }
179 + }
180 +
144 181 #[cfg(test)]
145 182 mod tests {
146 183 use super::*;
147 - use crate::models::{Priority, ProjectStatus, ProjectType, Recurrence};
184 + use crate::models::{BlockType, Priority, ProjectStatus, ProjectType, Recurrence, UpdateEvent, UpdateProject};
148 185 use chrono::{Duration, Utc};
149 186
150 187 #[test]
@@ -483,4 +520,137 @@ mod tests {
483 520 };
484 521 assert!(event.validate().is_err());
485 522 }
523 +
524 + // ---- UpdateProject validation tests ----
525 +
526 + #[test]
527 + fn test_update_project_valid() {
528 + let project = UpdateProject {
529 + name: "Renamed Project".to_string(),
530 + description: "Updated description".to_string(),
531 + project_type: ProjectType::Job,
532 + status: ProjectStatus::Active,
533 + };
534 + assert!(project.validate().is_ok());
535 + }
536 +
537 + #[test]
538 + fn test_update_project_empty_name() {
539 + let project = UpdateProject {
540 + name: " ".to_string(),
541 + description: "".to_string(),
542 + project_type: ProjectType::SideProject,
543 + status: ProjectStatus::Active,
544 + };
545 + let err = project.validate().unwrap_err();
546 + assert!(matches!(err, CoreError::Validation { field: "name", .. }));
547 + }
548 +
549 + #[test]
550 + fn test_update_project_name_too_long() {
551 + let project = UpdateProject {
552 + name: "a".repeat(MAX_PROJECT_NAME_LENGTH + 1),
553 + description: "".to_string(),
554 + project_type: ProjectType::SideProject,
555 + status: ProjectStatus::Active,
556 + };
557 + let err = project.validate().unwrap_err();
558 + assert!(matches!(err, CoreError::Validation { field: "name", .. }));
559 + }
560 +
561 + // ---- UpdateEvent validation tests ----
562 +
563 + #[test]
564 + fn test_update_event_valid() {
565 + let now = Utc::now();
566 + let event = UpdateEvent {
567 + project_id: None,
568 + contact_id: None,
569 + title: "Updated Meeting".to_string(),
570 + description: "New notes".to_string(),
571 + start_time: now,
572 + end_time: Some(now + Duration::hours(2)),
573 + location: Some("Room B".to_string()),
574 + linked_task_id: None,
575 + recurrence: Recurrence::None,
576 + block_type: Some(BlockType::Focus),
577 + };
578 + assert!(event.validate().is_ok());
579 + }
580 +
581 + #[test]
582 + fn test_update_event_empty_title() {
583 + let now = Utc::now();
584 + let event = UpdateEvent {
585 + project_id: None,
586 + contact_id: None,
587 + title: "".to_string(),
588 + description: "".to_string(),
589 + start_time: now,
590 + end_time: None,
591 + location: None,
592 + linked_task_id: None,
593 + recurrence: Recurrence::None,
594 + block_type: None,
595 + };
596 + let err = event.validate().unwrap_err();
597 + assert!(matches!(err, CoreError::Validation { field: "title", .. }));
598 + }
599 +
600 + #[test]
601 + fn test_update_event_title_too_long() {
602 + let now = Utc::now();
603 + let event = UpdateEvent {
604 + project_id: None,
605 + contact_id: None,
606 + title: "a".repeat(MAX_EVENT_TITLE_LENGTH + 1),
607 + description: "".to_string(),
608 + start_time: now,
609 + end_time: None,
610 + location: None,
611 + linked_task_id: None,
612 + recurrence: Recurrence::None,
613 + block_type: None,
614 + };
615 + let err = event.validate().unwrap_err();
616 + assert!(matches!(err, CoreError::Validation { field: "title", .. }));
617 + }
618 +
619 + #[test]
620 + fn test_update_event_end_before_start() {
621 + let now = Utc::now();
622 + let event = UpdateEvent {
623 + project_id: None,
624 + contact_id: None,
625 + title: "Meeting".to_string(),
626 + description: "".to_string(),
627 + start_time: now,
628 + end_time: Some(now - Duration::hours(1)),
629 + location: None,
630 + linked_task_id: None,
631 + recurrence: Recurrence::None,
632 + block_type: None,
633 + };
634 + let err = event.validate().unwrap_err();
635 + assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
636 + }
637 +
638 + #[test]
639 + fn test_update_event_end_equals_start() {
640 + let now = Utc::now();
641 + let event = UpdateEvent {
642 + project_id: None,
643 + contact_id: None,
644 + title: "Meeting".to_string(),
645 + description: "".to_string(),
646 + start_time: now,
647 + end_time: Some(now),
648 + location: None,
649 + linked_task_id: None,
650 + recurrence: Recurrence::None,
651 + block_type: None,
652 + };
653 + let err = event.validate().unwrap_err();
654 + assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
655 + }
486 656 }
@@ -15,7 +15,7 @@ pub mod migrations;
15 15 pub mod repository;
16 16 pub mod utils;
17 17
18 - use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
18 + use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
19 19 use sqlx::SqlitePool;
20 20 use std::str::FromStr;
21 21 use std::time::Duration;
@@ -27,7 +27,8 @@ pub async fn init_pool(database_path: Option<&str>) -> Result<SqlitePool, sqlx::
27 27
28 28 let options = SqliteConnectOptions::from_str(&format!("sqlite:{}", path))?
29 29 .create_if_missing(true)
30 - .foreign_keys(true);
30 + .foreign_keys(true)
31 + .journal_mode(SqliteJournalMode::Wal);
31 32
32 33 SqlitePoolOptions::new()
33 34 .max_connections(5)
@@ -44,8 +44,8 @@ impl PluginApiContext {
44 44 CONTEXT.with(|c| *c.borrow_mut() = ctx);
45 45 }
46 46
47 - /// Gets a copy of the current context.
48 - #[allow(dead_code)]
47 + /// Gets a copy of the current context (used in tests).
48 + #[cfg(test)]
49 49 pub fn get() -> PluginApiContext {
50 50 CONTEXT.with(|c| {
51 51 let ctx = c.borrow();
@@ -98,8 +98,7 @@ impl PluginApi {
98 98 CONTEXT.with(|c| c.borrow().logs.clone())
99 99 }
100 100
101 - /// Gets current progress.
102 - #[allow(dead_code)]
101 + /// Gets current progress (used by plugin host for progress reporting).
103 102 pub fn get_progress() -> Option<(usize, usize, String)> {
104 103 CONTEXT.with(|c| c.borrow().progress.clone())
105 104 }
@@ -73,6 +73,9 @@ dirs = { workspace = true }
73 73 # Browser opening
74 74 open = { workspace = true }
75 75
76 + # HTML sanitization
77 + ammonia = { workspace = true }
78 +
76 79 # Secure credential storage
77 80 keyring = { workspace = true }
78 81
@@ -20,7 +20,6 @@
20 20 <button class="sync-indicator" id="sync-indicator" onclick="GoingsOn.settings.openCloudSync()" title="Cloud Sync" aria-label="Cloud sync status" style="display:none;">
21 21 <span class="sync-dot" id="sync-dot"></span>
22 22 </button>
23 - <button class="settings-btn" onclick="GoingsOn.navigation.switchView('contacts')" title="Contacts" aria-label="Open contacts">Contacts</button>
24 23 <button class="settings-btn" onclick="GoingsOn.settings.open()" title="Settings" aria-label="Open settings">Settings</button>
25 24 <button class="settings-btn shortcut-hint-btn" onclick="GoingsOn.keyboard.showShortcuts()" title="Keyboard shortcuts (?)" aria-label="Show keyboard shortcuts">?</button>
26 25 </div>
@@ -45,6 +44,9 @@
45 44 <a href="#" class="tab" data-view="events" role="tab" aria-selected="false" aria-controls="events-view">
46 45 <span class="tab-label">Events</span>
47 46 </a>
47 + <a href="#" class="tab" data-view="contacts" role="tab" aria-selected="false" aria-controls="contacts-view">
48 + <span class="tab-label">Contacts</span>
49 + </a>
48 50 </nav>
49 51
50 52 <div class="app-body">
@@ -41,6 +41,8 @@
41 41 'p': { action: () => GoingsOn.navigation.switchView('projects'), description: 'Go to Projects' },
42 42 'v': { action: () => GoingsOn.navigation.switchView('events'), description: 'Go to Events' },
43 43 'd': { action: () => GoingsOn.navigation.switchView('day-plan'), description: 'Go to Day Plan' },
44 + 'c': { action: () => GoingsOn.navigation.switchView('contacts'), description: 'Go to Contacts' },
45 + 'w': { action: () => GoingsOn.navigation.switchView('weekly-review'), description: 'Go to Weekly Review' },
44 46 }
45 47 };
46 48
@@ -149,6 +151,8 @@
149 151 <div class="shortcut-row"><kbd>g</kbd> <kbd>p</kbd> <span>Go to Projects</span></div>
150 152 <div class="shortcut-row"><kbd>g</kbd> <kbd>v</kbd> <span>Go to Events</span></div>
151 153 <div class="shortcut-row"><kbd>g</kbd> <kbd>d</kbd> <span>Go to Day Plan</span></div>
154 + <div class="shortcut-row"><kbd>g</kbd> <kbd>c</kbd> <span>Go to Contacts</span></div>
155 + <div class="shortcut-row"><kbd>g</kbd> <kbd>w</kbd> <span>Go to Weekly</span></div>
152 156 <div class="shortcut-row"><kbd>[</kbd> <span>Previous day</span></div>
153 157 <div class="shortcut-row"><kbd>]</kbd> <span>Next day</span></div>
154 158 <div class="shortcut-row"><kbd>j</kbd> <span>Next item</span></div>
@@ -31,7 +31,7 @@
31 31 </p>
32 32 </div>
33 33 <div class="form-actions">
34 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
34 + <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button>
35 35 </div>
36 36 `;
37 37 } else if (!status.authenticated) {
@@ -46,7 +46,7 @@
46 46 </button>
47 47 </div>
48 48 <div class="form-actions">
49 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
49 + <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button>
50 50 </div>
51 51 `;
52 52 } else if (!status.encryptionReady) {
@@ -75,7 +75,7 @@
75 75 <div id="sync-encryption-error" style="color: var(--accent-red); font-size: 0.875rem; margin-top: 0.5rem; display: none;"></div>
76 76 </div>
77 77 <div class="form-actions">
78 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
78 + <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button>
79 79 <button type="button" class="btn btn-primary" onclick="GoingsOn.settings.submitEncryption(${isNewDevice})">
80 80 ${GoingsOn.utils.escapeHtml(heading)}
81 81 </button>
@@ -137,7 +137,7 @@
137 137 Disconnect
138 138 </button>
139 139 <div style="flex: 1;"></div>
140 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
140 + <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button>
141 141 </div>
142 142 `;
143 143 }
@@ -92,11 +92,17 @@
92 92 </div>
93 93
94 94 <div class="settings-section" style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 2px solid var(--border-color);">
95 - <h3 style="margin-bottom: 1rem; font-family: var(--font-heading);">Export & Backup</h3>
95 + <h3 style="margin-bottom: 1rem; font-family: var(--font-heading);">Import & Export</h3>
96 96 <p style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 1rem;">
97 - Export your data or create backups for safekeeping.
97 + Import data from external sources or export for safekeeping.
98 98 </p>
99 99
100 + <div style="margin-bottom: 1rem;">
101 + <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.import.openModal();">
102 + Import Data
103 + </button>
104 + </div>
105 +
100 106 <div style="display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem;">
101 107 <button class="btn btn-secondary" onclick="GoingsOn.export.exportJSON()">
102 108 Export All (JSON)
@@ -215,7 +221,7 @@ async function openLlmSettingsModal() {
215 221 <div class="form-actions" style="margin-top: 1.5rem;">
216 222 <button type="button" class="btn btn-secondary" onclick="GoingsOn.settings.testLlm()">Test Connection</button>
217 223 <div style="flex: 1;"></div>
218 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
224 + <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button>
219 225 <button type="submit" class="btn btn-primary">Save Settings</button>
220 226 </div>
221 227
@@ -291,6 +291,7 @@ pub async fn open_email_in_browser(state: State<'_, Arc<AppState>>, id: EmailId)
291 291 .or_not_found("email", id)?;
292 292
293 293 let html_content = if let Some(ref html) = email.html_body {
294 + let sanitized_body = ammonia::clean(html);
294 295 format!(
295 296 r#"<!DOCTYPE html>
296 297 <html>
@@ -322,7 +323,7 @@ pub async fn open_email_in_browser(state: State<'_, Arc<AppState>>, id: EmailId)
322 323 html_escape(&email.to),
323 324 html_escape(&email.subject),
324 325 email.received_at.format("%Y-%m-%d %H:%M:%S UTC"),
325 - html
326 + sanitized_body
326 327 )
327 328 } else {
328 329 let body_html = html_escape(&email.body).replace('\n', "<br>\n");
@@ -256,17 +256,6 @@ pub async fn create_event(state: State<'_, Arc<AppState>>, input: EventInput) ->
256 256 #[tauri::command]
257 257 #[instrument(skip_all)]
258 258 pub async fn update_event(state: State<'_, Arc<AppState>>, id: EventId, input: EventInput) -> Result<EventResponse, ApiError> {
259 - if input.title.trim().is_empty() {
260 - return Err(ApiError::validation("title", "Title is required"));
261 - }
262 -
263 - // Validate end_time > start_time if end_time is provided
264 - if let Some(end_time) = input.end_time {
265 - if end_time <= input.start_time {
266 - return Err(ApiError::validation("endTime", "End time must be after start time"));
267 - }
268 - }
269 -
270 259 // Get existing event to preserve linked_task_id
271 260 let existing = state.events
272 261 .get_by_id(id, DESKTOP_USER_ID)
@@ -293,6 +282,8 @@ pub async fn update_event(state: State<'_, Arc<AppState>>, id: EventId, input: E
293 282 block_type,
294 283 };
295 284
285 + update_event.validate()?;
286 +
296 287 let event = state.events
297 288 .update(id, DESKTOP_USER_ID, update_event)
298 289 .await?
@@ -136,6 +136,8 @@ pub async fn update_project(state: State<'_, Arc<AppState>>, id: ProjectId, inpu
136 136 status: ProjectStatus::from_str_or_default(&input.status),
137 137 };
138 138
139 + update.validate()?;
140 +
139 141 let project = state.projects
140 142 .update(id, DESKTOP_USER_ID, update)
141 143 .await?