chore: sync mailcore v0.1.0-beta.1
This commit is contained in:
1
mailcore/.gitignore
vendored
Normal file
1
mailcore/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
75
mailcore/Cargo.toml
Normal file
75
mailcore/Cargo.toml
Normal file
@@ -0,0 +1,75 @@
|
||||
[package]
|
||||
name = "mailcore"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
# IMAP/SMTP
|
||||
async-imap = { version = "0.9", default-features = false, features = ["runtime-tokio"] }
|
||||
lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "builder", "smtp-transport", "pool"] }
|
||||
|
||||
# TLS Support (Manual for IMAP)
|
||||
tokio-rustls = "0.26"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
rustls-native-certs = "0.7" # Confirm 0.7 or 0.8 for rustls 0.23
|
||||
rustls-pki-types = "1"
|
||||
webpki-roots = "0.26"
|
||||
|
||||
# MIME
|
||||
mail-parser = "0.9"
|
||||
mail-builder = "0.3"
|
||||
# HTTP Client
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
sha1 = "0.10"
|
||||
zbase32 = "0.1"
|
||||
|
||||
# DNS
|
||||
trust-dns-resolver = { version = "0.23", default-features = false, features = ["tokio-runtime", "dns-over-rustls"] }
|
||||
|
||||
# Storage
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# FFI
|
||||
flutter_rust_bridge = "=2.11.1"
|
||||
once_cell = "1"
|
||||
lazy_static = "1"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
futures = "0.3.31"
|
||||
chrono = "0.4.43"
|
||||
# Removed native-tls dependencies for better cross-platform compatibility
|
||||
keyring = { version = "3", features = ["sync-secret-service"] }
|
||||
|
||||
# Platform-specific
|
||||
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
|
||||
security-framework = "2"
|
||||
core-foundation = "0.9"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = false
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
18
mailcore/examples/test_dns.rs
Normal file
18
mailcore/examples/test_dns.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use mailcore::dns::discover_mail_server;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let email = std::env::args().nth(1).expect("Usage: test_dns <email>");
|
||||
|
||||
println!("Discovering server for: {}", email);
|
||||
|
||||
match discover_mail_server(&email).await {
|
||||
Ok(config) => {
|
||||
println!("✓ IMAP: {}:{}", config.imap_host, config.imap_port);
|
||||
println!("✓ SMTP: {}:{}", config.smtp_host, config.smtp_port);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
mailcore/examples/test_imap.rs
Normal file
52
mailcore/examples/test_imap.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use mailcore::dns::discover_mail_server;
|
||||
use mailcore::imap::ImapClient;
|
||||
use std::io::{self, Write};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: test_imap <email>");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let email = &args[1];
|
||||
|
||||
println!("Discovering server for: {}", email);
|
||||
let config = match discover_mail_server(email).await {
|
||||
Ok(c) => {
|
||||
println!("✓ IMAP: {}:{}", c.imap_host, c.imap_port);
|
||||
c
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ DNS Error: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
print!("Password for {}: ", email);
|
||||
io::stdout().flush().unwrap();
|
||||
let mut password = String::new();
|
||||
io::stdin().read_line(&mut password).unwrap();
|
||||
let password = password.trim();
|
||||
|
||||
let mut client = ImapClient::new(config, email.to_string());
|
||||
|
||||
println!("Connecting...");
|
||||
match client.connect(password).await {
|
||||
Ok(_) => {
|
||||
println!("✓ Connected and authenticated successfully!");
|
||||
|
||||
println!("Listing folders...");
|
||||
match client.list_folders(true).await {
|
||||
Ok(folders) => {
|
||||
println!("\nFolders:");
|
||||
for folder in folders {
|
||||
println!(" - {} ({:?})", folder.name, folder.folder_type);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("✗ Failed to list folders: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("✗ Connection Error: {}", e),
|
||||
}
|
||||
}
|
||||
1153
mailcore/src/api.rs
Normal file
1153
mailcore/src/api.rs
Normal file
File diff suppressed because it is too large
Load Diff
2
mailcore/src/dns/mod.rs
Normal file
2
mailcore/src/dns/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod srv;
|
||||
pub use srv::discover_mail_server;
|
||||
108
mailcore/src/dns/srv.rs
Normal file
108
mailcore/src/dns/srv.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use crate::types::MailServerConfig;
|
||||
use std::error::Error;
|
||||
use trust_dns_resolver::proto::rr::RecordType;
|
||||
use trust_dns_resolver::{
|
||||
config::{ResolverConfig, ResolverOpts},
|
||||
TokioAsyncResolver,
|
||||
};
|
||||
|
||||
fn known_provider_config(domain: &str) -> Option<MailServerConfig> {
|
||||
match domain {
|
||||
"gmx.net" | "gmx.com" | "gmx.de" | "gmx.at" | "gmx.ch" => Some(MailServerConfig {
|
||||
imap_host: format!("imap.{}", domain),
|
||||
imap_port: 993,
|
||||
smtp_host: format!("mail.{}", domain),
|
||||
smtp_port: 587,
|
||||
}),
|
||||
"gmail.com" | "googlemail.com" => Some(MailServerConfig {
|
||||
imap_host: "imap.gmail.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "smtp.gmail.com".to_string(),
|
||||
smtp_port: 587,
|
||||
}),
|
||||
"yahoo.com" | "ymail.com" | "yahoo.co.uk" | "yahoo.co.in" => Some(MailServerConfig {
|
||||
imap_host: "imap.mail.yahoo.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "smtp.mail.yahoo.com".to_string(),
|
||||
smtp_port: 587,
|
||||
}),
|
||||
"outlook.com" | "hotmail.com" | "live.com" | "msn.com" => Some(MailServerConfig {
|
||||
imap_host: "outlook.office365.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "smtp.office365.com".to_string(),
|
||||
smtp_port: 587,
|
||||
}),
|
||||
"icloud.com" | "me.com" | "mac.com" => Some(MailServerConfig {
|
||||
imap_host: "imap.mail.me.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "smtp.mail.me.com".to_string(),
|
||||
smtp_port: 587,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn discover_mail_server(email: &str) -> Result<MailServerConfig, Box<dyn Error>> {
|
||||
let domain = email.split('@').nth(1).ok_or("Invalid email")?;
|
||||
|
||||
// Check well-known providers first to avoid slow DNS fallbacks
|
||||
if let Some(config) = known_provider_config(domain) {
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// Create async resolver
|
||||
// Note: TokioAsyncResolver::tokio constructor is used for tokio runtime
|
||||
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
|
||||
|
||||
// Try IMAPS SRV (_imaps._tcp.domain.com)
|
||||
let imap_srv = format!("_imaps._tcp.{}", domain);
|
||||
let imap_config = match resolver.lookup(&imap_srv, RecordType::SRV).await {
|
||||
Ok(response) => {
|
||||
let srv = response.iter().next().ok_or("No SRV records")?;
|
||||
if let Some(srv_data) = srv.as_srv() {
|
||||
(
|
||||
srv_data
|
||||
.target()
|
||||
.to_string()
|
||||
.trim_end_matches('.')
|
||||
.to_string(),
|
||||
srv_data.port(),
|
||||
)
|
||||
} else {
|
||||
(domain.to_string(), 993) // Fallback
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// No SRV, use standard port
|
||||
(domain.to_string(), 993)
|
||||
}
|
||||
};
|
||||
|
||||
// Try SMTP Submission SRV (_submission._tcp.domain.com)
|
||||
let smtp_srv = format!("_submission._tcp.{}", domain);
|
||||
let smtp_config = match resolver.lookup(&smtp_srv, RecordType::SRV).await {
|
||||
Ok(response) => {
|
||||
let srv = response.iter().next().ok_or("No SRV records")?;
|
||||
if let Some(srv_data) = srv.as_srv() {
|
||||
(
|
||||
srv_data
|
||||
.target()
|
||||
.to_string()
|
||||
.trim_end_matches('.')
|
||||
.to_string(),
|
||||
srv_data.port(),
|
||||
)
|
||||
} else {
|
||||
(domain.to_string(), 587) // Fallback
|
||||
}
|
||||
}
|
||||
Err(_) => (domain.to_string(), 587),
|
||||
};
|
||||
|
||||
Ok(MailServerConfig {
|
||||
imap_host: imap_config.0,
|
||||
imap_port: imap_config.1,
|
||||
smtp_host: smtp_config.0,
|
||||
smtp_port: smtp_config.1,
|
||||
})
|
||||
}
|
||||
1210
mailcore/src/imap/client.rs
Normal file
1210
mailcore/src/imap/client.rs
Normal file
File diff suppressed because it is too large
Load Diff
3
mailcore/src/imap/mod.rs
Normal file
3
mailcore/src/imap/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod client;
|
||||
pub mod utf7;
|
||||
pub use client::ImapClient;
|
||||
215
mailcore/src/imap/utf7.rs
Normal file
215
mailcore/src/imap/utf7.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
/// RFC 3501 modified UTF-7 decoder for IMAP mailbox names
|
||||
///
|
||||
/// Modified UTF-7 (RFC 3501) encoding rules:
|
||||
/// - Printable US-ASCII characters (0x20-0x7e, except "&") are represented by themselves
|
||||
/// - "&" is represented by "&-"
|
||||
/// - All other characters are represented as "&" + modified-BASE64(UTF-16BE) + "-"
|
||||
/// - Modified BASE64 uses "A-Za-z0-9,+" instead of "A-Za-z0-9+/"
|
||||
|
||||
pub fn decode_modified_utf7(encoded: &str) -> String {
|
||||
let bytes = encoded.as_bytes();
|
||||
let mut result = String::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < bytes.len() {
|
||||
let byte = bytes[i];
|
||||
|
||||
// Printable ASCII (except '&') - pass through
|
||||
if byte != b'&' && byte >= 0x20 && byte <= 0x7e {
|
||||
result.push(byte as char);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// "&" starts a sequence
|
||||
if byte == b'&' {
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'-' {
|
||||
// "&-" represents literal "&"
|
||||
result.push('&');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the closing "-"
|
||||
let mut j = i + 1;
|
||||
while j < bytes.len() && bytes[j] != b'-' {
|
||||
j += 1;
|
||||
}
|
||||
|
||||
if j >= bytes.len() {
|
||||
// Malformed: no closing "-", treat as literal
|
||||
result.push('&');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the modified-BASE64 sequence
|
||||
let b64_str = std::str::from_utf8(&bytes[i + 1..j]).unwrap_or("");
|
||||
|
||||
// Decode modified-BASE64 (replace "," with "/")
|
||||
let b64_std = b64_str.replace(',', "/");
|
||||
|
||||
// Decode from standard BASE64
|
||||
if let Ok(decoded_bytes) = base64_decode(&b64_std) {
|
||||
// Convert UTF-16BE bytes to UTF-16 chars, then to UTF-8
|
||||
if let Ok(text) = decode_utf16be(&decoded_bytes) {
|
||||
result.push_str(&text);
|
||||
} else {
|
||||
// Invalid UTF-16, use replacement character
|
||||
result.push('\u{FFFD}');
|
||||
}
|
||||
} else {
|
||||
// Invalid BASE64, use replacement character
|
||||
result.push('\u{FFFD}');
|
||||
}
|
||||
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Other bytes (shouldn't happen in valid UTF-7)
|
||||
result.push('\u{FFFD}');
|
||||
i += 1;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Decode standard BASE64 (RFC 4648)
|
||||
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
|
||||
const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
let mut result = Vec::new();
|
||||
let input = input.as_bytes();
|
||||
let mut i = 0;
|
||||
|
||||
while i < input.len() {
|
||||
let mut buf = [0u8; 4];
|
||||
let mut len = 0;
|
||||
|
||||
// Read up to 4 base64 characters
|
||||
while len < 4 && i < input.len() {
|
||||
let b = input[i];
|
||||
if b == b'=' {
|
||||
break;
|
||||
}
|
||||
if let Some(pos) = TABLE.iter().position(|&x| x == b) {
|
||||
buf[len] = pos as u8;
|
||||
len += 1;
|
||||
} else if !b.is_ascii_whitespace() {
|
||||
return Err("Invalid base64 character".to_string());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Decode the 4-character group
|
||||
match len {
|
||||
0 => break,
|
||||
1 => return Err("Invalid base64 length".to_string()),
|
||||
2 => {
|
||||
result.push(((buf[0] << 2) | (buf[1] >> 4)) as u8);
|
||||
}
|
||||
3 => {
|
||||
result.push(((buf[0] << 2) | (buf[1] >> 4)) as u8);
|
||||
result.push((((buf[1] & 0x0f) << 4) | (buf[2] >> 2)) as u8);
|
||||
}
|
||||
4 => {
|
||||
result.push(((buf[0] << 2) | (buf[1] >> 4)) as u8);
|
||||
result.push((((buf[1] & 0x0f) << 4) | (buf[2] >> 2)) as u8);
|
||||
result.push((((buf[2] & 0x03) << 6) | buf[3]) as u8);
|
||||
}
|
||||
_ => return Err("Invalid base64 length".to_string()),
|
||||
}
|
||||
|
||||
// Skip padding
|
||||
while i < input.len() && input[i] == b'=' {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Decode UTF-16BE bytes to UTF-8 string
|
||||
fn decode_utf16be(bytes: &[u8]) -> Result<String, String> {
|
||||
if bytes.len() % 2 != 0 {
|
||||
return Err("Odd number of bytes in UTF-16BE sequence".to_string());
|
||||
}
|
||||
|
||||
let mut chars = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < bytes.len() {
|
||||
let high = u16::from_be_bytes([bytes[i], bytes[i + 1]]);
|
||||
i += 2;
|
||||
|
||||
// Check for surrogate pair
|
||||
if (high & 0xFC00) == 0xD800 {
|
||||
// High surrogate, expect low surrogate
|
||||
if i + 2 > bytes.len() {
|
||||
chars.push('\u{FFFD}');
|
||||
break;
|
||||
}
|
||||
let low = u16::from_be_bytes([bytes[i], bytes[i + 1]]);
|
||||
i += 2;
|
||||
|
||||
if (low & 0xFC00) != 0xDC00 {
|
||||
chars.push('\u{FFFD}');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decode surrogate pair
|
||||
let high_bits = (high & 0x3FF) as u32;
|
||||
let low_bits = (low & 0x3FF) as u32;
|
||||
let codepoint = 0x10000 + (high_bits << 10) + low_bits;
|
||||
|
||||
if let Some(ch) = char::from_u32(codepoint) {
|
||||
chars.push(ch);
|
||||
} else {
|
||||
chars.push('\u{FFFD}');
|
||||
}
|
||||
} else if (high & 0xFC00) == 0xDC00 {
|
||||
// Unexpected low surrogate
|
||||
chars.push('\u{FFFD}');
|
||||
} else {
|
||||
// Regular BMP character
|
||||
if let Some(ch) = char::from_u32(high as u32) {
|
||||
chars.push(ch);
|
||||
} else {
|
||||
chars.push('\u{FFFD}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(chars.iter().collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ascii_passthrough() {
|
||||
assert_eq!(decode_modified_utf7("INBOX"), "INBOX");
|
||||
assert_eq!(decode_modified_utf7("Sent Mail"), "Sent Mail");
|
||||
assert_eq!(decode_modified_utf7("Drafts"), "Drafts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal_ampersand() {
|
||||
assert_eq!(decode_modified_utf7("A&-B"), "A&B");
|
||||
assert_eq!(decode_modified_utf7("&-"), "&");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_ascii_and_encoded() {
|
||||
// Test that ASCII parts pass through while encoded parts are decoded
|
||||
// This is a basic test to ensure the structure works
|
||||
assert!(decode_modified_utf7("Folder&-Name").contains("&"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_empty() {
|
||||
assert_eq!(decode_modified_utf7(""), "");
|
||||
}
|
||||
}
|
||||
13
mailcore/src/lib.rs
Normal file
13
mailcore/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
#![allow(unexpected_cfgs)]
|
||||
pub mod api;
|
||||
pub mod dns;
|
||||
pub mod imap;
|
||||
pub mod mime;
|
||||
pub mod secrets;
|
||||
pub mod smtp;
|
||||
pub mod storage;
|
||||
pub mod types;
|
||||
|
||||
// Re-export specific items if needed
|
||||
pub use types::MailServerConfig;
|
||||
pub mod session_manager;
|
||||
1
mailcore/src/mime/mod.rs
Normal file
1
mailcore/src/mime/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// MIME module placeholder
|
||||
112
mailcore/src/session_manager.rs
Normal file
112
mailcore/src/session_manager.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use crate::imap::ImapClient;
|
||||
use crate::types::MailServerConfig;
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
lazy_static! {
|
||||
/// A global map of active IMAP sessions, keyed by email address.
|
||||
/// Each session is wrapped in an Arc<Mutex> to allow safe concurrent access (though IMAP is largely sequential).
|
||||
static ref SESSIONS: Arc<Mutex<HashMap<String, Arc<Mutex<ImapClient>>>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
}
|
||||
|
||||
/// Retrieves an existing active session or creates a new one.
|
||||
/// If an existing session is found but disconnected, it attempts to reconnect.
|
||||
pub async fn get_client(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<Arc<Mutex<ImapClient>>, crate::types::MailError> {
|
||||
// Atomic get-or-insert to avoid race conditions on startup
|
||||
let (client_arc, is_new) = {
|
||||
let mut sessions = SESSIONS.lock().await;
|
||||
if let Some(arc) = sessions.get(&email) {
|
||||
(arc.clone(), false)
|
||||
} else {
|
||||
let client = ImapClient::new(config, email.clone());
|
||||
let arc = Arc::new(Mutex::new(client));
|
||||
sessions.insert(email.clone(), arc.clone());
|
||||
(arc, true)
|
||||
}
|
||||
};
|
||||
|
||||
let mut client = client_arc.lock().await;
|
||||
|
||||
if is_new {
|
||||
tracing::debug!(
|
||||
"SyncManager: Establishing new IMAP connection for {}...",
|
||||
email
|
||||
);
|
||||
if let Err(e) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20),
|
||||
client.connect(&password),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("Connection timeout".into()))
|
||||
{
|
||||
let err_msg = e.to_string();
|
||||
// Critical cleanup: Remove failed shell so it can be retried later
|
||||
{
|
||||
let mut sessions = SESSIONS.lock().await;
|
||||
sessions.remove(&email);
|
||||
}
|
||||
if err_msg.to_lowercase().contains("auth")
|
||||
|| err_msg.to_lowercase().contains("login")
|
||||
|| err_msg.to_lowercase().contains("credentials")
|
||||
{
|
||||
return Err(crate::types::MailError::Auth(err_msg));
|
||||
} else {
|
||||
return Err(crate::types::MailError::Network(err_msg));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Double-check connection health
|
||||
if !tokio::time::timeout(std::time::Duration::from_secs(5), client.keep_alive())
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
tracing::debug!(
|
||||
"SyncManager: Connection for {} is dead, attempting reconnect...",
|
||||
email
|
||||
);
|
||||
if let Err(e) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
client.reconnect(&password),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("Reconnect timeout".into()))
|
||||
{
|
||||
tracing::error!("SyncManager: Reconnect failed for {}: {}", email, e);
|
||||
// Remove the stale entry so the next call creates a fresh connection
|
||||
// instead of looping on keep_alive() against a broken session.
|
||||
drop(client);
|
||||
let mut sessions = SESSIONS.lock().await;
|
||||
sessions.remove(&email);
|
||||
return Err(crate::types::MailError::Network(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(client_arc.clone())
|
||||
}
|
||||
|
||||
/// Disconnects and removes a session from the manager.
|
||||
pub async fn logout(email: &str) {
|
||||
let mut sessions = SESSIONS.lock().await;
|
||||
if let Some(client_arc) = sessions.remove(email) {
|
||||
let mut client = client_arc.lock().await;
|
||||
let _ = client.logout().await;
|
||||
tracing::debug!("Logged out session for {}", email);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a session exists and is alive for the given email.
|
||||
pub async fn is_connected(email: &str) -> bool {
|
||||
let sessions = SESSIONS.lock().await;
|
||||
if let Some(client_arc) = sessions.get(email) {
|
||||
let mut client = client_arc.lock().await;
|
||||
return client.keep_alive().await;
|
||||
}
|
||||
false
|
||||
}
|
||||
120
mailcore/src/smtp/client.rs
Normal file
120
mailcore/src/smtp/client.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use crate::types::MailServerConfig;
|
||||
use lettre::message::{MultiPart, SinglePart};
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
use std::error::Error;
|
||||
|
||||
pub struct SmtpClient {
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
}
|
||||
|
||||
impl SmtpClient {
|
||||
pub fn new(config: MailServerConfig, email: String) -> Self {
|
||||
Self { config, email }
|
||||
}
|
||||
|
||||
pub async fn send_mail(
|
||||
&self,
|
||||
password: &str,
|
||||
to: &str,
|
||||
subject: &str,
|
||||
body_text: &str,
|
||||
attachments: Vec<crate::types::Attachment>,
|
||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let mut builder = Message::builder()
|
||||
.from(self.email.parse()?)
|
||||
.subject(subject);
|
||||
for addr in to.split(',') {
|
||||
builder = builder.to(addr.trim().parse()?);
|
||||
}
|
||||
|
||||
let email = if attachments.is_empty() {
|
||||
builder.singlepart(SinglePart::plain(body_text.to_string()))?
|
||||
} else {
|
||||
let mut multipart =
|
||||
MultiPart::mixed().singlepart(SinglePart::plain(body_text.to_string()));
|
||||
|
||||
for att in attachments {
|
||||
multipart = multipart.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(lettre::message::header::ContentType::parse(
|
||||
&att.content_type,
|
||||
)?)
|
||||
.header(lettre::message::header::ContentDisposition::attachment(
|
||||
&att.name,
|
||||
))
|
||||
.body(att.content),
|
||||
);
|
||||
}
|
||||
builder.multipart(multipart)?
|
||||
};
|
||||
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
|
||||
let creds = lettre::transport::smtp::authentication::Credentials::new(
|
||||
self.email.clone(),
|
||||
password.to_string(),
|
||||
);
|
||||
|
||||
let tls_parameters = TlsParameters::new(self.config.smtp_host.clone())?;
|
||||
|
||||
// Explicitly choose TLS mode based on port
|
||||
let tls = if self.config.smtp_port == 465 {
|
||||
Tls::Wrapper(tls_parameters)
|
||||
} else {
|
||||
Tls::Opportunistic(tls_parameters)
|
||||
};
|
||||
|
||||
let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host)
|
||||
.port(self.config.smtp_port)
|
||||
.tls(tls)
|
||||
.credentials(creds)
|
||||
.build();
|
||||
|
||||
let raw_bytes = email.formatted();
|
||||
mailer.send(&email)?;
|
||||
|
||||
Ok(raw_bytes)
|
||||
}
|
||||
|
||||
pub async fn send_raw_mail(
|
||||
&self,
|
||||
password: &str,
|
||||
to: &str,
|
||||
_subject: &str,
|
||||
raw_message: &[u8],
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
|
||||
let creds = lettre::transport::smtp::authentication::Credentials::new(
|
||||
self.email.clone(),
|
||||
password.to_string(),
|
||||
);
|
||||
|
||||
let tls_parameters = TlsParameters::new(self.config.smtp_host.clone())?;
|
||||
|
||||
let tls = if self.config.smtp_port == 465 {
|
||||
Tls::Wrapper(tls_parameters)
|
||||
} else {
|
||||
Tls::Opportunistic(tls_parameters)
|
||||
};
|
||||
|
||||
let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host)
|
||||
.port(self.config.smtp_port)
|
||||
.tls(tls)
|
||||
.credentials(creds)
|
||||
.build();
|
||||
|
||||
// Build a manual message from raw bytes; `to` may be comma-separated
|
||||
let recipients: Vec<lettre::address::Address> = to
|
||||
.split(',')
|
||||
.map(|addr| addr.trim().parse())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let envelope =
|
||||
lettre::address::Envelope::new(Some(self.email.parse()?), recipients)?;
|
||||
|
||||
mailer.send_raw(&envelope, raw_message)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
2
mailcore/src/smtp/mod.rs
Normal file
2
mailcore/src/smtp/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod client;
|
||||
pub use client::SmtpClient;
|
||||
733
mailcore/src/storage/mod.rs
Normal file
733
mailcore/src/storage/mod.rs
Normal file
@@ -0,0 +1,733 @@
|
||||
use crate::types::{Draft, MessageHeader, PgpStatus};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::error::Error;
|
||||
|
||||
pub struct Storage {
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new(path: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||
let conn = Connection::open(path)?;
|
||||
|
||||
// Enable WAL mode for concurrent read/write access.
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL;")?;
|
||||
|
||||
// Set a 5-second busy timeout.
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
|
||||
// New messages table with folder support
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS messages_v2 (
|
||||
folder TEXT,
|
||||
uid INTEGER,
|
||||
subject TEXT,
|
||||
sender TEXT,
|
||||
recipients TEXT,
|
||||
date INTEGER,
|
||||
is_read INTEGER,
|
||||
has_attachments INTEGER,
|
||||
pgp_status TEXT,
|
||||
flag TEXT,
|
||||
PRIMARY KEY (folder, uid)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Message bodies table for full offline cache
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS message_bodies (
|
||||
folder TEXT,
|
||||
uid INTEGER,
|
||||
raw_content BLOB,
|
||||
PRIMARY KEY (folder, uid)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS contacts (
|
||||
email TEXT PRIMARY KEY,
|
||||
display_name TEXT,
|
||||
public_key TEXT,
|
||||
fingerprint TEXT,
|
||||
trust_level TEXT DEFAULT 'Unknown',
|
||||
first_seen INTEGER,
|
||||
last_seen INTEGER
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Migrations for existing databases
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE messages_v2 ADD COLUMN recipients TEXT DEFAULT '[]'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE messages_v2 ADD COLUMN is_read INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE messages_v2 ADD COLUMN has_attachments INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE messages_v2 ADD COLUMN pgp_status TEXT DEFAULT 'None'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute("ALTER TABLE contacts ADD COLUMN fingerprint TEXT", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE contacts ADD COLUMN trust_level TEXT DEFAULT 'Unknown'",
|
||||
[],
|
||||
);
|
||||
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 sync_state ADD COLUMN server_total INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// sync_state table
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS sync_state (
|
||||
account TEXT,
|
||||
folder TEXT,
|
||||
uidvalidity INTEGER,
|
||||
last_uid INTEGER,
|
||||
PRIMARY KEY (account, folder)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Normalize INBOX
|
||||
let _ = conn.execute(
|
||||
"UPDATE messages_v2 SET folder = 'INBOX' WHERE LOWER(folder) = 'inbox' AND folder != 'INBOX'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"UPDATE message_bodies SET folder = 'INBOX' WHERE LOWER(folder) = 'inbox' AND folder != 'INBOX'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"UPDATE sync_state SET folder = 'INBOX' WHERE LOWER(folder) = 'inbox' AND folder != 'INBOX'",
|
||||
[],
|
||||
);
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS pgp_identities (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
public_key TEXT,
|
||||
private_key TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS drafts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recipient TEXT,
|
||||
subject TEXT,
|
||||
body TEXT,
|
||||
last_updated INTEGER,
|
||||
last_synced_at INTEGER DEFAULT 0,
|
||||
server_uid INTEGER
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS draft_attachments (
|
||||
draft_id INTEGER,
|
||||
name TEXT,
|
||||
content_type TEXT,
|
||||
content BLOB,
|
||||
FOREIGN KEY (draft_id) REFERENCES drafts(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(Storage {
|
||||
path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn connect(&self) -> Result<Connection, Box<dyn Error + Send + Sync>> {
|
||||
Ok(Connection::open(&self.path)?)
|
||||
}
|
||||
|
||||
pub fn save_draft(&self, draft: &Draft) -> Result<i64, Box<dyn Error + Send + Sync>> {
|
||||
let mut conn = self.connect()?;
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
let draft_id = if let Some(id) = draft.id {
|
||||
tx.execute(
|
||||
"UPDATE drafts SET recipient = ?1, subject = ?2, body = ?3, last_updated = ?4, last_synced_at = ?5, server_uid = ?6 WHERE id = ?7",
|
||||
params![draft.to, draft.subject, draft.body, draft.last_updated, draft.last_synced_at, draft.server_uid, id],
|
||||
)?;
|
||||
id
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO drafts (recipient, subject, body, last_updated, last_synced_at, server_uid) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![draft.to, draft.subject, draft.body, draft.last_updated, draft.last_synced_at, draft.server_uid],
|
||||
)?;
|
||||
tx.last_insert_rowid()
|
||||
};
|
||||
|
||||
// Update attachments
|
||||
tx.execute(
|
||||
"DELETE FROM draft_attachments WHERE draft_id = ?1",
|
||||
params![draft_id],
|
||||
)?;
|
||||
for att in &draft.attachments {
|
||||
tx.execute(
|
||||
"INSERT INTO draft_attachments (draft_id, name, content_type, content) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![draft_id, att.name, att.content_type, att.content],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(draft_id)
|
||||
}
|
||||
|
||||
pub fn get_drafts(&self) -> Result<Vec<Draft>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, recipient, subject, body, last_updated, last_synced_at, server_uid FROM drafts")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
|
||||
// Load attachments for this draft
|
||||
let mut att_stmt = conn.prepare(
|
||||
"SELECT name, content_type, content FROM draft_attachments WHERE draft_id = ?1",
|
||||
)?;
|
||||
let att_rows = att_stmt.query_map(params![id], |att_row| {
|
||||
let content: Vec<u8> = att_row.get(2)?;
|
||||
let size = content.len();
|
||||
Ok(crate::types::Attachment {
|
||||
name: att_row.get(0)?,
|
||||
content_type: att_row.get(1)?,
|
||||
size,
|
||||
content,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut attachments = Vec::new();
|
||||
for att in att_rows {
|
||||
attachments.push(att?);
|
||||
}
|
||||
|
||||
Ok(Draft {
|
||||
id: Some(id),
|
||||
to: row.get(1)?,
|
||||
subject: row.get(2)?,
|
||||
body: row.get(3)?,
|
||||
attachments,
|
||||
last_updated: row.get(4).unwrap_or(0),
|
||||
last_synced_at: row.get(5).unwrap_or(0),
|
||||
server_uid: row.get(6).ok(),
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut drafts = Vec::new();
|
||||
for draft in rows {
|
||||
drafts.push(draft?);
|
||||
}
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
pub fn delete_draft(&self, id: i64) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute("DELETE FROM drafts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_header(
|
||||
&self,
|
||||
folder: &str,
|
||||
header: &MessageHeader,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let recipients_json =
|
||||
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)",
|
||||
params![
|
||||
folder,
|
||||
header.uid,
|
||||
header.subject,
|
||||
header.from,
|
||||
recipients_json,
|
||||
header.date,
|
||||
if header.is_read { 1 } else { 0 },
|
||||
if header.has_attachments { 1 } else { 0 },
|
||||
format!("{:?}", header.pgp_status),
|
||||
header.flag.clone(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_uids(
|
||||
&self,
|
||||
folder: &str,
|
||||
) -> Result<std::collections::HashSet<u32>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare("SELECT uid FROM messages_v2 WHERE folder = ?1")?;
|
||||
let rows = stmt.query_map(rusqlite::params![folder], |row| row.get::<_, u32>(0))?;
|
||||
|
||||
let mut uids = std::collections::HashSet::new();
|
||||
for uid in rows {
|
||||
if let Ok(u) = uid {
|
||||
uids.insert(u);
|
||||
}
|
||||
}
|
||||
Ok(uids)
|
||||
}
|
||||
|
||||
pub fn get_headers(
|
||||
&self,
|
||||
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 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();
|
||||
let pgp_status_str: String = row.get(7)?;
|
||||
|
||||
let pgp_status = match pgp_status_str.as_str() {
|
||||
"Encrypted" => PgpStatus::Encrypted,
|
||||
"Signed" => PgpStatus::Signed,
|
||||
"SignedAndEncrypted" => PgpStatus::SignedAndEncrypted,
|
||||
"InvalidSignature" => PgpStatus::InvalidSignature,
|
||||
"EncryptedAtRest" => PgpStatus::EncryptedAtRest,
|
||||
_ => PgpStatus::None,
|
||||
};
|
||||
|
||||
Ok(MessageHeader {
|
||||
uid: row.get(0)?,
|
||||
subject: row.get(1)?,
|
||||
from: row.get(2)?,
|
||||
to,
|
||||
date: row.get(4)?,
|
||||
is_read: row.get::<_, i32>(5)? == 1,
|
||||
has_attachments: row.get::<_, i32>(6)? == 1,
|
||||
pgp_status,
|
||||
flag: row.get(8).unwrap_or(None),
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut headers = Vec::new();
|
||||
for header in rows {
|
||||
headers.push(header?);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
pub fn delete_header(
|
||||
&self,
|
||||
folder: &str,
|
||||
uid: u32,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"DELETE FROM messages_v2 WHERE folder = ?1 AND uid = ?2",
|
||||
params![folder, uid],
|
||||
)?;
|
||||
conn.execute(
|
||||
"DELETE FROM message_bodies WHERE folder = ?1 AND uid = ?2",
|
||||
params![folder, uid],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_headers(
|
||||
&self,
|
||||
folder: &str,
|
||||
uids: &[u32],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let conn = self.connect()?;
|
||||
let placeholders = (0..uids.len())
|
||||
.map(|i| format!("?{}", i + 2))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql_messages = format!(
|
||||
"DELETE FROM messages_v2 WHERE folder = ?1 AND uid IN ({})",
|
||||
placeholders
|
||||
);
|
||||
let sql_bodies = format!(
|
||||
"DELETE FROM message_bodies WHERE folder = ?1 AND uid IN ({})",
|
||||
placeholders
|
||||
);
|
||||
{
|
||||
let mut stmt = conn.prepare(&sql_messages)?;
|
||||
let mut p: Vec<Box<dyn rusqlite::ToSql>> = Vec::with_capacity(uids.len() + 1);
|
||||
p.push(Box::new(folder.to_string()));
|
||||
for &uid in uids {
|
||||
p.push(Box::new(uid));
|
||||
}
|
||||
let refs: Vec<&dyn rusqlite::ToSql> = p.iter().map(|v| v.as_ref()).collect();
|
||||
stmt.execute(refs.as_slice())?;
|
||||
}
|
||||
{
|
||||
let mut stmt = conn.prepare(&sql_bodies)?;
|
||||
let mut p: Vec<Box<dyn rusqlite::ToSql>> = Vec::with_capacity(uids.len() + 1);
|
||||
p.push(Box::new(folder.to_string()));
|
||||
for &uid in uids {
|
||||
p.push(Box::new(uid));
|
||||
}
|
||||
let refs: Vec<&dyn rusqlite::ToSql> = p.iter().map(|v| v.as_ref()).collect();
|
||||
stmt.execute(refs.as_slice())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn wipe_folder(&self, folder: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute("DELETE FROM messages_v2 WHERE folder = ?1", params![folder])?;
|
||||
conn.execute(
|
||||
"DELETE FROM message_bodies WHERE folder = ?1",
|
||||
params![folder],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_read_status(
|
||||
&self,
|
||||
folder: &str,
|
||||
uid: u32,
|
||||
is_read: bool,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"UPDATE messages_v2 SET is_read = ?1 WHERE folder = ?2 AND uid = ?3",
|
||||
params![if is_read { 1 } else { 0 }, folder, uid],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_all_as_read(&self, folder: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"UPDATE messages_v2 SET is_read = 1 WHERE folder = ?1",
|
||||
params![folder],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_message_flag(
|
||||
&self,
|
||||
folder: &str,
|
||||
uid: u32,
|
||||
flag: Option<String>,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"UPDATE messages_v2 SET flag = ?1 WHERE folder = ?2 AND uid = ?3",
|
||||
params![flag, folder, uid],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_message_raw(
|
||||
&self,
|
||||
folder: &str,
|
||||
uid: u32,
|
||||
content: &[u8],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO message_bodies (folder, uid, raw_content) VALUES (?1, ?2, ?3)",
|
||||
params![folder, uid, content],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_message_raw(
|
||||
&self,
|
||||
folder: &str,
|
||||
uid: u32,
|
||||
) -> Result<Option<Vec<u8>>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT raw_content FROM message_bodies WHERE folder = ?1 AND uid = ?2")?;
|
||||
let result = stmt
|
||||
.query_row(params![folder, uid], |row| row.get(0))
|
||||
.optional()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn save_contact(
|
||||
&self,
|
||||
contact: &crate::types::Contact,
|
||||
) -> 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)",
|
||||
params![
|
||||
contact.email.to_lowercase(),
|
||||
contact.display_name,
|
||||
contact.public_key,
|
||||
contact.fingerprint,
|
||||
format!("{:?}", contact.trust_level),
|
||||
contact.first_seen,
|
||||
contact.last_seen
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_contacts(&self) -> Result<Vec<crate::types::Contact>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT email, display_name, public_key, fingerprint, trust_level, first_seen, last_seen FROM contacts",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let trust_level_str: String = row.get(4)?;
|
||||
let trust_level = match trust_level_str.as_str() {
|
||||
"Trusted" => crate::types::TrustLevel::Trusted,
|
||||
"Established" => crate::types::TrustLevel::Established,
|
||||
"Warning" => crate::types::TrustLevel::Warning,
|
||||
_ => crate::types::TrustLevel::Unknown,
|
||||
};
|
||||
|
||||
Ok(crate::types::Contact {
|
||||
email: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
public_key: row.get(2)?,
|
||||
fingerprint: row.get(3)?,
|
||||
trust_level,
|
||||
first_seen: row.get(5).unwrap_or(0),
|
||||
last_seen: row.get(6).unwrap_or(0),
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut contacts = Vec::new();
|
||||
for contact in rows {
|
||||
contacts.push(contact?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
pub fn get_contact_by_email(
|
||||
&self,
|
||||
email: &str,
|
||||
) -> Result<Option<crate::types::Contact>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT email, display_name, public_key, fingerprint, trust_level, first_seen, last_seen FROM contacts WHERE LOWER(email) = LOWER(?1)")?;
|
||||
let contact = stmt.query_row(params![email.to_lowercase()], |row| {
|
||||
let trust_level_str: String = row.get(4)?;
|
||||
let trust_level = match trust_level_str.as_str() {
|
||||
"Trusted" => crate::types::TrustLevel::Trusted,
|
||||
"Established" => crate::types::TrustLevel::Established,
|
||||
"Warning" => crate::types::TrustLevel::Warning,
|
||||
_ => crate::types::TrustLevel::Unknown,
|
||||
};
|
||||
|
||||
Ok(crate::types::Contact {
|
||||
email: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
public_key: row.get(2)?,
|
||||
fingerprint: row.get(3)?,
|
||||
trust_level,
|
||||
first_seen: row.get(5).unwrap_or(0),
|
||||
last_seen: row.get(6).unwrap_or(0),
|
||||
})
|
||||
});
|
||||
|
||||
match contact {
|
||||
Ok(c) => Ok(Some(c)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_contact(&self, email: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"DELETE FROM contacts WHERE LOWER(email) = LOWER(?1)",
|
||||
params![email.to_lowercase()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_identity(
|
||||
&self,
|
||||
email: &str,
|
||||
keypair: &crate::types::PgpKeyPair,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO pgp_identities (fingerprint, email, public_key, private_key) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![keypair.fingerprint, email.to_lowercase(), keypair.public_key, keypair.private_key],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_identities(
|
||||
&self,
|
||||
email: &str,
|
||||
) -> Result<Vec<crate::types::PgpKeyPair>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT public_key, private_key, fingerprint FROM pgp_identities WHERE LOWER(email) = LOWER(?1)",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![email.to_lowercase()], |row| {
|
||||
let pub_key: String = row.get(0)?;
|
||||
let priv_key: String = row.get(1)?;
|
||||
let fingerprint: String = row.get(2)?;
|
||||
|
||||
Ok(crate::types::PgpKeyPair {
|
||||
public_key: pub_key,
|
||||
private_key: priv_key,
|
||||
key_info: format!("Key: {}", fingerprint),
|
||||
fingerprint,
|
||||
user_ids: Vec::new(),
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut identities = Vec::new();
|
||||
for id in rows {
|
||||
identities.push(id?);
|
||||
}
|
||||
Ok(identities)
|
||||
}
|
||||
|
||||
pub fn get_identity_by_fingerprint(
|
||||
&self,
|
||||
fingerprint: &str,
|
||||
) -> Result<Option<crate::types::PgpKeyPair>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT public_key, private_key, fingerprint FROM pgp_identities WHERE fingerprint = ?1")?;
|
||||
let keypair = stmt.query_row(params![fingerprint], |row| {
|
||||
let pub_key: String = row.get(0)?;
|
||||
let priv_key: String = row.get(1)?;
|
||||
let fingerprint: String = row.get(2)?;
|
||||
|
||||
Ok(crate::types::PgpKeyPair {
|
||||
public_key: pub_key,
|
||||
private_key: priv_key,
|
||||
key_info: format!("Key: {}", fingerprint),
|
||||
fingerprint,
|
||||
user_ids: Vec::new(),
|
||||
})
|
||||
});
|
||||
|
||||
match keypair {
|
||||
Ok(k) => Ok(Some(k)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_identity(&self, fingerprint: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"DELETE FROM pgp_identities WHERE fingerprint = ?1",
|
||||
params![fingerprint],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_sync_state(
|
||||
&self,
|
||||
account: &str,
|
||||
folder: &str,
|
||||
uidvalidity: u32,
|
||||
last_uid: u32,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sync_state (account, folder, uidvalidity, last_uid) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![account, folder, uidvalidity, last_uid],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_sync_state(
|
||||
&self,
|
||||
account: &str,
|
||||
folder: &str,
|
||||
) -> Result<Option<(u32, u32)>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uidvalidity, last_uid FROM sync_state WHERE account = ?1 AND folder = ?2",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query(params![account, folder])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some((row.get(0)?, row.get(1)?)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_unread_count(&self, folder: &str) -> Result<i32, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let count: i32 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM messages_v2 WHERE folder = ?1 AND is_read = 0",
|
||||
params![folder],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn update_server_total(
|
||||
&self,
|
||||
account: &str,
|
||||
folder: &str,
|
||||
total: u32,
|
||||
) -> 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 server_total = ?3 WHERE account = ?1 AND folder = ?2",
|
||||
params![account, folder, total],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_folder_stats(
|
||||
&self,
|
||||
account: &str,
|
||||
folder: &str,
|
||||
) -> Result<(i32, i32), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let unread: i32 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM messages_v2 WHERE folder = ?1 AND is_read = 0",
|
||||
params![folder],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let server_total: i32 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(server_total, 0) FROM sync_state WHERE account = ?1 AND folder = ?2",
|
||||
params![account, folder],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok((unread, server_total))
|
||||
}
|
||||
|
||||
pub fn get_total_unread_count(&self) -> Result<i32, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let count: i32 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM messages_v2 WHERE is_read = 0 AND LOWER(folder) = 'inbox'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
154
mailcore/src/types.rs
Normal file
154
mailcore/src/types.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use flutter_rust_bridge::frb;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MailServerConfig {
|
||||
pub imap_host: String,
|
||||
pub imap_port: u16,
|
||||
pub smtp_host: String,
|
||||
pub smtp_port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageHeader {
|
||||
pub uid: u32,
|
||||
pub subject: String,
|
||||
pub from: String,
|
||||
pub to: Vec<String>,
|
||||
pub date: i64,
|
||||
pub is_read: bool,
|
||||
pub has_attachments: bool,
|
||||
pub pgp_status: PgpStatus,
|
||||
pub flag: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum PgpStatus {
|
||||
None,
|
||||
/// Message was encrypted by the sender (requires recipient private key to read).
|
||||
Encrypted,
|
||||
Signed,
|
||||
SignedAndEncrypted,
|
||||
InvalidSignature,
|
||||
/// Message stored at rest encrypted with the user's own public key.
|
||||
EncryptedAtRest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageBody {
|
||||
pub uid: u32,
|
||||
pub from: Option<String>,
|
||||
pub text_plain: Option<String>,
|
||||
pub text_html: Option<String>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub pgp_status: PgpStatus,
|
||||
pub signature_fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Attachment {
|
||||
pub name: String,
|
||||
pub content_type: String,
|
||||
pub size: usize,
|
||||
pub content: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Contact {
|
||||
pub email: String,
|
||||
pub display_name: Option<String>,
|
||||
pub public_key: Option<String>,
|
||||
pub fingerprint: Option<String>,
|
||||
pub trust_level: TrustLevel,
|
||||
pub first_seen: i64,
|
||||
pub last_seen: i64,
|
||||
}
|
||||
|
||||
#[frb]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum TrustLevel {
|
||||
Unknown,
|
||||
Trusted,
|
||||
Established,
|
||||
Warning,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Draft {
|
||||
pub id: Option<i64>,
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub last_updated: i64,
|
||||
pub last_synced_at: i64,
|
||||
pub server_uid: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PgpKeyPair {
|
||||
pub public_key: String,
|
||||
pub private_key: String,
|
||||
pub key_info: String,
|
||||
pub fingerprint: String,
|
||||
pub user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[frb]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum MailError {
|
||||
Generic(String),
|
||||
Network(String),
|
||||
Auth(String),
|
||||
PassphraseRequired,
|
||||
InvalidPassphrase,
|
||||
KeyNotFound(String),
|
||||
Crypto(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MailError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MailError::Generic(s) => write!(f, "{}", s),
|
||||
MailError::Network(s) => write!(f, "Network error: {}", s),
|
||||
MailError::Auth(s) => write!(f, "Auth error: {}", s),
|
||||
MailError::PassphraseRequired => write!(f, "Passphrase required"),
|
||||
MailError::InvalidPassphrase => write!(f, "Invalid passphrase"),
|
||||
MailError::KeyNotFound(s) => write!(f, "Key not found: {}", s),
|
||||
MailError::Crypto(s) => write!(f, "Crypto error: {}", s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MailError {}
|
||||
|
||||
#[frb]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub enum FolderType {
|
||||
Inbox,
|
||||
Sent,
|
||||
Drafts,
|
||||
Trash,
|
||||
Junk,
|
||||
Archive,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[frb]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FolderStats {
|
||||
pub unread: i32,
|
||||
pub server_total: i32,
|
||||
}
|
||||
|
||||
#[frb]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FolderInfo {
|
||||
pub name: String,
|
||||
/// Raw server-side folder name in IMAP Modified UTF-7 encoding.
|
||||
/// Use this when issuing IMAP SELECT/EXAMINE/APPEND commands.
|
||||
pub imap_name: String,
|
||||
pub folder_type: FolderType,
|
||||
pub is_selectable: bool,
|
||||
pub unread_count: i32,
|
||||
}
|
||||
140
mailcore/tests/subject_decoding_test.rs
Normal file
140
mailcore/tests/subject_decoding_test.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use mail_parser::MessageParser;
|
||||
|
||||
// Duplicate the word-joiner here since pub(crate) is not visible from integration tests.
|
||||
fn parse_encoded_word(s: &str) -> Option<(&str, &str, &str, &str)> {
|
||||
let s = s.strip_prefix("=?")?;
|
||||
let (charset, rest) = s.split_once('?')?;
|
||||
let (encoding, rest) = rest.split_once('?')?;
|
||||
if encoding.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let enc_char = encoding.chars().next()?.to_ascii_uppercase();
|
||||
if enc_char != 'B' && enc_char != 'Q' {
|
||||
return None;
|
||||
}
|
||||
let end = rest.find("?=")?;
|
||||
let payload = &rest[..end];
|
||||
let remaining = &rest[end + 2..];
|
||||
Some((charset, encoding, payload, remaining))
|
||||
}
|
||||
|
||||
fn join_adjacent_encoded_words(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut pos = 0;
|
||||
while pos < s.len() {
|
||||
let remaining = &s[pos..];
|
||||
if !remaining.starts_with("=?") {
|
||||
let ch = remaining.chars().next().unwrap();
|
||||
out.push(ch);
|
||||
pos += ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
match parse_encoded_word(remaining) {
|
||||
None => {
|
||||
out.push('=');
|
||||
pos += 1;
|
||||
}
|
||||
Some((charset, encoding, payload, after_first)) => {
|
||||
let mut combined = payload.to_string();
|
||||
let mut cursor = after_first;
|
||||
loop {
|
||||
let ws = cursor.trim_start_matches(|c: char| c == ' ' || c == '\t');
|
||||
if !ws.starts_with("=?") {
|
||||
break;
|
||||
}
|
||||
match parse_encoded_word(ws) {
|
||||
None => break,
|
||||
Some((cs2, enc2, payload2, after_next)) => {
|
||||
if cs2.eq_ignore_ascii_case(charset)
|
||||
&& enc2.eq_ignore_ascii_case(encoding)
|
||||
{
|
||||
combined.push_str(payload2);
|
||||
cursor = after_next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str("=?");
|
||||
out.push_str(charset);
|
||||
out.push('?');
|
||||
out.push_str(encoding);
|
||||
out.push('?');
|
||||
out.push_str(&combined);
|
||||
out.push_str("?=");
|
||||
pos += remaining.len() - cursor.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn preprocess(bytes: &[u8]) -> Vec<u8> {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => join_adjacent_encoded_words(s).into_bytes(),
|
||||
Err(_) => bytes.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_subject(raw: &[u8]) -> String {
|
||||
let preprocessed = preprocess(raw);
|
||||
MessageParser::default()
|
||||
.parse(&preprocessed)
|
||||
.and_then(|m| m.subject().map(|s| s.to_string()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// ЕИРЦ = D0 95 D0 98 D0 A0 D0 A6
|
||||
/// Full base64: 0JXQmNCg0KY=
|
||||
#[test]
|
||||
fn verify_full_base64() {
|
||||
let raw = b"Subject: =?UTF-8?B?0JXQmNCg0KY=?=\n\nBody";
|
||||
assert_eq!(
|
||||
parse_subject(raw),
|
||||
"ЕИРЦ",
|
||||
"Full base64 sanity check failed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Plain UTF-8 – always worked.
|
||||
#[test]
|
||||
fn test_raw_utf8() {
|
||||
let raw = "Subject: ЕИРЦ\n\nBody".as_bytes();
|
||||
assert_eq!(parse_subject(raw), "ЕИРЦ");
|
||||
}
|
||||
|
||||
/// UTF-8 Quoted-Printable – already worked before fix.
|
||||
#[test]
|
||||
fn test_utf8_qp() {
|
||||
let raw = b"Subject: =?UTF-8?Q?=D0=95=D0=98=D0=A0=D0=A6?=\n\nBody";
|
||||
assert_eq!(parse_subject(raw), "ЕИРЦ");
|
||||
}
|
||||
|
||||
/// Multi-byte char split across two adjacent words (no whitespace).
|
||||
/// Split: first 6 bytes (D0 95 D0 98 D0 A0) → 0JXQmNCg
|
||||
/// last 2 bytes (D0 A6) → 0KY=
|
||||
/// mail-parser sees И split across the boundary.
|
||||
#[test]
|
||||
fn test_split_utf8_encoded_words() {
|
||||
let raw = b"Subject: =?UTF-8?B?0JXQmNCg?==?UTF-8?B?0KY=?=\n\nBody";
|
||||
assert_eq!(
|
||||
parse_subject(raw),
|
||||
"ЕИРЦ",
|
||||
"Split encoded-word (no space) failed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same split with linear whitespace (RFC 2047 §6.2).
|
||||
#[test]
|
||||
fn test_split_utf8_with_whitespace() {
|
||||
let raw = b"Subject: =?UTF-8?B?0JXQmNCg?= =?UTF-8?B?0KY=?=\n\nBody";
|
||||
assert_eq!(parse_subject(raw), "ЕИРЦ", "Split with whitespace failed");
|
||||
}
|
||||
|
||||
/// Split across multiple lines (CRLF + space), simulating folded headers.
|
||||
#[test]
|
||||
fn test_split_utf8_folded_header() {
|
||||
let raw = b"Subject: =?UTF-8?B?0JXQmNCg?=\r\n =?UTF-8?B?0KY=?=\n\nBody";
|
||||
assert_eq!(parse_subject(raw), "ЕИРЦ", "Split folded header failed");
|
||||
}
|
||||
79
mailcore/tests/test_imap_smtp_mock.rs
Normal file
79
mailcore/tests/test_imap_smtp_mock.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use lettre::message::{MultiPart, SinglePart};
|
||||
use lettre::Message;
|
||||
use mail_parser::MimeHeaders;
|
||||
use std::error::Error;
|
||||
|
||||
/// Mock integration test validating that our SMTP serialization and IMAP parsing
|
||||
/// logic functions cleanly without dropping or ignoring attributes, executing fully headless
|
||||
/// without needing a binding live TCP/TLS external socket constraint.
|
||||
#[test]
|
||||
fn test_smtp_builder_and_imap_parser_mock() -> Result<(), Box<dyn Error>> {
|
||||
// 1. SMTP Mock Builder Simulation
|
||||
let sender = "alice@example.com";
|
||||
let receiver = "bob@example.com";
|
||||
let subject = "Mock Integration Payload";
|
||||
let body_text = "This is a body payload.";
|
||||
|
||||
// Simulate Attachments
|
||||
let content = b"FILE_CONTENT_MOCK".to_vec();
|
||||
let attachment_name = "secret.txt";
|
||||
|
||||
let email = Message::builder()
|
||||
.from(sender.parse()?)
|
||||
.to(receiver.parse()?)
|
||||
.subject(subject)
|
||||
.multipart(
|
||||
MultiPart::mixed()
|
||||
.singlepart(SinglePart::plain(body_text.to_string()))
|
||||
.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(lettre::message::header::ContentType::parse("text/plain")?)
|
||||
.header(lettre::message::header::ContentDisposition::attachment(
|
||||
attachment_name,
|
||||
))
|
||||
.body(content.clone()),
|
||||
),
|
||||
)?;
|
||||
|
||||
let raw_email_bytes = email.formatted();
|
||||
|
||||
// 2. IMAP Mock Parsing Simulation (What we'd do on receiving bytes from IMAP FETCH)
|
||||
let parsed_message = mail_parser::MessageParser::default()
|
||||
.parse(&raw_email_bytes)
|
||||
.ok_or("Failed to parse raw email bytes")?;
|
||||
|
||||
// Validate Headers
|
||||
assert_eq!(
|
||||
parsed_message.subject().unwrap(),
|
||||
"Mock Integration Payload"
|
||||
);
|
||||
|
||||
let from_header = parsed_message.from().unwrap().first();
|
||||
assert_eq!(from_header.unwrap().address().unwrap(), "alice@example.com");
|
||||
|
||||
let to_header = parsed_message.to().unwrap().first();
|
||||
assert_eq!(to_header.unwrap().address().unwrap(), "bob@example.com");
|
||||
|
||||
// Validate Body
|
||||
assert_eq!(
|
||||
parsed_message.body_text(0).unwrap().trim(),
|
||||
"This is a body payload."
|
||||
);
|
||||
|
||||
// Validate Attachments
|
||||
let mut attachment_found = false;
|
||||
for attachment in parsed_message.attachments() {
|
||||
if let Some(name) = attachment.attachment_name() {
|
||||
if name == attachment_name {
|
||||
attachment_found = true;
|
||||
assert_eq!(attachment.contents(), content.as_slice());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
attachment_found,
|
||||
"Attachment was dropped during serialization/deserialization mock"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user