mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
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:
@@ -1,4 +1,5 @@
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use crypto_primitives::aes::{Aes256Key, Iv, AES_256_KEY_SIZE};
|
use crypto_primitives::aes::{Aes256Key, Iv, AES_256_KEY_SIZE};
|
||||||
use crypto_primitives::blake3::blake3_kdf;
|
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 struct TutaSession {
|
||||||
pub logged_in: Arc<LoggedInSdk>,
|
pub logged_in: Arc<LoggedInSdk>,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
/// Bearer token issued at login; used to authenticate REST requests and
|
/// Bearer token issued at login; used to authenticate REST requests and
|
||||||
/// the realtime event-bus WebSocket query string.
|
/// the realtime event-bus WebSocket query string.
|
||||||
pub access_token: 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 {
|
impl TutaSession {
|
||||||
@@ -312,6 +347,13 @@ impl TutaSession {
|
|||||||
if mail.attachments.is_empty() {
|
if mail.attachments.is_empty() {
|
||||||
return Ok(Vec::new());
|
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 mail_facade = self.logged_in.mail_facade();
|
||||||
let mut out: Vec<(TutanotaFile, Vec<u8>)> = Vec::with_capacity(mail.attachments.len());
|
let mut out: Vec<(TutanotaFile, Vec<u8>)> = Vec::with_capacity(mail.attachments.len());
|
||||||
for file_id in &mail.attachments {
|
for file_id in &mail.attachments {
|
||||||
@@ -578,8 +620,145 @@ impl TutaSession {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
log::info!("Mail sent, message_id: {}", send_return.messageId);
|
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(())
|
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]
|
#[async_trait::async_trait]
|
||||||
@@ -789,6 +968,7 @@ pub async fn login_with_2fa(
|
|||||||
logged_in,
|
logged_in,
|
||||||
email: cfg.email.clone(),
|
email: cfg.email.clone(),
|
||||||
access_token,
|
access_token,
|
||||||
|
self_send_cache: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -863,6 +1043,7 @@ pub async fn login_with_2fa(
|
|||||||
logged_in,
|
logged_in,
|
||||||
email: cfg.email.clone(),
|
email: cfg.email.clone(),
|
||||||
access_token: credentials.access_token,
|
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)]
|
#[cfg(test)]
|
||||||
mod transient_tests {
|
mod transient_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user