mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
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:
@@ -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(),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
+1
-1
Submodule tuta-repo updated: 42170313a7...00266f32d4
Reference in New Issue
Block a user