diff --git a/crates/bridge/src/event_handler.rs b/crates/bridge/src/event_handler.rs index f0fbea1..9778bad 100644 --- a/crates/bridge/src/event_handler.rs +++ b/crates/bridge/src/event_handler.rs @@ -286,19 +286,25 @@ async fn apply_mail_set_entry_create( return; } - // MISS path: never seen this mail. Ask the backend for just that one - // mail. Needs the Mail's `list_id` (≠ the folder's entries_list_id) — - // sniff it from any cached Mail (single-MailGroup is the common case; - // caller falls back if the cache is still empty). - let Some(list_id) = store.mail_list_id().await else { + // MISS path: never seen this mail. The MailSetEntry payload is + // inline in `event.instance`; decrypting it gives us the referenced + // Mail's full `(list_id, element_id)` directly, so we can ask the + // backend for just that one mail. No global mail-list-id cache, no + // `sync_folder` fallback needed in the typical case. + let mail_id_tuple = resolve_mail_set_entry(backend, ev).await; + let Some(mail_tuple) = mail_id_tuple else { + // Inline decode failed AND we have no cached mail to clone — the + // folder will be brought up-to-date by the safety-net sync below. fallback_folders.insert(ev.instance_list_id.clone()); return; }; - match backend.load_mail(&list_id, &mail_eid).await { + let list_id = mail_tuple.list_id.to_string(); + let elem_id = mail_tuple.element_id.to_string(); + match backend.load_mail(&list_id, &elem_id).await { Ok(Some(mail)) => { debug!( "Event bus: targeted load_mail({}, {}) → {} (1 REST call)", - list_id, mail_eid, target_folder.imap_path + list_id, elem_id, target_folder.imap_path ); let mut stored = StoredMail { mail, @@ -306,21 +312,40 @@ async fn apply_mail_set_entry_create( rfc2822: None, uid: 0, }; - assign_uid_and_upsert(store, local_store, target_folder, &mail_eid, &mut stored).await; + assign_uid_and_upsert(store, local_store, target_folder, &elem_id, &mut stored).await; }, Ok(None) => { - debug!("MailSetEntry CREATE: mail {} not found on server", mail_eid); + debug!("MailSetEntry CREATE: mail {} not found on server", elem_id); }, Err(e) => { warn!( "MailSetEntry CREATE: load_mail({}, {}) failed: {e} — falling back", - list_id, mail_eid + list_id, elem_id ); fallback_folders.insert(ev.instance_list_id.clone()); }, } } +/// Try to discover the Mail referenced by a `MailSetEntry` CREATE event +/// without a REST round-trip. Preferred path: decrypt `event.instance` +/// inline (the entry carries `mail: IdTupleGenerated`). Falls back to the +/// in-memory cache by entry-id-derived mail id — if neither works, the +/// caller queues a fallback `sync_folder`. +async fn resolve_mail_set_entry( + backend: &dyn MailBackend, + ev: &EntityUpdateEvent, +) -> Option { + if let Some(json) = ev.instance.as_deref() { + match backend.decrypt_inline_mail_set_entry(json).await { + Ok(Some(entry)) => return Some(entry.mail), + Ok(None) => debug!("MailSetEntry inline: session key unresolved"), + Err(e) => warn!("MailSetEntry inline decrypt failed: {e}"), + } + } + None +} + async fn apply_mail_set_entry_delete( store: &MailStore, local_store: &LocalStore, @@ -378,33 +403,81 @@ async fn apply_mail_event( } store.remove_mail_everywhere(&ev.instance_id).await; }, - Operation::Update => match backend.load_mail(&ev.instance_list_id, &ev.instance_id).await { - Ok(Some(mail)) => { - store.refresh_mail_in_place(&mail).await; - let mail_json = serde_json::to_string(&mail).unwrap_or_default(); - if let Err(e) = local_store.refresh_mail_fields( - &ev.instance_id, - &mail.subject, - &mail.sender.name, - &mail.sender.address, - mail.unread, - &mail_json, - ) { - debug!("Could not refresh metadata for {}: {}", ev.instance_id, e); - } - }, - Ok(None) => { - // Disappeared between event and our follow-up load — treat - // like a delete. - let _ = local_store.delete_mail(&ev.instance_id); - store.remove_mail_everywhere(&ev.instance_id).await; - }, - Err(e) => warn!("Mail UPDATE: failed to load {}: {}", ev.instance_id, e), + Operation::Update => { + // Prefer the inline-decrypt path: the encrypted Mail is already + // in `event.instance`, no REST round-trip needed. Fall back to + // `load_mail` if the payload is absent or its session key is in a + // transient unresolvable state (e.g. post-reply attachment keys). + let mail = resolve_mail(backend, ev).await; + match mail { + Some(mail) => { + store.refresh_mail_in_place(&mail).await; + let mail_json = serde_json::to_string(&mail).unwrap_or_default(); + if let Err(e) = local_store.refresh_mail_fields( + &ev.instance_id, + &mail.subject, + &mail.sender.name, + &mail.sender.address, + mail.unread, + &mail_json, + ) { + debug!("Could not refresh metadata for {}: {}", ev.instance_id, e); + } + }, + None => { + // Disappeared / unresolvable — treat like a delete to + // keep the cache consistent. + let _ = local_store.delete_mail(&ev.instance_id); + store.remove_mail_everywhere(&ev.instance_id).await; + }, + } }, Operation::Create | Operation::Other(_) => {}, } } +/// Decrypt-inline first, REST-fallback second. Centralises the policy so +/// every event-bus consumer takes the same fast path when the payload is +/// already inline, and the same safety-net otherwise. +async fn resolve_mail( + backend: &dyn MailBackend, + ev: &EntityUpdateEvent, +) -> Option { + if let Some(json) = ev.instance.as_deref() { + match backend.decrypt_inline_mail(json).await { + Ok(Some(mail)) => { + debug!( + "Event bus: decrypted Mail {} inline (no REST)", + ev.instance_id + ); + return Some(mail); + }, + Ok(None) => { + debug!( + "Event bus: Mail {} session key unresolvable, falling back to load_mail", + ev.instance_id + ); + }, + Err(e) => { + warn!( + "Event bus: decrypt_inline_mail({}) failed: {e} — falling back", + ev.instance_id + ); + }, + } + } + match backend + .load_mail(&ev.instance_list_id, &ev.instance_id) + .await + { + Ok(opt) => opt, + Err(e) => { + warn!("Event bus: load_mail({}) failed: {e}", ev.instance_id); + None + }, + } +} + /// Allocate a fresh UID in `target_folder`, stamp it on `stored`, then /// upsert both `MailStore` and the `LocalStore` metadata row in one step. async fn assign_uid_and_upsert( diff --git a/crates/bridge/src/imap/session.rs b/crates/bridge/src/imap/session.rs index 62a41af..0b1742b 100644 --- a/crates/bridge/src/imap/session.rs +++ b/crates/bridge/src/imap/session.rs @@ -1460,6 +1460,17 @@ mod tests { }) .cloned()) } + async fn decrypt_inline_mail(&self, _json: &str) -> Result, String> { + // Tests stub: the inline path is exercised by handler tests with + // an explicit recorded payload, not through the mock's body. + Ok(None) + } + async fn decrypt_inline_mail_set_entry( + &self, + _json: &str, + ) -> Result, String> { + Ok(None) + } async fn load_mail_details(&self, mail: &Mail) -> Result, String> { let key = mail._id.as_ref().map(|id| id.element_id.to_string()).unwrap_or_default(); Ok(self.details.lock().unwrap().get(&key).cloned()) diff --git a/crates/bridge/src/sync.rs b/crates/bridge/src/sync.rs index 0b0d675..6af26ac 100644 --- a/crates/bridge/src/sync.rs +++ b/crates/bridge/src/sync.rs @@ -32,11 +32,6 @@ pub struct MailStore { folder_list: RwLock>, generation: watch::Sender, gen_counter: std::sync::atomic::AtomicU64, - /// Cached `Mail.list_id` for this user. A user has one (per `MailGroup`) - /// and every Mail in the cache shares it; we sniff it lazily from any - /// existing Mail so the event handler can `load_mail` brand-new mail - /// ids without re-listing a folder. - mail_list_id_cache: RwLock>, } impl MailStore { @@ -47,7 +42,6 @@ impl MailStore { folder_list: RwLock::new(Vec::new()), generation: tx, gen_counter: std::sync::atomic::AtomicU64::new(0), - mail_list_id_cache: RwLock::new(None), }) } @@ -239,26 +233,6 @@ impl MailStore { self.bump_generation(); } - /// Return the cached `Mail.list_id` for this user, sniffing it from any - /// Mail already in the store on first call. `None` only if the store - /// is still empty (very first boot, no mail seen yet). - pub async fn mail_list_id(&self) -> Option { - if let Some(cached) = self.mail_list_id_cache.read().await.clone() { - return Some(cached); - } - let folders = self.folders.read().await; - let sniffed = folders.values().find_map(|mails| { - mails - .iter() - .find_map(|m| m.mail._id.as_ref().map(|id| id.list_id.to_string())) - }); - drop(folders); - if let Some(id) = &sniffed { - *self.mail_list_id_cache.write().await = Some(id.clone()); - } - sniffed - } - /// Drop in-memory state for folders that are no longer on the server. /// Returns the ids that were removed so the caller can clean up the /// LocalStore + .eml files for them. @@ -925,24 +899,6 @@ mod tests { assert_eq!(store.get_folder("A").await.len(), 2); } - #[tokio::test] - async fn mail_list_id_sniffs_lazily_and_caches() { - let store = MailStore::new(); - // No mails yet — nothing to sniff. - assert!(store.mail_list_id().await.is_none()); - store - .set_folder("A", vec![stored(make_mail("listAAA", "e1", "s", true), 1)]) - .await; - assert_eq!(store.mail_list_id().await.as_deref(), Some("listAAA")); - // Now the cache is populated — calling again returns the same value - // even if the store changes (cache is intentionally sticky for a - // session; a different list_id would only appear with a different - // MailGroup, which forces a full restart anyway). - store - .set_folder("A", vec![stored(make_mail("listBBB", "e1", "s", true), 1)]) - .await; - assert_eq!(store.mail_list_id().await.as_deref(), Some("listAAA")); - } #[tokio::test] async fn prune_unknown_folders_drops_disappeared_ones() { diff --git a/crates/bridge/src/tuta.rs b/crates/bridge/src/tuta.rs index f73910d..498b0d0 100644 --- a/crates/bridge/src/tuta.rs +++ b/crates/bridge/src/tuta.rs @@ -48,6 +48,17 @@ pub trait MailBackend: Send + Sync { /// handler to fetch a freshly-created or updated mail without re-listing /// its folder. `Ok(None)` means the entity is no longer on the server. async fn load_mail(&self, list_id: &str, element_id: &str) -> Result, String>; + /// Decrypt the still-encrypted `event.instance` payload of a Mail event + /// directly (no REST round-trip). `Ok(None)` covers both "session key + /// transient" and "payload absent" so the handler can fall back to + /// `load_mail`. + async fn decrypt_inline_mail(&self, json: &str) -> Result, String>; + /// Same shape for a `MailSetEntry` event — gives the handler the + /// referenced `mail` IdTuple without ever asking the server. + async fn decrypt_inline_mail_set_entry( + &self, + json: &str, + ) -> Result, String>; async fn load_mail_details(&self, mail: &Mail) -> Result, String>; /// Enumerate all mail folders (system + custom, with hierarchy). async fn list_folders(&self) -> Result, String>; @@ -333,6 +344,23 @@ impl MailBackend for TutaSession { .map_err(|e| format!("{e}")) } + async fn decrypt_inline_mail(&self, json: &str) -> Result, String> { + self.crypto_client() + .decrypt_inline_and_parse::(json) + .await + .map_err(|e| format!("{e}")) + } + + async fn decrypt_inline_mail_set_entry( + &self, + json: &str, + ) -> Result, String> { + self.crypto_client() + .decrypt_inline_and_parse::(json) + .await + .map_err(|e| format!("{e}")) + } + async fn load_mail_details(&self, mail: &Mail) -> Result, String> { self.load_mail_details_impl(mail) .await