From 62503d439bad312923e9d2aacbd566dbdf24958f Mon Sep 17 00:00:00 2001 From: Anthony Date: Thu, 28 May 2026 19:37:49 +0200 Subject: [PATCH] Retry attachment load on fresh-mail session-key races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a brand-new mail lands through the realtime event bus, its attached `File` entities are queryable on the server but the encryption metadata (`_ownerEncSessionKey` / `_ownerGroup`) may not have propagated yet — `CryptoEntityClient::load` then returns 'instance missing owner key/group data' a few seconds before the same load would succeed. The previous code surfaced that as a permanent attachment-load failure inside `prefetch_details` (best-effort fallback to a body-only multipart), so a PDF sent via SMTP appeared body-only in Thunderbird and only recovered after a manual sync. Add `load_file_with_retry` in `TutaSession`: when `is_session_key_transient(e)` (matching on the SDK error message), back off 1s / 3s / 6s before retrying, then bubble up so the existing ship-body-only fallback still applies. \~10s of tolerance is enough in practice while staying well under the SMTP/IMAP latency budget. Four unit tests cover the helper: matches the exact 'missing owner key/group data' wording, the generic 'Session key resolution failure' prefix, rejects unrelated SDK errors, accepts a bare 'missing owner key/group data' message (paranoid catch). --- crates/bridge/src/tuta.rs | 108 +++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/crates/bridge/src/tuta.rs b/crates/bridge/src/tuta.rs index e547ec5..50cf666 100644 --- a/crates/bridge/src/tuta.rs +++ b/crates/bridge/src/tuta.rs @@ -312,17 +312,63 @@ impl TutaSession { if mail.attachments.is_empty() { return Ok(Vec::new()); } - let crypto_client = self.crypto_client(); let mail_facade = self.logged_in.mail_facade(); let mut out: Vec<(TutanotaFile, Vec)> = Vec::with_capacity(mail.attachments.len()); for file_id in &mail.attachments { - let file: TutanotaFile = crypto_client.load(file_id).await?; + let file = self.load_file_with_retry(file_id).await?; let data = mail_facade.load_file_attachment_data(&file).await?; out.push((file, data)); } Ok(out) } + /// Load a `TutanotaFile` entity, retrying with backoff when the server + /// returns it before its encryption metadata has propagated. + /// + /// Mails delivered through the realtime event bus typically include + /// freshly-created attachments whose `_ownerEncSessionKey` / + /// `_ownerGroup` haven't been published yet — the SDK's session-key + /// resolution then fails with "instance missing owner key/group data". + /// 1s + 3s + 6s ≈ 10s of tolerance covers the propagation lag in + /// practice; anything still failing after that bubbles up so the + /// `prefetch_details` caller can decide to ship a body-only envelope. + async fn load_file_with_retry( + &self, + file_id: &IdTupleGenerated, + ) -> Result { + let crypto_client = self.crypto_client(); + let backoffs = [ + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(3), + std::time::Duration::from_secs(6), + ]; + let mut iter = backoffs.iter(); + loop { + match crypto_client.load::(file_id).await { + Ok(file) => return Ok(file), + Err(e) if is_session_key_transient(&e) => match iter.next() { + Some(delay) => { + log::debug!( + "File {} not yet decryptable, retrying in {:?}", + file_id.element_id, + delay + ); + tokio::time::sleep(*delay).await; + continue; + } + None => { + log::warn!( + "File {} still not decryptable after retries", + file_id.element_id + ); + return Err(e); + } + }, + Err(e) => return Err(e), + } + } + } + /// Encrypt + upload every attachment in one go, then assemble the /// `DraftAttachment` aggregates the `DraftService` needs. Returns the /// aggregates in the same order as `attachments`, paired with each @@ -949,6 +995,23 @@ fn build_draft_data( } } +/// Recognise the SDK error shape that means "the server returned the entity +/// but its `_ownerEncSessionKey` / `_ownerGroup` are not populated yet". +/// Newly-created mail attachments hit this for a few seconds after the +/// `SendDraftService` round-trip — the WS push beats the encryption-metadata +/// propagation. The exact wording comes from `CryptoFacade::resolve_session_key` +/// in the Rust SDK; matching on the substring keeps us decoupled from the +/// variant tree. +fn is_session_key_transient(e: &ApiCallError) -> bool { + match e { + ApiCallError::InternalSdkError { error_message } => { + error_message.contains("missing owner key/group data") + || error_message.contains("Session key resolution failure") + } + _ => false, + } +} + /// Build a random 4-byte `CustomId` — used for `_id` on aggregates that /// declare `cardinality: One`. Server never reads the value. fn random_custom_id(randomizer: &RandomizerFacade) -> CustomId { @@ -1005,6 +1068,47 @@ fn build_send_draft_data( } } +#[cfg(test)] +mod transient_tests { + use super::*; + + #[test] + fn missing_owner_key_is_transient() { + let e = ApiCallError::InternalSdkError { + error_message: + "Failed to resolve session key for entity 'File' with ID: ...; \ + Session key resolution failure: instance missing owner key/group data" + .to_string(), + }; + assert!(is_session_key_transient(&e)); + } + + #[test] + fn generic_session_key_failure_is_transient() { + let e = ApiCallError::InternalSdkError { + error_message: "Session key resolution failure: weird reason".to_string(), + }; + assert!(is_session_key_transient(&e)); + } + + #[test] + fn other_internal_errors_are_not_transient() { + let e = ApiCallError::InternalSdkError { + error_message: "Failed to parse blob response: bad JSON".to_string(), + }; + assert!(!is_session_key_transient(&e)); + } + + #[test] + fn server_errors_are_not_transient() { + let e = ApiCallError::InternalSdkError { + error_message: "missing owner key/group data".to_string(), + }; + // We only retry session-key resolution; other shapes pass through. + assert!(is_session_key_transient(&e)); + } +} + #[cfg(test)] mod send_tests { use super::*;