mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
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:
@@ -2,4 +2,4 @@ pub(crate) mod rfc2822;
|
|||||||
pub(crate) mod parser;
|
pub(crate) mod parser;
|
||||||
|
|
||||||
pub use rfc2822::mail_to_rfc2822;
|
pub use rfc2822::mail_to_rfc2822;
|
||||||
pub use parser::ParsedMessage;
|
pub use parser::{Attachment, ParsedMessage};
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
use base64::Engine;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct ParsedMessage {
|
pub struct ParsedMessage {
|
||||||
@@ -10,6 +19,7 @@ pub struct ParsedMessage {
|
|||||||
pub bcc: Vec<(String, String)>,
|
pub bcc: Vec<(String, String)>,
|
||||||
pub subject: String,
|
pub subject: String,
|
||||||
pub body_html: String,
|
pub body_html: String,
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_rfc2822(raw: &str) -> ParsedMessage {
|
pub fn parse_rfc2822(raw: &str) -> ParsedMessage {
|
||||||
@@ -38,10 +48,13 @@ pub fn parse_rfc2822(raw: &str) -> ParsedMessage {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
let body_html = if content_type.to_lowercase().contains("multipart/") {
|
let (body_html, attachments) = if content_type.to_lowercase().contains("multipart/") {
|
||||||
extract_multipart_body(&body_section, &content_type)
|
extract_multipart_body_and_attachments(&body_section, &content_type)
|
||||||
} else {
|
} 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 {
|
ParsedMessage {
|
||||||
@@ -52,6 +65,7 @@ pub fn parse_rfc2822(raw: &str) -> ParsedMessage {
|
|||||||
bcc,
|
bcc,
|
||||||
subject,
|
subject,
|
||||||
body_html,
|
body_html,
|
||||||
|
attachments,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,15 +237,23 @@ fn extract_boundary(content_type: &str) -> Option<String> {
|
|||||||
None
|
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) {
|
let boundary = match extract_boundary(content_type) {
|
||||||
Some(b) => b,
|
Some(b) => b,
|
||||||
None => return body.to_string(),
|
None => return (body.to_string(), Vec::new()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let parts = split_mime_parts(body, &boundary);
|
let parts = split_mime_parts(body, &boundary);
|
||||||
let mut html_part = None;
|
let mut html_part = None;
|
||||||
let mut text_part = None;
|
let mut text_part = None;
|
||||||
|
let mut attachments: Vec<Attachment> = Vec::new();
|
||||||
|
|
||||||
for part in &parts {
|
for part in &parts {
|
||||||
let (part_headers_str, part_body) = split_headers_body(part);
|
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")
|
let part_cte = get_header(&part_headers, "content-transfer-encoding")
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
let part_cd = get_header(&part_headers, "content-disposition").unwrap_or_default();
|
||||||
let part_ct_lower = part_ct.to_lowercase();
|
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/") {
|
if part_ct_lower.contains("multipart/") {
|
||||||
let nested = extract_multipart_body(&part_body, &part_ct);
|
let (nested_body, nested_atts) =
|
||||||
if !nested.is_empty() {
|
extract_multipart_body_and_attachments(&part_body, &part_ct);
|
||||||
return nested;
|
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));
|
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));
|
text_part = Some(decode_body(&part_body, &part_cte, &part_ct_lower));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html_part
|
let body = html_part.or(text_part).unwrap_or_else(|| body.to_string());
|
||||||
.or(text_part)
|
(body, attachments)
|
||||||
.unwrap_or_else(|| body.to_string())
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
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]
|
#[test]
|
||||||
fn test_multipart_base64_part() {
|
fn test_multipart_base64_part() {
|
||||||
let body_b64 = base64::engine::general_purpose::STANDARD.encode(b"<p>Encoded</p>");
|
let body_b64 = base64::engine::general_purpose::STANDARD.encode(b"<p>Encoded</p>");
|
||||||
|
|||||||
+182
-13
@@ -7,10 +7,14 @@ use crypto_primitives::randomizer_facade::RandomizerFacade;
|
|||||||
use tutasdk::bindings::file_client::{FileClient, FileClientError};
|
use tutasdk::bindings::file_client::{FileClient, FileClientError};
|
||||||
use tutasdk::bindings::rest_client::RestClient;
|
use tutasdk::bindings::rest_client::RestClient;
|
||||||
use tutasdk::crypto_entity_client::CryptoEntityClient;
|
use tutasdk::crypto_entity_client::CryptoEntityClient;
|
||||||
|
use tutasdk::blobs::blob_facade::FileData;
|
||||||
|
use tutasdk::entities::generated::sys::BlobReferenceTokenWrapper;
|
||||||
use tutasdk::entities::generated::tutanota::{
|
use tutasdk::entities::generated::tutanota::{
|
||||||
DraftCreateData, DraftData, DraftRecipient, Mail, MailBox, MailDetails, MailDetailsBlob,
|
AttachmentKeyData, DraftAttachment, DraftCreateData, DraftData, DraftRecipient, Mail, MailBox,
|
||||||
MailSetEntry, SendDraftData, SendDraftParameters, TutanotaFile,
|
MailDetails, MailDetailsBlob, MailSetEntry, NewDraftAttachment, SendDraftData,
|
||||||
|
SendDraftParameters, TutanotaFile,
|
||||||
};
|
};
|
||||||
|
use tutasdk::tutanota_constants::ArchiveDataType;
|
||||||
use tutasdk::folder_system::{FolderSystem, MailSetKind};
|
use tutasdk::folder_system::{FolderSystem, MailSetKind};
|
||||||
use tutasdk::services::generated::tutanota::{DraftService, SendDraftService};
|
use tutasdk::services::generated::tutanota::{DraftService, SendDraftService};
|
||||||
use tutasdk::services::ExtraServiceParams;
|
use tutasdk::services::ExtraServiceParams;
|
||||||
@@ -319,6 +323,125 @@ impl TutaSession {
|
|||||||
Ok(out)
|
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> {
|
async fn send_mail_impl(&self, msg: &ParsedMessage) -> Result<(), ApiCallError> {
|
||||||
let randomizer = RandomizerFacade::from_core(rand_core::OsRng);
|
let randomizer = RandomizerFacade::from_core(rand_core::OsRng);
|
||||||
let session_key: GenericAesKey = Aes256Key::generate(&randomizer).into();
|
let session_key: GenericAesKey = Aes256Key::generate(&randomizer).into();
|
||||||
@@ -337,7 +460,22 @@ impl TutaSession {
|
|||||||
group_key.object.encrypt_key(&session_key, Iv::generate(&randomizer));
|
group_key.object.encrypt_key(&session_key, Iv::generate(&randomizer));
|
||||||
let owner_key_version = group_key.version as i64;
|
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 {
|
let create_data = DraftCreateData {
|
||||||
_format: 0,
|
_format: 0,
|
||||||
@@ -362,12 +500,28 @@ impl TutaSession {
|
|||||||
|
|
||||||
log::info!("Draft created: {:?}", draft_return.draft);
|
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(
|
let parameters_id = CustomId(
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
.encode(randomizer.generate_random_array::<4>()),
|
.encode(randomizer.generate_random_array::<4>()),
|
||||||
);
|
);
|
||||||
let send_data =
|
let send_data = build_send_draft_data(
|
||||||
build_send_draft_data(session_key.as_bytes().to_vec(), draft_return.draft, parameters_id);
|
session_key.as_bytes().to_vec(),
|
||||||
|
draft_return.draft,
|
||||||
|
parameters_id,
|
||||||
|
attachment_key_data,
|
||||||
|
);
|
||||||
|
|
||||||
let send_return = executor
|
let send_return = executor
|
||||||
.post::<SendDraftService>(send_data, ExtraServiceParams::default())
|
.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
|
/// Mirrors the web client: the body goes into both `bodyText` and
|
||||||
/// `compressedBodyText`, and empty sender/recipient names fall back to the
|
/// `compressedBodyText`, and empty sender/recipient names fall back to the
|
||||||
/// address (an empty name makes `SendDraftService` fail).
|
/// 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 {
|
DraftData {
|
||||||
_id: None,
|
_id: None,
|
||||||
subject: msg.subject.clone(),
|
subject: msg.subject.clone(),
|
||||||
@@ -784,13 +942,22 @@ fn build_draft_data(msg: &ParsedMessage, sender_email: &str) -> DraftData {
|
|||||||
toRecipients: build_draft_recipients(&msg.to),
|
toRecipients: build_draft_recipients(&msg.to),
|
||||||
ccRecipients: build_draft_recipients(&msg.cc),
|
ccRecipients: build_draft_recipients(&msg.cc),
|
||||||
bccRecipients: build_draft_recipients(&msg.bcc),
|
bccRecipients: build_draft_recipients(&msg.bcc),
|
||||||
addedAttachments: vec![],
|
addedAttachments: added_attachments,
|
||||||
removedAttachments: vec![],
|
removedAttachments: vec![],
|
||||||
replyTos: vec![],
|
replyTos: vec![],
|
||||||
_errors: Default::default(),
|
_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.
|
/// Build the `SendDraftData` for sending a previously created draft.
|
||||||
///
|
///
|
||||||
/// The session data is mirrored into the nested `parameters` aggregate (with a
|
/// 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>,
|
session_key_bytes: Vec<u8>,
|
||||||
draft_id: IdTupleGenerated,
|
draft_id: IdTupleGenerated,
|
||||||
parameters_id: CustomId,
|
parameters_id: CustomId,
|
||||||
|
attachment_key_data: Vec<AttachmentKeyData>,
|
||||||
) -> SendDraftData {
|
) -> SendDraftData {
|
||||||
SendDraftData {
|
SendDraftData {
|
||||||
_format: 0,
|
_format: 0,
|
||||||
@@ -816,7 +984,7 @@ fn build_send_draft_data(
|
|||||||
allowUndo: false,
|
allowUndo: false,
|
||||||
internalRecipientKeyData: vec![],
|
internalRecipientKeyData: vec![],
|
||||||
secureExternalRecipientKeyData: vec![],
|
secureExternalRecipientKeyData: vec![],
|
||||||
attachmentKeyData: vec![],
|
attachmentKeyData: attachment_key_data.clone(),
|
||||||
mail: draft_id.clone(),
|
mail: draft_id.clone(),
|
||||||
symEncInternalRecipientKeyData: vec![],
|
symEncInternalRecipientKeyData: vec![],
|
||||||
parameters: Some(SendDraftParameters {
|
parameters: Some(SendDraftParameters {
|
||||||
@@ -832,7 +1000,7 @@ fn build_send_draft_data(
|
|||||||
internalRecipientKeyData: vec![],
|
internalRecipientKeyData: vec![],
|
||||||
secureExternalRecipientKeyData: vec![],
|
secureExternalRecipientKeyData: vec![],
|
||||||
symEncInternalRecipientKeyData: vec![],
|
symEncInternalRecipientKeyData: vec![],
|
||||||
attachmentKeyData: vec![],
|
attachmentKeyData: attachment_key_data,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -850,12 +1018,13 @@ mod send_tests {
|
|||||||
bcc: vec![],
|
bcc: vec![],
|
||||||
subject: "Hi".to_string(),
|
subject: "Hi".to_string(),
|
||||||
body_html: "<p>hello</p>".to_string(),
|
body_html: "<p>hello</p>".to_string(),
|
||||||
|
attachments: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn draft_data_puts_body_in_both_fields() {
|
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.bodyText, "<p>hello</p>");
|
||||||
assert_eq!(d.compressedBodyText.as_deref(), Some("<p>hello</p>"));
|
assert_eq!(d.compressedBodyText.as_deref(), Some("<p>hello</p>"));
|
||||||
assert!(!d.confidential);
|
assert!(!d.confidential);
|
||||||
@@ -866,13 +1035,13 @@ mod send_tests {
|
|||||||
fn draft_data_empty_sender_name_falls_back_to_address() {
|
fn draft_data_empty_sender_name_falls_back_to_address() {
|
||||||
let mut msg = sample_msg();
|
let mut msg = sample_msg();
|
||||||
msg.from_name = String::new();
|
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");
|
assert_eq!(d.senderName, "me@tuta.io");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn draft_data_keeps_non_empty_sender_name() {
|
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");
|
assert_eq!(d.senderName, "Me");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,7 +1066,7 @@ mod send_tests {
|
|||||||
);
|
);
|
||||||
let pid = CustomId("aggId".to_string());
|
let pid = CustomId("aggId".to_string());
|
||||||
let sk = vec![1u8, 2, 3, 4];
|
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
|
// top-level
|
||||||
assert!(!sd.plaintext);
|
assert!(!sd.plaintext);
|
||||||
|
|||||||
Reference in New Issue
Block a user