sync: mailcore 0.2.1-beta
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -465,6 +465,7 @@ impl ImapClient {
|
||||
has_attachments,
|
||||
pgp_status,
|
||||
flag: message_flag,
|
||||
account_email: self.email.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -541,6 +542,7 @@ impl ImapClient {
|
||||
attachments,
|
||||
pgp_status: crate::types::PgpStatus::None,
|
||||
signature_fingerprint: None,
|
||||
sender_public_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -761,7 +763,9 @@ impl ImapClient {
|
||||
|
||||
if should_be_present && !server_uids.contains(&local.uid) {
|
||||
tracing::debug!("Pruning UID {} from {}", local.uid, mailbox);
|
||||
let _ = storage.delete_header(mailbox, local.uid);
|
||||
if let Err(e) = storage.delete_header(mailbox, local.uid) {
|
||||
tracing::error!("Failed to prune UID {} from {}: {}", local.uid, mailbox, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,30 +1037,48 @@ impl ImapClient {
|
||||
mailbox: &str,
|
||||
content: &[u8],
|
||||
_flags: Option<String>,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
) -> Result<u32, Box<dyn Error + Send + Sync>> {
|
||||
let session = self.session.as_mut().ok_or("Not connected")?;
|
||||
// Capture UIDNEXT before the APPEND so we can return the assigned UID.
|
||||
// RFC 3501 §2.3.1.1 guarantees UIDs are monotonically increasing and
|
||||
// APPEND assigns the next available UID.
|
||||
let mb = session.select(mailbox).await.map_err(|e| e.to_string())?;
|
||||
let uid_next = mb.uid_next.unwrap_or(0);
|
||||
session
|
||||
.append(mailbox, content)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
Ok(uid_next)
|
||||
}
|
||||
|
||||
pub async fn delete_all_messages(
|
||||
&mut self,
|
||||
mailbox: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
self.select_mailbox(mailbox).await?;
|
||||
let (_, exists) = self.select_mailbox(mailbox).await?;
|
||||
// RFC 3501: "1:*" is undefined on an empty mailbox; some servers return NO.
|
||||
if exists == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let session = self.session.as_mut().ok_or("Not connected")?;
|
||||
|
||||
// Mark every message as deleted
|
||||
// Mark messages deleted in batches of 500 sequence numbers. A single
|
||||
// "1:*" STORE on thousands of messages causes Gmail (and other large
|
||||
// servers) to stall for minutes before responding; batching keeps each
|
||||
// round-trip fast. Use .SILENT to suppress per-message FETCH responses.
|
||||
const BATCH: u32 = 500;
|
||||
let mut start = 1u32;
|
||||
while start <= exists {
|
||||
let end = (start + BATCH - 1).min(exists);
|
||||
session
|
||||
.uid_store("1:*", "+FLAGS (\\Deleted)")
|
||||
.store(format!("{start}:{end}"), "+FLAGS.SILENT (\\Deleted)")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
start += BATCH;
|
||||
}
|
||||
|
||||
// Expunge to permanently remove
|
||||
session
|
||||
@@ -1070,6 +1092,34 @@ impl ImapClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_messages_permanently(
|
||||
&mut self,
|
||||
mailbox: &str,
|
||||
uids: &[u32],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.select_mailbox(mailbox).await?;
|
||||
let session = self.session.as_mut().ok_or("Not connected")?;
|
||||
let uid_set = uids.iter().map(|u| u.to_string()).collect::<Vec<_>>().join(",");
|
||||
session
|
||||
.uid_store(&uid_set, "+FLAGS.SILENT (\\Deleted)")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
session
|
||||
.expunge()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_message(
|
||||
&mut self,
|
||||
mailbox: &str,
|
||||
|
||||
@@ -3,7 +3,6 @@ pub mod api;
|
||||
pub mod dns;
|
||||
pub mod imap;
|
||||
pub mod mime;
|
||||
pub mod secrets;
|
||||
pub mod smtp;
|
||||
pub mod storage;
|
||||
pub mod types;
|
||||
|
||||
@@ -83,10 +83,22 @@ impl Storage {
|
||||
let _ = conn.execute("ALTER TABLE contacts ADD COLUMN first_seen INTEGER", []);
|
||||
let _ = conn.execute("ALTER TABLE contacts ADD COLUMN last_seen INTEGER", []);
|
||||
let _ = conn.execute("ALTER TABLE messages_v2 ADD COLUMN flag TEXT", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE messages_v2 ADD COLUMN account_email TEXT NOT NULL DEFAULT ''",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE sync_state ADD COLUMN server_total INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE sync_state ADD COLUMN pending_wipe INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE sync_state ADD COLUMN pending_wipe_drafts INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// sync_state table
|
||||
conn.execute(
|
||||
@@ -154,7 +166,10 @@ impl Storage {
|
||||
}
|
||||
|
||||
fn connect(&self) -> Result<Connection, Box<dyn Error + Send + Sync>> {
|
||||
Ok(Connection::open(&self.path)?)
|
||||
let conn = Connection::open(&self.path)?;
|
||||
let _ = conn.execute("PRAGMA journal_mode=WAL", []);
|
||||
let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn save_draft(&self, draft: &Draft) -> Result<i64, Box<dyn Error + Send + Sync>> {
|
||||
@@ -243,6 +258,27 @@ impl Storage {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_drafts_by_server_uids(
|
||||
&self,
|
||||
uids: &[u32],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let conn = self.connect()?;
|
||||
let placeholders = (1..=uids.len())
|
||||
.map(|i| format!("?{i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!("DELETE FROM drafts WHERE server_uid IN ({placeholders})");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let p: Vec<Box<dyn rusqlite::ToSql>> =
|
||||
uids.iter().map(|&u| Box::new(u) as Box<dyn rusqlite::ToSql>).collect();
|
||||
let refs: Vec<&dyn rusqlite::ToSql> = p.iter().map(|v| v.as_ref()).collect();
|
||||
stmt.execute(refs.as_slice())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_header(
|
||||
&self,
|
||||
folder: &str,
|
||||
@@ -253,8 +289,8 @@ impl Storage {
|
||||
serde_json::to_string(&header.to).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO messages_v2 (folder, uid, subject, sender, recipients, date, is_read, has_attachments, pgp_status, flag)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"INSERT OR REPLACE INTO messages_v2 (folder, uid, subject, sender, recipients, date, is_read, has_attachments, pgp_status, flag, account_email)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
folder,
|
||||
header.uid,
|
||||
@@ -266,6 +302,7 @@ impl Storage {
|
||||
if header.has_attachments { 1 } else { 0 },
|
||||
format!("{:?}", header.pgp_status),
|
||||
header.flag.clone(),
|
||||
header.account_email,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -293,7 +330,7 @@ impl Storage {
|
||||
folder: &str,
|
||||
) -> Result<Vec<MessageHeader>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare("SELECT uid, subject, sender, recipients, date, is_read, has_attachments, pgp_status, flag FROM messages_v2 WHERE folder = ?1 ORDER BY date DESC")?;
|
||||
let mut stmt = conn.prepare("SELECT uid, subject, sender, recipients, date, is_read, has_attachments, pgp_status, flag, account_email FROM messages_v2 WHERE folder = ?1 ORDER BY date DESC")?;
|
||||
let rows = stmt.query_map(params![folder], |row| {
|
||||
let recipients_json: String = row.get(3)?;
|
||||
let to: Vec<String> = serde_json::from_str(&recipients_json).unwrap_or_default();
|
||||
@@ -318,6 +355,7 @@ impl Storage {
|
||||
has_attachments: row.get::<_, i32>(6)? == 1,
|
||||
pgp_status,
|
||||
flag: row.get(8).unwrap_or(None),
|
||||
account_email: row.get(9).unwrap_or_default(),
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -399,6 +437,72 @@ impl Storage {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn wipe_drafts(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute("DELETE FROM drafts", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_folder_pending_wipe(
|
||||
&self,
|
||||
account: &str,
|
||||
folder: &str,
|
||||
wipe_drafts_flag: bool,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO sync_state (account, folder, uidvalidity, last_uid, server_total) VALUES (?1, ?2, 0, 0, 0)",
|
||||
params![account, folder],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET pending_wipe = 1, pending_wipe_drafts = ?3, server_total = 0 WHERE account = ?1 AND folder = ?2",
|
||||
params![account, folder, wipe_drafts_flag as i32],
|
||||
)?;
|
||||
self.wipe_folder(folder)?;
|
||||
if wipe_drafts_flag {
|
||||
self.wipe_drafts()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_folder_pending_wipe(
|
||||
&self,
|
||||
account: &str,
|
||||
folder: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET pending_wipe = 0, pending_wipe_drafts = 0 WHERE account = ?1 AND folder = ?2",
|
||||
params![account, folder],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_folder_pending_wipe(&self, account: &str, folder: &str) -> bool {
|
||||
let Ok(conn) = self.connect() else { return false };
|
||||
conn.query_row(
|
||||
"SELECT pending_wipe FROM sync_state WHERE account = ?1 AND folder = ?2",
|
||||
params![account, folder],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
!= 0
|
||||
}
|
||||
|
||||
pub fn get_pending_wipes(
|
||||
&self,
|
||||
account: &str,
|
||||
) -> Result<Vec<(String, bool)>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT folder, pending_wipe_drafts FROM sync_state WHERE account = ?1 AND pending_wipe = 1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![account], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)? != 0))
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
pub fn update_read_status(
|
||||
&self,
|
||||
folder: &str,
|
||||
@@ -470,7 +574,13 @@ impl Storage {
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO contacts (email, display_name, public_key, fingerprint, trust_level, first_seen, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
"INSERT INTO contacts (email, display_name, public_key, fingerprint, trust_level, first_seen, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(email) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
public_key = excluded.public_key,
|
||||
fingerprint = excluded.fingerprint,
|
||||
trust_level = excluded.trust_level,
|
||||
last_seen = excluded.last_seen",
|
||||
params![
|
||||
contact.email.to_lowercase(),
|
||||
contact.display_name,
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct MessageHeader {
|
||||
pub has_attachments: bool,
|
||||
pub pgp_status: PgpStatus,
|
||||
pub flag: Option<String>,
|
||||
pub account_email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -43,6 +44,9 @@ pub struct MessageBody {
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub pgp_status: PgpStatus,
|
||||
pub signature_fingerprint: Option<String>,
|
||||
/// ASCII-armored PGP public key of the sender, extracted from an Autocrypt
|
||||
/// header or an application/pgp-keys MIME attachment.
|
||||
pub sender_public_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
133
mailcore/tests/pgp_key_extraction_test.rs
Normal file
133
mailcore/tests/pgp_key_extraction_test.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
/// Tests for PGP sender public key extraction in parse_mime_message:
|
||||
/// - Autocrypt header parsing
|
||||
/// - application/pgp-keys attachment fallback
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_autocrypt_header_extracts_sender_key() {
|
||||
// Minimal PGP public key bytes (32 bytes of zeros, just enough to test
|
||||
// the base64/armor wrapping — not a valid OpenPGP cert but structurally
|
||||
// correct for the extraction path).
|
||||
let raw_key_bytes = vec![0u8; 32];
|
||||
let b64_key = {
|
||||
use std::fmt::Write as _;
|
||||
let mut s = String::new();
|
||||
for byte in &raw_key_bytes {
|
||||
write!(s, "{:02x}", byte).unwrap();
|
||||
}
|
||||
// Use standard base64 encoding
|
||||
base64_encode(&raw_key_bytes)
|
||||
};
|
||||
|
||||
let raw_email = format!(
|
||||
"From: sender@example.com\r\n\
|
||||
To: recipient@example.com\r\n\
|
||||
Subject: Test Autocrypt\r\n\
|
||||
Autocrypt: addr=sender@example.com; prefer-encrypt=mutual; keydata={b64_key}\r\n\
|
||||
MIME-Version: 1.0\r\n\
|
||||
Content-Type: text/plain\r\n\
|
||||
\r\n\
|
||||
Hello world\r\n"
|
||||
);
|
||||
|
||||
let body = mailcore::api::parse_mime_message(raw_email.as_bytes())
|
||||
.await
|
||||
.expect("parse should succeed");
|
||||
|
||||
let key = body.sender_public_key.expect("sender_public_key should be set");
|
||||
assert!(
|
||||
key.contains("-----BEGIN PGP PUBLIC KEY BLOCK-----"),
|
||||
"extracted key should be armored: {}",
|
||||
key
|
||||
);
|
||||
assert!(
|
||||
key.contains("-----END PGP PUBLIC KEY BLOCK-----"),
|
||||
"extracted key should have armor footer: {}",
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pgp_keys_attachment_extracts_sender_key() {
|
||||
let fake_armored_key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\
|
||||
\n\
|
||||
mDMEYmFrZWtleWZha2VrZXlmYWtla2V5ZmFrZQ==\n\
|
||||
-----END PGP PUBLIC KEY BLOCK-----\n";
|
||||
|
||||
let boundary = "testboundary123";
|
||||
let raw_email = format!(
|
||||
"From: sender@example.com\r\n\
|
||||
To: recipient@example.com\r\n\
|
||||
Subject: Test PGP Keys Attachment\r\n\
|
||||
MIME-Version: 1.0\r\n\
|
||||
Content-Type: multipart/mixed; boundary=\"{boundary}\"\r\n\
|
||||
\r\n\
|
||||
--{boundary}\r\n\
|
||||
Content-Type: text/plain\r\n\
|
||||
\r\n\
|
||||
Hello world\r\n\
|
||||
\r\n\
|
||||
--{boundary}\r\n\
|
||||
Content-Type: application/pgp-keys; name=\"sender.asc\"\r\n\
|
||||
Content-Disposition: attachment; filename=\"sender.asc\"\r\n\
|
||||
\r\n\
|
||||
{fake_armored_key}\r\n\
|
||||
--{boundary}--\r\n"
|
||||
);
|
||||
|
||||
let body = mailcore::api::parse_mime_message(raw_email.as_bytes())
|
||||
.await
|
||||
.expect("parse should succeed");
|
||||
|
||||
let key = body.sender_public_key.expect("sender_public_key should be set from pgp-keys attachment");
|
||||
assert!(
|
||||
key.contains("BEGIN PGP PUBLIC KEY BLOCK"),
|
||||
"extracted key should contain PGP armor header: {}",
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_key_in_plain_email() {
|
||||
let raw_email = "From: sender@example.com\r\n\
|
||||
To: recipient@example.com\r\n\
|
||||
Subject: Plain email\r\n\
|
||||
MIME-Version: 1.0\r\n\
|
||||
Content-Type: text/plain\r\n\
|
||||
\r\n\
|
||||
No PGP here.\r\n";
|
||||
|
||||
let body = mailcore::api::parse_mime_message(raw_email.as_bytes())
|
||||
.await
|
||||
.expect("parse should succeed");
|
||||
|
||||
assert!(
|
||||
body.sender_public_key.is_none(),
|
||||
"sender_public_key should be None for plain emails"
|
||||
);
|
||||
}
|
||||
|
||||
/// Simple base64 encoder (standard alphabet, no line breaks) for test use.
|
||||
fn base64_encode(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
let b0 = input[i] as usize;
|
||||
let b1 = if i + 1 < input.len() { input[i + 1] as usize } else { 0 };
|
||||
let b2 = if i + 2 < input.len() { input[i + 2] as usize } else { 0 };
|
||||
out.push(ALPHABET[(b0 >> 2)] as char);
|
||||
out.push(ALPHABET[((b0 & 3) << 4) | (b1 >> 4)] as char);
|
||||
if i + 1 < input.len() {
|
||||
out.push(ALPHABET[((b1 & 0xf) << 2) | (b2 >> 6)] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
if i + 2 < input.len() {
|
||||
out.push(ALPHABET[b2 & 0x3f] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
i += 3;
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user