Surface mail attachments over IMAP as multipart/mixed parts

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).
This commit is contained in:
Anthony
2026-05-28 16:52:27 +02:00
parent 56bd92787b
commit cab8e0be0c
6 changed files with 271 additions and 24 deletions
+1 -1
View File
@@ -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(),
+12 -3
View File
@@ -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<Vec<(tutasdk::entities::generated::tutanota::TutanotaFile, Vec<u8>)>, 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<Vec<FolderInfo>, 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 },
+194 -12
View File
@@ -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("<p>(No body available)</p>");
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 `<img src="cid:…">` 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_<list>_<elem>` 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 <alice@tuta.com>\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 <bob@example.com>, charlie@example.com\r\n"));
@@ -494,4 +556,124 @@ mod tests {
base64::engine::general_purpose::STANDARD.encode(b"<p>Hello World</p>");
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("<p>The body</p>".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<super::AttachmentPart> = 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"<p>The body</p>");
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"));
}
}
+28 -6
View File
@@ -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) {
+35 -1
View File
@@ -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<Option<MailDetails>, String>;
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, 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<Vec<(TutanotaFile, Vec<u8>)>, String>;
/// Enumerate all mail folders (system + custom, with hierarchy).
async fn list_folders(&self) -> Result<Vec<FolderInfo>, String>;
async fn set_unread_status(&self, mail_ids: Vec<IdTupleGenerated>, unread: bool) -> Result<(), String>;
@@ -292,6 +301,24 @@ impl TutaSession {
}
}
async fn load_attachments_impl(
&self,
mail: &Mail,
) -> Result<Vec<(TutanotaFile, Vec<u8>)>, 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<u8>)> = 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<Vec<(TutanotaFile, Vec<u8>)>, String> {
self.load_attachments_impl(mail).await.map_err(|e| format!("{e}"))
}
async fn list_folders(&self) -> Result<Vec<FolderInfo>, 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}"))?;