sync: mailcore 0.2.1-beta

This commit is contained in:
Eugen Kaparulin
2026-05-17 01:23:31 +03:00
parent 0100d848bd
commit 3c266c390f
6 changed files with 516 additions and 26 deletions

View File

@@ -1,4 +1,5 @@
use crate::types::{FolderInfo, MailError, MailServerConfig};
use flutter_rust_bridge::frb;
use mail_parser::MimeHeaders;
@@ -418,6 +419,12 @@ pub async fn parse_mime_message(bytes: &[u8]) -> Result<crate::types::MessageBod
)
};
// Extract sender public key:
// 1. Try Autocrypt header first (RFC 7929)
let sender_public_key = extract_autocrypt_key(&parsed)
// 2. Fall back to application/pgp-keys attachment
.or_else(|| extract_pgp_keys_attachment(&parsed));
Ok(crate::types::MessageBody {
uid: 0, // No UID for raw bytes
from: from_addr,
@@ -426,12 +433,77 @@ pub async fn parse_mime_message(bytes: &[u8]) -> Result<crate::types::MessageBod
attachments,
pgp_status,
signature_fingerprint,
sender_public_key,
})
})
.await
.unwrap_or_else(|e| Err(MailError::Generic(format!("Task spawn error: {}", e))))
}
/// Parse the Autocrypt header and return the ASCII-armored public key, or None.
///
/// RFC 7929 / Autocrypt Level 1: the `keydata=` attribute holds the raw bytes
/// of the exported OpenPGP certificate, Base64-encoded (no line breaks, no armor).
/// We re-armor them so callers can use the result directly with Sequoia.
fn extract_autocrypt_key(parsed: &mail_parser::Message) -> Option<String> {
let header_value = parsed
.headers()
.iter()
.find(|h| h.name.as_str().eq_ignore_ascii_case("Autocrypt"))
.and_then(|h| match &h.value {
mail_parser::HeaderValue::Text(t) => Some(t.as_ref().to_string()),
mail_parser::HeaderValue::TextList(list) => list.first().map(|s| s.to_string()),
_ => None,
})?;
// Extract keydata= value (may be followed by ; or end of string)
let lower = header_value.to_lowercase();
let keydata_start = lower.find("keydata=")?;
let rest = &header_value[keydata_start + "keydata=".len()..];
let keydata_b64: String = rest
.split(';')
.next()?
.chars()
.filter(|c| !c.is_whitespace())
.collect();
if keydata_b64.is_empty() {
return None;
}
// Re-armor: wrap the raw base64 in PGP armor header/footer
// (The keydata is already the raw exported cert bytes in base64; we split
// into 64-char lines for canonical OpenPGP ASCII Armor format.)
let wrapped: String = keydata_b64
.chars()
.collect::<Vec<_>>()
.chunks(64)
.map(|chunk| chunk.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n");
Some(format!(
"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n{wrapped}\n-----END PGP PUBLIC KEY BLOCK-----\n"
))
}
/// Look for the first MIME part with Content-Type application/pgp-keys and
/// return its content decoded as UTF-8 (the ASCII-armored public key block).
fn extract_pgp_keys_attachment(parsed: &mail_parser::Message) -> Option<String> {
for part in &parsed.parts {
let is_pgp_keys = part
.content_type()
.map(|ct| {
ct.ctype().eq_ignore_ascii_case("application")
&& ct.subtype().map(|s| s.eq_ignore_ascii_case("pgp-keys")).unwrap_or(false)
})
.unwrap_or(false);
if is_pgp_keys {
return std::str::from_utf8(part.contents()).ok().map(|s| s.to_string());
}
}
None
}
fn detect_pgp_status(parsed: &mail_parser::Message) -> (crate::types::PgpStatus, Option<String>) {
let mut pgp_status = crate::types::PgpStatus::None;
let signature_fingerprint = None;
@@ -502,6 +574,12 @@ pub async fn sync_mailbox(
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
// Skip syncing folders pending a full wipe — avoids repopulating messages
// before the IMAP expunge completes.
if storage.is_folder_pending_wipe(&email, &mailbox) {
return Ok(vec![]);
}
// Step 1: Run sync on the shared session.
let sync_result = {
let client_arc =
@@ -626,6 +704,7 @@ pub async fn check_connection(email: String) -> bool {
pub fn get_cached_messages(
storage_path: String,
mailbox: String,
account_email: String,
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
@@ -634,9 +713,18 @@ pub fn get_cached_messages(
};
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
storage
let mut headers = storage
.get_headers(&mailbox)
.map_err(|e| MailError::Generic(e.to_string()))
.map_err(|e| MailError::Generic(e.to_string()))?;
// Ensure account_email is set on every returned header. Rows migrated from
// older schema versions will have an empty string in the column; overwrite
// them here so callers always get a non-empty value.
for h in &mut headers {
if h.account_email.is_empty() {
h.account_email = account_email.clone();
}
}
Ok(headers)
}
pub fn save_draft(path: String, draft: crate::types::Draft) -> Result<i64, MailError> {
@@ -737,10 +825,11 @@ pub async fn sync_drafts(
.write_to_vec()
.map_err(|e| MailError::Generic(e.to_string()))?;
if let Ok(_) = client
if let Ok(uid) = client
.append_message(&drafts_folder, &message_bytes, Some(r"\Draft".to_string()))
.await
{
draft.server_uid = Some(uid);
draft.last_synced_at = draft.last_updated;
let _ = storage.save_draft(&draft);
}
@@ -813,7 +902,10 @@ pub async fn move_message(
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let _ = storage.delete_header(&from_mailbox, uid);
storage
.delete_header(&from_mailbox, uid)
.map_err(|e| MailError::Generic(e.to_string()))?;
let _ = storage.delete_drafts_by_server_uids(&[uid]);
Ok(())
}
@@ -858,11 +950,62 @@ pub async fn move_messages(
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let _ = storage.delete_headers(&from_mailbox, &uids);
storage
.delete_headers(&from_mailbox, &uids)
.map_err(|e| MailError::Generic(e.to_string()))?;
let _ = storage.delete_drafts_by_server_uids(&uids);
Ok(())
}
pub async fn delete_messages_permanently(
config: MailServerConfig,
email: String,
password: String,
mailbox: String,
uids: Vec<u32>,
storage_path: String,
) -> Result<(), MailError> {
if uids.is_empty() {
return Ok(());
}
let mut client = crate::imap::ImapClient::new(config, email);
client
.connect(&password)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let result = client
.delete_messages_permanently(&mailbox, &uids)
.await
.map_err(|e| MailError::Network(e.to_string()));
let _ = client.logout().await;
result?;
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
storage
.delete_headers(&mailbox, &uids)
.map_err(|e| MailError::Generic(e.to_string()))?;
Ok(())
}
#[frb(sync)]
pub fn mark_as_read_local(
storage_path: String,
mailbox: String,
uid: u32,
) -> Result<(), MailError> {
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
storage
.update_read_status(&mailbox, uid, true)
.map_err(|e| MailError::Generic(e.to_string()))?;
Ok(())
}
pub async fn mark_as_read(
config: MailServerConfig,
email: String,
@@ -970,12 +1113,32 @@ pub async fn mark_all_as_read(
Ok(())
}
#[frb(sync)]
pub fn prepare_delete_all_messages(
email: String,
mailbox: String,
storage_path: String,
wipe_drafts: bool,
) -> Result<(), MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
storage
.set_folder_pending_wipe(&email, &mailbox, wipe_drafts)
.map_err(|e| MailError::Generic(e.to_string()))
}
pub async fn delete_all_messages(
config: MailServerConfig,
email: String,
password: String,
mailbox: String,
storage_path: String,
wipe_drafts: bool,
) -> Result<(), MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
@@ -1000,11 +1163,42 @@ pub async fn delete_all_messages(
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
// Wipe local cache (idempotent — also done by prepare, covers the resume path).
let _ = storage.wipe_folder(&mailbox);
let _ = storage.update_server_total(&email, &mailbox, 0);
if wipe_drafts {
let _ = storage.wipe_drafts();
}
let _ = storage.clear_folder_pending_wipe(&email, &mailbox);
Ok(())
}
pub async fn resume_pending_wipes(
config: MailServerConfig,
email: String,
password: String,
storage_path: String,
) -> Result<(), MailError> {
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let pending = storage
.get_pending_wipes(&email)
.map_err(|e| MailError::Generic(e.to_string()))?;
for (folder, wd) in pending {
let _ = delete_all_messages(
config.clone(),
email.clone(),
password.clone(),
folder,
storage_path.clone(),
wd,
)
.await;
}
Ok(())
}
pub async fn create_folder(
config: MailServerConfig,
email: String,
@@ -1059,17 +1253,17 @@ pub async fn delete_folder(
}
pub fn save_password(account: String, service: String, password: String) -> Result<(), MailError> {
crate::secrets::SecretManager::set_password(&account, &service, &password)
Err(crate::types::MailError::Generic("keyring not available".into()))?; Ok(())
.map_err(|e| MailError::Generic(e.to_string()))
}
pub fn get_password(account: String, service: String) -> Result<Option<String>, MailError> {
crate::secrets::SecretManager::get_password(&account, &service)
Ok(None) // keyring not available
.map_err(|e| MailError::Generic(e.to_string()))
}
pub fn delete_credential(account: String, service: String) -> Result<(), MailError> {
crate::secrets::SecretManager::delete_password(&account, &service)
Ok(()) // keyring not available
.map_err(|e| MailError::Generic(e.to_string()))
}