Skip to main content

max / goingson

Harden sync, email, plugin, and export subsystems Code fuzz remediation: sync push/pull accuracy, plugin registry hardening, backup FK integrity, email sync improvements, parser cleanup, and export safety improvements.
Co-Authored-By
Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-02 23:12 UTC
Commit: 8e4136ed753986aa78a4be515b815a2d932712c3
Parent: df3de31
28 files changed, +476 insertions, -184 deletions
@@ -47,6 +47,10 @@
47 47 pub sync_accounts: Arc<dyn SyncAccountRepository>,
48 48 pub sync_client: RwLock<Option<Arc<SyncKitClient>>>,
49 49 pub sync_lock: Arc<TokioMutex<()>>,
50 + /// Per-account email sync locks to prevent concurrent syncs on the same account.
51 + pub email_sync_locks: Arc<parking_lot::Mutex<std::collections::HashSet<goingson_core::EmailAccountId>>>,
52 + /// Per-account token refresh locks to prevent concurrent refreshes.
53 + pub token_refresh_locks: Arc<parking_lot::Mutex<std::collections::HashMap<uuid::Uuid, Arc<TokioMutex<()>>>>>,
50 54 pub data_dir: PathBuf,
51 55 }
52 56
@@ -66,12 +70,11 @@
66 70 .map_err(|e| format!("Failed to create app data dir: {}", e))?;
67 71
68 72 let db_path = app_data_dir.join("goingson.db");
69 - let db_url = format!("sqlite:{}?mode=rwc", db_path.display());
70 73
71 74 debug!(?db_path, "Connecting to database");
72 75
73 - // Create database connection pool
74 - let pool = SqlitePool::connect(&db_url)
76 + // Create database connection pool (WAL mode, FK enforcement, pool limits)
77 + let pool = goingson_db_sqlite::init_pool(Some(db_path.to_str().unwrap_or("goingson.db")))
75 78 .await
76 79 .map_err(|e| format!("Failed to connect to database: {}", e))?;
77 80
@@ -141,9 +144,19 @@
141 144 sync_accounts,
142 145 sync_client: RwLock::new(sync_client.map(Arc::new)),
143 146 sync_lock: Arc::new(TokioMutex::new(())),
147 + email_sync_locks: Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new())),
148 + token_refresh_locks: Arc::new(parking_lot::Mutex::new(std::collections::HashMap::new())),
144 149 data_dir: app_data_dir,
145 150 })
146 151 }
152 +
153 + /// Gets or creates a per-account token refresh lock.
154 + pub fn token_refresh_lock(&self, account_id: uuid::Uuid) -> Arc<TokioMutex<()>> {
155 + let mut locks = self.token_refresh_locks.lock();
156 + locks.entry(account_id)
157 + .or_insert_with(|| Arc::new(TokioMutex::new(())))
158 + .clone()
159 + }
147 160 }
148 161
149 162 /// Load a SyncKit API key from the keychain, migrating from plaintext file if needed.
@@ -73,6 +73,8 @@
73 73 sync_accounts: Arc::new(SqliteSyncAccountRepository::new(pool.clone())),
74 74 sync_client: parking_lot::RwLock::new(None),
75 75 sync_lock: Arc::new(tokio::sync::Mutex::new(())),
76 + email_sync_locks: Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new())),
77 + token_refresh_locks: Arc::new(parking_lot::Mutex::new(std::collections::HashMap::new())),
76 78 data_dir: std::path::PathBuf::from("/tmp/goingson-test"),
77 79 };
78 80
@@ -6,7 +6,7 @@
6 6
7 7 use std::collections::HashMap;
8 8
9 - use crate::id_types::{ProjectId, UserId};
9 + use crate::id_types::{ProjectId, TaskId, UserId};
10 10
11 11 use crate::error::CoreError;
12 12 use crate::models::{
@@ -25,6 +25,10 @@
25 25 pub events_restored: usize,
26 26 /// Number of emails restored.
27 27 pub emails_restored: usize,
28 + /// Number of subtasks restored.
29 + pub subtasks_restored: usize,
30 + /// Number of annotations restored.
31 + pub annotations_restored: usize,
28 32 }
29 33
30 34 /// Pre-parsed backup data for restoration.
@@ -65,7 +69,8 @@
65 69 }
66 70 }
67 71
68 - // Import tasks, remapping project_id references
72 + // Import tasks, remapping project_id references and restoring subtasks/annotations
73 + let mut task_id_map: HashMap<TaskId, TaskId> = HashMap::new();
69 74 for task in &input.tasks {
70 75 if tasks.get_by_id(task.id, user_id).await?.is_none() {
71 76 let new_task = crate::models::NewTask::builder(&task.description)
@@ -88,8 +93,38 @@
88 93 new_task
89 94 };
90 95
91 - tasks.create(user_id, new_task.build()).await?;
96 + let created = tasks.create(user_id, new_task.build()).await?;
97 + task_id_map.insert(task.id, created.id);
92 98 result.tasks_restored += 1;
99 +
100 + // Restore annotations
101 + for annotation in &task.annotations {
102 + if tasks.add_annotation(created.id, user_id, &annotation.note).await?.is_some() {
103 + result.annotations_restored += 1;
104 + }
105 + }
106 +
107 + // Restore subtasks (text-only; linked subtasks handled in second pass)
108 + for subtask in &task.subtasks {
109 + if subtask.linked_task_id.is_none() {
110 + if tasks.add_subtask(created.id, user_id, &subtask.text).await?.is_some() {
111 + result.subtasks_restored += 1;
112 + }
113 + }
114 + }
115 + }
116 + }
117 +
118 + // Second pass: restore subtasks with linked_task_id (requires all tasks to exist)
119 + for task in &input.tasks {
120 + let new_parent_id = task_id_map.get(&task.id).copied().unwrap_or(task.id);
121 + for subtask in &task.subtasks {
122 + if let Some(linked_id) = subtask.linked_task_id {
123 + let new_linked_id = task_id_map.get(&linked_id).copied().unwrap_or(linked_id);
124 + if tasks.add_subtask_link(new_parent_id, user_id, new_linked_id).await.ok().flatten().is_some() {
125 + result.subtasks_restored += 1;
126 + }
127 + }
93 128 }
94 129 }
95 130
@@ -65,6 +65,9 @@
65 65 /// Maximum scheduled duration in minutes (24 hours).
66 66 pub const MAX_SCHEDULED_DURATION_MINUTES: i32 = 24 * 60;
67 67
68 + /// Maximum relative date offset in days (~10 years).
69 + pub const MAX_RELATIVE_DATE_DAYS: i64 = 3650;
70 +
68 71 #[cfg(test)]
69 72 mod tests {
70 73 use super::*;
@@ -19,6 +19,8 @@
19 19 pub struct FetchedEmail {
20 20 pub message_id: Option<String>,
21 21 pub in_reply_to: Option<String>,
22 + /// First entry from the RFC 2822 References header (the thread root message-ID).
23 + pub references_root: Option<String>,
22 24 pub from: String,
23 25 pub to: String,
24 26 pub subject: String,
@@ -55,6 +57,21 @@
55 57 ) -> Result<SyncProcessResult, CoreError> {
56 58 let mut result = SyncProcessResult::default();
57 59
60 + // Ensure every email has a message_id for dedup: real if present, else synthetic hash
61 + let mut emails = emails;
62 + for e in &mut emails {
63 + if e.message_id.is_none() {
64 + use std::collections::hash_map::DefaultHasher;
65 + use std::hash::{Hash, Hasher};
66 + let mut h = DefaultHasher::new();
67 + e.from.hash(&mut h);
68 + e.to.hash(&mut h);
69 + e.subject.hash(&mut h);
70 + e.date.hash(&mut h);
71 + e.message_id = Some(format!("synth-{:x}", h.finish()));
72 + }
73 + }
74 +
58 75 // Batch check existing message IDs
59 76 let msg_ids: Vec<&str> = emails.iter()
60 77 .filter_map(|e| e.message_id.as_deref())
@@ -78,7 +95,9 @@
78 95 // thread_id groups conversations: use in_reply_to if this is a reply,
79 96 // otherwise fall back to message_id (starts a new thread). This means
80 97 // the first email in a thread has thread_id == message_id.
81 - let thread_id = email.in_reply_to.clone().or_else(|| email.message_id.clone());
98 + let thread_id = email.references_root.clone()
99 + .or_else(|| email.in_reply_to.clone())
100 + .or_else(|| email.message_id.clone());
82 101
83 102 if let Some(ref reply_to) = email.in_reply_to {
84 103 reply_to_ids.push(reply_to.clone());
@@ -137,6 +156,7 @@
137 156 let email = FetchedEmail {
138 157 message_id: Some("msg-1@example.com".to_string()),
139 158 in_reply_to: None,
159 + references_root: None,
140 160 from: "sender@example.com".to_string(),
141 161 to: "recipient@example.com".to_string(),
142 162 subject: "Test".to_string(),