From cab8e0be0c6abb757c6b288c19826d92e39f1210 Mon Sep 17 00:00:00 2001 From: Anthony Date: Thu, 28 May 2026 16:52:27 +0200 Subject: [PATCH] Surface mail attachments over IMAP as multipart/mixed parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the prefetch loop loads a mail's body, also load every entity in `mail.attachments`, decrypt the blob data with the new SDK helper and embed each attachment as a base64 part of a `multipart/mixed` RFC 2822 message. Thunderbird now renders PDFs / images / etc. inline rather than showing a body-only mail with no hint anything was attached. * `mail_to_rfc2822` now takes a slice of (TutanotaFile, &[u8]) and emits a multi-part envelope when it's non-empty; the simple text/html case is unchanged. The boundary is derived from the mail's IdTuple so the cached .eml.enc bytes stay stable across rewrites. * `MailBackend::load_attachments` is the new trait method; the TutaSession implementation loads each File via `crypto_client.load` (auto-decrypted via the file's own `_ownerEncSessionKey`) then asks the new `MailFacade::load_file_attachment_data` for the concatenated decrypted bytes. * `prefetch_details` does a best-effort fetch — partial failure logs a warning and ships the body alone, on the assumption the user can re-open later and the next sweep will retry. A new unit test asserts the multipart structure (boundary, body part, attachment part with name/MIME/filename, closing boundary). --- crates/bridge/src/event_handler.rs | 2 +- crates/bridge/src/imap/session.rs | 15 ++- crates/bridge/src/mail/rfc2822.rs | 206 +++++++++++++++++++++++++++-- crates/bridge/src/sync.rs | 34 ++++- crates/bridge/src/tuta.rs | 36 ++++- tuta-repo | 2 +- 6 files changed, 271 insertions(+), 24 deletions(-) diff --git a/crates/bridge/src/event_handler.rs b/crates/bridge/src/event_handler.rs index 4110ce3..5e4ea8c 100644 --- a/crates/bridge/src/event_handler.rs +++ b/crates/bridge/src/event_handler.rs @@ -333,7 +333,7 @@ async fn apply_mail_set_entry_create( ); let rfc2822 = details .as_ref() - .map(|d| crate::mail::mail_to_rfc2822(&mail, Some(d))); + .map(|d| crate::mail::mail_to_rfc2822(&mail, Some(d), &[])); let mut stored = StoredMail { mail, details: details.clone(), diff --git a/crates/bridge/src/imap/session.rs b/crates/bridge/src/imap/session.rs index f8e414b..dfd770b 100644 --- a/crates/bridge/src/imap/session.rs +++ b/crates/bridge/src/imap/session.rs @@ -392,6 +392,7 @@ impl ImapSession { let rfc = mail_to_rfc2822( &self.mails[idx].mail, self.mails[idx].details.as_ref(), + &[], ); self.mails[idx].rfc2822 = Some(rfc); } else if self.mails[idx].rfc2822.is_none() { @@ -660,7 +661,7 @@ impl ImapSession { let details = sm.details.or(old_details); let rfc2822 = sm.rfc2822.or(old_rfc).unwrap_or_else(|| { - mail_to_rfc2822(&sm.mail, details.as_ref()) + mail_to_rfc2822(&sm.mail, details.as_ref(), &[]) }); self.mails.push(CachedMail { @@ -1485,6 +1486,14 @@ mod tests { let key = mail._id.as_ref().map(|id| id.element_id.to_string()).unwrap_or_default(); Ok(self.details.lock().unwrap().get(&key).cloned()) } + async fn load_attachments( + &self, + _mail: &Mail, + ) -> Result)>, String> { + // The mock has no attachment fixtures; the IMAP-session tests + // only exercise body/header paths. + Ok(Vec::new()) + } async fn list_folders(&self) -> Result, String> { Ok(vec![inbox_folder()]) } @@ -1603,8 +1612,8 @@ mod tests { let backend = Arc::new(MockBackend::with_mails(vec![m1.clone(), m2.clone()])); let store = MailStore::new(); - let rfc1 = crate::mail::mail_to_rfc2822(&m1, Some(&d1)); - let rfc2 = crate::mail::mail_to_rfc2822(&m2, Some(&d2)); + let rfc1 = crate::mail::mail_to_rfc2822(&m1, Some(&d1), &[]); + let rfc2 = crate::mail::mail_to_rfc2822(&m2, Some(&d2), &[]); store.set_folder_list(vec![inbox_folder()]).await; store.set_folder("inbox", vec![ StoredMail { mail: m1, details: Some(d1), rfc2822: Some(rfc1), uid: 1 }, diff --git a/crates/bridge/src/mail/rfc2822.rs b/crates/bridge/src/mail/rfc2822.rs index f819495..a26fb6f 100644 --- a/crates/bridge/src/mail/rfc2822.rs +++ b/crates/bridge/src/mail/rfc2822.rs @@ -1,7 +1,16 @@ use base64::Engine; -use tutasdk::entities::generated::tutanota::{Mail, MailAddress, MailDetails}; +use tutasdk::entities::generated::tutanota::{Mail, MailAddress, MailDetails, TutanotaFile}; -pub fn mail_to_rfc2822(mail: &Mail, details: Option<&MailDetails>) -> String { +/// One decrypted attachment as it lands in the RFC 2822 we serve over IMAP: +/// the [`TutanotaFile`] entity (for name + MIME type + cid) and the raw +/// decrypted bytes (for the body of the part). +pub type AttachmentPart<'a> = (&'a TutanotaFile, &'a [u8]); + +pub fn mail_to_rfc2822( + mail: &Mail, + details: Option<&MailDetails>, + attachments: &[AttachmentPart<'_>], +) -> String { let mut msg = String::with_capacity(4096); let date_str = format_rfc2822_date(mail.receivedDate.as_millis()); @@ -38,10 +47,6 @@ pub fn mail_to_rfc2822(mail: &Mail, details: Option<&MailDetails>) -> String { msg.push_str(&format!("To: {}\r\n", format_address(first))); } - msg.push_str("MIME-Version: 1.0\r\n"); - msg.push_str("Content-Type: text/html; charset=UTF-8\r\n"); - msg.push_str("Content-Transfer-Encoding: base64\r\n"); - if let Some(ref id) = mail._id { msg.push_str(&format!( "Message-ID: <{}.{}@tutabridge.local>\r\n", @@ -49,19 +54,76 @@ pub fn mail_to_rfc2822(mail: &Mail, details: Option<&MailDetails>) -> String { )); } - msg.push_str("\r\n"); + msg.push_str("MIME-Version: 1.0\r\n"); let body_text = details .and_then(|d| d.body.compressedText.as_deref().or(d.body.text.as_deref())) .unwrap_or("

(No body available)

"); - let encoded = base64_encode_body(body_text.as_bytes()); - msg.push_str(&encoded); - msg.push_str("\r\n"); + if attachments.is_empty() { + msg.push_str("Content-Type: text/html; charset=UTF-8\r\n"); + msg.push_str("Content-Transfer-Encoding: base64\r\n"); + msg.push_str("\r\n"); + msg.push_str(&base64_encode_body(body_text.as_bytes())); + msg.push_str("\r\n"); + } else { + // The boundary is derived from the mail's element id so the same + // mail always produces the same MIME boundary — keeps `.eml.enc` + // bytes stable across rewrites. + let boundary = build_boundary(mail); + msg.push_str(&format!( + "Content-Type: multipart/mixed; boundary=\"{}\"\r\n", + boundary + )); + msg.push_str("\r\n"); + msg.push_str("This is a multi-part message in MIME format.\r\n"); + + msg.push_str(&format!("--{}\r\n", boundary)); + msg.push_str("Content-Type: text/html; charset=UTF-8\r\n"); + msg.push_str("Content-Transfer-Encoding: base64\r\n\r\n"); + msg.push_str(&base64_encode_body(body_text.as_bytes())); + msg.push_str("\r\n"); + + for (file, data) in attachments { + msg.push_str(&format!("--{}\r\n", boundary)); + let mime = file.mimeType.as_deref().unwrap_or("application/octet-stream"); + let name_encoded = encode_header_value(&file.name); + msg.push_str(&format!( + "Content-Type: {}; name=\"{}\"\r\n", + mime, name_encoded + )); + msg.push_str("Content-Transfer-Encoding: base64\r\n"); + msg.push_str(&format!( + "Content-Disposition: attachment; filename=\"{}\"\r\n", + name_encoded + )); + if let Some(ref cid) = file.cid { + // Some Tuta files (inline images) carry a Content-ID — propagate + // it so HTML `` references still resolve. + msg.push_str(&format!("Content-ID: <{}>\r\n", cid)); + } + msg.push_str("\r\n"); + msg.push_str(&base64_encode_body(data)); + msg.push_str("\r\n"); + } + msg.push_str(&format!("--{}--\r\n", boundary)); + } msg } +/// Build a MIME boundary that is stable for a given mail and unlikely to +/// collide with payload bytes. Format: `=_TutaBridge__` where the +/// ids are the mail's `IdTuple` — they contain only base64-ext characters +/// (so safe in a Content-Type header) and uniquely identify the mail. +fn build_boundary(mail: &Mail) -> String { + if let Some(ref id) = mail._id { + format!("=_TutaBridge_{}_{}", id.list_id, id.element_id) + } else { + "=_TutaBridge_unknown".to_owned() + } +} + pub(crate) fn format_address(addr: &MailAddress) -> String { if addr.name.is_empty() { addr.address.clone() @@ -373,7 +435,7 @@ mod tests { _errors: Default::default(), }; - let rfc = mail_to_rfc2822(&mail, None); + let rfc = mail_to_rfc2822(&mail, None, &[]); assert!(rfc.contains("Date: Wed, 25 Dec 2024 12:37:25 +0000\r\n")); assert!(rfc.contains("From: Alice \r\n")); @@ -484,7 +546,7 @@ mod tests { }, }; - let rfc = mail_to_rfc2822(&mail, Some(&details)); + let rfc = mail_to_rfc2822(&mail, Some(&details), &[]); assert!(rfc.contains("From: sender@tuta.com\r\n")); assert!(rfc.contains("To: Bob , charlie@example.com\r\n")); @@ -494,4 +556,124 @@ mod tests { base64::engine::general_purpose::STANDARD.encode(b"

Hello World

"); assert!(rfc.contains(&body_b64)); } + + #[test] + fn test_mail_to_rfc2822_with_attachment_emits_multipart() { + use tutasdk::date::DateTime; + use tutasdk::entities::generated::tutanota::{Body, Recipients, TutanotaFile}; + use tutasdk::IdTupleGenerated; + + let mail = Mail { + _id: Some(IdTupleGenerated::new( + test_id("list_att"), + test_id("elem_att"), + )), + _permissions: test_id("perm_att"), + _format: 0, + _ownerEncSessionKey: None, + subject: "With Attachment".to_string(), + receivedDate: DateTime::from_millis(0), + state: 2, + unread: false, + confidential: false, + replyType: 0, + _ownerGroup: None, + differentEnvelopeSender: None, + listUnsubscribe: false, + movedTime: None, + phishingStatus: 0, + authStatus: None, + method: 0, + recipientCount: 1, + encryptionAuthStatus: None, + _ownerKeyVersion: None, + processingState: 0, + processNeeded: false, + sendAt: None, + serverClassificationData: None, + _kdfNonce: None, + sender: MailAddress { + _id: None, + name: "Alice".to_string(), + address: "alice@tuta.com".to_string(), + contact: None, + _errors: Default::default(), + }, + attachments: vec![], + conversationEntry: IdTupleGenerated::new( + test_id("conv_l"), + test_id("conv_e"), + ), + firstRecipient: Some(MailAddress { + _id: None, + name: "".to_string(), + address: "bob@example.com".to_string(), + contact: None, + _errors: Default::default(), + }), + mailDetails: None, + mailDetailsDraft: None, + bucketKey: None, + sets: vec![], + clientSpamClassifierResult: None, + _errors: Default::default(), + }; + let details = MailDetails { + _id: None, + sentDate: DateTime::from_millis(0), + authStatus: 0, + replyTos: vec![], + recipients: Recipients { + _id: None, + toRecipients: vec![], + ccRecipients: vec![], + bccRecipients: vec![], + }, + headers: None, + body: Body { + _id: None, + text: Some("

The body

".to_string()), + compressedText: None, + _errors: Default::default(), + }, + }; + let file = TutanotaFile { + _id: Some(IdTupleGenerated::new( + test_id("file_list"), + test_id("file_elem"), + )), + _permissions: test_id("file_perm"), + _format: 0, + _ownerEncSessionKey: None, + name: "doc.pdf".to_string(), + size: 5, + mimeType: Some("application/pdf".to_string()), + _ownerGroup: None, + cid: None, + _ownerKeyVersion: None, + _kdfNonce: None, + parent: None, + subFiles: None, + blobs: vec![], + _errors: Default::default(), + }; + let data: &[u8] = b"PDFDA"; + let attachments: Vec = vec![(&file, data)]; + let rfc = mail_to_rfc2822(&mail, Some(&details), &attachments); + + assert!(rfc.contains("Content-Type: multipart/mixed; boundary=\"=_TutaBridge_list_att_elem_att\"")); + assert!(rfc.contains("--=_TutaBridge_list_att_elem_att\r\n")); + // Body part: text/html base64 + assert!(rfc.contains("Content-Type: text/html; charset=UTF-8\r\n")); + let body_b64 = + base64::engine::general_purpose::STANDARD.encode(b"

The body

"); + assert!(rfc.contains(&body_b64)); + // Attachment part + assert!(rfc.contains("Content-Type: application/pdf; name=\"doc.pdf\"")); + assert!(rfc.contains("Content-Disposition: attachment; filename=\"doc.pdf\"")); + let pdf_b64 = base64::engine::general_purpose::STANDARD.encode(data); + assert!(rfc.contains(&pdf_b64)); + // Closing boundary + assert!(rfc.ends_with("--=_TutaBridge_list_att_elem_att--\r\n")); + } } diff --git a/crates/bridge/src/sync.rs b/crates/bridge/src/sync.rs index 7a20f53..f16df78 100644 --- a/crates/bridge/src/sync.rs +++ b/crates/bridge/src/sync.rs @@ -4,7 +4,7 @@ use std::time::Duration; use log::{debug, info, warn}; use tokio::sync::{watch, RwLock}; -use tutasdk::entities::generated::tutanota::{Mail, MailDetails}; +use tutasdk::entities::generated::tutanota::{Mail, MailDetails, TutanotaFile}; use crate::mail::mail_to_rfc2822; use crate::store::{LocalStore, MailMetadata}; @@ -453,10 +453,10 @@ async fn load_cached_folder( } Some(eml) }, - Ok(None) => Some(mail_to_rfc2822(&mail, None)), + Ok(None) => Some(mail_to_rfc2822(&mail, None, &[])), Err(e) => { warn!("Failed to read cached eml {}: {e}", meta.element_id); - Some(mail_to_rfc2822(&mail, None)) + Some(mail_to_rfc2822(&mail, None, &[])) }, }; @@ -529,7 +529,7 @@ pub(crate) async fn sync_folder( uid, }); } else { - let rfc2822 = mail_to_rfc2822(mail, None); + let rfc2822 = mail_to_rfc2822(mail, None, &[]); updated.push(StoredMail { mail: mail.clone(), details: None, @@ -611,7 +611,29 @@ async fn prefetch_details( let result = retry(|| backend.load_mail_details(mail)).await; match result { Ok(Some(details)) => { - let rfc2822 = mail_to_rfc2822(mail, Some(&details)); + // Best-effort attachment fetch: if any blob fails to load we + // still ship the body — the user can re-open the mail later + // and the prefetch sweep will retry. Empty `mail.attachments` + // short-circuits inside the backend so this is free for the + // overwhelmingly common "no attachments" case. + let attachments_owned = match backend.load_attachments(mail).await { + Ok(atts) => atts, + Err(e) => { + warn!( + "Failed to load attachments for {}: {e}", + mail._id + .as_ref() + .map(|id| id.element_id.to_string()) + .unwrap_or_default() + ); + Vec::new() + } + }; + let attachment_refs: Vec<(&TutanotaFile, &[u8])> = attachments_owned + .iter() + .map(|(f, d)| (f, d.as_slice())) + .collect(); + let rfc2822 = mail_to_rfc2822(mail, Some(&details), &attachment_refs); if let Some(id) = mail._id.as_ref() { let eid = id.element_id.to_string(); @@ -632,7 +654,7 @@ async fn prefetch_details( // `MailDetailsDraft` (legacy / malformed). Persist a // headers-only `.eml` and mark the row done so we don't // re-attempt every prefetch sweep. - let rfc2822 = mail_to_rfc2822(mail, None); + let rfc2822 = mail_to_rfc2822(mail, None, &[]); if let Some(id) = mail._id.as_ref() { let eid = id.element_id.to_string(); if let Err(e) = local_store.write_eml(&eid, &rfc2822) { diff --git a/crates/bridge/src/tuta.rs b/crates/bridge/src/tuta.rs index c6ac1d2..5f45cb4 100644 --- a/crates/bridge/src/tuta.rs +++ b/crates/bridge/src/tuta.rs @@ -9,7 +9,7 @@ use tutasdk::bindings::rest_client::RestClient; use tutasdk::crypto_entity_client::CryptoEntityClient; use tutasdk::entities::generated::tutanota::{ DraftCreateData, DraftData, DraftRecipient, Mail, MailBox, MailDetails, MailDetailsBlob, - MailSetEntry, SendDraftData, SendDraftParameters, + MailSetEntry, SendDraftData, SendDraftParameters, TutanotaFile, }; use tutasdk::folder_system::{FolderSystem, MailSetKind}; use tutasdk::services::generated::tutanota::{DraftService, SendDraftService}; @@ -67,6 +67,15 @@ pub trait MailBackend: Send + Sync { json: &str, ) -> Result, String>; async fn load_mail_details(&self, mail: &Mail) -> Result, String>; + /// Load and decrypt every attachment of `mail`. Returns a vector of + /// `(file metadata, decrypted bytes)` in the order they appear in + /// `mail.attachments`. Empty if the mail has no attachments. Errors are + /// per-mail (no partial returns): if any one attachment fails the whole + /// call returns `Err` so the caller can decide to retry the prefetch. + async fn load_attachments( + &self, + mail: &Mail, + ) -> Result)>, String>; /// Enumerate all mail folders (system + custom, with hierarchy). async fn list_folders(&self) -> Result, String>; async fn set_unread_status(&self, mail_ids: Vec, unread: bool) -> Result<(), String>; @@ -292,6 +301,24 @@ impl TutaSession { } } + async fn load_attachments_impl( + &self, + mail: &Mail, + ) -> Result)>, ApiCallError> { + 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 data = mail_facade.load_file_attachment_data(&file).await?; + out.push((file, data)); + } + Ok(out) + } + async fn send_mail_impl(&self, msg: &ParsedMessage) -> Result<(), ApiCallError> { let randomizer = RandomizerFacade::from_core(rand_core::OsRng); let session_key: GenericAesKey = Aes256Key::generate(&randomizer).into(); @@ -403,6 +430,13 @@ impl MailBackend for TutaSession { .map_err(|e| format!("{e}")) } + async fn load_attachments( + &self, + mail: &Mail, + ) -> Result)>, String> { + self.load_attachments_impl(mail).await.map_err(|e| format!("{e}")) + } + async fn list_folders(&self) -> Result, String> { let mailbox = self.load_mailbox().await.map_err(|e| format!("{e}"))?; let folder_system = self.load_folders(&mailbox).await.map_err(|e| format!("{e}"))?; diff --git a/tuta-repo b/tuta-repo index 4217031..00266f3 160000 --- a/tuta-repo +++ b/tuta-repo @@ -1 +1 @@ -Subproject commit 42170313a7c40ec35a221a64597ed3f9075f0518 +Subproject commit 00266f32d4b8dc96ee843fff320829f25bb2acd2