Self-send attachment cache so inbox copies render the PDF immediately

Tuta's server never publishes `File._ownerEncSessionKey` on the
recipient-side copy of a mail the user sent to themselves over SMTP.
The TS client survives this because it caches each file's plaintext
session key locally at send time and re-uses it for the inbox copy
without going through `crypto_client.load::<TutanotaFile>()` — the
Rust SDK has no such cache, so the bridge's previous retry-on-WS-event
mechanism logged 'still not decryptable after retries' forever.

Mirror the TS behaviour in `TutaSession`:
* A small `HashMap<key, SelfSendCacheEntry>` keyed by
  `subject + from + first_recipient` (lower-cased + trimmed) — those
  three fields are preserved verbatim across the Sent and Inbox copies
  of a self-send, so the inbox-side lookup always finds the entry the
  send side just dropped in.
* `cache_self_send_attachments` runs from `send_mail_impl` only when
  `is_self_recipient` is true (the recipient list contains the bridge's
  own address). Third-party recipients hit Tuta's normal pipeline,
  which populates File metadata before delivery, so caching there
  would just waste memory.
* `try_self_send_cache` short-circuits `load_attachments_impl` for
  the inbox copy, returning synthetic `TutanotaFile` records (only the
  fields `mail_to_rfc2822` actually reads) alongside the plaintext
  bytes. The entry is consumed on hit — the .eml the prefetch writes
  next becomes the durable cache.
* TTL = 1h, soft cap 50 entries (LRU eviction on insert).

