Format the workspace with rustfmt

Make the tree rustfmt-clean so CI can enforce `cargo fmt --check`.
This commit is contained in:
Anthony
2026-05-29 14:59:39 +02:00
parent 2e708f7362
commit a8a02ee0bf
18 changed files with 1158 additions and 898 deletions
+32 -12
View File
@@ -82,7 +82,8 @@ pub async fn export_eml(
output: &Path,
mut progress: impl FnMut(&BackupProgress),
) -> Result<BackupStats, String> {
std::fs::create_dir_all(output).map_err(|e| format!("Cannot create {}: {e}", output.display()))?;
std::fs::create_dir_all(output)
.map_err(|e| format!("Cannot create {}: {e}", output.display()))?;
let folders = backend.list_folders().await?;
let mut stats = BackupStats::default();
@@ -394,7 +395,11 @@ mod tests {
folder: &FolderInfo,
_limit: usize,
) -> Result<Vec<Mail>, String> {
Ok(self.mails.get(&folder.entries_list_id).cloned().unwrap_or_default())
Ok(self
.mails
.get(&folder.entries_list_id)
.cloned()
.unwrap_or_default())
}
async fn load_mail_details(&self, _mail: &Mail) -> Result<Option<MailDetails>, String> {
*self.server_loads.lock().unwrap() += 1;
@@ -449,7 +454,8 @@ mod tests {
fn temp_store() -> (LocalStore, std::path::PathBuf) {
let randomizer = RandomizerFacade::from_core(rand_core::OsRng);
let key: GenericAesKey = GenericAesKey::Aes256(Aes256Key::generate(&randomizer));
let tmp = std::env::temp_dir().join(format!("tutabridge_backup_test_{}", rand::random::<u64>()));
let tmp =
std::env::temp_dir().join(format!("tutabridge_backup_test_{}", rand::random::<u64>()));
std::fs::create_dir_all(&tmp).unwrap();
let store = LocalStore::open(&tmp.join("s.db"), &tmp.join("mails"), key).unwrap();
(store, tmp)
@@ -486,7 +492,8 @@ mod tests {
server_loads: std::sync::Mutex::new(0),
};
let out = std::env::temp_dir().join(format!("tutabridge_backup_out_{}", rand::random::<u64>()));
let out =
std::env::temp_dir().join(format!("tutabridge_backup_out_{}", rand::random::<u64>()));
let mut progress_calls = 0;
let stats = export_eml(&backend, &store, &out, |_p| progress_calls += 1)
.await
@@ -494,7 +501,10 @@ mod tests {
assert_eq!(stats.folders, 2);
assert_eq!(stats.mails_written, 3);
assert_eq!(stats.from_cache, 1, "the seeded mail should come from cache");
assert_eq!(
stats.from_cache, 1,
"the seeded mail should come from cache"
);
assert_eq!(stats.from_server, 2, "the other two should be fetched");
assert_eq!(progress_calls, 3);
assert!(stats.errors.is_empty());
@@ -504,7 +514,10 @@ mod tests {
// Files landed in the right per-folder dirs with the date prefix.
let inbox = out.join("INBOX");
let sent = out.join("Sent");
let inbox_files: Vec<_> = std::fs::read_dir(&inbox).unwrap().filter_map(|e| e.ok()).collect();
let inbox_files: Vec<_> = std::fs::read_dir(&inbox)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(inbox_files.len(), 2);
assert_eq!(std::fs::read_dir(&sent).unwrap().count(), 1);
@@ -512,13 +525,17 @@ mod tests {
let cached_path = inbox.join("20241225-123725_Cached1--3-9.eml");
let cached_eml = std::fs::read_to_string(&cached_path).unwrap();
let from_cache_b64 = base64::engine::general_purpose::STANDARD.encode(b"<p>from cache</p>");
assert!(cached_eml.contains(&from_cache_b64), "cached body should be served verbatim");
assert!(
cached_eml.contains(&from_cache_b64),
"cached body should be served verbatim"
);
// --- second run resumes: everything is already on disk ---
let stats2 = export_eml(&backend, &store, &out, |_p| {})
.await
.unwrap();
assert_eq!(stats2.skipped, 3, "a re-run must skip every already-exported mail");
let stats2 = export_eml(&backend, &store, &out, |_p| {}).await.unwrap();
assert_eq!(
stats2.skipped, 3,
"a re-run must skip every already-exported mail"
);
assert_eq!(stats2.mails_written, 0);
assert_eq!(stats2.from_server, 0, "no server fetch on a resume");
assert_eq!(
@@ -538,7 +555,10 @@ mod tests {
// First backup: one mail fetched.
let mut m1 = HashMap::new();
m1.insert("inbox_entries".to_string(), vec![make_mail("Old1--3-9", "old")]);
m1.insert(
"inbox_entries".to_string(),
vec![make_mail("Old1--3-9", "old")],
);
let backend1 = MockBackend {
folders: vec![folder("inbox", "inbox_entries", "INBOX")],
mails: m1,
+6 -4
View File
@@ -189,7 +189,8 @@ impl BridgeHandle {
self.emit_log("TLS initialized");
self.emit_log(&format!("Authenticating as {}...", config.email));
let session = match tuta::login_with_2fa(&config, password.as_deref(), totp_callback).await {
let session = match tuta::login_with_2fa(&config, password.as_deref(), totp_callback).await
{
Ok(s) => s,
Err(e) => {
let msg = format!("Login failed: {e}");
@@ -290,7 +291,7 @@ impl BridgeHandle {
"Event bus catch-up state loaded ({} group(s))",
m.len()
));
},
}
Ok(_) => self.emit_log("Event bus catch-up state is empty (first launch)"),
Err(e) => self.emit_log(&format!("Could not load event_bus_state: {e}")),
}
@@ -356,7 +357,7 @@ impl BridgeHandle {
tokio::spawn(async move {
if let Err(e) = client.run(token, uid, event_tx, shutdown).await {
match e {
tutasdk::event_bus::EventBusError::Stopped => {},
tutasdk::event_bus::EventBusError::Stopped => {}
_ => log::warn!("Event bus exited: {e}"),
}
}
@@ -378,7 +379,8 @@ impl BridgeHandle {
imap_tls,
pw.clone(),
));
let mut smtp_handle = tokio::spawn(smtp::serve(smtp_port, backend.clone(), smtp_tls, pw));
let mut smtp_handle =
tokio::spawn(smtp::serve(smtp_port, backend.clone(), smtp_tls, pw));
tokio::select! {
_ = rx => {
+3 -1
View File
@@ -81,7 +81,9 @@ pub fn ensure_bridge_password(config: &mut Config) -> Result<String, Box<dyn std
Ok(password)
}
pub fn regenerate_bridge_password(config: &mut Config) -> Result<String, Box<dyn std::error::Error>> {
pub fn regenerate_bridge_password(
config: &mut Config,
) -> Result<String, Box<dyn std::error::Error>> {
let password = generate_bridge_password();
config.bridge_password = Some(password.clone());
save_config(config)?;
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,8 +1,8 @@
mod session;
mod utf7;
use log::{debug, error, info};
use std::sync::Arc;
use log::{info, error, debug};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::watch;
@@ -56,7 +56,9 @@ async fn handle_connection(
let mut store_watch: watch::Receiver<u64> = store.subscribe();
let mut session = ImapSession::new(store, backend, password_hash);
writer.write_all(b"* OK TutaBridge IMAP4rev1 ready\r\n").await?;
writer
.write_all(b"* OK TutaBridge IMAP4rev1 ready\r\n")
.await?;
writer.flush().await?;
let mut line = String::new();
+225 -89
View File
@@ -1,9 +1,9 @@
use log::{debug, info};
use std::sync::Arc;
use log::{info, debug};
use tutasdk::entities::generated::tutanota::{Mail, MailDetails};
use crate::mail::rfc2822::{extract_headers, format_internal_date};
use crate::mail::mail_to_rfc2822;
use crate::mail::rfc2822::{extract_headers, format_internal_date};
use crate::sync::MailStore;
use crate::tuta::{FolderInfo, MailBackend};
@@ -72,18 +72,16 @@ impl ImapSession {
None => return vec![],
};
let decoded = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
line.trim(),
) {
Ok(d) => d,
Err(_) => {
return vec![format!(
"{} NO [AUTHENTICATIONFAILED] Invalid base64\r\n",
tag
)];
}
};
let decoded =
match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, line.trim()) {
Ok(d) => d,
Err(_) => {
return vec![format!(
"{} NO [AUTHENTICATIONFAILED] Invalid base64\r\n",
tag
)];
}
};
// PLAIN format: \0authcid\0password (authzid is empty)
let parts: Vec<&[u8]> = decoded.splitn(3, |&b| b == 0).collect();
@@ -289,7 +287,10 @@ impl ImapSession {
let folder = match self.store.folder_by_imap_path(&folder_name).await {
Some(f) => f,
None => {
return vec![format!("{} NO [NONEXISTENT] Mailbox does not exist\r\n", tag)];
return vec![format!(
"{} NO [NONEXISTENT] Mailbox does not exist\r\n",
tag
)];
}
};
let folder_id = folder.id.clone();
@@ -660,9 +661,10 @@ 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(), &[])
});
let rfc2822 = sm
.rfc2822
.or(old_rfc)
.unwrap_or_else(|| mail_to_rfc2822(&sm.mail, details.as_ref(), &[]));
self.mails.push(CachedMail {
mail: sm.mail,
@@ -673,7 +675,11 @@ impl ImapSession {
});
}
debug!("Refreshed {} mails for {} from store", self.mails.len(), folder_id);
debug!(
"Refreshed {} mails for {} from store",
self.mails.len(),
folder_id
);
Ok(())
}
@@ -717,7 +723,6 @@ impl ImapSession {
None
}
}
}
fn build_fetch_response(seq: usize, cached: &CachedMail, items: &str, uid_mode: bool) -> String {
@@ -817,9 +822,7 @@ fn build_envelope(cached: &CachedMail) -> String {
.map(|id| format!("<{}.{}@tutabridge.local>", id.list_id, id.element_id))
.unwrap_or_default();
format!(
"(\"{date}\" \"{subject}\" ({from}) ({from}) ({from}) ({to}) NIL NIL NIL \"{msg_id}\")"
)
format!("(\"{date}\" \"{subject}\" ({from}) ({from}) ({from}) ({to}) NIL NIL NIL \"{msg_id}\")")
}
fn imap_quote(s: &str) -> String {
@@ -904,7 +907,10 @@ fn parse_imap_token(s: &str) -> (String, &str) {
(result, "")
} else {
let end = s.find(char::is_whitespace).unwrap_or(s.len());
(s[..end].to_string(), if end < s.len() { &s[end..] } else { "" })
(
s[..end].to_string(),
if end < s.len() { &s[end..] } else { "" },
)
}
}
@@ -1201,10 +1207,7 @@ mod tests {
contact: None,
_errors: Default::default(),
};
assert_eq!(
format_envelope_addr(&addr),
"(NIL NIL \"localonly\" \"\")"
);
assert_eq!(format_envelope_addr(&addr), "(NIL NIL \"localonly\" \"\")");
}
#[test]
@@ -1218,10 +1221,7 @@ mod tests {
};
let result = format_envelope_addr(&addr);
// Inner quotes replaced with single quotes, wrapped in IMAP string delimiters
assert_eq!(
result,
"(\"John 'JD' Doe\" NIL \"john\" \"example.com\")"
);
assert_eq!(result, "(\"John 'JD' Doe\" NIL \"john\" \"example.com\")");
}
// --- extract_headers (via rfc2822 module) ---
@@ -1324,10 +1324,10 @@ mod tests {
// Integration tests with MockBackend
// =================================================================
use std::sync::Mutex;
use crate::mail::ParsedMessage;
use crate::sync::MailStore;
use crate::tuta::MailBackend;
use crate::mail::ParsedMessage;
use std::sync::Mutex;
use tutasdk::entities::generated::tutanota::{Body, Recipients};
struct MockBackend {
@@ -1358,7 +1358,10 @@ mod tests {
}
fn add_details(&self, element_id: &str, details: MailDetails) {
self.details.lock().unwrap().insert(element_id.to_string(), details);
self.details
.lock()
.unwrap()
.insert(element_id.to_string(), details);
}
}
@@ -1396,8 +1399,20 @@ mod tests {
.set_folder(
"inbox",
vec![
StoredMail { mail: m1, details: None, rfc2822: None, uid: 1, attachments_pending: false },
StoredMail { mail: m2, details: None, rfc2822: None, uid: 2, attachments_pending: false },
StoredMail {
mail: m1,
details: None,
rfc2822: None,
uid: 1,
attachments_pending: false,
},
StoredMail {
mail: m2,
details: None,
rfc2822: None,
uid: 2,
attachments_pending: false,
},
],
)
.await;
@@ -1406,7 +1421,10 @@ mod tests {
session.handle_command("b SELECT INBOX").await;
let resp = session.handle_command("c UID MOVE 1 Work").await;
assert!(resp.iter().any(|r| r.contains("EXPUNGE")), "expected EXPUNGE, got {resp:?}");
assert!(
resp.iter().any(|r| r.contains("EXPUNGE")),
"expected EXPUNGE, got {resp:?}"
);
assert!(resp.last().unwrap().contains("OK UID MOVE"));
let moved = backend.moved.lock().unwrap();
@@ -1422,7 +1440,10 @@ mod tests {
session.handle_command("a LOGIN u p").await;
session.handle_command("b SELECT INBOX").await;
let resp = session.handle_command("c UID COPY 1 Work").await;
assert!(resp[0].contains("NO"), "COPY should be rejected, got {resp:?}");
assert!(
resp[0].contains("NO"),
"COPY should be rejected, got {resp:?}"
);
}
async fn populate_store(store: &MailStore, mails: &[Mail]) {
@@ -1451,10 +1472,18 @@ mod tests {
#[async_trait::async_trait]
impl MailBackend for MockBackend {
async fn load_mail_ids_for_folder(&self, _folder: &FolderInfo, _limit: usize) -> Result<Vec<Mail>, String> {
async fn load_mail_ids_for_folder(
&self,
_folder: &FolderInfo,
_limit: usize,
) -> Result<Vec<Mail>, String> {
Ok(self.mails.lock().unwrap().clone())
}
async fn load_mail(&self, _list_id: &str, element_id: &str) -> Result<Option<Mail>, String> {
async fn load_mail(
&self,
_list_id: &str,
element_id: &str,
) -> Result<Option<Mail>, String> {
Ok(self
.mails
.lock()
@@ -1487,13 +1516,23 @@ mod tests {
Ok(None)
}
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, String> {
let key = mail._id.as_ref().map(|id| id.element_id.to_string()).unwrap_or_default();
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> {
) -> 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())
@@ -1501,7 +1540,11 @@ mod tests {
async fn list_folders(&self) -> Result<Vec<FolderInfo>, String> {
Ok(vec![inbox_folder()])
}
async fn set_unread_status(&self, mail_ids: Vec<IdTupleGenerated>, unread: bool) -> Result<(), String> {
async fn set_unread_status(
&self,
mail_ids: Vec<IdTupleGenerated>,
unread: bool,
) -> Result<(), String> {
self.unread_calls.lock().unwrap().push((mail_ids, unread));
Ok(())
}
@@ -1509,8 +1552,15 @@ mod tests {
self.trashed.lock().unwrap().extend(mail_ids);
Ok(())
}
async fn move_mails(&self, mail_ids: Vec<IdTupleGenerated>, target: &FolderInfo) -> Result<(), String> {
self.moved.lock().unwrap().push((mail_ids, target.id.clone()));
async fn move_mails(
&self,
mail_ids: Vec<IdTupleGenerated>,
target: &FolderInfo,
) -> Result<(), String> {
self.moved
.lock()
.unwrap()
.push((mail_ids, target.id.clone()));
Ok(())
}
async fn send_mail(&self, msg: &ParsedMessage) -> Result<(), String> {
@@ -1521,10 +1571,7 @@ mod tests {
fn make_mail(element_id: &str, subject: &str, unread: bool) -> Mail {
Mail {
_id: Some(IdTupleGenerated::new(
test_id("list1"),
test_id(element_id),
)),
_id: Some(IdTupleGenerated::new(test_id("list1"), test_id(element_id))),
_permissions: test_id("perm1"),
_format: 0,
_ownerEncSessionKey: None,
@@ -1557,10 +1604,7 @@ mod tests {
_errors: Default::default(),
},
attachments: vec![],
conversationEntry: IdTupleGenerated::new(
test_id("conv_list"),
test_id("conv_elem"),
),
conversationEntry: IdTupleGenerated::new(test_id("conv_list"), test_id("conv_elem")),
firstRecipient: Some(MailAddress {
_id: None,
name: "Recip".to_string(),
@@ -1619,10 +1663,27 @@ mod tests {
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, attachments_pending: false },
StoredMail { mail: m2, details: Some(d2), rfc2822: Some(rfc2), uid: 2, attachments_pending: false },
]).await;
store
.set_folder(
"inbox",
vec![
StoredMail {
mail: m1,
details: Some(d1),
rfc2822: Some(rfc1),
uid: 1,
attachments_pending: false,
},
StoredMail {
mail: m2,
details: Some(d2),
rfc2822: Some(rfc2),
uid: 2,
attachments_pending: false,
},
],
)
.await;
let mut session = ImapSession::new(store, backend, None);
// LOGIN
@@ -1643,13 +1704,15 @@ mod tests {
// FETCH FLAGS
let resp = session.handle_command("A004 FETCH 1:* (FLAGS UID)").await;
assert!(resp[0].contains("* 1 FETCH"));
assert!(resp[0].contains("FLAGS ()")); // unread → no \Seen
assert!(resp[0].contains("FLAGS ()")); // unread → no \Seen
assert!(resp[1].contains("* 2 FETCH"));
assert!(resp[1].contains("FLAGS (\\Seen)")); // read → \Seen
assert!(resp[1].contains("FLAGS (\\Seen)")); // read → \Seen
assert!(resp.last().unwrap().contains("OK FETCH"));
// UID FETCH with BODY
let resp = session.handle_command("A005 UID FETCH 1 (BODY.PEEK[])").await;
let resp = session
.handle_command("A005 UID FETCH 1 (BODY.PEEK[])")
.await;
assert!(resp[0].contains("BODY[]"));
// Body is base64-encoded HTML, verify the literal is present
let b64_body = base64::engine::general_purpose::STANDARD.encode(b"<p>Body 1</p>");
@@ -1658,9 +1721,11 @@ mod tests {
#[tokio::test]
async fn test_store_seen_flag_calls_backend() {
let backend = Arc::new(MockBackend::with_mails(vec![
make_mail("m1", "Unread mail", true),
]));
let backend = Arc::new(MockBackend::with_mails(vec![make_mail(
"m1",
"Unread mail",
true,
)]));
let store = MailStore::new();
populate_store(&store, &backend.mails.lock().unwrap()).await;
let mut session = ImapSession::new(store, backend.clone(), None);
@@ -1697,7 +1762,9 @@ mod tests {
session.handle_command("A002 SELECT INBOX").await;
// Mark mail 2 as deleted
session.handle_command("A003 STORE 2 +FLAGS (\\Deleted)").await;
session
.handle_command("A003 STORE 2 +FLAGS (\\Deleted)")
.await;
// Verify deleted flag in FETCH
let resp = session.handle_command("A004 FETCH 2 (FLAGS)").await;
@@ -1714,7 +1781,10 @@ mod tests {
// Verify only 2 mails remain
let resp = session.handle_command("A006 FETCH 1:* (FLAGS UID)").await;
let fetch_lines: Vec<_> = resp.iter().filter(|r| r.contains("* ") && r.contains("FETCH")).collect();
let fetch_lines: Vec<_> = resp
.iter()
.filter(|r| r.contains("* ") && r.contains("FETCH"))
.collect();
assert_eq!(fetch_lines.len(), 2);
}
@@ -1760,15 +1830,17 @@ mod tests {
fn extract_uid(line: &str) -> u32 {
let uid_pos = line.find("UID ").unwrap();
let rest = &line[uid_pos + 4..];
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
let end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
rest[..end].parse().unwrap()
}
#[tokio::test]
async fn test_idle_end_with_done() {
let backend = Arc::new(MockBackend::with_mails(vec![
make_mail("m1", "Test", false),
]));
let backend = Arc::new(MockBackend::with_mails(vec![make_mail(
"m1", "Test", false,
)]));
let (_store, mut session) = make_session(backend).await;
session.handle_command("A001 LOGIN user pass").await;
@@ -1847,9 +1919,9 @@ mod tests {
#[tokio::test]
async fn test_close_resets_state() {
let backend = Arc::new(MockBackend::with_mails(vec![
make_mail("m1", "Test", false),
]));
let backend = Arc::new(MockBackend::with_mails(vec![make_mail(
"m1", "Test", false,
)]));
let (_store, mut session) = make_session(backend).await;
session.handle_command("A001 LOGIN user pass").await;
@@ -1875,13 +1947,20 @@ mod tests {
session.handle_command("A002 SELECT INBOX").await;
// Delete mails 1 and 3
session.handle_command("A003 STORE 1 +FLAGS (\\Deleted)").await;
session.handle_command("A004 STORE 3 +FLAGS (\\Deleted)").await;
session
.handle_command("A003 STORE 1 +FLAGS (\\Deleted)")
.await;
session
.handle_command("A004 STORE 3 +FLAGS (\\Deleted)")
.await;
let resp = session.handle_command("A005 EXPUNGE").await;
// Mail 1 is expunged at seq 1, then mail 3 becomes seq 2
let expunge_lines: Vec<_> = resp.iter().filter(|r| r.starts_with("* ") && r.contains("EXPUNGE")).collect();
let expunge_lines: Vec<_> = resp
.iter()
.filter(|r| r.starts_with("* ") && r.contains("EXPUNGE"))
.collect();
assert_eq!(expunge_lines.len(), 2);
assert!(expunge_lines[0].contains("* 1 EXPUNGE"));
assert!(expunge_lines[1].contains("* 2 EXPUNGE"));
@@ -1946,15 +2025,30 @@ mod tests {
.set_folder(
"inbox",
vec![
StoredMail { mail: m1, details: None, rfc2822: None, uid: 1, attachments_pending: false },
StoredMail { mail: m2, details: None, rfc2822: None, uid: 2, attachments_pending: false },
StoredMail {
mail: m1,
details: None,
rfc2822: None,
uid: 1,
attachments_pending: false,
},
StoredMail {
mail: m2,
details: None,
rfc2822: None,
uid: 2,
attachments_pending: false,
},
],
)
.await;
let resp = session.check_new_mail().await;
// No EXPUNGE — only an added mail. EXISTS reports the new total.
assert!(!resp.iter().any(|r| r.contains("EXPUNGE")), "got {resp:?}");
assert!(resp.iter().any(|r| r.contains("* 2 EXISTS")), "got {resp:?}");
assert!(
resp.iter().any(|r| r.contains("* 2 EXISTS")),
"got {resp:?}"
);
}
#[tokio::test]
@@ -1967,13 +2061,25 @@ mod tests {
store
.set_folder(
"inbox",
vec![StoredMail { mail: m1, details: None, rfc2822: None, uid: 1, attachments_pending: false }],
vec![StoredMail {
mail: m1,
details: None,
rfc2822: None,
uid: 1,
attachments_pending: false,
}],
)
.await;
let resp = session.check_new_mail().await;
// m2 was seqno 2 in the session view.
assert!(resp.iter().any(|r| r.contains("* 2 EXPUNGE")), "got {resp:?}");
assert!(resp.iter().any(|r| r.contains("* 1 EXISTS")), "got {resp:?}");
assert!(
resp.iter().any(|r| r.contains("* 2 EXPUNGE")),
"got {resp:?}"
);
assert!(
resp.iter().any(|r| r.contains("* 1 EXISTS")),
"got {resp:?}"
);
}
#[tokio::test]
@@ -1990,16 +2096,34 @@ mod tests {
.set_folder(
"inbox",
vec![
StoredMail { mail: old1, details: None, rfc2822: None, uid: 1, attachments_pending: false },
StoredMail { mail: new3, details: None, rfc2822: None, uid: 3, attachments_pending: false },
StoredMail {
mail: old1,
details: None,
rfc2822: None,
uid: 1,
attachments_pending: false,
},
StoredMail {
mail: new3,
details: None,
rfc2822: None,
uid: 3,
attachments_pending: false,
},
],
)
.await;
let resp = session.check_new_mail().await;
// e2 was at seqno 2 — must EXPUNGE it. Then EXISTS reports the
// (unchanged) count so the client refetches and discovers e3.
assert!(resp.iter().any(|r| r.contains("* 2 EXPUNGE")), "got {resp:?}");
assert!(resp.iter().any(|r| r.contains("* 2 EXISTS")), "got {resp:?}");
assert!(
resp.iter().any(|r| r.contains("* 2 EXPUNGE")),
"got {resp:?}"
);
assert!(
resp.iter().any(|r| r.contains("* 2 EXISTS")),
"got {resp:?}"
);
}
#[tokio::test]
@@ -2014,12 +2138,24 @@ mod tests {
store
.set_folder(
"inbox",
vec![StoredMail { mail: m1, details: None, rfc2822: None, uid: 1, attachments_pending: false }],
vec![StoredMail {
mail: m1,
details: None,
rfc2822: None,
uid: 1,
attachments_pending: false,
}],
)
.await;
let resp = session.check_new_mail().await;
let expunge_idx_3 = resp.iter().position(|r| r.contains("* 3 EXPUNGE")).expect("missing 3");
let expunge_idx_2 = resp.iter().position(|r| r.contains("* 2 EXPUNGE")).expect("missing 2");
let expunge_idx_3 = resp
.iter()
.position(|r| r.contains("* 3 EXPUNGE"))
.expect("missing 3");
let expunge_idx_2 = resp
.iter()
.position(|r| r.contains("* 2 EXPUNGE"))
.expect("missing 2");
assert!(
expunge_idx_3 < expunge_idx_2,
"EXPUNGE 3 must come before EXPUNGE 2, got {resp:?}",
+3 -3
View File
@@ -2,10 +2,10 @@ pub mod backup;
pub mod bridge;
pub mod config;
pub mod event_handler;
pub mod store;
pub mod sync;
pub mod tuta;
pub mod imap;
pub mod mail;
pub mod smtp;
pub mod store;
pub mod sync;
pub mod tls;
pub mod tuta;
+4 -4
View File
@@ -22,8 +22,8 @@ pub fn compute_bodystructure(rfc2822: &str) -> String {
let headers = parse_headers(&headers_text);
let content_type = get_header(&headers, "content-type")
.unwrap_or_else(|| "text/html; charset=UTF-8".to_owned());
let cte = get_header(&headers, "content-transfer-encoding")
.unwrap_or_else(|| "7bit".to_owned());
let cte =
get_header(&headers, "content-transfer-encoding").unwrap_or_else(|| "7bit".to_owned());
bodystructure_for(&content_type, &cte, &body, &headers)
}
@@ -71,8 +71,8 @@ fn part_structure(part: &str) -> String {
let headers = parse_headers(&headers_text);
let content_type =
get_header(&headers, "content-type").unwrap_or_else(|| "text/plain".to_owned());
let cte = get_header(&headers, "content-transfer-encoding")
.unwrap_or_else(|| "7bit".to_owned());
let cte =
get_header(&headers, "content-transfer-encoding").unwrap_or_else(|| "7bit".to_owned());
bodystructure_for(&content_type, &cte, &body, &headers)
}
+41 -20
View File
@@ -52,7 +52,11 @@ pub fn parse_rfc2822(raw: &str) -> ParsedMessage {
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(),
)
};
@@ -90,20 +94,29 @@ pub(super) fn parse_headers(header_section: &str) -> Vec<(String, String)> {
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()));
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.push((
current_name.to_lowercase(),
current_value.trim().to_string(),
));
}
headers
}
pub(super) fn get_header(headers: &[(String, String)], name: &str) -> Option<String> {
headers.iter().find(|(n, _)| n == name).map(|(_, v)| v.clone())
headers
.iter()
.find(|(n, _)| n == name)
.map(|(_, v)| v.clone())
}
fn parse_address_single(raw: &str) -> (String, String) {
@@ -202,10 +215,9 @@ fn decode_q_encoding(s: &str) -> String {
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,
) {
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;
@@ -228,7 +240,9 @@ pub(super) fn extract_boundary(content_type: &str) -> Option<String> {
let boundary = if rest.starts_with('"') {
rest[1..].split('"').next().unwrap_or("")
} else {
rest.split(|c: char| c.is_whitespace() || c == ';').next().unwrap_or("")
rest.split(|c: char| c.is_whitespace() || c == ';')
.next()
.unwrap_or("")
};
if !boundary.is_empty() {
return Some(boundary.to_string());
@@ -267,8 +281,7 @@ fn extract_multipart_body_and_attachments(
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/"));
|| (extract_param(&part_ct, "name").is_some() && !part_ct_lower.contains("text/"));
if part_ct_lower.contains("multipart/") {
let (nested_body, nested_atts) =
@@ -284,10 +297,10 @@ fn extract_multipart_body_and_attachments(
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")
@@ -307,7 +320,8 @@ fn extract_multipart_body_and_attachments(
});
} 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() && text_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));
}
}
@@ -348,13 +362,23 @@ pub(super) fn split_mime_parts(body: &str, boundary: &str) -> Vec<String> {
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());
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());
parts.push(
current
.trim_start_matches("\r\n")
.trim_start_matches('\n')
.to_string(),
);
}
current = String::new();
in_part = true;
@@ -401,10 +425,7 @@ fn decode_quoted_printable(s: &str) -> String {
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,
) {
if let Ok(val) = u8::from_str_radix(std::str::from_utf8(&hex).unwrap_or(""), 16) {
result.push(val);
}
i += 3;
+14 -26
View File
@@ -86,7 +86,10 @@ pub fn mail_to_rfc2822(
for (file, data) in attachments {
msg.push_str(&format!("--{}\r\n", boundary));
let mime = file.mimeType.as_deref().unwrap_or("application/octet-stream");
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",
@@ -380,10 +383,7 @@ mod tests {
use tutasdk::IdTupleGenerated;
let mail = Mail {
_id: Some(IdTupleGenerated::new(
test_id("list1"),
test_id("elem1"),
)),
_id: Some(IdTupleGenerated::new(test_id("list1"), test_id("elem1"))),
_permissions: test_id("perm1"),
_format: 0,
_ownerEncSessionKey: None,
@@ -416,10 +416,7 @@ mod tests {
_errors: Default::default(),
},
attachments: vec![],
conversationEntry: IdTupleGenerated::new(
test_id("conv_list1"),
test_id("conv_elem1"),
),
conversationEntry: IdTupleGenerated::new(test_id("conv_list1"), test_id("conv_elem1")),
firstRecipient: Some(MailAddress {
_id: None,
name: "Bob".to_string(),
@@ -456,10 +453,7 @@ mod tests {
use tutasdk::IdTupleGenerated;
let mail = Mail {
_id: Some(IdTupleGenerated::new(
test_id("list2"),
test_id("elem2"),
)),
_id: Some(IdTupleGenerated::new(test_id("list2"), test_id("elem2"))),
_permissions: test_id("perm2"),
_format: 0,
_ownerEncSessionKey: None,
@@ -492,10 +486,7 @@ mod tests {
_errors: Default::default(),
},
attachments: vec![],
conversationEntry: IdTupleGenerated::new(
test_id("conv_list2"),
test_id("conv_elem2"),
),
conversationEntry: IdTupleGenerated::new(test_id("conv_list2"), test_id("conv_elem2")),
firstRecipient: None,
mailDetails: None,
mailDetailsDraft: None,
@@ -552,8 +543,7 @@ mod tests {
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>");
let body_b64 = base64::engine::general_purpose::STANDARD.encode(b"<p>Hello World</p>");
assert!(rfc.contains(&body_b64));
}
@@ -600,10 +590,7 @@ mod tests {
_errors: Default::default(),
},
attachments: vec![],
conversationEntry: IdTupleGenerated::new(
test_id("conv_l"),
test_id("conv_e"),
),
conversationEntry: IdTupleGenerated::new(test_id("conv_l"), test_id("conv_e")),
firstRecipient: Some(MailAddress {
_id: None,
name: "".to_string(),
@@ -661,12 +648,13 @@ mod tests {
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(
"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>");
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\""));
+27 -25
View File
@@ -1,6 +1,6 @@
use std::sync::Arc;
use base64::Engine;
use log::{info, error, debug};
use log::{debug, error, info};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
@@ -13,9 +13,15 @@ enum SmtpState {
Init,
Greeted,
MailFrom(String),
RcptTo { from: String, to: Vec<String> },
RcptTo {
from: String,
to: Vec<String>,
},
#[allow(dead_code)]
Data { from: String, to: Vec<String> },
Data {
from: String,
to: Vec<String>,
},
Quit,
}
@@ -139,9 +145,7 @@ async fn handle_connection(
}
Err(e) => {
error!("SMTP: failed to send via Tuta: {}", e);
writer
.write_all(b"451 Temporary failure\r\n")
.await?;
writer.write_all(b"451 Temporary failure\r\n").await?;
}
}
state = SmtpState::Greeted;
@@ -157,7 +161,11 @@ async fn handle_connection(
continue;
}
let cmd = trimmed.split_whitespace().next().unwrap_or("").to_uppercase();
let cmd = trimmed
.split_whitespace()
.next()
.unwrap_or("")
.to_uppercase();
let response = match cmd.as_str() {
"EHLO" | "HELO" => {
state = SmtpState::Greeted;
@@ -208,19 +216,17 @@ async fn handle_connection(
}
"250 OK\r\n".to_string()
}
"DATA" => {
match &state {
SmtpState::RcptTo { from, to } => {
state = SmtpState::Data {
from: from.clone(),
to: to.clone(),
};
in_data = true;
"354 Start mail input; end with <CRLF>.<CRLF>\r\n".to_string()
}
_ => "503 Bad sequence\r\n".to_string(),
"DATA" => match &state {
SmtpState::RcptTo { from, to } => {
state = SmtpState::Data {
from: from.clone(),
to: to.clone(),
};
in_data = true;
"354 Start mail input; end with <CRLF>.<CRLF>\r\n".to_string()
}
}
_ => "503 Bad sequence\r\n".to_string(),
},
"RSET" => {
state = SmtpState::Greeted;
"250 OK\r\n".to_string()
@@ -252,11 +258,7 @@ fn extract_address(line: &str) -> String {
}
}
}
line.split(':')
.nth(1)
.unwrap_or("")
.trim()
.to_string()
line.split(':').nth(1).unwrap_or("").trim().to_string()
}
fn verify_smtp_plain_data(data: &str, expected: &Option<String>) -> String {
+24 -16
View File
@@ -320,8 +320,7 @@ impl LocalStore {
if !deleted.is_empty() {
conn.execute_batch("BEGIN IMMEDIATE")?;
{
let mut stmt =
conn.prepare_cached("DELETE FROM mails WHERE element_id = ?1")?;
let mut stmt = conn.prepare_cached("DELETE FROM mails WHERE element_id = ?1")?;
for eid in &deleted {
stmt.execute([eid])?;
}
@@ -397,8 +396,7 @@ impl LocalStore {
pub fn total_count(&self) -> Result<usize, StoreError> {
let conn = self.conn.lock().unwrap();
let count: i64 =
conn.query_row("SELECT COUNT(*) FROM mails", [], |row| row.get(0))?;
let count: i64 = conn.query_row("SELECT COUNT(*) FROM mails", [], |row| row.get(0))?;
Ok(count as usize)
}
@@ -443,11 +441,7 @@ impl LocalStore {
/// Persist the last processed batch id for a group (event-bus catch-up
/// resumes from this point on the next reconnect).
pub fn set_event_bus_batch_id(
&self,
group_id: &str,
batch_id: &str,
) -> Result<(), StoreError> {
pub fn set_event_bus_batch_id(&self, group_id: &str, batch_id: &str) -> Result<(), StoreError> {
let conn = self.conn.lock().unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -540,7 +534,8 @@ mod tests {
fn open_memory_store() -> LocalStore {
let key = test_key();
let tmp_dir = std::env::temp_dir().join(format!("tutabridge_test_{}", rand::random::<u64>()));
let tmp_dir =
std::env::temp_dir().join(format!("tutabridge_test_{}", rand::random::<u64>()));
std::fs::create_dir_all(&tmp_dir).unwrap();
let db_path = tmp_dir.join("test.db");
let mails_dir = tmp_dir.join("mails");
@@ -586,7 +581,9 @@ mod tests {
#[test]
fn test_upsert_and_load_metadata() {
let store = open_memory_store();
store.upsert_mail_metadata(&meta("abc123", "inbox", 1700000000000)).unwrap();
store
.upsert_mail_metadata(&meta("abc123", "inbox", 1700000000000))
.unwrap();
let loaded = store.load_folder_metadata("inbox").unwrap();
assert_eq!(loaded.len(), 1);
@@ -600,7 +597,9 @@ mod tests {
fn test_metadata_is_per_folder() {
let store = open_memory_store();
store.upsert_mail_metadata(&meta("a", "inbox", 1)).unwrap();
store.upsert_mail_metadata(&meta("b", "custom1", 2)).unwrap();
store
.upsert_mail_metadata(&meta("b", "custom1", 2))
.unwrap();
assert_eq!(store.load_folder_metadata("inbox").unwrap().len(), 1);
assert_eq!(store.load_folder_metadata("custom1").unwrap().len(), 1);
@@ -641,7 +640,10 @@ mod tests {
let store = open_memory_store();
let rfc2822 = "From: test@example.com\r\nSubject: Hello\r\n\r\nBody text here";
store.write_eml("test_mail", rfc2822).unwrap();
assert_eq!(store.read_eml("test_mail").unwrap(), Some(rfc2822.to_string()));
assert_eq!(
store.read_eml("test_mail").unwrap(),
Some(rfc2822.to_string())
);
}
#[test]
@@ -662,7 +664,9 @@ mod tests {
#[test]
fn test_reset() {
let store = open_memory_store();
store.upsert_mail_metadata(&meta("abc", "inbox", 0)).unwrap();
store
.upsert_mail_metadata(&meta("abc", "inbox", 0))
.unwrap();
store.write_eml("abc", "content").unwrap();
store.reset().unwrap();
@@ -729,7 +733,9 @@ mod tests {
#[test]
fn delete_mail_removes_metadata_and_eml() {
let store = open_memory_store();
store.upsert_mail_metadata(&meta("del1", "inbox", 0)).unwrap();
store
.upsert_mail_metadata(&meta("del1", "inbox", 0))
.unwrap();
store.write_eml("del1", "content").unwrap();
assert_eq!(store.mail_count("inbox").unwrap(), 1);
assert!(store.has_eml("del1"));
@@ -742,7 +748,9 @@ mod tests {
#[test]
fn test_mark_has_details() {
let store = open_memory_store();
store.upsert_mail_metadata(&meta("det", "inbox", 0)).unwrap();
store
.upsert_mail_metadata(&meta("det", "inbox", 0))
.unwrap();
assert!(!store.load_folder_metadata("inbox").unwrap()[0].has_details);
store.mark_has_details("det").unwrap();
+29 -25
View File
@@ -121,11 +121,7 @@ impl MailStore {
/// Refresh an existing mail's metadata in every folder that holds it
/// (body/details preserved). No-op if the mail is not cached.
pub async fn refresh_mail_in_place(&self, mail: &Mail) {
let Some(eid) = mail
._id
.as_ref()
.map(|id| id.element_id.to_string())
else {
let Some(eid) = mail._id.as_ref().map(|id| id.element_id.to_string()) else {
return;
};
let mut folders = self.folders.write().await;
@@ -508,19 +504,16 @@ async fn load_cached_folder(
Ok(Some(eml)) => {
if !meta.has_details {
if let Err(e) = local_store.mark_has_details(&meta.element_id) {
warn!(
"Failed to heal has_details for {}: {e}",
meta.element_id
);
warn!("Failed to heal has_details for {}: {e}", meta.element_id);
}
}
Some(eml)
},
}
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, &[]))
},
}
};
stored_mails.push(StoredMail {
@@ -582,7 +575,12 @@ pub(crate) async fn sync_folder(
let elem_id = mail._id.as_ref().map(|id| id.element_id.to_string());
let uid = elem_id
.as_ref()
.and_then(|id| existing_map.get(id).map(|m| m.uid).or_else(|| new_uids.get(id).copied()))
.and_then(|id| {
existing_map
.get(id)
.map(|m| m.uid)
.or_else(|| new_uids.get(id).copied())
})
.unwrap_or(0);
if let Some(existing) = elem_id.as_ref().and_then(|id| existing_map.get(id)) {
@@ -798,17 +796,13 @@ async fn prefetch_details(
match backend.load_attachments(&stored.mail).await {
Ok(attachments) => {
let refs: Vec<(&TutanotaFile, &[u8])> = attachments
.iter()
.map(|(f, d)| (f, d.as_slice()))
.collect();
let refs: Vec<(&TutanotaFile, &[u8])> =
attachments.iter().map(|(f, d)| (f, d.as_slice())).collect();
let rfc2822 = mail_to_rfc2822(&stored.mail, Some(details), &refs);
if let Err(e) = local_store.write_eml(&eid, &rfc2822) {
warn!("Failed to rewrite eml {}: {}", eid, e);
}
store
.update_mail_rfc2822(&folder.id, &eid, rfc2822)
.await;
store.update_mail_rfc2822(&folder.id, &eid, rfc2822).await;
attachment_retry_history.remove(&eid);
debug!("Attachment retry succeeded for {}", eid);
}
@@ -826,7 +820,11 @@ async fn prefetch_details(
// Clear the pending flag so we don't keep retrying a
// permanent failure (e.g. server returned 404).
store
.update_mail_rfc2822(&folder.id, &eid, stored.rfc2822.clone().unwrap_or_default())
.update_mail_rfc2822(
&folder.id,
&eid,
stored.rfc2822.clone().unwrap_or_default(),
)
.await;
attachment_retry_history.remove(&eid);
}
@@ -855,7 +853,12 @@ where
match f().await {
Ok(v) => return Ok(v),
Err(e) if attempt < MAX_RETRIES => {
warn!("Attempt {} failed: {}, retrying in {:?}", attempt + 1, e, delay);
warn!(
"Attempt {} failed: {}, retrying in {:?}",
attempt + 1,
e,
delay
);
tokio::time::sleep(delay).await;
delay = backoff(delay);
}
@@ -988,7 +991,10 @@ mod tests {
let b = store.get_folder("folderB").await;
assert_eq!(a[0].mail.subject, "Hello [updated]");
assert!(!a[0].mail.unread);
assert_eq!(a[0].uid, 7, "UID is per-folder state, must survive a refresh");
assert_eq!(
a[0].uid, 7,
"UID is per-folder state, must survive a refresh"
);
assert_eq!(b[0].mail.subject, "Hello [updated]");
assert_eq!(b[0].uid, 12);
}
@@ -1127,7 +1133,6 @@ mod tests {
assert_eq!(store.get_folder("A").await.len(), 2);
}
#[tokio::test]
async fn prune_unknown_folders_drops_disappeared_ones() {
let store = MailStore::new();
@@ -1137,8 +1142,7 @@ mod tests {
store
.set_folder("gone", vec![stored(make_mail("L1", "M2", "g", true), 2)])
.await;
let known: std::collections::HashSet<String> =
["keep".to_string()].into_iter().collect();
let known: std::collections::HashSet<String> = ["keep".to_string()].into_iter().collect();
let removed = store.prune_unknown_folders(&known).await;
assert_eq!(removed, vec!["gone".to_string()]);
assert_eq!(store.get_folder("keep").await.len(), 1);
+11 -9
View File
@@ -18,13 +18,17 @@ fn key_path() -> PathBuf {
cert_dir().join("key.pem")
}
pub fn load_or_create_tls_acceptor() -> Result<TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> {
pub fn load_or_create_tls_acceptor() -> Result<TlsAcceptor, Box<dyn std::error::Error + Send + Sync>>
{
let cert_file = cert_path();
let key_file = key_path();
let (cert_pem, key_pem) = if cert_file.exists() && key_file.exists() {
log::info!("Loading TLS certificate from {}", cert_file.display());
(std::fs::read_to_string(&cert_file)?, std::fs::read_to_string(&key_file)?)
(
std::fs::read_to_string(&cert_file)?,
std::fs::read_to_string(&key_file)?,
)
} else {
log::info!("Generating self-signed TLS certificate...");
let (cert, key) = generate_self_signed()?;
@@ -61,20 +65,18 @@ fn load_certs(
pem: &str,
) -> Result<Vec<CertificateDer<'static>>, Box<dyn std::error::Error + Send + Sync>> {
let mut reader = std::io::BufReader::new(pem.as_bytes());
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()?;
let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut reader).collect::<Result<Vec<_>, _>>()?;
if certs.is_empty() {
return Err("No certificates found in PEM".into());
}
Ok(certs)
}
fn load_key(
pem: &str,
) -> Result<PrivateKeyDer<'static>, Box<dyn std::error::Error + Send + Sync>> {
fn load_key(pem: &str) -> Result<PrivateKeyDer<'static>, Box<dyn std::error::Error + Send + Sync>> {
let mut reader = std::io::BufReader::new(pem.as_bytes());
let keys: Vec<PrivatePkcs8KeyDer<'static>> = rustls_pemfile::pkcs8_private_keys(&mut reader)
.collect::<Result<Vec<_>, _>>()?;
let keys: Vec<PrivatePkcs8KeyDer<'static>> =
rustls_pemfile::pkcs8_private_keys(&mut reader).collect::<Result<Vec<_>, _>>()?;
let key = keys
.into_iter()
.next()
+60 -43
View File
@@ -1,24 +1,24 @@
use base64::Engine;
use std::collections::HashMap;
use std::sync::Arc;
use crypto_primitives::aes::{Aes256Key, Iv, AES_256_KEY_SIZE};
use crypto_primitives::blake3::blake3_kdf;
use crypto_primitives::key::GenericAesKey;
use crypto_primitives::randomizer_facade::RandomizerFacade;
use std::collections::HashMap;
use std::sync::Arc;
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::crypto_entity_client::CryptoEntityClient;
use tutasdk::entities::generated::sys::BlobReferenceTokenWrapper;
use tutasdk::entities::generated::tutanota::{
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;
use tutasdk::tutanota_constants::ArchiveDataType;
use tutasdk::{ApiCallError, CustomId, IdTupleGenerated, ListLoadDirection, LoggedInSdk, Sdk};
use crate::config::Config;
@@ -48,7 +48,11 @@ pub const IMAP_DELIMITER: char = '/';
#[async_trait::async_trait]
pub trait MailBackend: Send + Sync {
async fn load_mail_ids_for_folder(&self, folder: &FolderInfo, limit: usize) -> Result<Vec<Mail>, String>;
async fn load_mail_ids_for_folder(
&self,
folder: &FolderInfo,
limit: usize,
) -> Result<Vec<Mail>, String>;
/// Load a single mail by `(list_id, element_id)` — used by the event-bus
/// handler to fetch a freshly-created or updated mail without re-listing
/// its folder. `Ok(None)` means the entity is no longer on the server.
@@ -77,16 +81,21 @@ pub trait MailBackend: Send + Sync {
/// `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>;
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>;
async fn set_unread_status(
&self,
mail_ids: Vec<IdTupleGenerated>,
unread: bool,
) -> Result<(), String>;
async fn trash_mails(&self, mail_ids: Vec<IdTupleGenerated>) -> Result<(), String>;
/// Move mails into the given target folder.
async fn move_mails(&self, mail_ids: Vec<IdTupleGenerated>, target: &FolderInfo) -> Result<(), String>;
async fn move_mails(
&self,
mail_ids: Vec<IdTupleGenerated>,
target: &FolderInfo,
) -> Result<(), String>;
async fn send_mail(&self, msg: &ParsedMessage) -> Result<(), String>;
}
@@ -221,8 +230,7 @@ impl TutaSession {
"tutabridge local storage v1",
AES_256_KEY_SIZE,
);
GenericAesKey::from_bytes(&derived)
.map_err(|e| format!("Key derivation error: {e:?}"))
GenericAesKey::from_bytes(&derived).map_err(|e| format!("Key derivation error: {e:?}"))
}
fn crypto_client(&self) -> Arc<CryptoEntityClient> {
@@ -301,7 +309,11 @@ impl TutaSession {
.await
{
Ok(batch) => mails.extend(batch),
Err(e) => log::warn!("Failed to batch load mails from list {}: {}", list_id_str, e),
Err(e) => log::warn!(
"Failed to batch load mails from list {}: {}",
list_id_str,
e
),
}
}
@@ -322,7 +334,7 @@ impl TutaSession {
Err(e) => {
log::error!("Failed to load mail details draft: {e}");
Err(e)
},
}
}
} else if mail.mailDetails.is_some() {
match mail_facade.load_mail_details_blob(mail).await {
@@ -330,7 +342,7 @@ impl TutaSession {
Err(e) => {
log::error!("Failed to load mail details blob: {e}");
Err(e)
},
}
}
} else {
// Legacy mail without either reference — body lives nowhere we
@@ -477,9 +489,7 @@ impl TutaSession {
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}"
))
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));
@@ -556,8 +566,9 @@ impl TutaSession {
.get_current_sym_group_key(&mail_group_id)
.await?;
let owner_enc_session_key =
group_key.object.encrypt_key(&session_key, Iv::generate(&randomizer));
let owner_enc_session_key = group_key
.object
.encrypt_key(&session_key, Iv::generate(&randomizer));
let owner_key_version = group_key.version as i64;
// Upload every attachment first — the resulting `DraftAttachment`
@@ -669,10 +680,7 @@ impl TutaSession {
/// the File entities can't be decrypted. Returns `None` if this is not
/// a self-send, the cache has nothing for the envelope, or the entry
/// is older than the TTL.
async fn try_self_send_cache(
&self,
mail: &Mail,
) -> Option<Vec<(TutanotaFile, Vec<u8>)>> {
async fn try_self_send_cache(&self, mail: &Mail) -> Option<Vec<(TutanotaFile, Vec<u8>)>> {
if !mail.sender.address.eq_ignore_ascii_case(&self.email) {
return None;
}
@@ -760,7 +768,11 @@ fn self_send_cache_key(subject: &str, sender: &str, recipient: &str) -> String {
#[async_trait::async_trait]
impl MailBackend for TutaSession {
async fn load_mail_ids_for_folder(&self, folder: &FolderInfo, limit: usize) -> Result<Vec<Mail>, String> {
async fn load_mail_ids_for_folder(
&self,
folder: &FolderInfo,
limit: usize,
) -> Result<Vec<Mail>, String> {
let entries_list_id = tutasdk::GeneratedId(folder.entries_list_id.clone());
self.load_mail_ids_for_folder_impl(&entries_list_id, limit)
.await
@@ -810,16 +822,18 @@ 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 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}"))?;
let folder_system = self
.load_folders(&mailbox)
.await
.map_err(|e| format!("{e}"))?;
let mut result = Vec::new();
for indented in folder_system.indented_list() {
@@ -888,7 +902,11 @@ impl MailBackend for TutaSession {
.map_err(|e| format!("{e}"))
}
async fn move_mails(&self, mail_ids: Vec<IdTupleGenerated>, target: &FolderInfo) -> Result<(), String> {
async fn move_mails(
&self,
mail_ids: Vec<IdTupleGenerated>,
target: &FolderInfo,
) -> Result<(), String> {
let target_folder = IdTupleGenerated::new(
tutasdk::GeneratedId(target.list_id.clone()),
tutasdk::GeneratedId(target.id.clone()),
@@ -998,7 +1016,10 @@ pub async fn login_with_2fa(
.any(|c| c.r#type == i64::from(tutasdk::tutanota_constants::SecondFactorType::Totp));
if !has_totp {
return Err("Account requires U2F/WebAuthn 2FA which is not supported — only TOTP is supported".into());
return Err(
"Account requires U2F/WebAuthn 2FA which is not supported — only TOTP is supported"
.into(),
);
}
let totp_code = match &totp_callback {
@@ -1018,7 +1039,9 @@ pub async fn login_with_2fa(
.is_second_factor_pending(&access_token)
.await
.map_err(|e| {
Box::<dyn std::error::Error + Send + Sync>::from(format!("2FA poll failed: {e}"))
Box::<dyn std::error::Error + Send + Sync>::from(format!(
"2FA poll failed: {e}"
))
})?;
if !pending {
cleared = true;
@@ -1084,18 +1107,15 @@ fn save_credentials(email: &str, creds: &tutasdk::login::Credentials) {
fn load_credentials(email: &str) -> Option<tutasdk::login::Credentials> {
let mut cache = CREDENTIALS_CACHE.lock().unwrap();
if let Some(cached) = cache.as_ref() {
return cached.clone();
}
let result = load_credentials_from_keyring(email);
*cache = Some(result.clone());
result
}
fn load_credentials_from_keyring(email: &str) -> Option<tutasdk::login::Credentials> {
let entry = keyring::Entry::new(KEYRING_SERVICE, email).ok()?;
let json_str = entry.get_password().ok()?;
@@ -1118,14 +1138,12 @@ fn load_credentials_from_keyring(email: &str) -> Option<tutasdk::login::Credenti
}
fn delete_credentials(email: &str) {
if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, email) {
let _ = entry.delete_credential();
}
*CREDENTIALS_CACHE.lock().unwrap() = None;
}
/// Map SMTP recipients to `DraftRecipient`, falling back to the address when
/// the display name is empty (Tuta's send service rejects empty names).
fn build_draft_recipients(recipients: &[(String, String)]) -> Vec<DraftRecipient> {
@@ -1328,10 +1346,9 @@ mod transient_tests {
#[test]
fn missing_owner_key_is_transient() {
let e = ApiCallError::InternalSdkError {
error_message:
"Failed to resolve session key for entity 'File' with ID: ...; \
error_message: "Failed to resolve session key for entity 'File' with ID: ...; \
Session key resolution failure: instance missing owner key/group data"
.to_string(),
.to_string(),
};
assert!(is_session_key_transient(&e));
}