Compare commits
10 Commits
v0.2.1-bet
...
v0.2.2-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.**
|
**PGP-First Email. Private by Default.**
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +78,24 @@ The Linux release is a self-contained AppImage — no installation required.
|
|||||||
./Korax-x86_64.AppImage
|
./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.
|
Coming soon.
|
||||||
|
|
||||||
@@ -181,13 +198,17 @@ The PGP/Sequoia integration, advanced privacy features, the proprietary Rust eng
|
|||||||
|
|
||||||
## Security
|
## 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
|
## 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.
|
**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-parser = "0.9"
|
||||||
mail-builder = "0.3"
|
mail-builder = "0.3"
|
||||||
# HTTP Client
|
# 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"
|
sha1 = "0.10"
|
||||||
zbase32 = "0.1"
|
zbase32 = "0.1"
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ thiserror = "1"
|
|||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
chrono = "0.4.43"
|
chrono = "0.4.43"
|
||||||
# Removed native-tls dependencies for better cross-platform compatibility
|
# Removed native-tls dependencies for better cross-platform compatibility
|
||||||
@@ -63,6 +64,7 @@ core-foundation = "0.9"
|
|||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
jni = "0.21"
|
jni = "0.21"
|
||||||
|
tracing-android = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use mailcore::dns::discover_mail_server;
|
use mailcore::dns::discover_mail_server;
|
||||||
use mailcore::imap::ImapClient;
|
use mailcore::imap::ImapClient;
|
||||||
|
use mailcore::types::AuthCredential;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -27,12 +28,16 @@ async fn main() {
|
|||||||
io::stdout().flush().unwrap();
|
io::stdout().flush().unwrap();
|
||||||
let mut password = String::new();
|
let mut password = String::new();
|
||||||
io::stdin().read_line(&mut password).unwrap();
|
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...");
|
println!("Connecting...");
|
||||||
match client.connect(password).await {
|
match client.connect().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
println!("✓ Connected and authenticated successfully!");
|
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;
|
use mail_parser::MimeHeaders;
|
||||||
|
|
||||||
@@ -12,7 +13,19 @@ lazy_static! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_logger() {
|
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
|
// Initialize Rustls with 'ring' as the default provider to prevent crashes
|
||||||
// when multiple providers (ring, aws-lc-rs) are present in the dependency graph.
|
// 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();
|
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> {
|
pub async fn discover_server(email: String) -> Result<MailServerConfig, MailError> {
|
||||||
crate::dns::discover_mail_server(&email)
|
crate::dns::discover_mail_server(&email)
|
||||||
.await
|
.await
|
||||||
@@ -30,12 +151,12 @@ pub async fn discover_server(email: String) -> Result<MailServerConfig, MailErro
|
|||||||
pub async fn connect_and_list_folders(
|
pub async fn connect_and_list_folders(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
show_all_folders: bool,
|
show_all_folders: bool,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
) -> Result<Vec<FolderInfo>, MailError> {
|
) -> Result<Vec<FolderInfo>, MailError> {
|
||||||
// This call will create/cache the session in the manager
|
// 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 client = client_arc.lock().await;
|
||||||
let mut folders = client
|
let mut folders = client
|
||||||
@@ -66,7 +187,7 @@ pub fn get_total_unread_count(storage_path: String) -> i32 {
|
|||||||
pub async fn get_messages(
|
pub async fn get_messages(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
|
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
|
||||||
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
||||||
@@ -75,7 +196,7 @@ pub async fn get_messages(
|
|||||||
mailbox
|
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;
|
let mut client = client_arc.lock().await;
|
||||||
client
|
client
|
||||||
@@ -91,7 +212,7 @@ pub async fn get_messages(
|
|||||||
pub async fn get_message_body(
|
pub async fn get_message_body(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
@@ -116,9 +237,9 @@ pub async fn get_message_body(
|
|||||||
body
|
body
|
||||||
})
|
})
|
||||||
} else {
|
} 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
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -160,7 +281,7 @@ pub async fn get_message_body(
|
|||||||
pub async fn set_message_flag(
|
pub async fn set_message_flag(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
flag: Option<String>,
|
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.
|
// 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
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -230,11 +351,11 @@ pub fn get_contact_by_email(
|
|||||||
pub async fn append_to_sent(
|
pub async fn append_to_sent(
|
||||||
config: &MailServerConfig,
|
config: &MailServerConfig,
|
||||||
email: &str,
|
email: &str,
|
||||||
password: &str,
|
credential: AuthCredential,
|
||||||
message_bytes: &[u8],
|
message_bytes: &[u8],
|
||||||
) {
|
) {
|
||||||
let mut client = crate::imap::ImapClient::new(config.clone(), email.to_string());
|
let mut client = crate::imap::ImapClient::new(config.clone(), email.to_string(), credential);
|
||||||
if let Err(e) = client.connect(password).await {
|
if let Err(e) = client.connect().await {
|
||||||
tracing::warn!("append_to_sent: IMAP connect failed: {}", e);
|
tracing::warn!("append_to_sent: IMAP connect failed: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -257,7 +378,7 @@ pub async fn append_to_sent(
|
|||||||
pub async fn send_email(
|
pub async fn send_email(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
to: String,
|
to: String,
|
||||||
subject: String,
|
subject: String,
|
||||||
body: 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()));
|
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 smtp_client = crate::smtp::SmtpClient::new(config.clone(), email.clone(), credential.clone());
|
||||||
let message_bytes = client
|
let message_bytes = smtp_client
|
||||||
.send_mail(&password, &to, &subject, &body, attachments)
|
.send_mail(&to, &subject, &body, attachments)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn prefetch_bodies(
|
pub async fn prefetch_bodies(
|
||||||
config: crate::types::MailServerConfig,
|
config: crate::types::MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
uids: Vec<u32>,
|
uids: Vec<u32>,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
@@ -308,9 +429,9 @@ pub async fn prefetch_bodies(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use a DEDICATED connection so prefetch never blocks user-initiated fetches
|
// 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
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
client
|
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 {
|
Ok(crate::types::MessageBody {
|
||||||
uid: 0, // No UID for raw bytes
|
uid: 0, // No UID for raw bytes
|
||||||
from: from_addr,
|
from: from_addr,
|
||||||
@@ -426,12 +553,77 @@ pub async fn parse_mime_message(bytes: &[u8]) -> Result<crate::types::MessageBod
|
|||||||
attachments,
|
attachments,
|
||||||
pgp_status,
|
pgp_status,
|
||||||
signature_fingerprint,
|
signature_fingerprint,
|
||||||
|
sender_public_key,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|e| Err(MailError::Generic(format!("Task spawn error: {}", e))))
|
.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>) {
|
fn detect_pgp_status(parsed: &mail_parser::Message) -> (crate::types::PgpStatus, Option<String>) {
|
||||||
let mut pgp_status = crate::types::PgpStatus::None;
|
let mut pgp_status = crate::types::PgpStatus::None;
|
||||||
let signature_fingerprint = 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(
|
pub async fn sync_mailbox(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
cache_days: i64,
|
cache_days: i64,
|
||||||
@@ -502,10 +694,16 @@ pub async fn sync_mailbox(
|
|||||||
let storage = crate::storage::Storage::new(&storage_path)
|
let storage = crate::storage::Storage::new(&storage_path)
|
||||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
.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.
|
// Step 1: Run sync on the shared session.
|
||||||
let sync_result = {
|
let sync_result = {
|
||||||
let client_arc =
|
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?;
|
.await?;
|
||||||
let mut client = client_arc.lock().await;
|
let mut client = client_arc.lock().await;
|
||||||
client
|
client
|
||||||
@@ -517,8 +715,8 @@ pub async fn sync_mailbox(
|
|||||||
// Step 2: FLAG refresh on a dedicated connection.
|
// Step 2: FLAG refresh on a dedicated connection.
|
||||||
let uids = sync_result.uids_for_flag_refresh;
|
let uids = sync_result.uids_for_flag_refresh;
|
||||||
if !uids.is_empty() {
|
if !uids.is_empty() {
|
||||||
let mut dedicated = crate::imap::ImapClient::new(config, email);
|
let mut dedicated = crate::imap::ImapClient::new(config, email, credential);
|
||||||
match dedicated.connect(&password).await {
|
match dedicated.connect().await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let Err(e) = dedicated.refresh_flags(&mailbox, &uids, &storage).await {
|
if let Err(e) = dedicated.refresh_flags(&mailbox, &uids, &storage).await {
|
||||||
tracing::warn!("FLAG refresh via dedicated connection failed: {}", e);
|
tracing::warn!("FLAG refresh via dedicated connection failed: {}", e);
|
||||||
@@ -540,7 +738,7 @@ pub async fn sync_mailbox(
|
|||||||
pub async fn listen_for_updates(
|
pub async fn listen_for_updates(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
) -> Result<tokio::sync::mpsc::Receiver<bool>, MailError> {
|
) -> Result<tokio::sync::mpsc::Receiver<bool>, MailError> {
|
||||||
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
||||||
@@ -549,9 +747,9 @@ pub async fn listen_for_updates(
|
|||||||
mailbox
|
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
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
client
|
client
|
||||||
@@ -559,7 +757,6 @@ pub async fn listen_for_updates(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
let password_clone = password.clone();
|
|
||||||
let mailbox_clone = mailbox.clone();
|
let mailbox_clone = mailbox.clone();
|
||||||
let email_clone = email.clone();
|
let email_clone = email.clone();
|
||||||
|
|
||||||
@@ -596,7 +793,7 @@ pub async fn listen_for_updates(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("IDLE error for {}: {}", email_clone, e);
|
tracing::error!("IDLE error for {}: {}", email_clone, e);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
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);
|
tracing::error!("IDLE reconnect failed: {}", re_err);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -626,6 +823,7 @@ pub async fn check_connection(email: String) -> bool {
|
|||||||
pub fn get_cached_messages(
|
pub fn get_cached_messages(
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
|
account_email: String,
|
||||||
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
|
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
|
||||||
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
||||||
"INBOX".to_string()
|
"INBOX".to_string()
|
||||||
@@ -634,9 +832,18 @@ pub fn get_cached_messages(
|
|||||||
};
|
};
|
||||||
let storage = crate::storage::Storage::new(&storage_path)
|
let storage = crate::storage::Storage::new(&storage_path)
|
||||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||||
storage
|
let mut headers = storage
|
||||||
.get_headers(&mailbox)
|
.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> {
|
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(
|
pub async fn sync_drafts(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
) -> Result<(), MailError> {
|
) -> Result<(), MailError> {
|
||||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -737,10 +944,11 @@ pub async fn sync_drafts(
|
|||||||
.write_to_vec()
|
.write_to_vec()
|
||||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
.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()))
|
.append_message(&drafts_folder, &message_bytes, Some(r"\Draft".to_string()))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
draft.server_uid = Some(uid);
|
||||||
draft.last_synced_at = draft.last_updated;
|
draft.last_synced_at = draft.last_updated;
|
||||||
let _ = storage.save_draft(&draft);
|
let _ = storage.save_draft(&draft);
|
||||||
}
|
}
|
||||||
@@ -780,7 +988,7 @@ pub async fn sync_drafts(
|
|||||||
pub async fn move_message(
|
pub async fn move_message(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
from_mailbox: String,
|
from_mailbox: String,
|
||||||
to_mailbox: String,
|
to_mailbox: String,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
@@ -796,9 +1004,9 @@ pub async fn move_message(
|
|||||||
} else {
|
} else {
|
||||||
to_mailbox
|
to_mailbox
|
||||||
};
|
};
|
||||||
let mut client = crate::imap::ImapClient::new(config, email);
|
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.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)
|
let storage = crate::storage::Storage::new(&storage_path)
|
||||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -821,7 +1032,7 @@ pub async fn move_message(
|
|||||||
pub async fn move_messages(
|
pub async fn move_messages(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
from_mailbox: String,
|
from_mailbox: String,
|
||||||
to_mailbox: String,
|
to_mailbox: String,
|
||||||
uids: Vec<u32>,
|
uids: Vec<u32>,
|
||||||
@@ -841,9 +1052,9 @@ pub async fn move_messages(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut client = crate::imap::ImapClient::new(config, email);
|
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.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)
|
let storage = crate::storage::Storage::new(&storage_path)
|
||||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
.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(())
|
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(
|
pub async fn mark_as_read(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
@@ -877,9 +1139,9 @@ pub async fn mark_as_read(
|
|||||||
mailbox
|
mailbox
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -902,7 +1164,7 @@ pub async fn mark_as_read(
|
|||||||
pub async fn mark_as_unread(
|
pub async fn mark_as_unread(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
@@ -913,9 +1175,9 @@ pub async fn mark_as_unread(
|
|||||||
mailbox
|
mailbox
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.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(
|
pub async fn mark_all_as_read(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
) -> Result<(), MailError> {
|
) -> Result<(), MailError> {
|
||||||
@@ -948,9 +1210,9 @@ pub async fn mark_all_as_read(
|
|||||||
mailbox
|
mailbox
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -970,12 +1232,32 @@ pub async fn mark_all_as_read(
|
|||||||
Ok(())
|
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(
|
pub async fn delete_all_messages(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
|
wipe_drafts: bool,
|
||||||
) -> Result<(), MailError> {
|
) -> Result<(), MailError> {
|
||||||
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
||||||
"INBOX".to_string()
|
"INBOX".to_string()
|
||||||
@@ -983,9 +1265,9 @@ pub async fn delete_all_messages(
|
|||||||
mailbox
|
mailbox
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.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)
|
let storage = crate::storage::Storage::new(&storage_path)
|
||||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
.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.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(())
|
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(
|
pub async fn create_folder(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
folder_name: String,
|
folder_name: String,
|
||||||
) -> Result<(), MailError> {
|
) -> Result<(), MailError> {
|
||||||
let folder_name = if folder_name.to_uppercase() == "INBOX" {
|
let folder_name = if folder_name.to_uppercase() == "INBOX" {
|
||||||
@@ -1016,9 +1329,9 @@ pub async fn create_folder(
|
|||||||
} else {
|
} else {
|
||||||
folder_name
|
folder_name
|
||||||
};
|
};
|
||||||
let mut client = crate::imap::ImapClient::new(config, email);
|
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
let result = client.create_folder(&folder_name).await.map_err(|e| {
|
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(
|
pub async fn delete_folder(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
folder_name: String,
|
folder_name: String,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
) -> Result<(), MailError> {
|
) -> Result<(), MailError> {
|
||||||
@@ -1040,9 +1353,9 @@ pub async fn delete_folder(
|
|||||||
} else {
|
} else {
|
||||||
folder_name
|
folder_name
|
||||||
};
|
};
|
||||||
let mut client = crate::imap::ImapClient::new(config, email);
|
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
let result = client.delete_folder(&folder_name).await.map_err(|e| {
|
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> {
|
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()))
|
.map_err(|e| MailError::Generic(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_password(account: String, service: String) -> Result<Option<String>, MailError> {
|
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()))
|
.map_err(|e| MailError::Generic(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_credential(account: String, service: String) -> Result<(), MailError> {
|
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()))
|
.map_err(|e| MailError::Generic(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_older_messages(
|
pub async fn load_older_messages(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
oldest_uid: u32,
|
oldest_uid: u32,
|
||||||
batch_size: 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 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
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
client
|
client
|
||||||
@@ -1143,11 +1456,12 @@ pub fn get_folder_stats(
|
|||||||
) -> Result<crate::types::FolderStats, MailError> {
|
) -> Result<crate::types::FolderStats, MailError> {
|
||||||
let storage = crate::storage::Storage::new(&storage_path)
|
let storage = crate::storage::Storage::new(&storage_path)
|
||||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
.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)
|
.get_folder_stats(&email, &mailbox)
|
||||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||||
Ok(crate::types::FolderStats {
|
Ok(crate::types::FolderStats {
|
||||||
unread,
|
unread,
|
||||||
server_total,
|
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::extensions::idle::IdleResponse;
|
||||||
use async_imap::Client;
|
use async_imap::Client;
|
||||||
use futures::stream::TryStreamExt;
|
use futures::stream::TryStreamExt;
|
||||||
@@ -25,18 +25,20 @@ pub struct ImapClient {
|
|||||||
session: Option<async_imap::Session<TlsStream<TcpStream>>>,
|
session: Option<async_imap::Session<TlsStream<TcpStream>>>,
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
|
auth: AuthCredential,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImapClient {
|
impl ImapClient {
|
||||||
pub fn new(config: MailServerConfig, email: String) -> Self {
|
pub fn new(config: MailServerConfig, email: String, auth: AuthCredential) -> Self {
|
||||||
Self {
|
Self {
|
||||||
session: None,
|
session: None,
|
||||||
config,
|
config,
|
||||||
email,
|
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!(
|
tracing::debug!(
|
||||||
"ImapClient: Starting connection to {}:{}",
|
"ImapClient: Starting connection to {}:{}",
|
||||||
self.config.imap_host,
|
self.config.imap_host,
|
||||||
@@ -46,16 +48,20 @@ impl ImapClient {
|
|||||||
let mut root_cert_store = rustls::RootCertStore::empty();
|
let mut root_cert_store = rustls::RootCertStore::empty();
|
||||||
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||||
|
|
||||||
tracing::debug!("ImapClient: Loading native certs...");
|
// Skip rustls_native_certs on Android — the cert store path scan blocks
|
||||||
// Load native certs
|
// or returns nothing useful; webpki_roots covers all major CAs.
|
||||||
match rustls_native_certs::load_native_certs() {
|
#[cfg(not(target_os = "android"))]
|
||||||
Ok(certs) => {
|
{
|
||||||
for cert in certs {
|
tracing::debug!("ImapClient: Loading native certs...");
|
||||||
let _ = root_cert_store.add(cert);
|
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();
|
.to_owned();
|
||||||
|
|
||||||
tracing::debug!("ImapClient: Performing TLS handshake for {}...", dns_name);
|
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
|
// Create IMAP client
|
||||||
tracing::debug!("ImapClient: Creating async_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);
|
tracing::debug!("ImapClient: Attempting login for {}...", self.email);
|
||||||
let session = client
|
let session = match &self.auth.clone() {
|
||||||
.login(&self.email, password)
|
AuthCredential::Password(pw) => tokio::time::timeout(
|
||||||
|
Duration::from_secs(15),
|
||||||
|
client.login(&self.email, pw),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
|
.map_err(|_| "IMAP login timed out after 15s".to_string())?
|
||||||
.map_err(|(e, _client)| {
|
.map_err(|(e, _client)| {
|
||||||
tracing::error!("ImapClient: Login failed: {}", e);
|
tracing::error!("ImapClient: Login failed: {}", e);
|
||||||
e.to_string()
|
e.to_string()
|
||||||
})?;
|
})?,
|
||||||
|
};
|
||||||
|
|
||||||
tracing::debug!("ImapClient: Successfully logged in as {}", self.email);
|
tracing::debug!("ImapClient: Successfully logged in as {}", self.email);
|
||||||
self.session = Some(session);
|
self.session = Some(session);
|
||||||
Ok(())
|
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
|
// Force close existing session if any
|
||||||
if let Some(mut session) = self.session.take() {
|
if let Some(mut session) = self.session.take() {
|
||||||
let _ = session.logout().await;
|
let _ = session.logout().await;
|
||||||
}
|
}
|
||||||
self.connect(password).await
|
self.connect().await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
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.
|
/// Use this before any operation to ensure we have a valid session.
|
||||||
pub async fn ensure_connected(
|
pub async fn ensure_connected(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
&mut self,
|
|
||||||
password: &str,
|
|
||||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
|
||||||
if !self.keep_alive().await {
|
if !self.keep_alive().await {
|
||||||
tracing::debug!("Reconnecting session for {}", self.email);
|
tracing::debug!("Reconnecting session for {}", self.email);
|
||||||
self.reconnect(password).await?;
|
self.reconnect().await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -465,6 +499,7 @@ impl ImapClient {
|
|||||||
has_attachments,
|
has_attachments,
|
||||||
pgp_status,
|
pgp_status,
|
||||||
flag: message_flag,
|
flag: message_flag,
|
||||||
|
account_email: self.email.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +576,7 @@ impl ImapClient {
|
|||||||
attachments,
|
attachments,
|
||||||
pgp_status: crate::types::PgpStatus::None,
|
pgp_status: crate::types::PgpStatus::None,
|
||||||
signature_fingerprint: None,
|
signature_fingerprint: None,
|
||||||
|
sender_public_key: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,7 +797,9 @@ impl ImapClient {
|
|||||||
|
|
||||||
if should_be_present && !server_uids.contains(&local.uid) {
|
if should_be_present && !server_uids.contains(&local.uid) {
|
||||||
tracing::debug!("Pruning UID {} from {}", local.uid, mailbox);
|
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,
|
mailbox: &str,
|
||||||
content: &[u8],
|
content: &[u8],
|
||||||
_flags: Option<String>,
|
_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")?;
|
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
|
session
|
||||||
.append(mailbox, content)
|
.append(mailbox, content)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(uid_next)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_messages(
|
pub async fn delete_all_messages(
|
||||||
&mut self,
|
&mut self,
|
||||||
mailbox: &str,
|
mailbox: &str,
|
||||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
) -> 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")?;
|
let session = self.session.as_mut().ok_or("Not connected")?;
|
||||||
|
|
||||||
// Mark every message as deleted
|
// Mark messages deleted in batches of 500 sequence numbers. A single
|
||||||
session
|
// "1:*" STORE on thousands of messages causes Gmail (and other large
|
||||||
.uid_store("1:*", "+FLAGS (\\Deleted)")
|
// servers) to stall for minutes before responding; batching keeps each
|
||||||
.await
|
// round-trip fast. Use .SILENT to suppress per-message FETCH responses.
|
||||||
.map_err(|e| e.to_string())?
|
const BATCH: u32 = 500;
|
||||||
.try_collect::<Vec<_>>()
|
let mut start = 1u32;
|
||||||
.await
|
while start <= exists {
|
||||||
.map_err(|e| e.to_string())?;
|
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
|
// Expunge to permanently remove
|
||||||
session
|
session
|
||||||
@@ -1070,6 +1126,34 @@ impl ImapClient {
|
|||||||
Ok(())
|
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(
|
pub async fn delete_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
mailbox: &str,
|
mailbox: &str,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ pub mod api;
|
|||||||
pub mod dns;
|
pub mod dns;
|
||||||
pub mod imap;
|
pub mod imap;
|
||||||
pub mod mime;
|
pub mod mime;
|
||||||
pub mod secrets;
|
|
||||||
pub mod smtp;
|
pub mod smtp;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::imap::ImapClient;
|
use crate::imap::ImapClient;
|
||||||
use crate::types::MailServerConfig;
|
use crate::types::{AuthCredential, MailServerConfig};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -16,7 +16,7 @@ lazy_static! {
|
|||||||
pub async fn get_client(
|
pub async fn get_client(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
) -> Result<Arc<Mutex<ImapClient>>, crate::types::MailError> {
|
) -> Result<Arc<Mutex<ImapClient>>, crate::types::MailError> {
|
||||||
// Atomic get-or-insert to avoid race conditions on startup
|
// Atomic get-or-insert to avoid race conditions on startup
|
||||||
let (client_arc, is_new) = {
|
let (client_arc, is_new) = {
|
||||||
@@ -24,7 +24,7 @@ pub async fn get_client(
|
|||||||
if let Some(arc) = sessions.get(&email) {
|
if let Some(arc) = sessions.get(&email) {
|
||||||
(arc.clone(), false)
|
(arc.clone(), false)
|
||||||
} else {
|
} else {
|
||||||
let client = ImapClient::new(config, email.clone());
|
let client = ImapClient::new(config, email.clone(), credential);
|
||||||
let arc = Arc::new(Mutex::new(client));
|
let arc = Arc::new(Mutex::new(client));
|
||||||
sessions.insert(email.clone(), arc.clone());
|
sessions.insert(email.clone(), arc.clone());
|
||||||
(arc, true)
|
(arc, true)
|
||||||
@@ -40,7 +40,7 @@ pub async fn get_client(
|
|||||||
);
|
);
|
||||||
if let Err(e) = tokio::time::timeout(
|
if let Err(e) = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(20),
|
std::time::Duration::from_secs(20),
|
||||||
client.connect(&password),
|
client.connect(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| Err("Connection timeout".into()))
|
.unwrap_or_else(|_| Err("Connection timeout".into()))
|
||||||
@@ -72,7 +72,7 @@ pub async fn get_client(
|
|||||||
);
|
);
|
||||||
if let Err(e) = tokio::time::timeout(
|
if let Err(e) = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(15),
|
std::time::Duration::from_secs(15),
|
||||||
client.reconnect(&password),
|
client.reconnect(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| Err("Reconnect timeout".into()))
|
.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::message::{MultiPart, SinglePart};
|
||||||
|
use lettre::transport::smtp::authentication::Mechanism;
|
||||||
use lettre::{Message, SmtpTransport, Transport};
|
use lettre::{Message, SmtpTransport, Transport};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
||||||
pub struct SmtpClient {
|
pub struct SmtpClient {
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
|
auth: AuthCredential,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SmtpClient {
|
impl SmtpClient {
|
||||||
pub fn new(config: MailServerConfig, email: String) -> Self {
|
pub fn new(config: MailServerConfig, email: String, auth: AuthCredential) -> Self {
|
||||||
Self { config, email }
|
Self { config, email, auth }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_mail(
|
pub async fn send_mail(
|
||||||
&self,
|
&self,
|
||||||
password: &str,
|
|
||||||
to: &str,
|
to: &str,
|
||||||
subject: &str,
|
subject: &str,
|
||||||
body_text: &str,
|
body_text: &str,
|
||||||
@@ -51,11 +52,6 @@ impl SmtpClient {
|
|||||||
|
|
||||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
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_parameters = TlsParameters::new(self.config.smtp_host.clone())?;
|
||||||
|
|
||||||
// Explicitly choose TLS mode based on port
|
// Explicitly choose TLS mode based on port
|
||||||
@@ -65,11 +61,7 @@ impl SmtpClient {
|
|||||||
Tls::Opportunistic(tls_parameters)
|
Tls::Opportunistic(tls_parameters)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host)
|
let mailer = self.build_transport(tls)?;
|
||||||
.port(self.config.smtp_port)
|
|
||||||
.tls(tls)
|
|
||||||
.credentials(creds)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let raw_bytes = email.formatted();
|
let raw_bytes = email.formatted();
|
||||||
mailer.send(&email)?;
|
mailer.send(&email)?;
|
||||||
@@ -79,18 +71,12 @@ impl SmtpClient {
|
|||||||
|
|
||||||
pub async fn send_raw_mail(
|
pub async fn send_raw_mail(
|
||||||
&self,
|
&self,
|
||||||
password: &str,
|
|
||||||
to: &str,
|
to: &str,
|
||||||
_subject: &str,
|
_subject: &str,
|
||||||
raw_message: &[u8],
|
raw_message: &[u8],
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
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_parameters = TlsParameters::new(self.config.smtp_host.clone())?;
|
||||||
|
|
||||||
let tls = if self.config.smtp_port == 465 {
|
let tls = if self.config.smtp_port == 465 {
|
||||||
@@ -99,11 +85,7 @@ impl SmtpClient {
|
|||||||
Tls::Opportunistic(tls_parameters)
|
Tls::Opportunistic(tls_parameters)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host)
|
let mailer = self.build_transport(tls)?;
|
||||||
.port(self.config.smtp_port)
|
|
||||||
.tls(tls)
|
|
||||||
.credentials(creds)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Build a manual message from raw bytes; `to` may be comma-separated
|
// Build a manual message from raw bytes; `to` may be comma-separated
|
||||||
let recipients: Vec<lettre::address::Address> = to
|
let recipients: Vec<lettre::address::Address> = to
|
||||||
@@ -117,4 +99,26 @@ impl SmtpClient {
|
|||||||
|
|
||||||
Ok(())
|
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 first_seen INTEGER", []);
|
||||||
let _ = conn.execute("ALTER TABLE contacts ADD COLUMN last_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 flag TEXT", []);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE messages_v2 ADD COLUMN account_email TEXT NOT NULL DEFAULT ''",
|
||||||
|
[],
|
||||||
|
);
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"ALTER TABLE sync_state ADD COLUMN server_total INTEGER NOT NULL DEFAULT 0",
|
"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
|
// sync_state table
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -154,7 +166,10 @@ impl Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn connect(&self) -> Result<Connection, Box<dyn Error + Send + Sync>> {
|
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>> {
|
pub fn save_draft(&self, draft: &Draft) -> Result<i64, Box<dyn Error + Send + Sync>> {
|
||||||
@@ -243,6 +258,27 @@ impl Storage {
|
|||||||
Ok(())
|
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(
|
pub fn save_header(
|
||||||
&self,
|
&self,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
@@ -253,8 +289,8 @@ impl Storage {
|
|||||||
serde_json::to_string(&header.to).unwrap_or_else(|_| "[]".to_string());
|
serde_json::to_string(&header.to).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO messages_v2 (folder, uid, subject, sender, recipients, date, is_read, has_attachments, pgp_status, flag)
|
"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)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
params![
|
params![
|
||||||
folder,
|
folder,
|
||||||
header.uid,
|
header.uid,
|
||||||
@@ -266,6 +302,7 @@ impl Storage {
|
|||||||
if header.has_attachments { 1 } else { 0 },
|
if header.has_attachments { 1 } else { 0 },
|
||||||
format!("{:?}", header.pgp_status),
|
format!("{:?}", header.pgp_status),
|
||||||
header.flag.clone(),
|
header.flag.clone(),
|
||||||
|
header.account_email,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -293,7 +330,7 @@ impl Storage {
|
|||||||
folder: &str,
|
folder: &str,
|
||||||
) -> Result<Vec<MessageHeader>, Box<dyn Error + Send + Sync>> {
|
) -> Result<Vec<MessageHeader>, Box<dyn Error + Send + Sync>> {
|
||||||
let conn = self.connect()?;
|
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 rows = stmt.query_map(params![folder], |row| {
|
||||||
let recipients_json: String = row.get(3)?;
|
let recipients_json: String = row.get(3)?;
|
||||||
let to: Vec<String> = serde_json::from_str(&recipients_json).unwrap_or_default();
|
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,
|
has_attachments: row.get::<_, i32>(6)? == 1,
|
||||||
pgp_status,
|
pgp_status,
|
||||||
flag: row.get(8).unwrap_or(None),
|
flag: row.get(8).unwrap_or(None),
|
||||||
|
account_email: row.get(9).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -399,6 +437,72 @@ impl Storage {
|
|||||||
Ok(())
|
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(
|
pub fn update_read_status(
|
||||||
&self,
|
&self,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
@@ -470,7 +574,13 @@ impl Storage {
|
|||||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
let conn = self.connect()?;
|
let conn = self.connect()?;
|
||||||
conn.execute(
|
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![
|
params![
|
||||||
contact.email.to_lowercase(),
|
contact.email.to_lowercase(),
|
||||||
contact.display_name,
|
contact.display_name,
|
||||||
@@ -704,7 +814,7 @@ impl Storage {
|
|||||||
&self,
|
&self,
|
||||||
account: &str,
|
account: &str,
|
||||||
folder: &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 conn = self.connect()?;
|
||||||
let unread: i32 = conn.query_row(
|
let unread: i32 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM messages_v2 WHERE folder = ?1 AND is_read = 0",
|
"SELECT COUNT(*) FROM messages_v2 WHERE folder = ?1 AND is_read = 0",
|
||||||
@@ -718,7 +828,12 @@ impl Storage {
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.unwrap_or(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>> {
|
pub fn get_total_unread_count(&self) -> Result<i32, Box<dyn Error + Send + Sync>> {
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
use flutter_rust_bridge::frb;
|
use flutter_rust_bridge::frb;
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MailServerConfig {
|
pub struct MailServerConfig {
|
||||||
pub imap_host: String,
|
pub imap_host: String,
|
||||||
@@ -20,6 +28,7 @@ pub struct MessageHeader {
|
|||||||
pub has_attachments: bool,
|
pub has_attachments: bool,
|
||||||
pub pgp_status: PgpStatus,
|
pub pgp_status: PgpStatus,
|
||||||
pub flag: Option<String>,
|
pub flag: Option<String>,
|
||||||
|
pub account_email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -43,6 +52,9 @@ pub struct MessageBody {
|
|||||||
pub attachments: Vec<Attachment>,
|
pub attachments: Vec<Attachment>,
|
||||||
pub pgp_status: PgpStatus,
|
pub pgp_status: PgpStatus,
|
||||||
pub signature_fingerprint: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -104,6 +116,7 @@ pub enum MailError {
|
|||||||
InvalidPassphrase,
|
InvalidPassphrase,
|
||||||
KeyNotFound(String),
|
KeyNotFound(String),
|
||||||
Crypto(String),
|
Crypto(String),
|
||||||
|
TokenRefreshFailed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for MailError {
|
impl std::fmt::Display for MailError {
|
||||||
@@ -116,6 +129,7 @@ impl std::fmt::Display for MailError {
|
|||||||
MailError::InvalidPassphrase => write!(f, "Invalid passphrase"),
|
MailError::InvalidPassphrase => write!(f, "Invalid passphrase"),
|
||||||
MailError::KeyNotFound(s) => write!(f, "Key not found: {}", s),
|
MailError::KeyNotFound(s) => write!(f, "Key not found: {}", s),
|
||||||
MailError::Crypto(s) => write!(f, "Crypto error: {}", 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 struct FolderStats {
|
||||||
pub unread: i32,
|
pub unread: i32,
|
||||||
pub server_total: i32,
|
pub server_total: i32,
|
||||||
|
pub local_total: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[frb]
|
#[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
|
||||||
|
}
|
||||||
57
privacy-policy.md
Normal file
57
privacy-policy.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# 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. 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, share, or transmit your data to any third party.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
Korax requests only the **INTERNET** permission, used solely to connect to your configured IMAP/SMTP servers.
|
||||||
|
|
||||||
|
## 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