chore: sync mailcore v0.1.0-beta.1
This commit is contained in:
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,
|
||||
}
|
||||
Reference in New Issue
Block a user