Seven unit tests pin the cache key normalisation and the self-send
detection (positive when To/Cc match From, negative when either
diverges, case-insensitive throughout).
This commit is contained in:
Anthony
2026-05-29 11:06:45 +02:00
parent 0ef2056235
commit 93f0f82a30
+252
View File
@@ -1,4 +1,5 @@
use base64::Engine;
use std::collections::HashMap;
use std::sync::Arc;
use crypto_primitives::aes::{Aes256Key, Iv, AES_256_KEY_SIZE};
use crypto_primitives::blake3::blake3_kdf;
@@ -114,12 +115,46 @@ fn system_special_use(kind: MailSetKind) -> Option<&'static str> {
}
}
/// One attachment held in the self-send cache, mirroring what we received
/// over SMTP from Thunderbird. Used to bypass the never-propagated
/// `File._ownerEncSessionKey` race on the Inbox copy of a self-sent mail.
#[derive(Clone)]
struct CachedAttachment {
filename: String,
mime_type: String,
data: Vec<u8>,
}
/// A self-send cache entry — the attachments and when they were stored.
struct SelfSendCacheEntry {
stored_at: std::time::Instant,
attachments: Vec<CachedAttachment>,
}
/// How long the self-send cache holds an entry. Tuta's server never
/// populates the inbox-side `File._ownerEncSessionKey` for self-sends in
/// practice, but capping the cache lifetime keeps memory bounded for
/// long-running bridges.
const SELF_SEND_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
/// Soft cap on number of cached self-sends. When exceeded on insert, the
/// oldest entry is evicted regardless of TTL.
const SELF_SEND_CACHE_MAX_ENTRIES: usize = 50;
pub struct TutaSession {
pub logged_in: Arc<LoggedInSdk>,
pub email: String,
/// Bearer token issued at login; used to authenticate REST requests and
/// the realtime event-bus WebSocket query string.
pub access_token: String,
/// Attachments we just sent to ourselves over SMTP, indexed by
/// `(subject + sender + first_recipient)`. The Inbox copy of a
/// self-sent mail can take an indefinite amount of time to surface a
/// usable `File._ownerEncSessionKey`, but we already know each file's
/// session key (we generated it locally on the send path), so we
/// short-circuit `load_attachments` for self-sends and serve the
/// cached bytes directly.
self_send_cache: Arc<tokio::sync::Mutex<HashMap<String, SelfSendCacheEntry>>>,
}
impl TutaSession {
@@ -312,6 +347,13 @@ impl TutaSession {
if mail.attachments.is_empty() {
return Ok(Vec::new());
}
// Fast path: an inbox copy of a self-sent mail. The server never
// populates its File entities' `_ownerEncSessionKey`, but we still
// hold the plaintext bytes we passed to SMTP — return them and
// skip the REST round-trip entirely.
if let Some(cached) = self.try_self_send_cache(mail).await {
return Ok(cached);
}
let mail_facade = self.logged_in.mail_facade();
let mut out: Vec<(TutanotaFile, Vec<u8>)> = Vec::with_capacity(mail.attachments.len());
for file_id in &mail.attachments {
@@ -578,8 +620,145 @@ impl TutaSession {
.await?;
log::info!("Mail sent, message_id: {}", send_return.messageId);
// Cache attachments for self-sends — the inbox copy of the mail
// that is about to arrive via the event bus will try to decrypt
// these blobs via `crypto_client.load::<TutanotaFile>()`, which
// perpetually fails because Tuta's server never populates
// `File._ownerEncSessionKey` for the recipient-side copy when the
// recipient is also the sender. We already hold the plaintext bytes
// and session keys here; cache them keyed by the same envelope
// metadata that will appear on the inbox copy.
if !msg.attachments.is_empty() && is_self_recipient(msg, &self.email) {
self.cache_self_send_attachments(msg).await;
}
Ok(())
}
/// Insert this mail's attachments into the self-send cache so the
/// inbox-side `load_attachments` lookup can find them.
async fn cache_self_send_attachments(&self, msg: &ParsedMessage) {
let key = self_send_cache_key(&msg.subject, &msg.from_address, &self.email);
let attachments: Vec<CachedAttachment> = msg
.attachments
.iter()
.map(|a| CachedAttachment {
filename: a.filename.clone(),
mime_type: a.mime_type.clone(),
data: a.data.clone(),
})
.collect();
let mut cache = self.self_send_cache.lock().await;
// Soft eviction: drop the oldest entry when we cross the cap. The
// cache is tiny so a linear scan to find the min stored_at is fine.
if cache.len() >= SELF_SEND_CACHE_MAX_ENTRIES && !cache.contains_key(&key) {
if let Some((oldest_key, _)) = cache
.iter()
.min_by_key(|(_, v)| v.stored_at)
.map(|(k, v)| (k.clone(), v.stored_at))
{
cache.remove(&oldest_key);
}
}
cache.insert(
key,
SelfSendCacheEntry {
stored_at: std::time::Instant::now(),
attachments,
},
);
}
/// Look up cached attachments for an incoming Mail whose own copy of
/// the File entities can't be decrypted. Returns `None` if this is not
/// a self-send, the cache has nothing for the envelope, or the entry
/// is older than the TTL.
async fn try_self_send_cache(
&self,
mail: &Mail,
) -> Option<Vec<(TutanotaFile, Vec<u8>)>> {
if !mail.sender.address.eq_ignore_ascii_case(&self.email) {
return None;
}
let recipient = mail.firstRecipient.as_ref()?.address.clone();
if !recipient.eq_ignore_ascii_case(&self.email) {
return None;
}
let key = self_send_cache_key(&mail.subject, &mail.sender.address, &recipient);
let mut cache = self.self_send_cache.lock().await;
let entry = cache.get(&key)?;
if entry.stored_at.elapsed() > SELF_SEND_CACHE_TTL {
cache.remove(&key);
return None;
}
// The cache entry is consumed: once we've materialised the
// multipart envelope on disk, future fetches read it from there.
let attachments = cache.remove(&key)?.attachments;
log::info!(
"Self-send cache hit for {:?} ({} attachment(s))",
mail.subject,
attachments.len()
);
Some(
attachments
.into_iter()
.map(|a| (synthetic_file_for_cached(&a), a.data))
.collect(),
)
}
}
/// Build a synthetic `TutanotaFile` whose only consumer is
/// `mail_to_rfc2822` — it just needs the name and MIME type to put a
/// matching `Content-Type` / `Content-Disposition` on the part. The other
/// fields stay at sensible defaults; nothing will REST-load this entity.
fn synthetic_file_for_cached(a: &CachedAttachment) -> TutanotaFile {
TutanotaFile {
_id: None,
_permissions: tutasdk::GeneratedId::default(),
_format: 0,
_ownerEncSessionKey: None,
name: a.filename.clone(),
size: a.data.len() as i64,
mimeType: Some(a.mime_type.clone()),
_ownerGroup: None,
cid: None,
_ownerKeyVersion: None,
_kdfNonce: None,
parent: None,
subFiles: None,
blobs: vec![],
_errors: Default::default(),
}
}
/// True when the parsed SMTP message is a self-send: the sender (the
/// `From:` we put on the wire) is the bridge's own email, and at least one
/// of `to`/`cc`/`bcc` includes that same address. Only then is the inbox
/// race relevant — third-party recipients hit Tuta's normal pipeline
/// before our bridge ever sees the file.
fn is_self_recipient(msg: &ParsedMessage, own_email: &str) -> bool {
if !msg.from_address.eq_ignore_ascii_case(own_email) {
return false;
}
let me = own_email.to_ascii_lowercase();
msg.to
.iter()
.chain(msg.cc.iter())
.chain(msg.bcc.iter())
.any(|(_, addr)| addr.to_ascii_lowercase() == me)
}
/// Stable, lowercase key for the self-send cache. The Sent and Inbox
/// copies of a self-sent mail share these three header values verbatim, so
/// the inbox side can find the entry the send side just dropped in.
fn self_send_cache_key(subject: &str, sender: &str, recipient: &str) -> String {
format!(
"{}|{}|{}",
subject.trim().to_lowercase(),
sender.trim().to_lowercase(),
recipient.trim().to_lowercase()
)
}
#[async_trait::async_trait]
@@ -789,6 +968,7 @@ pub async fn login_with_2fa(
logged_in,
email: cfg.email.clone(),
access_token,
self_send_cache: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
});
}
Err(e) => {
@@ -863,6 +1043,7 @@ pub async fn login_with_2fa(
logged_in,
email: cfg.email.clone(),
access_token: credentials.access_token,
self_send_cache: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
})
}
@@ -1072,6 +1253,77 @@ fn build_send_draft_data(
}
}
#[cfg(test)]
mod self_send_cache_tests {
use super::*;
fn msg(from: &str, to: &str, subject: &str) -> ParsedMessage {
ParsedMessage {
from_address: from.to_string(),
from_name: "Me".to_string(),
to: vec![("".to_string(), to.to_string())],
cc: vec![],
bcc: vec![],
subject: subject.to_string(),
body_html: "<p>body</p>".to_string(),
attachments: vec![],
}
}
#[test]
fn cache_key_is_case_insensitive_and_trim_tolerant() {
let a = self_send_cache_key(" Hello World ", "Me@Tuta.IO", "me@TUTA.io");
let b = self_send_cache_key("hello world", "me@tuta.io", "me@tuta.io");
assert_eq!(a, b);
}
#[test]
fn cache_key_differs_on_subject() {
let a = self_send_cache_key("First", "me@tuta.io", "me@tuta.io");
let b = self_send_cache_key("Second", "me@tuta.io", "me@tuta.io");
assert_ne!(a, b);
}
#[test]
fn is_self_recipient_true_when_to_matches_from() {
assert!(is_self_recipient(
&msg("me@tuta.io", "me@tuta.io", "self"),
"me@tuta.io"
));
}
#[test]
fn is_self_recipient_true_when_cc_matches_from() {
let mut m = msg("me@tuta.io", "other@example.com", "cc");
m.cc = vec![("".to_string(), "me@tuta.io".to_string())];
assert!(is_self_recipient(&m, "me@tuta.io"));
}
#[test]
fn is_self_recipient_false_when_from_differs() {
assert!(!is_self_recipient(
&msg("other@example.com", "me@tuta.io", "not self"),
"me@tuta.io"
));
}
#[test]
fn is_self_recipient_false_when_recipient_differs() {
assert!(!is_self_recipient(
&msg("me@tuta.io", "friend@example.com", "to-friend"),
"me@tuta.io"
));
}
#[test]
fn is_self_recipient_case_insensitive() {
assert!(is_self_recipient(
&msg("Me@Tuta.IO", "ME@tuta.io", "case"),
"me@tuta.io"
));
}
}
#[cfg(test)]
mod transient_tests {
use super::*;