mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
Restructure into Cargo workspace with Tauri desktop GUI (#1)
Split the bridge into a tutabridge-core crate, a Tauri v2 desktop app (src-tauri) and a React/TS UI (ui), keeping the CLI entrypoint at the workspace root. Add encrypted local storage (SQLCipher metadata index + encrypted .eml files) so mail persists across launches and only the delta is fetched. Wire the bridge to the Tuta Rust SDK via the tuta-repo submodule (batch loading, MailDetailsBlob reading, interactive 2FA login). Implement SMTP sending: build the draft and send it through Tuta's DraftService/SendDraftService, mirroring the web client (body in compressedBodyText, non-empty sender/recipient names, populated SendDraftParameters). Add unit tests for the draft/send payload building.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
pub(crate) mod rfc2822;
|
||||
pub(crate) mod parser;
|
||||
|
||||
pub use rfc2822::mail_to_rfc2822;
|
||||
pub use parser::ParsedMessage;
|
||||
@@ -0,0 +1,536 @@
|
||||
use base64::Engine;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ParsedMessage {
|
||||
pub from_address: String,
|
||||
pub from_name: String,
|
||||
pub to: Vec<(String, String)>,
|
||||
pub cc: Vec<(String, String)>,
|
||||
pub bcc: Vec<(String, String)>,
|
||||
pub subject: String,
|
||||
pub body_html: String,
|
||||
}
|
||||
|
||||
pub fn parse_rfc2822(raw: &str) -> ParsedMessage {
|
||||
let (header_section, body_section) = split_headers_body(raw);
|
||||
let headers = parse_headers(&header_section);
|
||||
|
||||
let from_raw = get_header(&headers, "from").unwrap_or_default();
|
||||
let (from_name, from_address) = parse_address_single(&from_raw);
|
||||
|
||||
let to = get_header(&headers, "to")
|
||||
.map(|v| parse_address_list(&v))
|
||||
.unwrap_or_default();
|
||||
let cc = get_header(&headers, "cc")
|
||||
.map(|v| parse_address_list(&v))
|
||||
.unwrap_or_default();
|
||||
let bcc = get_header(&headers, "bcc")
|
||||
.map(|v| parse_address_list(&v))
|
||||
.unwrap_or_default();
|
||||
|
||||
let subject = get_header(&headers, "subject")
|
||||
.map(|s| decode_header_value(&s))
|
||||
.unwrap_or_default();
|
||||
|
||||
let content_type = get_header(&headers, "content-type").unwrap_or_default();
|
||||
let content_transfer_encoding = get_header(&headers, "content-transfer-encoding")
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
|
||||
let body_html = if content_type.to_lowercase().contains("multipart/") {
|
||||
extract_multipart_body(&body_section, &content_type)
|
||||
} else {
|
||||
decode_body(&body_section, &content_transfer_encoding, &content_type.to_lowercase())
|
||||
};
|
||||
|
||||
ParsedMessage {
|
||||
from_address,
|
||||
from_name,
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject,
|
||||
body_html,
|
||||
}
|
||||
}
|
||||
|
||||
fn split_headers_body(raw: &str) -> (String, String) {
|
||||
if let Some(pos) = raw.find("\r\n\r\n") {
|
||||
(raw[..pos].to_string(), raw[pos + 4..].to_string())
|
||||
} else if let Some(pos) = raw.find("\n\n") {
|
||||
(raw[..pos].to_string(), raw[pos + 2..].to_string())
|
||||
} else {
|
||||
(raw.to_string(), String::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_headers(header_section: &str) -> Vec<(String, String)> {
|
||||
let mut headers = Vec::new();
|
||||
let mut current_name = String::new();
|
||||
let mut current_value = String::new();
|
||||
|
||||
for line in header_section.lines() {
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
current_value.push(' ');
|
||||
current_value.push_str(line.trim());
|
||||
} else if let Some((name, value)) = line.split_once(':') {
|
||||
if !current_name.is_empty() {
|
||||
headers.push((current_name.to_lowercase(), current_value.trim().to_string()));
|
||||
}
|
||||
current_name = name.trim().to_string();
|
||||
current_value = value.to_string();
|
||||
}
|
||||
}
|
||||
if !current_name.is_empty() {
|
||||
headers.push((current_name.to_lowercase(), current_value.trim().to_string()));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn get_header(headers: &[(String, String)], name: &str) -> Option<String> {
|
||||
headers.iter().find(|(n, _)| n == name).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
fn parse_address_single(raw: &str) -> (String, String) {
|
||||
let raw = raw.trim();
|
||||
if let Some(lt) = raw.find('<') {
|
||||
if let Some(gt) = raw.find('>') {
|
||||
let addr = raw[lt + 1..gt].trim().to_string();
|
||||
let name = decode_header_value(raw[..lt].trim().trim_matches('"'));
|
||||
return (name, addr);
|
||||
}
|
||||
}
|
||||
(String::new(), raw.to_string())
|
||||
}
|
||||
|
||||
fn parse_address_list(raw: &str) -> Vec<(String, String)> {
|
||||
let mut result = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut current = String::new();
|
||||
|
||||
for ch in raw.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
depth += 1;
|
||||
current.push(ch);
|
||||
}
|
||||
'>' => {
|
||||
depth -= 1;
|
||||
current.push(ch);
|
||||
}
|
||||
',' if depth == 0 => {
|
||||
let trimmed = current.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
result.push(parse_address_single(&trimmed));
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
_ => current.push(ch),
|
||||
}
|
||||
}
|
||||
let trimmed = current.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
result.push(parse_address_single(&trimmed));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn decode_header_value(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
if !s.contains("=?") {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
let mut result = String::new();
|
||||
let mut remaining = s;
|
||||
|
||||
while let Some(start) = remaining.find("=?") {
|
||||
result.push_str(&remaining[..start]);
|
||||
remaining = &remaining[start + 2..];
|
||||
|
||||
let parts: Vec<&str> = remaining.splitn(4, '?').collect();
|
||||
if parts.len() >= 3 {
|
||||
let encoding = parts[1].to_uppercase();
|
||||
let encoded = parts[2];
|
||||
if let Some(end_marker) = remaining.find("?=") {
|
||||
let decoded = if encoding == "B" {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.ok()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
} else if encoding == "Q" {
|
||||
Some(decode_q_encoding(encoded))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(text) = decoded {
|
||||
result.push_str(&text);
|
||||
remaining = &remaining[end_marker + 2..];
|
||||
let ws_stripped = remaining.trim_start();
|
||||
if ws_stripped.starts_with("=?") {
|
||||
remaining = ws_stripped;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push_str("=?");
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
fn decode_q_encoding(s: &str) -> String {
|
||||
let mut result = Vec::new();
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'=' && i + 2 < bytes.len() {
|
||||
if let Ok(byte) = u8::from_str_radix(
|
||||
std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""),
|
||||
16,
|
||||
) {
|
||||
result.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if bytes[i] == b'_' {
|
||||
result.push(b' ');
|
||||
} else {
|
||||
result.push(bytes[i]);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8(result).unwrap_or_else(|_| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_boundary(content_type: &str) -> Option<String> {
|
||||
let lower = content_type.to_lowercase();
|
||||
if let Some(pos) = lower.find("boundary=") {
|
||||
let rest = &content_type[pos + 9..];
|
||||
let boundary = if rest.starts_with('"') {
|
||||
rest[1..].split('"').next().unwrap_or("")
|
||||
} else {
|
||||
rest.split(|c: char| c.is_whitespace() || c == ';').next().unwrap_or("")
|
||||
};
|
||||
if !boundary.is_empty() {
|
||||
return Some(boundary.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_multipart_body(body: &str, content_type: &str) -> String {
|
||||
let boundary = match extract_boundary(content_type) {
|
||||
Some(b) => b,
|
||||
None => return body.to_string(),
|
||||
};
|
||||
|
||||
let parts = split_mime_parts(body, &boundary);
|
||||
let mut html_part = None;
|
||||
let mut text_part = None;
|
||||
|
||||
for part in &parts {
|
||||
let (part_headers_str, part_body) = split_headers_body(part);
|
||||
let part_headers = parse_headers(&part_headers_str);
|
||||
let part_ct = get_header(&part_headers, "content-type").unwrap_or_default();
|
||||
let part_cte = get_header(&part_headers, "content-transfer-encoding")
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
let part_ct_lower = part_ct.to_lowercase();
|
||||
|
||||
if part_ct_lower.contains("multipart/") {
|
||||
let nested = extract_multipart_body(&part_body, &part_ct);
|
||||
if !nested.is_empty() {
|
||||
return nested;
|
||||
}
|
||||
} else if part_ct_lower.contains("text/html") {
|
||||
html_part = Some(decode_body(&part_body, &part_cte, &part_ct_lower));
|
||||
} else if part_ct_lower.contains("text/plain") && html_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())
|
||||
}
|
||||
|
||||
fn split_mime_parts(body: &str, boundary: &str) -> Vec<String> {
|
||||
let delimiter = format!("--{}", boundary);
|
||||
let end_delimiter = format!("--{}--", boundary);
|
||||
let mut parts = Vec::new();
|
||||
let mut in_part = false;
|
||||
let mut current = String::new();
|
||||
|
||||
for line in body.lines() {
|
||||
if line.starts_with(&end_delimiter) {
|
||||
if in_part && !current.is_empty() {
|
||||
parts.push(current.trim_start_matches("\r\n").trim_start_matches('\n').to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
if line.starts_with(&delimiter) {
|
||||
if in_part && !current.is_empty() {
|
||||
parts.push(current.trim_start_matches("\r\n").trim_start_matches('\n').to_string());
|
||||
}
|
||||
current = String::new();
|
||||
in_part = true;
|
||||
continue;
|
||||
}
|
||||
if in_part {
|
||||
current.push_str(line);
|
||||
current.push('\n');
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn decode_body(body: &str, transfer_encoding: &str, content_type: &str) -> String {
|
||||
let decoded = if transfer_encoding.contains("base64") {
|
||||
let clean: String = body.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(&clean)
|
||||
.ok()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.unwrap_or_else(|| body.to_string())
|
||||
} else if transfer_encoding.contains("quoted-printable") {
|
||||
decode_quoted_printable(body)
|
||||
} else {
|
||||
body.to_string()
|
||||
};
|
||||
|
||||
if content_type.contains("text/plain") && !content_type.contains("text/html") {
|
||||
format!("<pre>{}</pre>", html_escape(&decoded))
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_quoted_printable(s: &str) -> String {
|
||||
let mut result = Vec::new();
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'=' {
|
||||
if i + 2 < bytes.len() && bytes[i + 1] == b'\r' && bytes[i + 2] == b'\n' {
|
||||
i += 3;
|
||||
} else if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
|
||||
i += 2;
|
||||
} else if i + 2 < bytes.len() {
|
||||
let hex = [bytes[i + 1], bytes[i + 2]];
|
||||
if let Ok(val) = u8::from_str_radix(
|
||||
std::str::from_utf8(&hex).unwrap_or(""),
|
||||
16,
|
||||
) {
|
||||
result.push(val);
|
||||
}
|
||||
i += 3;
|
||||
} else {
|
||||
result.push(b'=');
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
result.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(result).unwrap_or_else(|_| s.to_string())
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_message() {
|
||||
let raw = "From: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nSubject: Hello\r\n\r\n<p>Hi Bob</p>";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert_eq!(msg.from_name, "Alice");
|
||||
assert_eq!(msg.from_address, "alice@example.com");
|
||||
assert_eq!(msg.to.len(), 1);
|
||||
assert_eq!(msg.to[0].1, "bob@example.com");
|
||||
assert_eq!(msg.subject, "Hello");
|
||||
assert_eq!(msg.body_html, "<p>Hi Bob</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_recipients() {
|
||||
let raw = "From: a@b.com\r\nTo: Bob <bob@x.com>, Charlie <charlie@x.com>\r\nCc: Dave <dave@x.com>\r\nSubject: Test\r\n\r\nbody";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert_eq!(msg.to.len(), 2);
|
||||
assert_eq!(msg.to[0].1, "bob@x.com");
|
||||
assert_eq!(msg.to[1].1, "charlie@x.com");
|
||||
assert_eq!(msg.cc.len(), 1);
|
||||
assert_eq!(msg.cc[0].1, "dave@x.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_base64_body() {
|
||||
let body_b64 = base64::engine::general_purpose::STANDARD.encode(b"<p>Hello</p>");
|
||||
let raw = format!(
|
||||
"From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Transfer-Encoding: base64\r\nContent-Type: text/html\r\n\r\n{}",
|
||||
body_b64
|
||||
);
|
||||
let msg = parse_rfc2822(&raw);
|
||||
assert_eq!(msg.body_html, "<p>Hello</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_plain_text_body() {
|
||||
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: text/plain\r\n\r\nHello <world>";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert_eq!(msg.body_html, "<pre>Hello <world></pre>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_encoded_subject() {
|
||||
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: =?UTF-8?B?SMOpbGxv?=\r\n\r\nbody";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert_eq!(msg.subject, "H\u{e9}llo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_address_no_name() {
|
||||
let (name, addr) = parse_address_single("bob@example.com");
|
||||
assert_eq!(name, "");
|
||||
assert_eq!(addr, "bob@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_address_with_quotes() {
|
||||
let (name, addr) = parse_address_single("\"John Doe\" <john@x.com>");
|
||||
assert_eq!(name, "John Doe");
|
||||
assert_eq!(addr, "john@x.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_q_encoding() {
|
||||
assert_eq!(decode_q_encoding("Hello_=C3=A9"), "Hello \u{e9}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_headers_body_lf() {
|
||||
let raw = "From: a@b.com\nTo: b@c.com\n\nBody";
|
||||
let (h, b) = split_headers_body(raw);
|
||||
assert_eq!(h, "From: a@b.com\nTo: b@c.com");
|
||||
assert_eq!(b, "Body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_folded_headers() {
|
||||
let raw = "From: a@b.com\r\nSubject: very long\r\n subject line\r\nTo: b@c.com\r\n\r\nbody";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert_eq!(msg.subject, "very long subject line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multipart_alternative() {
|
||||
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: multipart/alternative; boundary=\"abc123\"\r\n\r\n--abc123\r\nContent-Type: text/plain\r\n\r\nHello plain\r\n--abc123\r\nContent-Type: text/html\r\n\r\n<p>Hello HTML</p>\r\n--abc123--";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert!(msg.body_html.contains("Hello HTML"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multipart_mixed_with_nested() {
|
||||
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n--outer\r\nContent-Type: multipart/alternative; boundary=\"inner\"\r\n\r\n--inner\r\nContent-Type: text/plain\r\n\r\nPlain text\r\n--inner\r\nContent-Type: text/html\r\n\r\n<p>HTML body</p>\r\n--inner--\r\n--outer--";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert!(msg.body_html.contains("HTML body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multipart_plain_only() {
|
||||
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: multipart/alternative; boundary=\"bnd\"\r\n\r\n--bnd\r\nContent-Type: text/plain\r\n\r\nJust plain\r\n--bnd--";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert!(msg.body_html.contains("Just plain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_boundary_quoted() {
|
||||
assert_eq!(
|
||||
extract_boundary("multipart/alternative; boundary=\"abc_123\""),
|
||||
Some("abc_123".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_boundary_unquoted() {
|
||||
assert_eq!(
|
||||
extract_boundary("multipart/mixed; boundary=abc123"),
|
||||
Some("abc123".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multipart_base64_part() {
|
||||
let body_b64 = base64::engine::general_purpose::STANDARD.encode(b"<p>Encoded</p>");
|
||||
let raw = format!(
|
||||
"From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: multipart/alternative; boundary=\"sep\"\r\n\r\n--sep\r\nContent-Type: text/html\r\nContent-Transfer-Encoding: base64\r\n\r\n{}\r\n--sep--",
|
||||
body_b64
|
||||
);
|
||||
let msg = parse_rfc2822(&raw);
|
||||
assert_eq!(msg.body_html, "<p>Encoded</p>");
|
||||
}
|
||||
|
||||
// --- multi-encoded-word subjects ---
|
||||
|
||||
#[test]
|
||||
fn test_decode_multi_encoded_words() {
|
||||
let s = "=?UTF-8?B?SMOpbGxv?= =?UTF-8?B?IE1vbmRl?=";
|
||||
let result = decode_header_value(s);
|
||||
assert_eq!(result, "H\u{e9}llo Monde");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_mixed_encoded_and_plain() {
|
||||
let s = "Re: =?UTF-8?B?SMOpbGxv?= there";
|
||||
let result = decode_header_value(s);
|
||||
assert_eq!(result, "Re: H\u{e9}llo there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_q_encoded_word() {
|
||||
let s = "=?UTF-8?Q?Hello_=C3=A9?=";
|
||||
let result = decode_header_value(s);
|
||||
assert_eq!(result, "Hello \u{e9}");
|
||||
}
|
||||
|
||||
// --- quoted-printable soft break ---
|
||||
|
||||
#[test]
|
||||
fn test_qp_soft_break_crlf() {
|
||||
let input = "Hello=\r\nWorld";
|
||||
let result = decode_quoted_printable(input);
|
||||
assert_eq!(result, "HelloWorld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qp_soft_break_lf() {
|
||||
let input = "Hello=\nWorld";
|
||||
let result = decode_quoted_printable(input);
|
||||
assert_eq!(result, "HelloWorld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qp_no_byte_loss() {
|
||||
let input = "line1=\nABC";
|
||||
let result = decode_quoted_printable(input);
|
||||
assert_eq!(result, "line1ABC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qp_encoded_chars() {
|
||||
let input = "caf=C3=A9";
|
||||
let result = decode_quoted_printable(input);
|
||||
assert_eq!(result, "caf\u{e9}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
use base64::Engine;
|
||||
use tutasdk::entities::generated::tutanota::{Mail, MailAddress, MailDetails};
|
||||
|
||||
pub fn mail_to_rfc2822(mail: &Mail, details: Option<&MailDetails>) -> String {
|
||||
let mut msg = String::with_capacity(4096);
|
||||
|
||||
let date_str = format_rfc2822_date(mail.receivedDate.as_millis());
|
||||
msg.push_str(&format!("Date: {}\r\n", date_str));
|
||||
|
||||
msg.push_str(&format!("From: {}\r\n", format_address(&mail.sender)));
|
||||
|
||||
msg.push_str(&format!(
|
||||
"Subject: {}\r\n",
|
||||
encode_header_value(&mail.subject)
|
||||
));
|
||||
|
||||
if let Some(details) = details {
|
||||
let to_addrs: Vec<String> = details
|
||||
.recipients
|
||||
.toRecipients
|
||||
.iter()
|
||||
.map(format_address)
|
||||
.collect();
|
||||
if !to_addrs.is_empty() {
|
||||
msg.push_str(&format!("To: {}\r\n", to_addrs.join(", ")));
|
||||
}
|
||||
|
||||
let cc_addrs: Vec<String> = details
|
||||
.recipients
|
||||
.ccRecipients
|
||||
.iter()
|
||||
.map(format_address)
|
||||
.collect();
|
||||
if !cc_addrs.is_empty() {
|
||||
msg.push_str(&format!("Cc: {}\r\n", cc_addrs.join(", ")));
|
||||
}
|
||||
} else if let Some(ref first) = mail.firstRecipient {
|
||||
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",
|
||||
id.list_id, id.element_id
|
||||
));
|
||||
}
|
||||
|
||||
msg.push_str("\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");
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
pub(crate) fn format_address(addr: &MailAddress) -> String {
|
||||
if addr.name.is_empty() {
|
||||
addr.address.clone()
|
||||
} else {
|
||||
format!("{} <{}>", encode_header_value(&addr.name), addr.address)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode_header_value(s: &str) -> String {
|
||||
if s.is_ascii() && !s.contains('\r') && !s.contains('\n') {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"=?UTF-8?B?{}?=",
|
||||
base64::engine::general_purpose::STANDARD.encode(s.as_bytes())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_rfc2822_date(millis: u64) -> String {
|
||||
let secs = millis / 1000;
|
||||
let days = secs / 86400;
|
||||
let time_of_day = secs % 86400;
|
||||
let hours = time_of_day / 3600;
|
||||
let minutes = (time_of_day % 3600) / 60;
|
||||
let seconds = time_of_day % 60;
|
||||
|
||||
let weekday = ((days + 4) % 7) as usize; // 0=Sun, epoch was Thursday
|
||||
let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
let (year, month, day) = days_to_ymd(days);
|
||||
let months = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
let month_idx = month.saturating_sub(1).min(11) as usize;
|
||||
|
||||
format!(
|
||||
"{}, {:02} {} {:04} {:02}:{:02}:{:02} +0000",
|
||||
weekdays[weekday], day, months[month_idx], year, hours, minutes, seconds
|
||||
)
|
||||
}
|
||||
|
||||
/// Howard Hinnant's civil_from_days algorithm
|
||||
/// Returns (year, month 1-12, day 1-31)
|
||||
pub(crate) fn days_to_ymd(days: u64) -> (u64, u64, u64) {
|
||||
let z = days + 719468;
|
||||
let era = z / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
pub(crate) fn format_internal_date(millis: u64) -> String {
|
||||
let secs = millis / 1000;
|
||||
let days = secs / 86400;
|
||||
let tod = secs % 86400;
|
||||
let h = tod / 3600;
|
||||
let m = (tod % 3600) / 60;
|
||||
let s = tod % 60;
|
||||
|
||||
let (year, month, day) = days_to_ymd(days);
|
||||
let months = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
let month_idx = month.saturating_sub(1).min(11) as usize;
|
||||
|
||||
format!(
|
||||
"{:02}-{}-{:04} {:02}:{:02}:{:02} +0000",
|
||||
day, months[month_idx], year, h, m, s
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn base64_encode_body(data: &[u8]) -> String {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(data);
|
||||
encoded
|
||||
.as_bytes()
|
||||
.chunks(76)
|
||||
.map(|chunk| std::str::from_utf8(chunk).unwrap_or(""))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n")
|
||||
}
|
||||
|
||||
pub(crate) fn extract_headers(rfc: &str) -> String {
|
||||
if let Some(pos) = rfc.find("\r\n\r\n") {
|
||||
format!("{}\r\n", &rfc[..pos + 2])
|
||||
} else {
|
||||
rfc.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_id(s: &str) -> tutasdk::GeneratedId {
|
||||
tutasdk::GeneratedId(s.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_days_to_ymd_epoch() {
|
||||
assert_eq!(days_to_ymd(0), (1970, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_days_to_ymd_known_dates() {
|
||||
// 2024-01-01 = day 19723 since epoch
|
||||
assert_eq!(days_to_ymd(19723), (2024, 1, 1));
|
||||
// 2000-02-29 (leap year) = day 11016
|
||||
assert_eq!(days_to_ymd(11016), (2000, 2, 29));
|
||||
// 2026-05-20 = day 20593
|
||||
assert_eq!(days_to_ymd(20593), (2026, 5, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_rfc2822_date_epoch() {
|
||||
let result = format_rfc2822_date(0);
|
||||
assert_eq!(result, "Thu, 01 Jan 1970 00:00:00 +0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_rfc2822_date_known() {
|
||||
// 2024-12-25 12:37:25 UTC = 1735130245000 ms
|
||||
let result = format_rfc2822_date(1735130245000);
|
||||
assert_eq!(result, "Wed, 25 Dec 2024 12:37:25 +0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_internal_date_epoch() {
|
||||
let result = format_internal_date(0);
|
||||
assert_eq!(result, "01-Jan-1970 00:00:00 +0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_internal_date_known() {
|
||||
let result = format_internal_date(1735130245000);
|
||||
assert_eq!(result, "25-Dec-2024 12:37:25 +0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_header_ascii() {
|
||||
assert_eq!(encode_header_value("Hello World"), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_header_utf8() {
|
||||
let result = encode_header_value("Héllo Wörld");
|
||||
assert!(result.starts_with("=?UTF-8?B?"));
|
||||
assert!(result.ends_with("?="));
|
||||
|
||||
// Decode to verify round-trip
|
||||
let b64_part = &result[10..result.len() - 2];
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64_part)
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(decoded).unwrap(), "Héllo Wörld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_header_with_newline() {
|
||||
let result = encode_header_value("Line1\r\nLine2");
|
||||
assert!(result.starts_with("=?UTF-8?B?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_header_empty() {
|
||||
assert_eq!(encode_header_value(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_address_name_and_email() {
|
||||
let addr = MailAddress {
|
||||
_id: None,
|
||||
name: "John Doe".to_string(),
|
||||
address: "john@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
};
|
||||
assert_eq!(format_address(&addr), "John Doe <john@example.com>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_address_email_only() {
|
||||
let addr = MailAddress {
|
||||
_id: None,
|
||||
name: "".to_string(),
|
||||
address: "john@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
};
|
||||
assert_eq!(format_address(&addr), "john@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_address_utf8_name() {
|
||||
let addr = MailAddress {
|
||||
_id: None,
|
||||
name: "Jéan-François".to_string(),
|
||||
address: "jf@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
};
|
||||
let result = format_address(&addr);
|
||||
assert!(result.contains("=?UTF-8?B?"));
|
||||
assert!(result.ends_with(" <jf@example.com>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base64_encode_body_short() {
|
||||
let result = base64_encode_body(b"Hello");
|
||||
assert_eq!(result, "SGVsbG8=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base64_encode_body_long_wraps() {
|
||||
let long_text = "A".repeat(200);
|
||||
let result = base64_encode_body(long_text.as_bytes());
|
||||
for line in result.split("\r\n") {
|
||||
assert!(line.len() <= 76, "Line too long: {} chars", line.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base64_encode_body_empty() {
|
||||
assert_eq!(base64_encode_body(b""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_headers_normal() {
|
||||
let rfc = "From: a@b.com\r\nTo: c@d.com\r\n\r\nBody here";
|
||||
let headers = extract_headers(rfc);
|
||||
// extract_headers includes the trailing \r\n\r\n separator
|
||||
assert_eq!(headers, "From: a@b.com\r\nTo: c@d.com\r\n\r\n");
|
||||
assert!(!headers.contains("Body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_headers_no_body() {
|
||||
let rfc = "From: a@b.com\r\nTo: c@d.com";
|
||||
let headers = extract_headers(rfc);
|
||||
assert_eq!(headers, rfc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_to_rfc2822_minimal() {
|
||||
use tutasdk::date::DateTime;
|
||||
use tutasdk::IdTupleGenerated;
|
||||
|
||||
let mail = Mail {
|
||||
_id: Some(IdTupleGenerated::new(
|
||||
test_id("list1"),
|
||||
test_id("elem1"),
|
||||
)),
|
||||
_permissions: test_id("perm1"),
|
||||
_format: 0,
|
||||
_ownerEncSessionKey: None,
|
||||
subject: "Test Subject".to_string(),
|
||||
receivedDate: DateTime::from_millis(1735130245000),
|
||||
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_list1"),
|
||||
test_id("conv_elem1"),
|
||||
),
|
||||
firstRecipient: Some(MailAddress {
|
||||
_id: None,
|
||||
name: "Bob".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 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"));
|
||||
assert!(rfc.contains("Subject: Test Subject\r\n"));
|
||||
assert!(rfc.contains("To: Bob <bob@example.com>\r\n"));
|
||||
assert!(rfc.contains("MIME-Version: 1.0\r\n"));
|
||||
assert!(rfc.contains("Content-Type: text/html; charset=UTF-8\r\n"));
|
||||
assert!(rfc.contains("Content-Transfer-Encoding: base64\r\n"));
|
||||
assert!(rfc.contains("Message-ID: <"));
|
||||
// Body should be base64 of "<p>(No body available)</p>"
|
||||
assert!(rfc.contains("\r\n\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_to_rfc2822_with_details() {
|
||||
use tutasdk::date::DateTime;
|
||||
use tutasdk::entities::generated::tutanota::{Body, Recipients};
|
||||
use tutasdk::IdTupleGenerated;
|
||||
|
||||
let mail = Mail {
|
||||
_id: Some(IdTupleGenerated::new(
|
||||
test_id("list2"),
|
||||
test_id("elem2"),
|
||||
)),
|
||||
_permissions: test_id("perm2"),
|
||||
_format: 0,
|
||||
_ownerEncSessionKey: None,
|
||||
subject: "With Details".to_string(),
|
||||
receivedDate: DateTime::from_millis(0),
|
||||
state: 2,
|
||||
unread: true,
|
||||
confidential: false,
|
||||
replyType: 0,
|
||||
_ownerGroup: None,
|
||||
differentEnvelopeSender: None,
|
||||
listUnsubscribe: false,
|
||||
movedTime: None,
|
||||
phishingStatus: 0,
|
||||
authStatus: None,
|
||||
method: 0,
|
||||
recipientCount: 2,
|
||||
encryptionAuthStatus: None,
|
||||
_ownerKeyVersion: None,
|
||||
processingState: 0,
|
||||
processNeeded: false,
|
||||
sendAt: None,
|
||||
serverClassificationData: None,
|
||||
_kdfNonce: None,
|
||||
sender: MailAddress {
|
||||
_id: None,
|
||||
name: "".to_string(),
|
||||
address: "sender@tuta.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
},
|
||||
attachments: vec![],
|
||||
conversationEntry: IdTupleGenerated::new(
|
||||
test_id("conv_list2"),
|
||||
test_id("conv_elem2"),
|
||||
),
|
||||
firstRecipient: None,
|
||||
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![
|
||||
MailAddress {
|
||||
_id: None,
|
||||
name: "Bob".to_string(),
|
||||
address: "bob@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
},
|
||||
MailAddress {
|
||||
_id: None,
|
||||
name: "".to_string(),
|
||||
address: "charlie@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
},
|
||||
],
|
||||
ccRecipients: vec![MailAddress {
|
||||
_id: None,
|
||||
name: "Dave".to_string(),
|
||||
address: "dave@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
}],
|
||||
bccRecipients: vec![],
|
||||
},
|
||||
headers: None,
|
||||
body: Body {
|
||||
_id: None,
|
||||
text: Some("<p>Hello World</p>".to_string()),
|
||||
compressedText: None,
|
||||
_errors: Default::default(),
|
||||
},
|
||||
};
|
||||
|
||||
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"));
|
||||
assert!(rfc.contains("Cc: Dave <dave@example.com>\r\n"));
|
||||
// Body should be base64 of "<p>Hello World</p>"
|
||||
let body_b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(b"<p>Hello World</p>");
|
||||
assert!(rfc.contains(&body_b64));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user