Wire inline decrypt into the event handler — Mail UPDATEs go REST-free

Two new MailBackend methods, `decrypt_inline_mail` and
`decrypt_inline_mail_set_entry`, delegate to the SDK's
`CryptoEntityClient::decrypt_inline_and_parse<T>`. The event handler now
takes the inline path first, and only falls back to `load_mail` if the
payload was missing or its session key was unresolvable:

- Mail UPDATE: a new `resolve_mail` helper centralises the
  decrypt-then-fallback policy. Most UPDATE events (read/unread, label
  moves, …) now do zero REST calls, since the encrypted Mail rides
  inside `event.instance`.
- MailSetEntry CREATE miss path: decode `event.instance` of the
  MailSetEntry inline, read its `mail: IdTupleGenerated` field, then
  `load_mail` it with the correct list_id. Drops the previous
  `mail_list_id_cache` sniffing hack on MailStore (and its test) —
  the inline payload always carries the correct list_id, no need to
  guess from any cached Mail.

`resolve_mail_set_entry` factors the same try-inline-first pattern for
MailSetEntry events. Both helpers fall back to a `sync_folder` if every
path fails, preserving the no-silent-miss guarantee from Phase 2.

`MockBackend` returns `Ok(None)` from both new methods by default so
existing handler tests keep their REST behaviour; the live SDK fixture
test (`tests/decrypt_inline_test.rs` on the SDK side) covers the real
decryption.

165 bridge lib tests pass.
This commit is contained in:
Anthony
2026-05-28 15:05:54 +02:00
parent 0bd8dc4110
commit 922738fd1e
4 changed files with 144 additions and 76 deletions
+105 -32
View File
@@ -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<tutasdk::IdTupleGenerated> {
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<tutasdk::entities::generated::tutanota::Mail> {
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(
+11
View File
@@ -1460,6 +1460,17 @@ mod tests {
})
.cloned())
}
async fn decrypt_inline_mail(&self, _json: &str) -> Result<Option<Mail>, 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<Option<tutasdk::entities::generated::tutanota::MailSetEntry>, String> {
Ok(None)
}
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, 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())
-44
View File
@@ -32,11 +32,6 @@ pub struct MailStore {
folder_list: RwLock<Vec<FolderInfo>>,
generation: watch::Sender<u64>,
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<Option<String>>,
}
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<String> {
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() {
+28
View File
@@ -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<Option<Mail>, 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<Option<Mail>, 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<Option<MailSetEntry>, String>;
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, String>;
/// Enumerate all mail folders (system + custom, with hierarchy).
async fn list_folders(&self) -> Result<Vec<FolderInfo>, String>;
@@ -333,6 +344,23 @@ impl MailBackend for TutaSession {
.map_err(|e| format!("{e}"))
}
async fn decrypt_inline_mail(&self, json: &str) -> Result<Option<Mail>, String> {
self.crypto_client()
.decrypt_inline_and_parse::<Mail>(json)
.await
.map_err(|e| format!("{e}"))
}
async fn decrypt_inline_mail_set_entry(
&self,
json: &str,
) -> Result<Option<MailSetEntry>, String> {
self.crypto_client()
.decrypt_inline_and_parse::<MailSetEntry>(json)
.await
.map_err(|e| format!("{e}"))
}
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, String> {
self.load_mail_details_impl(mail)
.await