Compare commits
11 Commits
v0.2.1-bet
...
v0.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2449cda1f3 | ||
|
|
622d8a9f78 | ||
|
|
bf176c13df | ||
|
|
4a7a08244f | ||
|
|
e6df0d451e | ||
|
|
467928d541 | ||
|
|
ffbf1372fd | ||
|
|
c48a14d4e8 | ||
|
|
c76d1197d0 | ||
|
|
c5fd8b21f5 | ||
|
|
3c266c390f |
31
README.md
31
README.md
@@ -8,7 +8,7 @@
|
||||
**PGP-First Email. Private by Default.**
|
||||
|
||||
[](LICENSE)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
</div>
|
||||
@@ -78,7 +78,24 @@ The Linux release is a self-contained AppImage — no installation required.
|
||||
./Korax-x86_64.AppImage
|
||||
```
|
||||
|
||||
### macOS · Android · iOS
|
||||
### Windows
|
||||
|
||||
Neither build is code-signed, so Windows SmartScreen will warn on first run — "More info" → "Run anyway".
|
||||
|
||||
1. Download either from the [Releases](https://git.k-ops.eu/korax/korax-public/releases) page:
|
||||
|
||||
| Asset | What it is |
|
||||
|---|---|
|
||||
| `Korax-*-windows-x86_64.zip` | Standard build. Unzip and run `korax.exe`. |
|
||||
| `Korax-*-windows-x86_64-portable.zip` | Self-extracting portable. Double-click the bundled exe and it unpacks itself to `%LOCALAPPDATA%\Korax\<version>\`, drops a Desktop shortcut and launches — no install wizard and no admin rights. |
|
||||
|
||||
### Android
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=eu.k_ops.korax&pli=1">
|
||||
<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="60"/>
|
||||
</a>
|
||||
|
||||
### macOS · iOS
|
||||
|
||||
Coming soon.
|
||||
|
||||
@@ -181,13 +198,17 @@ The PGP/Sequoia integration, advanced privacy features, the proprietary Rust eng
|
||||
|
||||
## Security
|
||||
|
||||
**Key storage:** Private keys are never written to SQLite or disk in plaintext. On every platform, they are stored in the OS secure enclave (iOS Keychain, macOS Keychain, Android Keystore).
|
||||
**Key storage:** Private keys are never written to SQLite or disk in plaintext. On the platforms that ship, they go to the OS credential store — Windows Credential Manager, Android Keystore, or the Secret Service (libsecret) on Linux.
|
||||
|
||||
## Notices
|
||||
|
||||
- [Discontinuing native Gmail login](korax-gmail-login-discontinuation.md) — Gmail accounts are connected via IMAP/SMTP with a Google App Password instead of "Sign in with Google".
|
||||
|
||||
## Support & Contributing
|
||||
|
||||
**Bug Reports:** Korax includes a built-in bug reporting feature directly within the application. Please use that for all general issues and feedback.
|
||||
**Bug Reports:** Use the in-app bug reporting feature, or file an issue on [Gitea Issues](https://git.k-ops.eu/korax/korax-public/issues).
|
||||
|
||||
**Reporting vulnerabilities:** Do not open a public issue. Contact the maintainer directly at the address shown in the application's About screen.
|
||||
**Reporting vulnerabilities:** Do not open a public issue. Report directly to korax@k-ops.eu.
|
||||
|
||||
**Contributing:** Core development is handled internally. At this time, we do not accept external pull requests or community code contributions.
|
||||
|
||||
|
||||
39
korax-gmail-login-discontinuation.md
Normal file
39
korax-gmail-login-discontinuation.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Discontinuing Native Gmail Login in Korax
|
||||
|
||||
**Date:** July 2026
|
||||
|
||||
## Summary
|
||||
|
||||
Korax will no longer support native "Sign in with Google" / Gmail OAuth login. Users who want to use a Gmail account with Korax will need to connect it via IMAP/SMTP with a Google App Password instead.
|
||||
|
||||
## Why
|
||||
|
||||
Korax's Gmail integration relies on the `gmail.modify` OAuth scope to read, send, and manage messages on the user's behalf. Google classifies `gmail.modify` as a **restricted scope**.
|
||||
|
||||
As of 2026, any app requesting a restricted scope must complete an annual **CASA (Cloud Application Security Assessment) at AL1** (formerly "Tier 2"). This assessment:
|
||||
|
||||
- Requires either a paid authorized-lab security scan (roughly $500–$4,500/year) or a paid commercial self-scan — the previous free, fully self-service scanning option has been discontinued by Google.
|
||||
- Must be renewed every year, for as long as the app requests the scope.
|
||||
- Takes 2–6 weeks to complete, with no guarantee of first-pass approval.
|
||||
|
||||
For an independent app like Korax, this recurring cost and process isn't sustainable to keep native Gmail OAuth alive.
|
||||
|
||||
## What this means for users
|
||||
|
||||
- **New setup:** Gmail accounts can still be connected to Korax, but via IMAP/SMTP using a [Google App Password](https://myaccount.google.com/apppasswords) instead of the "Sign in with Google" button.
|
||||
- **Requirement:** App Passwords require Google 2-Step Verification (2FA) to be enabled on the account.
|
||||
- **Existing users:** Korax is still in open beta with no existing users on native Gmail login, so there is no migration or cutover date to manage — this change applies from the start.
|
||||
- **Other providers:** Non-Gmail accounts (any standard IMAP/SMTP provider) are unaffected.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Option | Why not chosen |
|
||||
|---|---|
|
||||
| Authorized lab CASA scan | Ongoing annual cost not viable for a one-time-purchase indie app |
|
||||
| Commercial self-scan | Still a recurring paid cost; free self-scan path was removed by Google |
|
||||
| Narrower OAuth scopes (`gmail.send`, `gmail.labels`) | Don't cover reading/decrypting mail content, which is core to Korax's PGP functionality |
|
||||
| Drop Gmail support entirely | Not chosen — IMAP/App Password path preserves functionality without the compliance overhead |
|
||||
|
||||
## Questions
|
||||
|
||||
If you run into issues setting up Gmail via App Password, use the built-in bug reporting feature in the app.
|
||||
@@ -22,7 +22,8 @@ webpki-roots = "0.26"
|
||||
mail-parser = "0.9"
|
||||
mail-builder = "0.3"
|
||||
# HTTP Client
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
base64 = "0.22"
|
||||
sha1 = "0.10"
|
||||
zbase32 = "0.1"
|
||||
|
||||
@@ -50,7 +51,7 @@ thiserror = "1"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
futures = "0.3.31"
|
||||
chrono = "0.4.43"
|
||||
# Removed native-tls dependencies for better cross-platform compatibility
|
||||
@@ -63,6 +64,7 @@ core-foundation = "0.9"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21"
|
||||
tracing-android = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use mailcore::dns::discover_mail_server;
|
||||
use mailcore::imap::ImapClient;
|
||||
use mailcore::types::AuthCredential;
|
||||
use std::io::{self, Write};
|
||||
|
||||
#[tokio::main]
|
||||
@@ -27,12 +28,16 @@ async fn main() {
|
||||
io::stdout().flush().unwrap();
|
||||
let mut password = String::new();
|
||||
io::stdin().read_line(&mut password).unwrap();
|
||||
let password = password.trim();
|
||||
let password = password.trim().to_string();
|
||||
|
||||
let mut client = ImapClient::new(config, email.to_string());
|
||||
let mut client = ImapClient::new(
|
||||
config,
|
||||
email.to_string(),
|
||||
AuthCredential::Password(password),
|
||||
);
|
||||
|
||||
println!("Connecting...");
|
||||
match client.connect(password).await {
|
||||
match client.connect().await {
|
||||
Ok(_) => {
|
||||
println!("✓ Connected and authenticated successfully!");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::types::{FolderInfo, MailError, MailServerConfig};
|
||||
use crate::types::{AuthCredential, FolderInfo, MailError, MailServerConfig};
|
||||
use flutter_rust_bridge::frb;
|
||||
|
||||
use mail_parser::MimeHeaders;
|
||||
|
||||
@@ -12,7 +13,19 @@ lazy_static! {
|
||||
}
|
||||
|
||||
pub fn init_logger() {
|
||||
tracing_subscriber::fmt::init();
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
use tracing_subscriber::prelude::*;
|
||||
if let Ok(android_layer) = tracing_android::layer("KoraxRust") {
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(android_layer)
|
||||
.try_init();
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
tracing_subscriber::fmt::init();
|
||||
}
|
||||
|
||||
// Initialize Rustls with 'ring' as the default provider to prevent crashes
|
||||
// when multiple providers (ring, aws-lc-rs) are present in the dependency graph.
|
||||
@@ -20,6 +33,114 @@ pub fn init_logger() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
}
|
||||
|
||||
/// Step-by-step IMAP connection diagnostic. Returns a multi-line string describing
|
||||
/// each phase (TCP → TLS → greeting) with timing and outcome.
|
||||
/// Use this from Flutter to identify exactly where the hang is on Android.
|
||||
pub async fn diagnose_imap_connection(host: String, port: u32, email: String) -> String {
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("Diagnosing connection for {}\n", email));
|
||||
let mut root_cert_store = rustls::RootCertStore::empty();
|
||||
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
let tls_config = match rustls::ClientConfig::builder_with_provider(Arc::new(
|
||||
rustls::crypto::ring::default_provider(),
|
||||
))
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| e.to_string())
|
||||
{
|
||||
Ok(b) => b.with_root_certificates(root_cert_store).with_no_client_auth(),
|
||||
Err(e) => {
|
||||
out.push_str(&format!("TLS config: FAILED ({})\n", e));
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
|
||||
// Phase 1: TCP connect
|
||||
let t0 = Instant::now();
|
||||
let stream = match tokio::time::timeout(Duration::from_secs(10), TcpStream::connect(&addr))
|
||||
.await
|
||||
{
|
||||
Err(_) => {
|
||||
out.push_str(&format!("TCP: TIMEOUT after 10s\n"));
|
||||
return out;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
out.push_str(&format!("TCP: FAILED ({}) in {:.1}s\n", e, t0.elapsed().as_secs_f32()));
|
||||
return out;
|
||||
}
|
||||
Ok(Ok(s)) => {
|
||||
out.push_str(&format!("TCP: OK in {:.1}s\n", t0.elapsed().as_secs_f32()));
|
||||
s
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 2: TLS handshake
|
||||
let connector = TlsConnector::from(Arc::new(tls_config));
|
||||
let server_name = match rustls_pki_types::ServerName::try_from(host.clone())
|
||||
.map_err(|_| format!("Invalid DNS name: {}", host))
|
||||
.and_then(|n| Ok(n.to_owned()))
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
out.push_str(&format!("TLS name: FAILED ({})\n", e));
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
let t1 = Instant::now();
|
||||
let mut tls_stream = match tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connector.connect(server_name, stream),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(_) => {
|
||||
out.push_str(&format!("TLS: TIMEOUT after 10s\n"));
|
||||
return out;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
out.push_str(&format!("TLS: FAILED ({}) in {:.1}s\n", e, t1.elapsed().as_secs_f32()));
|
||||
return out;
|
||||
}
|
||||
Ok(Ok(s)) => {
|
||||
out.push_str(&format!("TLS: OK in {:.1}s\n", t1.elapsed().as_secs_f32()));
|
||||
s
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 3: Read IMAP server greeting (first line)
|
||||
let mut greeting = vec![0u8; 512];
|
||||
let t2 = Instant::now();
|
||||
match tokio::time::timeout(Duration::from_secs(10), tls_stream.read(&mut greeting)).await {
|
||||
Err(_) => {
|
||||
out.push_str(&format!("Greeting: TIMEOUT after 10s\n"));
|
||||
return out;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
out.push_str(&format!("Greeting: READ FAILED ({}) in {:.1}s\n", e, t2.elapsed().as_secs_f32()));
|
||||
return out;
|
||||
}
|
||||
Ok(Ok(n)) => {
|
||||
let line = String::from_utf8_lossy(&greeting[..n.min(120)]).to_string();
|
||||
out.push_str(&format!(
|
||||
"Greeting: OK in {:.1}s — {:?}\n",
|
||||
t2.elapsed().as_secs_f32(),
|
||||
line.trim()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
pub async fn discover_server(email: String) -> Result<MailServerConfig, MailError> {
|
||||
crate::dns::discover_mail_server(&email)
|
||||
.await
|
||||
@@ -30,12 +151,12 @@ pub async fn discover_server(email: String) -> Result<MailServerConfig, MailErro
|
||||
pub async fn connect_and_list_folders(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
show_all_folders: bool,
|
||||
storage_path: String,
|
||||
) -> Result<Vec<FolderInfo>, MailError> {
|
||||
// This call will create/cache the session in the manager
|
||||
let client_arc = crate::session_manager::get_client(config, email, password).await?;
|
||||
let client_arc = crate::session_manager::get_client(config, email, credential).await?;
|
||||
|
||||
let mut client = client_arc.lock().await;
|
||||
let mut folders = client
|
||||
@@ -66,7 +187,7 @@ pub fn get_total_unread_count(storage_path: String) -> i32 {
|
||||
pub async fn get_messages(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
|
||||
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
||||
@@ -75,7 +196,7 @@ pub async fn get_messages(
|
||||
mailbox
|
||||
};
|
||||
|
||||
let client_arc = crate::session_manager::get_client(config, email, password).await?;
|
||||
let client_arc = crate::session_manager::get_client(config, email, credential).await?;
|
||||
|
||||
let mut client = client_arc.lock().await;
|
||||
client
|
||||
@@ -91,7 +212,7 @@ pub async fn get_messages(
|
||||
pub async fn get_message_body(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
uid: u32,
|
||||
storage_path: String,
|
||||
@@ -116,9 +237,9 @@ pub async fn get_message_body(
|
||||
body
|
||||
})
|
||||
} else {
|
||||
let mut client = crate::imap::ImapClient::new(config.clone(), email.clone());
|
||||
let mut client = crate::imap::ImapClient::new(config.clone(), email.clone(), credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
|
||||
@@ -160,7 +281,7 @@ pub async fn get_message_body(
|
||||
pub async fn set_message_flag(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
uid: u32,
|
||||
flag: Option<String>,
|
||||
@@ -175,9 +296,9 @@ pub async fn set_message_flag(
|
||||
}
|
||||
|
||||
// Dedicated connection — never blocked by sync_mailbox holding the shared session lock.
|
||||
let mut client = crate::imap::ImapClient::new(config, email);
|
||||
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
|
||||
@@ -230,11 +351,11 @@ pub fn get_contact_by_email(
|
||||
pub async fn append_to_sent(
|
||||
config: &MailServerConfig,
|
||||
email: &str,
|
||||
password: &str,
|
||||
credential: AuthCredential,
|
||||
message_bytes: &[u8],
|
||||
) {
|
||||
let mut client = crate::imap::ImapClient::new(config.clone(), email.to_string());
|
||||
if let Err(e) = client.connect(password).await {
|
||||
let mut client = crate::imap::ImapClient::new(config.clone(), email.to_string(), credential);
|
||||
if let Err(e) = client.connect().await {
|
||||
tracing::warn!("append_to_sent: IMAP connect failed: {}", e);
|
||||
return;
|
||||
}
|
||||
@@ -257,7 +378,7 @@ pub async fn append_to_sent(
|
||||
pub async fn send_email(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
to: String,
|
||||
subject: String,
|
||||
body: String,
|
||||
@@ -271,19 +392,19 @@ pub async fn send_email(
|
||||
return Err(MailError::Generic("PGP encryption not supported in mailcore. Use mailmore.".to_string()));
|
||||
}
|
||||
|
||||
let client = crate::smtp::SmtpClient::new(config.clone(), email.clone());
|
||||
let message_bytes = client
|
||||
.send_mail(&password, &to, &subject, &body, attachments)
|
||||
let smtp_client = crate::smtp::SmtpClient::new(config.clone(), email.clone(), credential.clone());
|
||||
let message_bytes = smtp_client
|
||||
.send_mail(&to, &subject, &body, attachments)
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
append_to_sent(&config, &email, &password, &message_bytes).await;
|
||||
append_to_sent(&config, &email, credential, &message_bytes).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn prefetch_bodies(
|
||||
config: crate::types::MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
uids: Vec<u32>,
|
||||
storage_path: String,
|
||||
@@ -308,9 +429,9 @@ pub async fn prefetch_bodies(
|
||||
}
|
||||
|
||||
// Use a DEDICATED connection so prefetch never blocks user-initiated fetches
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
client
|
||||
@@ -418,6 +539,12 @@ pub async fn parse_mime_message(bytes: &[u8]) -> Result<crate::types::MessageBod
|
||||
)
|
||||
};
|
||||
|
||||
// Extract sender public key:
|
||||
// 1. Try Autocrypt header first (RFC 7929)
|
||||
let sender_public_key = extract_autocrypt_key(&parsed)
|
||||
// 2. Fall back to application/pgp-keys attachment
|
||||
.or_else(|| extract_pgp_keys_attachment(&parsed));
|
||||
|
||||
Ok(crate::types::MessageBody {
|
||||
uid: 0, // No UID for raw bytes
|
||||
from: from_addr,
|
||||
@@ -426,12 +553,77 @@ pub async fn parse_mime_message(bytes: &[u8]) -> Result<crate::types::MessageBod
|
||||
attachments,
|
||||
pgp_status,
|
||||
signature_fingerprint,
|
||||
sender_public_key,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(MailError::Generic(format!("Task spawn error: {}", e))))
|
||||
}
|
||||
|
||||
/// Parse the Autocrypt header and return the ASCII-armored public key, or None.
|
||||
///
|
||||
/// RFC 7929 / Autocrypt Level 1: the `keydata=` attribute holds the raw bytes
|
||||
/// of the exported OpenPGP certificate, Base64-encoded (no line breaks, no armor).
|
||||
/// We re-armor them so callers can use the result directly with Sequoia.
|
||||
fn extract_autocrypt_key(parsed: &mail_parser::Message) -> Option<String> {
|
||||
let header_value = parsed
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.name.as_str().eq_ignore_ascii_case("Autocrypt"))
|
||||
.and_then(|h| match &h.value {
|
||||
mail_parser::HeaderValue::Text(t) => Some(t.as_ref().to_string()),
|
||||
mail_parser::HeaderValue::TextList(list) => list.first().map(|s| s.to_string()),
|
||||
_ => None,
|
||||
})?;
|
||||
|
||||
// Extract keydata= value (may be followed by ; or end of string)
|
||||
let lower = header_value.to_lowercase();
|
||||
let keydata_start = lower.find("keydata=")?;
|
||||
let rest = &header_value[keydata_start + "keydata=".len()..];
|
||||
let keydata_b64: String = rest
|
||||
.split(';')
|
||||
.next()?
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect();
|
||||
|
||||
if keydata_b64.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Re-armor: wrap the raw base64 in PGP armor header/footer
|
||||
// (The keydata is already the raw exported cert bytes in base64; we split
|
||||
// into 64-char lines for canonical OpenPGP ASCII Armor format.)
|
||||
let wrapped: String = keydata_b64
|
||||
.chars()
|
||||
.collect::<Vec<_>>()
|
||||
.chunks(64)
|
||||
.map(|chunk| chunk.iter().collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Some(format!(
|
||||
"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n{wrapped}\n-----END PGP PUBLIC KEY BLOCK-----\n"
|
||||
))
|
||||
}
|
||||
|
||||
/// Look for the first MIME part with Content-Type application/pgp-keys and
|
||||
/// return its content decoded as UTF-8 (the ASCII-armored public key block).
|
||||
fn extract_pgp_keys_attachment(parsed: &mail_parser::Message) -> Option<String> {
|
||||
for part in &parsed.parts {
|
||||
let is_pgp_keys = part
|
||||
.content_type()
|
||||
.map(|ct| {
|
||||
ct.ctype().eq_ignore_ascii_case("application")
|
||||
&& ct.subtype().map(|s| s.eq_ignore_ascii_case("pgp-keys")).unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if is_pgp_keys {
|
||||
return std::str::from_utf8(part.contents()).ok().map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_pgp_status(parsed: &mail_parser::Message) -> (crate::types::PgpStatus, Option<String>) {
|
||||
let mut pgp_status = crate::types::PgpStatus::None;
|
||||
let signature_fingerprint = None;
|
||||
@@ -489,7 +681,7 @@ fn detect_pgp_status(parsed: &mail_parser::Message) -> (crate::types::PgpStatus,
|
||||
pub async fn sync_mailbox(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
storage_path: String,
|
||||
cache_days: i64,
|
||||
@@ -502,10 +694,16 @@ pub async fn sync_mailbox(
|
||||
let storage = crate::storage::Storage::new(&storage_path)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
|
||||
// Skip syncing folders pending a full wipe — avoids repopulating messages
|
||||
// before the IMAP expunge completes.
|
||||
if storage.is_folder_pending_wipe(&email, &mailbox) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Step 1: Run sync on the shared session.
|
||||
let sync_result = {
|
||||
let client_arc =
|
||||
crate::session_manager::get_client(config.clone(), email.clone(), password.clone())
|
||||
crate::session_manager::get_client(config.clone(), email.clone(), credential.clone())
|
||||
.await?;
|
||||
let mut client = client_arc.lock().await;
|
||||
client
|
||||
@@ -517,8 +715,8 @@ pub async fn sync_mailbox(
|
||||
// Step 2: FLAG refresh on a dedicated connection.
|
||||
let uids = sync_result.uids_for_flag_refresh;
|
||||
if !uids.is_empty() {
|
||||
let mut dedicated = crate::imap::ImapClient::new(config, email);
|
||||
match dedicated.connect(&password).await {
|
||||
let mut dedicated = crate::imap::ImapClient::new(config, email, credential);
|
||||
match dedicated.connect().await {
|
||||
Ok(()) => {
|
||||
if let Err(e) = dedicated.refresh_flags(&mailbox, &uids, &storage).await {
|
||||
tracing::warn!("FLAG refresh via dedicated connection failed: {}", e);
|
||||
@@ -540,7 +738,7 @@ pub async fn sync_mailbox(
|
||||
pub async fn listen_for_updates(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
) -> Result<tokio::sync::mpsc::Receiver<bool>, MailError> {
|
||||
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
||||
@@ -549,9 +747,9 @@ pub async fn listen_for_updates(
|
||||
mailbox
|
||||
};
|
||||
|
||||
let mut client = crate::imap::ImapClient::new(config.clone(), email.clone());
|
||||
let mut client = crate::imap::ImapClient::new(config.clone(), email.clone(), credential.clone());
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
client
|
||||
@@ -559,7 +757,6 @@ pub async fn listen_for_updates(
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
|
||||
let password_clone = password.clone();
|
||||
let mailbox_clone = mailbox.clone();
|
||||
let email_clone = email.clone();
|
||||
|
||||
@@ -596,7 +793,7 @@ pub async fn listen_for_updates(
|
||||
Err(e) => {
|
||||
tracing::error!("IDLE error for {}: {}", email_clone, e);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
if let Err(re_err) = client.reconnect(&password_clone).await {
|
||||
if let Err(re_err) = client.reconnect().await {
|
||||
tracing::error!("IDLE reconnect failed: {}", re_err);
|
||||
break;
|
||||
}
|
||||
@@ -626,6 +823,7 @@ pub async fn check_connection(email: String) -> bool {
|
||||
pub fn get_cached_messages(
|
||||
storage_path: String,
|
||||
mailbox: String,
|
||||
account_email: String,
|
||||
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
|
||||
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
||||
"INBOX".to_string()
|
||||
@@ -634,9 +832,18 @@ pub fn get_cached_messages(
|
||||
};
|
||||
let storage = crate::storage::Storage::new(&storage_path)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
storage
|
||||
let mut headers = storage
|
||||
.get_headers(&mailbox)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
// Ensure account_email is set on every returned header. Rows migrated from
|
||||
// older schema versions will have an empty string in the column; overwrite
|
||||
// them here so callers always get a non-empty value.
|
||||
for h in &mut headers {
|
||||
if h.account_email.is_empty() {
|
||||
h.account_email = account_email.clone();
|
||||
}
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
pub fn save_draft(path: String, draft: crate::types::Draft) -> Result<i64, MailError> {
|
||||
@@ -666,12 +873,12 @@ pub fn delete_draft(path: String, id: i64) -> Result<(), MailError> {
|
||||
pub async fn sync_drafts(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
storage_path: String,
|
||||
) -> Result<(), MailError> {
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
|
||||
@@ -737,10 +944,11 @@ pub async fn sync_drafts(
|
||||
.write_to_vec()
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
|
||||
if let Ok(_) = client
|
||||
if let Ok(uid) = client
|
||||
.append_message(&drafts_folder, &message_bytes, Some(r"\Draft".to_string()))
|
||||
.await
|
||||
{
|
||||
draft.server_uid = Some(uid);
|
||||
draft.last_synced_at = draft.last_updated;
|
||||
let _ = storage.save_draft(&draft);
|
||||
}
|
||||
@@ -780,7 +988,7 @@ pub async fn sync_drafts(
|
||||
pub async fn move_message(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
from_mailbox: String,
|
||||
to_mailbox: String,
|
||||
uid: u32,
|
||||
@@ -796,9 +1004,9 @@ pub async fn move_message(
|
||||
} else {
|
||||
to_mailbox
|
||||
};
|
||||
let mut client = crate::imap::ImapClient::new(config, email);
|
||||
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
|
||||
@@ -813,7 +1021,10 @@ pub async fn move_message(
|
||||
|
||||
let storage = crate::storage::Storage::new(&storage_path)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
let _ = storage.delete_header(&from_mailbox, uid);
|
||||
storage
|
||||
.delete_header(&from_mailbox, uid)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
let _ = storage.delete_drafts_by_server_uids(&[uid]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -821,7 +1032,7 @@ pub async fn move_message(
|
||||
pub async fn move_messages(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
from_mailbox: String,
|
||||
to_mailbox: String,
|
||||
uids: Vec<u32>,
|
||||
@@ -841,9 +1052,9 @@ pub async fn move_messages(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut client = crate::imap::ImapClient::new(config, email);
|
||||
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
|
||||
@@ -858,15 +1069,66 @@ pub async fn move_messages(
|
||||
|
||||
let storage = crate::storage::Storage::new(&storage_path)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
let _ = storage.delete_headers(&from_mailbox, &uids);
|
||||
storage
|
||||
.delete_headers(&from_mailbox, &uids)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
let _ = storage.delete_drafts_by_server_uids(&uids);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_messages_permanently(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
uids: Vec<u32>,
|
||||
storage_path: String,
|
||||
) -> Result<(), MailError> {
|
||||
if uids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||
client
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
|
||||
let result = client
|
||||
.delete_messages_permanently(&mailbox, &uids)
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()));
|
||||
|
||||
let _ = client.logout().await;
|
||||
result?;
|
||||
|
||||
let storage = crate::storage::Storage::new(&storage_path)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
storage
|
||||
.delete_headers(&mailbox, &uids)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[frb(sync)]
|
||||
pub fn mark_as_read_local(
|
||||
storage_path: String,
|
||||
mailbox: String,
|
||||
uid: u32,
|
||||
) -> Result<(), MailError> {
|
||||
let storage = crate::storage::Storage::new(&storage_path)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
storage
|
||||
.update_read_status(&mailbox, uid, true)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn mark_as_read(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
uid: u32,
|
||||
storage_path: String,
|
||||
@@ -877,9 +1139,9 @@ pub async fn mark_as_read(
|
||||
mailbox
|
||||
};
|
||||
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
|
||||
@@ -902,7 +1164,7 @@ pub async fn mark_as_read(
|
||||
pub async fn mark_as_unread(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
uid: u32,
|
||||
storage_path: String,
|
||||
@@ -913,9 +1175,9 @@ pub async fn mark_as_unread(
|
||||
mailbox
|
||||
};
|
||||
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
|
||||
@@ -938,7 +1200,7 @@ pub async fn mark_as_unread(
|
||||
pub async fn mark_all_as_read(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
storage_path: String,
|
||||
) -> Result<(), MailError> {
|
||||
@@ -948,9 +1210,9 @@ pub async fn mark_all_as_read(
|
||||
mailbox
|
||||
};
|
||||
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
|
||||
@@ -970,12 +1232,32 @@ pub async fn mark_all_as_read(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[frb(sync)]
|
||||
pub fn prepare_delete_all_messages(
|
||||
email: String,
|
||||
mailbox: String,
|
||||
storage_path: String,
|
||||
wipe_drafts: bool,
|
||||
) -> Result<(), MailError> {
|
||||
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
||||
"INBOX".to_string()
|
||||
} else {
|
||||
mailbox
|
||||
};
|
||||
let storage = crate::storage::Storage::new(&storage_path)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
storage
|
||||
.set_folder_pending_wipe(&email, &mailbox, wipe_drafts)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn delete_all_messages(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
storage_path: String,
|
||||
wipe_drafts: bool,
|
||||
) -> Result<(), MailError> {
|
||||
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
||||
"INBOX".to_string()
|
||||
@@ -983,9 +1265,9 @@ pub async fn delete_all_messages(
|
||||
mailbox
|
||||
};
|
||||
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
||||
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
|
||||
@@ -1000,15 +1282,46 @@ pub async fn delete_all_messages(
|
||||
|
||||
let storage = crate::storage::Storage::new(&storage_path)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
// Wipe local cache (idempotent — also done by prepare, covers the resume path).
|
||||
let _ = storage.wipe_folder(&mailbox);
|
||||
let _ = storage.update_server_total(&email, &mailbox, 0);
|
||||
if wipe_drafts {
|
||||
let _ = storage.wipe_drafts();
|
||||
}
|
||||
let _ = storage.clear_folder_pending_wipe(&email, &mailbox);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resume_pending_wipes(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
credential: AuthCredential,
|
||||
storage_path: String,
|
||||
) -> Result<(), MailError> {
|
||||
let storage = crate::storage::Storage::new(&storage_path)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
let pending = storage
|
||||
.get_pending_wipes(&email)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
for (folder, wd) in pending {
|
||||
let _ = delete_all_messages(
|
||||
config.clone(),
|
||||
email.clone(),
|
||||
credential.clone(),
|
||||
folder,
|
||||
storage_path.clone(),
|
||||
wd,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_folder(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
folder_name: String,
|
||||
) -> Result<(), MailError> {
|
||||
let folder_name = if folder_name.to_uppercase() == "INBOX" {
|
||||
@@ -1016,9 +1329,9 @@ pub async fn create_folder(
|
||||
} else {
|
||||
folder_name
|
||||
};
|
||||
let mut client = crate::imap::ImapClient::new(config, email);
|
||||
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
let result = client.create_folder(&folder_name).await.map_err(|e| {
|
||||
@@ -1031,7 +1344,7 @@ pub async fn create_folder(
|
||||
pub async fn delete_folder(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
folder_name: String,
|
||||
storage_path: String,
|
||||
) -> Result<(), MailError> {
|
||||
@@ -1040,9 +1353,9 @@ pub async fn delete_folder(
|
||||
} else {
|
||||
folder_name
|
||||
};
|
||||
let mut client = crate::imap::ImapClient::new(config, email);
|
||||
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
let result = client.delete_folder(&folder_name).await.map_err(|e| {
|
||||
@@ -1059,24 +1372,24 @@ pub async fn delete_folder(
|
||||
}
|
||||
|
||||
pub fn save_password(account: String, service: String, password: String) -> Result<(), MailError> {
|
||||
crate::secrets::SecretManager::set_password(&account, &service, &password)
|
||||
Err(crate::types::MailError::Generic("keyring not available".into()))?; Ok(())
|
||||
.map_err(|e| MailError::Generic(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn get_password(account: String, service: String) -> Result<Option<String>, MailError> {
|
||||
crate::secrets::SecretManager::get_password(&account, &service)
|
||||
Ok(None) // keyring not available
|
||||
.map_err(|e| MailError::Generic(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn delete_credential(account: String, service: String) -> Result<(), MailError> {
|
||||
crate::secrets::SecretManager::delete_password(&account, &service)
|
||||
Ok(()) // keyring not available
|
||||
.map_err(|e| MailError::Generic(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn load_older_messages(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
mailbox: String,
|
||||
oldest_uid: u32,
|
||||
batch_size: u32,
|
||||
@@ -1090,9 +1403,9 @@ pub async fn load_older_messages(
|
||||
|
||||
let local_uids = storage.get_uids(&mailbox).unwrap_or_default();
|
||||
|
||||
let mut client = crate::imap::ImapClient::new(config, email);
|
||||
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||
client
|
||||
.connect(&password)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||
client
|
||||
@@ -1143,11 +1456,12 @@ pub fn get_folder_stats(
|
||||
) -> Result<crate::types::FolderStats, MailError> {
|
||||
let storage = crate::storage::Storage::new(&storage_path)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
let (unread, server_total) = storage
|
||||
let (unread, server_total, local_total) = storage
|
||||
.get_folder_stats(&email, &mailbox)
|
||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||
Ok(crate::types::FolderStats {
|
||||
unread,
|
||||
server_total,
|
||||
local_total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::types::{FolderInfo, FolderType, MailServerConfig};
|
||||
use crate::types::{AuthCredential, FolderInfo, FolderType, MailServerConfig};
|
||||
use async_imap::extensions::idle::IdleResponse;
|
||||
use async_imap::Client;
|
||||
use futures::stream::TryStreamExt;
|
||||
@@ -25,18 +25,20 @@ pub struct ImapClient {
|
||||
session: Option<async_imap::Session<TlsStream<TcpStream>>>,
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
auth: AuthCredential,
|
||||
}
|
||||
|
||||
impl ImapClient {
|
||||
pub fn new(config: MailServerConfig, email: String) -> Self {
|
||||
pub fn new(config: MailServerConfig, email: String, auth: AuthCredential) -> Self {
|
||||
Self {
|
||||
session: None,
|
||||
config,
|
||||
email,
|
||||
auth,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(&mut self, password: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
pub async fn connect(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
tracing::debug!(
|
||||
"ImapClient: Starting connection to {}:{}",
|
||||
self.config.imap_host,
|
||||
@@ -46,16 +48,20 @@ impl ImapClient {
|
||||
let mut root_cert_store = rustls::RootCertStore::empty();
|
||||
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
tracing::debug!("ImapClient: Loading native certs...");
|
||||
// Load native certs
|
||||
match rustls_native_certs::load_native_certs() {
|
||||
Ok(certs) => {
|
||||
for cert in certs {
|
||||
let _ = root_cert_store.add(cert);
|
||||
// Skip rustls_native_certs on Android — the cert store path scan blocks
|
||||
// or returns nothing useful; webpki_roots covers all major CAs.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
tracing::debug!("ImapClient: Loading native certs...");
|
||||
match rustls_native_certs::load_native_certs() {
|
||||
Ok(certs) => {
|
||||
for cert in certs {
|
||||
let _ = root_cert_store.add(cert);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("ImapClient: Failed to load native certs: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("ImapClient: Failed to load native certs: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,33 +92,64 @@ impl ImapClient {
|
||||
.to_owned();
|
||||
|
||||
tracing::debug!("ImapClient: Performing TLS handshake for {}...", dns_name);
|
||||
let tls_stream = connector.connect(server_name, stream).await?;
|
||||
let tls_stream = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connector.connect(server_name, stream),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("TLS handshake with {} timed out after 10 seconds", dns_name))?
|
||||
.map_err(|e| format!("TLS handshake with {} failed: {}", dns_name, e))?;
|
||||
|
||||
// Create IMAP client
|
||||
tracing::debug!("ImapClient: Creating async_imap client...");
|
||||
let client = Client::new(tls_stream);
|
||||
let mut client = Client::new(tls_stream);
|
||||
|
||||
// Login
|
||||
// async_imap does NOT read the server greeting automatically. The greeting must be
|
||||
// consumed before sending any command; otherwise authenticate()'s do_auth_handshake()
|
||||
// reads it instead of the "+" continuation and hangs indefinitely.
|
||||
tracing::debug!("ImapClient: Reading server greeting for {}...", self.config.imap_host);
|
||||
match tokio::time::timeout(Duration::from_secs(10), client.read_response()).await {
|
||||
Ok(Some(Ok(greeting))) => {
|
||||
tracing::debug!("ImapClient: Server greeting: {:?}", greeting.parsed());
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
return Err(format!("Failed to read server greeting: {}", e).into());
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err("Server closed connection before greeting".into());
|
||||
}
|
||||
Err(_) => {
|
||||
return Err("Timed out waiting for server greeting".into());
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate — explicit timeout so the caller gets a specific error message
|
||||
// rather than waiting for the session_manager's global 20s wall-clock.
|
||||
tracing::debug!("ImapClient: Attempting login for {}...", self.email);
|
||||
let session = client
|
||||
.login(&self.email, password)
|
||||
let session = match &self.auth.clone() {
|
||||
AuthCredential::Password(pw) => tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
client.login(&self.email, pw),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "IMAP login timed out after 15s".to_string())?
|
||||
.map_err(|(e, _client)| {
|
||||
tracing::error!("ImapClient: Login failed: {}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
})?,
|
||||
};
|
||||
|
||||
tracing::debug!("ImapClient: Successfully logged in as {}", self.email);
|
||||
self.session = Some(session);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reconnect(&mut self, password: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
pub async fn reconnect(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
// Force close existing session if any
|
||||
if let Some(mut session) = self.session.take() {
|
||||
let _ = session.logout().await;
|
||||
}
|
||||
self.connect(password).await
|
||||
self.connect().await
|
||||
}
|
||||
|
||||
pub async fn logout(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
@@ -139,13 +176,10 @@ impl ImapClient {
|
||||
}
|
||||
|
||||
/// Use this before any operation to ensure we have a valid session.
|
||||
pub async fn ensure_connected(
|
||||
&mut self,
|
||||
password: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
pub async fn ensure_connected(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if !self.keep_alive().await {
|
||||
tracing::debug!("Reconnecting session for {}", self.email);
|
||||
self.reconnect(password).await?;
|
||||
self.reconnect().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -465,6 +499,7 @@ impl ImapClient {
|
||||
has_attachments,
|
||||
pgp_status,
|
||||
flag: message_flag,
|
||||
account_email: self.email.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -541,6 +576,7 @@ impl ImapClient {
|
||||
attachments,
|
||||
pgp_status: crate::types::PgpStatus::None,
|
||||
signature_fingerprint: None,
|
||||
sender_public_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -761,7 +797,9 @@ impl ImapClient {
|
||||
|
||||
if should_be_present && !server_uids.contains(&local.uid) {
|
||||
tracing::debug!("Pruning UID {} from {}", local.uid, mailbox);
|
||||
let _ = storage.delete_header(mailbox, local.uid);
|
||||
if let Err(e) = storage.delete_header(mailbox, local.uid) {
|
||||
tracing::error!("Failed to prune UID {} from {}: {}", local.uid, mailbox, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,30 +1071,48 @@ impl ImapClient {
|
||||
mailbox: &str,
|
||||
content: &[u8],
|
||||
_flags: Option<String>,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
) -> Result<u32, Box<dyn Error + Send + Sync>> {
|
||||
let session = self.session.as_mut().ok_or("Not connected")?;
|
||||
// Capture UIDNEXT before the APPEND so we can return the assigned UID.
|
||||
// RFC 3501 §2.3.1.1 guarantees UIDs are monotonically increasing and
|
||||
// APPEND assigns the next available UID.
|
||||
let mb = session.select(mailbox).await.map_err(|e| e.to_string())?;
|
||||
let uid_next = mb.uid_next.unwrap_or(0);
|
||||
session
|
||||
.append(mailbox, content)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
Ok(uid_next)
|
||||
}
|
||||
|
||||
pub async fn delete_all_messages(
|
||||
&mut self,
|
||||
mailbox: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
self.select_mailbox(mailbox).await?;
|
||||
let (_, exists) = self.select_mailbox(mailbox).await?;
|
||||
// RFC 3501: "1:*" is undefined on an empty mailbox; some servers return NO.
|
||||
if exists == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let session = self.session.as_mut().ok_or("Not connected")?;
|
||||
|
||||
// Mark every message as deleted
|
||||
session
|
||||
.uid_store("1:*", "+FLAGS (\\Deleted)")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Mark messages deleted in batches of 500 sequence numbers. A single
|
||||
// "1:*" STORE on thousands of messages causes Gmail (and other large
|
||||
// servers) to stall for minutes before responding; batching keeps each
|
||||
// round-trip fast. Use .SILENT to suppress per-message FETCH responses.
|
||||
const BATCH: u32 = 500;
|
||||
let mut start = 1u32;
|
||||
while start <= exists {
|
||||
let end = (start + BATCH - 1).min(exists);
|
||||
session
|
||||
.store(format!("{start}:{end}"), "+FLAGS.SILENT (\\Deleted)")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
start += BATCH;
|
||||
}
|
||||
|
||||
// Expunge to permanently remove
|
||||
session
|
||||
@@ -1070,6 +1126,34 @@ impl ImapClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_messages_permanently(
|
||||
&mut self,
|
||||
mailbox: &str,
|
||||
uids: &[u32],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.select_mailbox(mailbox).await?;
|
||||
let session = self.session.as_mut().ok_or("Not connected")?;
|
||||
let uid_set = uids.iter().map(|u| u.to_string()).collect::<Vec<_>>().join(",");
|
||||
session
|
||||
.uid_store(&uid_set, "+FLAGS.SILENT (\\Deleted)")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
session
|
||||
.expunge()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_message(
|
||||
&mut self,
|
||||
mailbox: &str,
|
||||
|
||||
@@ -3,7 +3,6 @@ pub mod api;
|
||||
pub mod dns;
|
||||
pub mod imap;
|
||||
pub mod mime;
|
||||
pub mod secrets;
|
||||
pub mod smtp;
|
||||
pub mod storage;
|
||||
pub mod types;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::imap::ImapClient;
|
||||
use crate::types::MailServerConfig;
|
||||
use crate::types::{AuthCredential, MailServerConfig};
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -16,7 +16,7 @@ lazy_static! {
|
||||
pub async fn get_client(
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
password: String,
|
||||
credential: AuthCredential,
|
||||
) -> Result<Arc<Mutex<ImapClient>>, crate::types::MailError> {
|
||||
// Atomic get-or-insert to avoid race conditions on startup
|
||||
let (client_arc, is_new) = {
|
||||
@@ -24,7 +24,7 @@ pub async fn get_client(
|
||||
if let Some(arc) = sessions.get(&email) {
|
||||
(arc.clone(), false)
|
||||
} else {
|
||||
let client = ImapClient::new(config, email.clone());
|
||||
let client = ImapClient::new(config, email.clone(), credential);
|
||||
let arc = Arc::new(Mutex::new(client));
|
||||
sessions.insert(email.clone(), arc.clone());
|
||||
(arc, true)
|
||||
@@ -40,7 +40,7 @@ pub async fn get_client(
|
||||
);
|
||||
if let Err(e) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20),
|
||||
client.connect(&password),
|
||||
client.connect(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("Connection timeout".into()))
|
||||
@@ -72,7 +72,7 @@ pub async fn get_client(
|
||||
);
|
||||
if let Err(e) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
client.reconnect(&password),
|
||||
client.reconnect(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("Reconnect timeout".into()))
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
use crate::types::MailServerConfig;
|
||||
use crate::types::{AuthCredential, MailServerConfig};
|
||||
use lettre::message::{MultiPart, SinglePart};
|
||||
use lettre::transport::smtp::authentication::Mechanism;
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
use std::error::Error;
|
||||
|
||||
pub struct SmtpClient {
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
auth: AuthCredential,
|
||||
}
|
||||
|
||||
impl SmtpClient {
|
||||
pub fn new(config: MailServerConfig, email: String) -> Self {
|
||||
Self { config, email }
|
||||
pub fn new(config: MailServerConfig, email: String, auth: AuthCredential) -> Self {
|
||||
Self { config, email, auth }
|
||||
}
|
||||
|
||||
pub async fn send_mail(
|
||||
&self,
|
||||
password: &str,
|
||||
to: &str,
|
||||
subject: &str,
|
||||
body_text: &str,
|
||||
@@ -51,11 +52,6 @@ impl SmtpClient {
|
||||
|
||||
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
|
||||
@@ -65,11 +61,7 @@ impl SmtpClient {
|
||||
Tls::Opportunistic(tls_parameters)
|
||||
};
|
||||
|
||||
let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host)
|
||||
.port(self.config.smtp_port)
|
||||
.tls(tls)
|
||||
.credentials(creds)
|
||||
.build();
|
||||
let mailer = self.build_transport(tls)?;
|
||||
|
||||
let raw_bytes = email.formatted();
|
||||
mailer.send(&email)?;
|
||||
@@ -79,18 +71,12 @@ impl SmtpClient {
|
||||
|
||||
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 {
|
||||
@@ -99,11 +85,7 @@ impl SmtpClient {
|
||||
Tls::Opportunistic(tls_parameters)
|
||||
};
|
||||
|
||||
let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host)
|
||||
.port(self.config.smtp_port)
|
||||
.tls(tls)
|
||||
.credentials(creds)
|
||||
.build();
|
||||
let mailer = self.build_transport(tls)?;
|
||||
|
||||
// Build a manual message from raw bytes; `to` may be comma-separated
|
||||
let recipients: Vec<lettre::address::Address> = to
|
||||
@@ -117,4 +99,26 @@ impl SmtpClient {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a configured `SmtpTransport` using the stored auth credential.
|
||||
fn build_transport(
|
||||
&self,
|
||||
tls: lettre::transport::smtp::client::Tls,
|
||||
) -> Result<SmtpTransport, Box<dyn Error>> {
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
|
||||
let (creds, mechanisms) = match &self.auth {
|
||||
AuthCredential::Password(pw) => (
|
||||
Credentials::new(self.email.clone(), pw.clone()),
|
||||
vec![Mechanism::Plain],
|
||||
),
|
||||
};
|
||||
|
||||
Ok(SmtpTransport::builder_dangerous(&self.config.smtp_host)
|
||||
.port(self.config.smtp_port)
|
||||
.tls(tls)
|
||||
.credentials(creds)
|
||||
.authentication(mechanisms)
|
||||
.build())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +83,22 @@ impl Storage {
|
||||
let _ = conn.execute("ALTER TABLE contacts ADD COLUMN first_seen INTEGER", []);
|
||||
let _ = conn.execute("ALTER TABLE contacts ADD COLUMN last_seen INTEGER", []);
|
||||
let _ = conn.execute("ALTER TABLE messages_v2 ADD COLUMN flag TEXT", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE messages_v2 ADD COLUMN account_email TEXT NOT NULL DEFAULT ''",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE sync_state ADD COLUMN server_total INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE sync_state ADD COLUMN pending_wipe INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE sync_state ADD COLUMN pending_wipe_drafts INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// sync_state table
|
||||
conn.execute(
|
||||
@@ -154,7 +166,10 @@ impl Storage {
|
||||
}
|
||||
|
||||
fn connect(&self) -> Result<Connection, Box<dyn Error + Send + Sync>> {
|
||||
Ok(Connection::open(&self.path)?)
|
||||
let conn = Connection::open(&self.path)?;
|
||||
let _ = conn.execute("PRAGMA journal_mode=WAL", []);
|
||||
let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn save_draft(&self, draft: &Draft) -> Result<i64, Box<dyn Error + Send + Sync>> {
|
||||
@@ -243,6 +258,27 @@ impl Storage {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_drafts_by_server_uids(
|
||||
&self,
|
||||
uids: &[u32],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let conn = self.connect()?;
|
||||
let placeholders = (1..=uids.len())
|
||||
.map(|i| format!("?{i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!("DELETE FROM drafts WHERE server_uid IN ({placeholders})");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let p: Vec<Box<dyn rusqlite::ToSql>> =
|
||||
uids.iter().map(|&u| Box::new(u) as Box<dyn rusqlite::ToSql>).collect();
|
||||
let refs: Vec<&dyn rusqlite::ToSql> = p.iter().map(|v| v.as_ref()).collect();
|
||||
stmt.execute(refs.as_slice())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_header(
|
||||
&self,
|
||||
folder: &str,
|
||||
@@ -253,8 +289,8 @@ impl Storage {
|
||||
serde_json::to_string(&header.to).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO messages_v2 (folder, uid, subject, sender, recipients, date, is_read, has_attachments, pgp_status, flag)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"INSERT OR REPLACE INTO messages_v2 (folder, uid, subject, sender, recipients, date, is_read, has_attachments, pgp_status, flag, account_email)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
folder,
|
||||
header.uid,
|
||||
@@ -266,6 +302,7 @@ impl Storage {
|
||||
if header.has_attachments { 1 } else { 0 },
|
||||
format!("{:?}", header.pgp_status),
|
||||
header.flag.clone(),
|
||||
header.account_email,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -293,7 +330,7 @@ impl Storage {
|
||||
folder: &str,
|
||||
) -> Result<Vec<MessageHeader>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare("SELECT uid, subject, sender, recipients, date, is_read, has_attachments, pgp_status, flag FROM messages_v2 WHERE folder = ?1 ORDER BY date DESC")?;
|
||||
let mut stmt = conn.prepare("SELECT uid, subject, sender, recipients, date, is_read, has_attachments, pgp_status, flag, account_email FROM messages_v2 WHERE folder = ?1 ORDER BY date DESC")?;
|
||||
let rows = stmt.query_map(params![folder], |row| {
|
||||
let recipients_json: String = row.get(3)?;
|
||||
let to: Vec<String> = serde_json::from_str(&recipients_json).unwrap_or_default();
|
||||
@@ -318,6 +355,7 @@ impl Storage {
|
||||
has_attachments: row.get::<_, i32>(6)? == 1,
|
||||
pgp_status,
|
||||
flag: row.get(8).unwrap_or(None),
|
||||
account_email: row.get(9).unwrap_or_default(),
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -399,6 +437,72 @@ impl Storage {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn wipe_drafts(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute("DELETE FROM drafts", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_folder_pending_wipe(
|
||||
&self,
|
||||
account: &str,
|
||||
folder: &str,
|
||||
wipe_drafts_flag: bool,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO sync_state (account, folder, uidvalidity, last_uid, server_total) VALUES (?1, ?2, 0, 0, 0)",
|
||||
params![account, folder],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET pending_wipe = 1, pending_wipe_drafts = ?3, server_total = 0 WHERE account = ?1 AND folder = ?2",
|
||||
params![account, folder, wipe_drafts_flag as i32],
|
||||
)?;
|
||||
self.wipe_folder(folder)?;
|
||||
if wipe_drafts_flag {
|
||||
self.wipe_drafts()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_folder_pending_wipe(
|
||||
&self,
|
||||
account: &str,
|
||||
folder: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET pending_wipe = 0, pending_wipe_drafts = 0 WHERE account = ?1 AND folder = ?2",
|
||||
params![account, folder],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_folder_pending_wipe(&self, account: &str, folder: &str) -> bool {
|
||||
let Ok(conn) = self.connect() else { return false };
|
||||
conn.query_row(
|
||||
"SELECT pending_wipe FROM sync_state WHERE account = ?1 AND folder = ?2",
|
||||
params![account, folder],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
!= 0
|
||||
}
|
||||
|
||||
pub fn get_pending_wipes(
|
||||
&self,
|
||||
account: &str,
|
||||
) -> Result<Vec<(String, bool)>, Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT folder, pending_wipe_drafts FROM sync_state WHERE account = ?1 AND pending_wipe = 1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![account], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)? != 0))
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
pub fn update_read_status(
|
||||
&self,
|
||||
folder: &str,
|
||||
@@ -470,7 +574,13 @@ impl Storage {
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let conn = self.connect()?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO contacts (email, display_name, public_key, fingerprint, trust_level, first_seen, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
"INSERT INTO contacts (email, display_name, public_key, fingerprint, trust_level, first_seen, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(email) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
public_key = excluded.public_key,
|
||||
fingerprint = excluded.fingerprint,
|
||||
trust_level = excluded.trust_level,
|
||||
last_seen = excluded.last_seen",
|
||||
params![
|
||||
contact.email.to_lowercase(),
|
||||
contact.display_name,
|
||||
@@ -704,7 +814,7 @@ impl Storage {
|
||||
&self,
|
||||
account: &str,
|
||||
folder: &str,
|
||||
) -> Result<(i32, i32), Box<dyn Error + Send + Sync>> {
|
||||
) -> Result<(i32, 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",
|
||||
@@ -718,7 +828,12 @@ impl Storage {
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok((unread, server_total))
|
||||
let local_total: i32 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM messages_v2 WHERE folder = ?1",
|
||||
params![folder],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok((unread, server_total, local_total))
|
||||
}
|
||||
|
||||
pub fn get_total_unread_count(&self) -> Result<i32, Box<dyn Error + Send + Sync>> {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
use flutter_rust_bridge::frb;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Authentication credential for IMAP and SMTP connections.
|
||||
/// `Password` uses plain LOGIN/PLAIN.
|
||||
#[frb]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthCredential {
|
||||
Password(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MailServerConfig {
|
||||
pub imap_host: String,
|
||||
@@ -20,6 +28,7 @@ pub struct MessageHeader {
|
||||
pub has_attachments: bool,
|
||||
pub pgp_status: PgpStatus,
|
||||
pub flag: Option<String>,
|
||||
pub account_email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -43,6 +52,9 @@ pub struct MessageBody {
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub pgp_status: PgpStatus,
|
||||
pub signature_fingerprint: Option<String>,
|
||||
/// ASCII-armored PGP public key of the sender, extracted from an Autocrypt
|
||||
/// header or an application/pgp-keys MIME attachment.
|
||||
pub sender_public_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -104,6 +116,7 @@ pub enum MailError {
|
||||
InvalidPassphrase,
|
||||
KeyNotFound(String),
|
||||
Crypto(String),
|
||||
TokenRefreshFailed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MailError {
|
||||
@@ -116,6 +129,7 @@ impl std::fmt::Display for MailError {
|
||||
MailError::InvalidPassphrase => write!(f, "Invalid passphrase"),
|
||||
MailError::KeyNotFound(s) => write!(f, "Key not found: {}", s),
|
||||
MailError::Crypto(s) => write!(f, "Crypto error: {}", s),
|
||||
MailError::TokenRefreshFailed(s) => write!(f, "Token refresh failed: {}", s),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,6 +153,7 @@ pub enum FolderType {
|
||||
pub struct FolderStats {
|
||||
pub unread: i32,
|
||||
pub server_total: i32,
|
||||
pub local_total: i32,
|
||||
}
|
||||
|
||||
#[frb]
|
||||
|
||||
133
mailcore/tests/pgp_key_extraction_test.rs
Normal file
133
mailcore/tests/pgp_key_extraction_test.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
/// Tests for PGP sender public key extraction in parse_mime_message:
|
||||
/// - Autocrypt header parsing
|
||||
/// - application/pgp-keys attachment fallback
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_autocrypt_header_extracts_sender_key() {
|
||||
// Minimal PGP public key bytes (32 bytes of zeros, just enough to test
|
||||
// the base64/armor wrapping — not a valid OpenPGP cert but structurally
|
||||
// correct for the extraction path).
|
||||
let raw_key_bytes = vec![0u8; 32];
|
||||
let b64_key = {
|
||||
use std::fmt::Write as _;
|
||||
let mut s = String::new();
|
||||
for byte in &raw_key_bytes {
|
||||
write!(s, "{:02x}", byte).unwrap();
|
||||
}
|
||||
// Use standard base64 encoding
|
||||
base64_encode(&raw_key_bytes)
|
||||
};
|
||||
|
||||
let raw_email = format!(
|
||||
"From: sender@example.com\r\n\
|
||||
To: recipient@example.com\r\n\
|
||||
Subject: Test Autocrypt\r\n\
|
||||
Autocrypt: addr=sender@example.com; prefer-encrypt=mutual; keydata={b64_key}\r\n\
|
||||
MIME-Version: 1.0\r\n\
|
||||
Content-Type: text/plain\r\n\
|
||||
\r\n\
|
||||
Hello world\r\n"
|
||||
);
|
||||
|
||||
let body = mailcore::api::parse_mime_message(raw_email.as_bytes())
|
||||
.await
|
||||
.expect("parse should succeed");
|
||||
|
||||
let key = body.sender_public_key.expect("sender_public_key should be set");
|
||||
assert!(
|
||||
key.contains("-----BEGIN PGP PUBLIC KEY BLOCK-----"),
|
||||
"extracted key should be armored: {}",
|
||||
key
|
||||
);
|
||||
assert!(
|
||||
key.contains("-----END PGP PUBLIC KEY BLOCK-----"),
|
||||
"extracted key should have armor footer: {}",
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pgp_keys_attachment_extracts_sender_key() {
|
||||
let fake_armored_key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\
|
||||
\n\
|
||||
mDMEYmFrZWtleWZha2VrZXlmYWtla2V5ZmFrZQ==\n\
|
||||
-----END PGP PUBLIC KEY BLOCK-----\n";
|
||||
|
||||
let boundary = "testboundary123";
|
||||
let raw_email = format!(
|
||||
"From: sender@example.com\r\n\
|
||||
To: recipient@example.com\r\n\
|
||||
Subject: Test PGP Keys Attachment\r\n\
|
||||
MIME-Version: 1.0\r\n\
|
||||
Content-Type: multipart/mixed; boundary=\"{boundary}\"\r\n\
|
||||
\r\n\
|
||||
--{boundary}\r\n\
|
||||
Content-Type: text/plain\r\n\
|
||||
\r\n\
|
||||
Hello world\r\n\
|
||||
\r\n\
|
||||
--{boundary}\r\n\
|
||||
Content-Type: application/pgp-keys; name=\"sender.asc\"\r\n\
|
||||
Content-Disposition: attachment; filename=\"sender.asc\"\r\n\
|
||||
\r\n\
|
||||
{fake_armored_key}\r\n\
|
||||
--{boundary}--\r\n"
|
||||
);
|
||||
|
||||
let body = mailcore::api::parse_mime_message(raw_email.as_bytes())
|
||||
.await
|
||||
.expect("parse should succeed");
|
||||
|
||||
let key = body.sender_public_key.expect("sender_public_key should be set from pgp-keys attachment");
|
||||
assert!(
|
||||
key.contains("BEGIN PGP PUBLIC KEY BLOCK"),
|
||||
"extracted key should contain PGP armor header: {}",
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_key_in_plain_email() {
|
||||
let raw_email = "From: sender@example.com\r\n\
|
||||
To: recipient@example.com\r\n\
|
||||
Subject: Plain email\r\n\
|
||||
MIME-Version: 1.0\r\n\
|
||||
Content-Type: text/plain\r\n\
|
||||
\r\n\
|
||||
No PGP here.\r\n";
|
||||
|
||||
let body = mailcore::api::parse_mime_message(raw_email.as_bytes())
|
||||
.await
|
||||
.expect("parse should succeed");
|
||||
|
||||
assert!(
|
||||
body.sender_public_key.is_none(),
|
||||
"sender_public_key should be None for plain emails"
|
||||
);
|
||||
}
|
||||
|
||||
/// Simple base64 encoder (standard alphabet, no line breaks) for test use.
|
||||
fn base64_encode(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
let b0 = input[i] as usize;
|
||||
let b1 = if i + 1 < input.len() { input[i + 1] as usize } else { 0 };
|
||||
let b2 = if i + 2 < input.len() { input[i + 2] as usize } else { 0 };
|
||||
out.push(ALPHABET[(b0 >> 2)] as char);
|
||||
out.push(ALPHABET[((b0 & 3) << 4) | (b1 >> 4)] as char);
|
||||
if i + 1 < input.len() {
|
||||
out.push(ALPHABET[((b1 & 0xf) << 2) | (b2 >> 6)] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
if i + 2 < input.len() {
|
||||
out.push(ALPHABET[b2 & 0x3f] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
i += 3;
|
||||
}
|
||||
out
|
||||
}
|
||||
66
privacy-policy.md
Normal file
66
privacy-policy.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Korax Privacy Policy
|
||||
|
||||
_Last updated: 30 September 2026_
|
||||
|
||||
## Who we are
|
||||
|
||||
Korax is a PGP-first email client developed and distributed by **K-Ops Oy**. Questions about this policy can be sent to: **korax@k-ops.eu**
|
||||
|
||||
## What data Korax processes
|
||||
|
||||
Korax connects directly to your email provider using the IMAP and SMTP protocols. The following data is processed on your device:
|
||||
|
||||
| Data | Purpose | Where it is stored |
|
||||
|------|---------|-------------------|
|
||||
| IMAP/SMTP server addresses, port, and TLS settings | Connect to your mail server | On-device SQLite database |
|
||||
| Email account credentials (username and password) | Authenticate with your mail server | OS secure credential store (Windows Credential Manager / Android Keystore / Secret Service on Linux) |
|
||||
| Email message content (headers, body, attachments) | Display your mail | On-device SQLite cache |
|
||||
| Email addresses of senders and recipients | Display contacts and compose mail | On-device SQLite database |
|
||||
| PGP keys you generate or import | Encrypt and decrypt mail | On-device SQLite database |
|
||||
|
||||
## What we do not collect
|
||||
|
||||
- Korax does **not** operate any servers that receive your data.
|
||||
- Your email content, credentials, and keys never leave your device through Korax. Apart from the optional lookups described below, all network communication goes directly between your device and your configured mail server.
|
||||
- Korax does **not** collect analytics, crash reports, usage statistics, or any telemetry.
|
||||
- Korax does **not** display advertising.
|
||||
- Korax does **not** sell or share your data, and has no analytics or advertising partners.
|
||||
|
||||
## Optional lookups
|
||||
|
||||
Two features contact a third-party service. Both can be switched off in the app's settings.
|
||||
|
||||
- **Key server lookup** (Settings → Auto-lookup Keyservers): to find a contact's public PGP key, Korax sends that contact's email address, or a key ID or fingerprint, to the public OpenPGP key server `keys.openpgp.org`.
|
||||
- **Remote avatars** (off unless you turn them on): Korax requests a profile picture from Gravatar, which receives a hash of the sender's email address, and a website icon from DuckDuckGo, which receives the sender's domain. These services also see your IP address.
|
||||
|
||||
Nothing else leaves your device through Korax.
|
||||
|
||||
## Permissions
|
||||
|
||||
Korax requests only the **INTERNET** permission, used to connect to your configured IMAP/SMTP servers and, if enabled, the optional lookups above.
|
||||
|
||||
## Data retention and deletion
|
||||
|
||||
All data is stored locally on your device. To delete your data, remove your accounts within the app (Settings → Accounts) or uninstall Korax. Uninstalling the app removes all locally cached messages and settings.
|
||||
|
||||
## Security
|
||||
|
||||
- Credentials are stored in the OS secure credential store and are not accessible to other apps.
|
||||
- All connections to mail servers use TLS encryption.
|
||||
- PGP private keys are stored in the OS secure credential store (Windows Credential Manager / Android Keystore / Secret Service on Linux) and never written to disk in plaintext.
|
||||
|
||||
## Children
|
||||
|
||||
Korax is not directed at children under 13 and does not knowingly collect data from children.
|
||||
|
||||
## GDPR (EU residents)
|
||||
|
||||
Korax processes data solely on your own device under your control. Because no data is transmitted to or stored by K-Ops Oy, we do not act as a data controller or data processor under the GDPR for your email content. You have full control over your data at all times.
|
||||
|
||||
## Changes to this policy
|
||||
|
||||
If we update this policy, the new version will be published at this URL with an updated "Last updated" date.
|
||||
|
||||
## Contact
|
||||
|
||||
Privacy questions: **korax@k-ops.eu**
|
||||
Reference in New Issue
Block a user