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

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,
}