chore: sync mailcore v0.1.0-beta.1

This commit is contained in:
Eugen Kaparulin
2026-04-17 09:05:47 +03:00
parent 2a4f740cbd
commit 90c8571d26
19 changed files with 4191 additions and 0 deletions

1
mailcore/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

75
mailcore/Cargo.toml Normal file
View 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"

View 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);
}
}
}

View 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

File diff suppressed because it is too large Load Diff

2
mailcore/src/dns/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod srv;
pub use srv::discover_mail_server;

108
mailcore/src/dns/srv.rs Normal file
View 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

File diff suppressed because it is too large Load Diff

3
mailcore/src/imap/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod client;
pub mod utf7;
pub use client::ImapClient;

215
mailcore/src/imap/utf7.rs Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
// MIME module placeholder

View 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
View 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
View File

@@ -0,0 +1,2 @@
pub mod client;
pub use client::SmtpClient;

733
mailcore/src/storage/mod.rs Normal file
View 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
View 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,
}

View 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");
}

View 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(())
}