Send attachments from Thunderbird through to Tuta

Wire the SMTP send path so that when Thunderbird hands the bridge a
`multipart/mixed` message its non-text parts are forwarded as real
Tuta attachments rather than dropped.

Parser side (mail/parser.rs)
* `ParsedMessage` grows an `attachments: Vec<Attachment>` field.
* `extract_multipart_body_and_attachments` walks every part, treats
  anything carrying `Content-Disposition: attachment` or a
  `name=` parameter (and not a text/* type) as a file, decodes
  base64 / quoted-printable, picks up the filename from
  Content-Disposition first then Content-Type's `name=`.

Send side (tuta.rs::send_mail_impl)
* `build_added_attachments` generates a per-file session key, uses
  the existing `BlobFacade::encrypt_and_upload_multiple` to ship the
  encrypted blob bytes, then assembles a `DraftAttachment` aggregate
  with the random aggregate `_id`s the instance mapper requires.
* After `DraftService` persists the draft, `build_attachment_key_data`
  reloads the resulting Mail, zips its `attachments[]` IdTuples with
  the session keys we kept locally, and produces
  `AttachmentKeyData[]` for both the top-level `SendDraftData` and
  its nested `parameters` aggregate (server reads from the latter).
* Empty-attachments path is a no-op, matching the previous behaviour.

Three new parser unit tests pin down the multipart-with-PDF happy
path, the filename-fallback to Content-Type `name=`, and that
`multipart/alternative` text parts are not misclassified as files.
This commit is contained in:
Anthony
2026-05-28 18:24:56 +02:00
parent c4ecc82daf
commit 299804459a
3 changed files with 311 additions and 27 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ pub(crate) mod rfc2822;
pub(crate) mod parser;
pub use rfc2822::mail_to_rfc2822;
pub use parser::ParsedMessage;
pub use parser::{Attachment, ParsedMessage};
+128 -13
View File
@@ -1,5 +1,14 @@
use base64::Engine;
/// One parsed file attachment from an incoming RFC 2822 message — the
/// minimum the bridge needs to forward it to Tuta as a `DraftAttachment`.
#[derive(Debug, Clone)]
pub struct Attachment {
pub filename: String,
pub mime_type: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ParsedMessage {
@@ -10,6 +19,7 @@ pub struct ParsedMessage {
pub bcc: Vec<(String, String)>,
pub subject: String,
pub body_html: String,
pub attachments: Vec<Attachment>,
}
pub fn parse_rfc2822(raw: &str) -> ParsedMessage {
@@ -38,10 +48,13 @@ pub fn parse_rfc2822(raw: &str) -> ParsedMessage {
.unwrap_or_default()
.to_lowercase();
let body_html = if content_type.to_lowercase().contains("multipart/") {
extract_multipart_body(&body_section, &content_type)
let (body_html, attachments) = if content_type.to_lowercase().contains("multipart/") {
extract_multipart_body_and_attachments(&body_section, &content_type)
} else {
decode_body(&body_section, &content_transfer_encoding, &content_type.to_lowercase())
(
decode_body(&body_section, &content_transfer_encoding, &content_type.to_lowercase()),
Vec::new(),
)
};
ParsedMessage {
@@ -52,6 +65,7 @@ pub fn parse_rfc2822(raw: &str) -> ParsedMessage {
bcc,
subject,
body_html,
attachments,
}
}
@@ -223,15 +237,23 @@ fn extract_boundary(content_type: &str) -> Option<String> {
None
}
fn extract_multipart_body(body: &str, content_type: &str) -> String {
/// Walk a multipart MIME body, picking up:
/// * the user-facing HTML (or plain) body (first non-attachment text part);
/// * every part that looks like a file attachment (non-text, or a part
/// with `Content-Disposition: attachment` / a `name=` in its Content-Type).
fn extract_multipart_body_and_attachments(
body: &str,
content_type: &str,
) -> (String, Vec<Attachment>) {
let boundary = match extract_boundary(content_type) {
Some(b) => b,
None => return body.to_string(),
None => return (body.to_string(), Vec::new()),
};
let parts = split_mime_parts(body, &boundary);
let mut html_part = None;
let mut text_part = None;
let mut attachments: Vec<Attachment> = Vec::new();
for part in &parts {
let (part_headers_str, part_body) = split_headers_body(part);
@@ -240,23 +262,80 @@ fn extract_multipart_body(body: &str, content_type: &str) -> String {
let part_cte = get_header(&part_headers, "content-transfer-encoding")
.unwrap_or_default()
.to_lowercase();
let part_cd = get_header(&part_headers, "content-disposition").unwrap_or_default();
let part_ct_lower = part_ct.to_lowercase();
let part_cd_lower = part_cd.to_lowercase();
let is_attachment = part_cd_lower.contains("attachment")
|| (extract_param(&part_ct, "name").is_some()
&& !part_ct_lower.contains("text/"));
if part_ct_lower.contains("multipart/") {
let nested = extract_multipart_body(&part_body, &part_ct);
if !nested.is_empty() {
return nested;
let (nested_body, nested_atts) =
extract_multipart_body_and_attachments(&part_body, &part_ct);
if html_part.is_none() && !nested_body.is_empty() {
html_part = Some(nested_body);
}
} else if part_ct_lower.contains("text/html") {
attachments.extend(nested_atts);
} else if is_attachment {
let data = match part_cte.as_str() {
cte if cte.contains("base64") => {
let clean: String = part_body.chars().filter(|c| !c.is_whitespace()).collect();
base64::engine::general_purpose::STANDARD
.decode(&clean)
.unwrap_or_default()
},
cte if cte.contains("quoted-printable") => {
decode_quoted_printable(&part_body).into_bytes()
},
_ => part_body.as_bytes().to_vec(),
};
let filename = extract_param(&part_cd, "filename")
.or_else(|| extract_param(&part_ct, "name"))
.unwrap_or_else(|| "attachment.bin".to_owned());
let filename = decode_header_value(&filename);
let mime_type = part_ct
.split(';')
.next()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "application/octet-stream".to_owned());
attachments.push(Attachment {
filename,
mime_type,
data,
});
} else if part_ct_lower.contains("text/html") && html_part.is_none() {
html_part = Some(decode_body(&part_body, &part_cte, &part_ct_lower));
} else if part_ct_lower.contains("text/plain") && html_part.is_none() {
} else if part_ct_lower.contains("text/plain") && html_part.is_none() && text_part.is_none() {
text_part = Some(decode_body(&part_body, &part_cte, &part_ct_lower));
}
}
html_part
.or(text_part)
.unwrap_or_else(|| body.to_string())
let body = html_part.or(text_part).unwrap_or_else(|| body.to_string());
(body, attachments)
}
/// Pull a `key=value` parameter out of a header value such as a Content-Type
/// (`text/plain; charset="UTF-8"; name="doc.pdf"`). Handles both quoted and
/// unquoted forms; returns `None` if the parameter is absent.
fn extract_param(header: &str, key: &str) -> Option<String> {
let lower = header.to_lowercase();
let needle = format!("{}=", key.to_lowercase());
let pos = lower.find(&needle)?;
let rest = &header[pos + needle.len()..];
let value = if rest.starts_with('"') {
rest[1..].split('"').next().unwrap_or("")
} else {
rest.split(|c: char| c == ';' || c.is_whitespace())
.next()
.unwrap_or("")
};
if value.is_empty() {
None
} else {
Some(value.to_owned())
}
}
fn split_mime_parts(body: &str, boundary: &str) -> Vec<String> {
@@ -470,6 +549,42 @@ mod tests {
);
}
#[test]
fn test_multipart_with_attachment_extracts_both_body_and_file() {
let body = b"this is a fake pdf";
let body_b64 = base64::engine::general_purpose::STANDARD.encode(body);
let raw = format!(
"From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: multipart/mixed; boundary=\"xx\"\r\n\r\n--xx\r\nContent-Type: text/html\r\n\r\n<p>HTML body</p>\r\n--xx\r\nContent-Type: application/pdf; name=\"doc.pdf\"\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=\"doc.pdf\"\r\n\r\n{}\r\n--xx--",
body_b64
);
let msg = parse_rfc2822(&raw);
assert!(msg.body_html.contains("HTML body"));
assert_eq!(msg.attachments.len(), 1);
let att = &msg.attachments[0];
assert_eq!(att.filename, "doc.pdf");
assert_eq!(att.mime_type, "application/pdf");
assert_eq!(att.data, body);
}
#[test]
fn test_multipart_attachment_without_filename_uses_content_type_name() {
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: multipart/mixed; boundary=\"yy\"\r\n\r\n--yy\r\nContent-Type: text/plain\r\n\r\nbody\r\n--yy\r\nContent-Type: image/png; name=\"avatar.png\"\r\nContent-Transfer-Encoding: base64\r\n\r\nUE5HSEVBREVS\r\n--yy--";
let msg = parse_rfc2822(raw);
assert_eq!(msg.attachments.len(), 1);
assert_eq!(msg.attachments[0].filename, "avatar.png");
assert_eq!(msg.attachments[0].mime_type, "image/png");
assert_eq!(msg.attachments[0].data, b"PNGHEADER");
}
#[test]
fn test_multipart_alternative_ignores_alternative_parts_as_attachments() {
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: multipart/alternative; boundary=\"alt\"\r\n\r\n--alt\r\nContent-Type: text/plain\r\n\r\nplain body\r\n--alt\r\nContent-Type: text/html\r\n\r\n<p>HTML body</p>\r\n--alt--";
let msg = parse_rfc2822(raw);
// text/html and text/plain alternatives must not be picked up as attachments
assert!(msg.attachments.is_empty());
assert!(msg.body_html.contains("HTML body"));
}
#[test]
fn test_multipart_base64_part() {
let body_b64 = base64::engine::general_purpose::STANDARD.encode(b"<p>Encoded</p>");
+182 -13
View File
@@ -7,10 +7,14 @@ use crypto_primitives::randomizer_facade::RandomizerFacade;
use tutasdk::bindings::file_client::{FileClient, FileClientError};
use tutasdk::bindings::rest_client::RestClient;
use tutasdk::crypto_entity_client::CryptoEntityClient;
use tutasdk::blobs::blob_facade::FileData;
use tutasdk::entities::generated::sys::BlobReferenceTokenWrapper;
use tutasdk::entities::generated::tutanota::{
DraftCreateData, DraftData, DraftRecipient, Mail, MailBox, MailDetails, MailDetailsBlob,
MailSetEntry, SendDraftData, SendDraftParameters, TutanotaFile,
AttachmentKeyData, DraftAttachment, DraftCreateData, DraftData, DraftRecipient, Mail, MailBox,
MailDetails, MailDetailsBlob, MailSetEntry, NewDraftAttachment, SendDraftData,
SendDraftParameters, TutanotaFile,
};
use tutasdk::tutanota_constants::ArchiveDataType;
use tutasdk::folder_system::{FolderSystem, MailSetKind};
use tutasdk::services::generated::tutanota::{DraftService, SendDraftService};
use tutasdk::services::ExtraServiceParams;
@@ -319,6 +323,125 @@ impl TutaSession {
Ok(out)
}
/// Encrypt + upload every attachment in one go, then assemble the
/// `DraftAttachment` aggregates the `DraftService` needs. Returns the
/// aggregates in the same order as `attachments`, paired with each
/// file's plaintext session key so the later `SendDraftService` call can
/// hand it to the server.
async fn build_added_attachments(
&self,
attachments: &[crate::mail::Attachment],
mail_group_id: &tutasdk::GeneratedId,
mail_group_key: &GenericAesKey,
mail_group_key_version: u64,
randomizer: &RandomizerFacade,
) -> Result<(Vec<DraftAttachment>, Vec<GenericAesKey>), ApiCallError> {
if attachments.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
let session_keys: Vec<GenericAesKey> = (0..attachments.len())
.map(|_| Aes256Key::generate(randomizer).into())
.collect();
let file_data: Vec<FileData> = attachments
.iter()
.zip(session_keys.iter())
.map(|(att, sk)| FileData {
session_key: sk.clone(),
data: att.data.as_slice(),
})
.collect();
let tokens_per_file: Vec<Vec<BlobReferenceTokenWrapper>> = self
.logged_in
.blob_facade()
.encrypt_and_upload_multiple(
ArchiveDataType::Attachments,
mail_group_id,
file_data.iter(),
)
.await?;
if tokens_per_file.len() != attachments.len() {
return Err(ApiCallError::internal(format!(
"blob upload returned {} token sets for {} attachments",
tokens_per_file.len(),
attachments.len()
)));
}
let mut drafts: Vec<DraftAttachment> = Vec::with_capacity(attachments.len());
for ((att, file_sk), tokens) in attachments
.iter()
.zip(session_keys.iter())
.zip(tokens_per_file.into_iter())
{
let enc_file_name = file_sk
.encrypt_data(att.filename.as_bytes(), Iv::generate(randomizer))
.map_err(|e| {
ApiCallError::internal(format!("Failed to encrypt attachment name: {e}"))
})?;
let enc_mime_type = file_sk
.encrypt_data(att.mime_type.as_bytes(), Iv::generate(randomizer))
.map_err(|e| {
ApiCallError::internal(format!(
"Failed to encrypt attachment mime type: {e}"
))
})?;
let owner_enc_file_sk = mail_group_key.encrypt_key(file_sk, Iv::generate(randomizer));
let new_draft = NewDraftAttachment {
_id: Some(random_custom_id(randomizer)),
encFileName: enc_file_name,
encMimeType: enc_mime_type,
encCid: None,
referenceTokens: tokens,
};
drafts.push(DraftAttachment {
_id: Some(random_custom_id(randomizer)),
ownerEncFileSessionKey: owner_enc_file_sk,
ownerKeyVersion: mail_group_key_version as i64,
newFile: Some(new_draft),
existingFile: None,
});
}
Ok((drafts, session_keys))
}
/// After `DraftService` succeeds, the saved Mail's `attachments` Vec
/// holds the freshly-allocated `File` IdTuples (in the same order as
/// the `addedAttachments` we just submitted). Reload the Mail, zip
/// those IdTuples with our local session keys, and produce the
/// `AttachmentKeyData[]` the server expects from `SendDraftService`.
async fn build_attachment_key_data(
&self,
draft_id: &IdTupleGenerated,
session_keys: &[GenericAesKey],
randomizer: &RandomizerFacade,
) -> Result<Vec<AttachmentKeyData>, ApiCallError> {
let mail: Mail = self.crypto_client().load(draft_id).await?;
if mail.attachments.len() != session_keys.len() {
return Err(ApiCallError::internal(format!(
"DraftService returned {} attachments but we uploaded {}",
mail.attachments.len(),
session_keys.len()
)));
}
Ok(mail
.attachments
.into_iter()
.zip(session_keys.iter())
.map(|(file_id, sk)| AttachmentKeyData {
_id: Some(random_custom_id(randomizer)),
bucketEncFileSessionKey: None,
fileSessionKey: Some(sk.as_bytes().to_vec()),
file: file_id,
})
.collect())
}
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();
@@ -337,7 +460,22 @@ impl TutaSession {
group_key.object.encrypt_key(&session_key, Iv::generate(&randomizer));
let owner_key_version = group_key.version as i64;
let draft_data = build_draft_data(msg, &self.email);
// Upload every attachment first — the resulting `DraftAttachment`
// aggregates ride along inside `DraftData.addedAttachments`, and we
// stash each per-file session key in `attachment_keys` so the later
// `SendDraftService` call can hand it to the server in
// `attachmentKeyData[]`.
let (added_attachments, attachment_keys) = self
.build_added_attachments(
&msg.attachments,
&mail_group_id,
&group_key.object,
group_key.version,
&randomizer,
)
.await?;
let draft_data = build_draft_data(msg, &self.email, added_attachments);
let create_data = DraftCreateData {
_format: 0,
@@ -362,12 +500,28 @@ impl TutaSession {
log::info!("Draft created: {:?}", draft_return.draft);
// To populate `attachmentKeyData[]` we need each File entity's
// IdTuple — the server allocated those when the draft was created.
// We load the freshly-saved Mail just to read its `attachments`
// field (order matches our `addedAttachments`) and zip with the
// session keys we generated earlier.
let attachment_key_data = if attachment_keys.is_empty() {
Vec::new()
} else {
self.build_attachment_key_data(&draft_return.draft, &attachment_keys, &randomizer)
.await?
};
let parameters_id = CustomId(
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(randomizer.generate_random_array::<4>()),
);
let send_data =
build_send_draft_data(session_key.as_bytes().to_vec(), draft_return.draft, parameters_id);
let send_data = build_send_draft_data(
session_key.as_bytes().to_vec(),
draft_return.draft,
parameters_id,
attachment_key_data,
);
let send_return = executor
.post::<SendDraftService>(send_data, ExtraServiceParams::default())
@@ -767,7 +921,11 @@ fn build_draft_recipients(recipients: &[(String, String)]) -> Vec<DraftRecipient
/// Mirrors the web client: the body goes into both `bodyText` and
/// `compressedBodyText`, and empty sender/recipient names fall back to the
/// address (an empty name makes `SendDraftService` fail).
fn build_draft_data(msg: &ParsedMessage, sender_email: &str) -> DraftData {
fn build_draft_data(
msg: &ParsedMessage,
sender_email: &str,
added_attachments: Vec<DraftAttachment>,
) -> DraftData {
DraftData {
_id: None,
subject: msg.subject.clone(),
@@ -784,13 +942,22 @@ fn build_draft_data(msg: &ParsedMessage, sender_email: &str) -> DraftData {
toRecipients: build_draft_recipients(&msg.to),
ccRecipients: build_draft_recipients(&msg.cc),
bccRecipients: build_draft_recipients(&msg.bcc),
addedAttachments: vec![],
addedAttachments: added_attachments,
removedAttachments: vec![],
replyTos: vec![],
_errors: Default::default(),
}
}
/// Build a random 4-byte `CustomId` — used for `_id` on aggregates that
/// declare `cardinality: One`. Server never reads the value.
fn random_custom_id(randomizer: &RandomizerFacade) -> CustomId {
CustomId(
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(randomizer.generate_random_array::<4>()),
)
}
/// Build the `SendDraftData` for sending a previously created draft.
///
/// The session data is mirrored into the nested `parameters` aggregate (with a
@@ -802,6 +969,7 @@ fn build_send_draft_data(
session_key_bytes: Vec<u8>,
draft_id: IdTupleGenerated,
parameters_id: CustomId,
attachment_key_data: Vec<AttachmentKeyData>,
) -> SendDraftData {
SendDraftData {
_format: 0,
@@ -816,7 +984,7 @@ fn build_send_draft_data(
allowUndo: false,
internalRecipientKeyData: vec![],
secureExternalRecipientKeyData: vec![],
attachmentKeyData: vec![],
attachmentKeyData: attachment_key_data.clone(),
mail: draft_id.clone(),
symEncInternalRecipientKeyData: vec![],
parameters: Some(SendDraftParameters {
@@ -832,7 +1000,7 @@ fn build_send_draft_data(
internalRecipientKeyData: vec![],
secureExternalRecipientKeyData: vec![],
symEncInternalRecipientKeyData: vec![],
attachmentKeyData: vec![],
attachmentKeyData: attachment_key_data,
}),
}
}
@@ -850,12 +1018,13 @@ mod send_tests {
bcc: vec![],
subject: "Hi".to_string(),
body_html: "<p>hello</p>".to_string(),
attachments: vec![],
}
}
#[test]
fn draft_data_puts_body_in_both_fields() {
let d = build_draft_data(&sample_msg(), "me@tuta.io");
let d = build_draft_data(&sample_msg(), "me@tuta.io", vec![]);
assert_eq!(d.bodyText, "<p>hello</p>");
assert_eq!(d.compressedBodyText.as_deref(), Some("<p>hello</p>"));
assert!(!d.confidential);
@@ -866,13 +1035,13 @@ mod send_tests {
fn draft_data_empty_sender_name_falls_back_to_address() {
let mut msg = sample_msg();
msg.from_name = String::new();
let d = build_draft_data(&msg, "me@tuta.io");
let d = build_draft_data(&msg, "me@tuta.io", vec![]);
assert_eq!(d.senderName, "me@tuta.io");
}
#[test]
fn draft_data_keeps_non_empty_sender_name() {
let d = build_draft_data(&sample_msg(), "me@tuta.io");
let d = build_draft_data(&sample_msg(), "me@tuta.io", vec![]);
assert_eq!(d.senderName, "Me");
}
@@ -897,7 +1066,7 @@ mod send_tests {
);
let pid = CustomId("aggId".to_string());
let sk = vec![1u8, 2, 3, 4];
let sd = build_send_draft_data(sk.clone(), draft_id.clone(), pid.clone());
let sd = build_send_draft_data(sk.clone(), draft_id.clone(), pid.clone(), vec![]);
// top-level
assert!(!sd.plaintext);