Fix IMAP search tag mismatch and clean up compiler warnings
Remove UIDVALIDITY/UIDNEXT from SELECT response to prevent imap-proto from leaving the tagged SELECT line unconsumed in the buffer, which caused the subsequent SEARCH command to assert on a stale tag (a122 vs a123). Also fix empty SEARCH response to omit the trailing space before CRLF. Remove dead diagnostic code and unused functions throughout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
687e8d028b
commit
e858236f51
7 changed files with 31 additions and 401 deletions
|
|
@ -72,8 +72,6 @@ struct MessageListPayload {
|
|||
/// Full message with (still-encrypted) body.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Message {
|
||||
#[serde(rename = "ID")]
|
||||
pub id: String,
|
||||
#[serde(rename = "Subject")]
|
||||
pub subject: String,
|
||||
#[serde(rename = "Sender")]
|
||||
|
|
@ -250,8 +248,6 @@ pub struct UserKey {
|
|||
#[serde(rename = "PrivateKey")]
|
||||
pub private_key: String,
|
||||
/// 1 = primary key.
|
||||
#[serde(rename = "Primary")]
|
||||
pub primary: u32,
|
||||
#[serde(rename = "Active")]
|
||||
pub active: u32,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,7 @@ use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData, SymE
|
|||
use pgp::ser::Serialize;
|
||||
use pgp::types::{KeyVersion, PublicKeyTrait};
|
||||
use pgp::{Deserializable, Message, SignedPublicKey, SignedSecretKey};
|
||||
use tracing::{error, info, warn};
|
||||
use crate::srp::bcrypt_base64_encode;
|
||||
|
||||
// ── Key passphrase derivation ─────────────────────────────────────────────────
|
||||
use tracing::info;
|
||||
|
||||
/// Derive the passphrase used to unlock a user private key.
|
||||
///
|
||||
|
|
@ -59,90 +56,6 @@ pub fn derive_key_passphrase(password: &str, key_salt_b64: &str) -> Result<Strin
|
|||
Ok(full[full.len() - 31..].to_string())
|
||||
}
|
||||
|
||||
/// Replicates the exact bcryptjs salt-decoding behaviour used by ProtonMail's web client.
|
||||
///
|
||||
/// ProtonMail JS calls `bcryptjs.hashSync(password, "$2y$10$" + keySalt_b64)`.
|
||||
/// bcryptjs decodes the 22 chars after `"$2y$10$"` using its own 64-char alphabet
|
||||
/// (`./A-Za-z0-9`). Crucially, bcryptjs's `base64_decode` BREAKs out of its loop
|
||||
/// when c1 or c2 of any 4-char group is invalid (64/255) — it does NOT skip/filter.
|
||||
///
|
||||
/// Example: salt `/+FQehpJut/ngT45Nv/YhQ==`
|
||||
/// c1='/' (valid=1), c2='+' (invalid=255) → BREAK after 0 bytes → 16-byte zero salt.
|
||||
///
|
||||
/// Standard base64 decoding (what `derive_key_passphrase` does) can produce
|
||||
/// completely different bytes for the same salt string, causing an AES-key mismatch.
|
||||
pub fn derive_key_passphrase_js(password: &str, key_salt_b64: &str) -> Result<String, String> {
|
||||
if key_salt_b64.is_empty() {
|
||||
return Ok(password.to_string());
|
||||
}
|
||||
|
||||
// bcrypt alphabet — same index ordering bcryptjs uses in its decode table.
|
||||
const ALPHA: &[u8] = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut table = [255u8; 256];
|
||||
for (i, &c) in ALPHA.iter().enumerate() {
|
||||
table[c as usize] = i as u8;
|
||||
}
|
||||
|
||||
// bcryptjs reads the first 22 chars after "$2y$10$", which are the first 22
|
||||
// chars of key_salt_b64 (standard base64 of 16 bytes = 22 data chars + "==").
|
||||
let chars: Vec<u8> = key_salt_b64.bytes().take(22).collect();
|
||||
|
||||
// Replicate bcryptjs base64_decode exactly: BREAK on any invalid char.
|
||||
// Any byte not written stays 0 (bcryptjs initialises its salt array to zeroes).
|
||||
let mut salt = [0u8; 16];
|
||||
let mut pos = 0usize;
|
||||
let mut i = 0usize;
|
||||
while pos < 16 && i + 1 < chars.len() {
|
||||
let c1 = table[chars[i] as usize] as u32;
|
||||
let c2 = table[chars[i + 1] as usize] as u32;
|
||||
if c1 == 255 || c2 == 255 { break; } // bcryptjs breaks on invalid c1 or c2
|
||||
if pos < 16 { salt[pos] = ((c1 << 2) | ((c2 & 0x30) >> 4)) as u8; pos += 1; }
|
||||
|
||||
if i + 2 >= chars.len() { break; }
|
||||
let c3 = table[chars[i + 2] as usize] as u32;
|
||||
if c3 == 255 { break; } // bcryptjs breaks on invalid c3
|
||||
if pos < 16 { salt[pos] = (((c2 & 0x0f) << 4) | ((c3 & 0x3c) >> 2)) as u8; pos += 1; }
|
||||
|
||||
if i + 3 >= chars.len() { break; }
|
||||
let c4 = table[chars[i + 3] as usize] as u32;
|
||||
if c4 == 255 { break; } // bcryptjs breaks on invalid c4
|
||||
if pos < 16 { salt[pos] = (((c3 & 0x03) << 6) | c4) as u8; pos += 1; }
|
||||
|
||||
i += 4;
|
||||
}
|
||||
|
||||
info!(" [bcrypt-js] effective salt bytes: {}",
|
||||
salt.iter().map(|b| format!("{b:02x}")).collect::<String>());
|
||||
|
||||
let parts = bcrypt::hash_with_salt(password, 10, salt)
|
||||
.map_err(|e| format!("bcrypt-js decode error: {e}"))?;
|
||||
Ok(parts.format_for_version(bcrypt::Version::TwoY))
|
||||
}
|
||||
|
||||
/// Same derivation using pwhash (kept for comparison / fallback).
|
||||
pub fn derive_key_passphrase_pwhash(password: &str, key_salt_b64: &str) -> Result<String, String> {
|
||||
if key_salt_b64.is_empty() {
|
||||
return Ok(password.to_string());
|
||||
}
|
||||
|
||||
let salt_bytes = B64.decode(key_salt_b64).map_err(|e| e.to_string())?;
|
||||
let encoded_salt = bcrypt_base64_encode(&salt_bytes);
|
||||
|
||||
let hashed = pwhash::bcrypt::hash_with(
|
||||
pwhash::bcrypt::BcryptSetup {
|
||||
salt: Some(&encoded_salt),
|
||||
cost: Some(10),
|
||||
variant: Some(pwhash::bcrypt::BcryptVariant::V2y),
|
||||
},
|
||||
password,
|
||||
)
|
||||
.map_err(|e| format!("bcrypt error (pwhash): {e}"))?;
|
||||
|
||||
Ok(hashed.to_string())
|
||||
}
|
||||
|
||||
// ── Private key management ────────────────────────────────────────────────────
|
||||
|
||||
/// A parsed, ready-to-use private key.
|
||||
///
|
||||
/// `SignedSecretKey` is `Clone`, so `PrivateKey` derives it too. We clone the
|
||||
|
|
@ -207,50 +120,6 @@ pub fn dump_key_pool(pool: &[PrivateKey]) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Print primary key ID + all subkey IDs for a PGP-armored public key.
|
||||
/// Useful to compare against the PKESK recipient key ID in the draft body.
|
||||
pub fn log_public_key_ids(pub_key_armored: &str, label: &str) {
|
||||
let Ok((pk, _)) =
|
||||
SignedPublicKey::from_armor_single(Cursor::new(pub_key_armored.as_bytes()))
|
||||
else {
|
||||
error!(" [key-ids] {label}: failed to parse public key");
|
||||
return;
|
||||
};
|
||||
info!(
|
||||
" [key-ids] {label} primary: id={:?} algo={:?}",
|
||||
pk.key_id(),
|
||||
pk.algorithm()
|
||||
);
|
||||
for (i, sk) in pk.public_subkeys.iter().enumerate() {
|
||||
info!(
|
||||
" [key-ids] {label} subkey[{i}]: id={:?} algo={:?}",
|
||||
sk.key_id(),
|
||||
sk.algorithm()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Print the PKESK recipient key IDs of a PGP-armored message.
|
||||
pub fn dump_message_pkesk(armored: &str) {
|
||||
let Ok((msg, _)) = Message::from_armor_single(Cursor::new(armored.as_bytes())) else {
|
||||
warn!(" [body-diag] failed to parse message armor");
|
||||
return;
|
||||
};
|
||||
if let pgp::Message::Encrypted { esk, .. } = &msg {
|
||||
for e in esk {
|
||||
if let pgp::composed::message::Esk::PublicKeyEncryptedSessionKey(pkesk) = e {
|
||||
let kid = pkesk.id().map(|k| format!("{k:?}")).unwrap_or("(anon)".into());
|
||||
let ver = pkesk.version();
|
||||
info!(" [body-diag] PKESK version={ver:?} recipient={kid}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!(" [body-diag] message is not Encrypted variant");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message decryption ────────────────────────────────────────────────────────
|
||||
|
||||
/// Decrypt an address key's `Token` field using the user's private key.
|
||||
///
|
||||
/// In newer ProtonMail accounts, each address key's passphrase is stored as a
|
||||
|
|
@ -294,141 +163,6 @@ pub fn decrypt_token(encrypted_token: &str, user_key: &PrivateKey) -> Result<Str
|
|||
String::from_utf8(bytes).map_err(|e| format!("token utf8: {e}"))
|
||||
}
|
||||
|
||||
// ── Passphrase diagnostic ─────────────────────────────────────────────────────
|
||||
|
||||
/// Independently test passphrase candidates against every ECDH subkey's encrypted material.
|
||||
///
|
||||
/// Uses rpgp's own S2K `derive_key` (so we trust that part), then runs AES-256-CFB decrypt
|
||||
/// and SHA-1 checksum verification ourselves — bypassing rpgp's unlock path — so that we
|
||||
/// can isolate whether the problem is the passphrase or something inside rpgp.
|
||||
///
|
||||
/// Prints a result line for every (subkey × candidate) combination.
|
||||
/// Also runs a manual S2K to detect if rpgp's derive_key uses the raw count byte or decoded count.
|
||||
pub fn diagnose_subkey_passphrase(key: &PrivateKey, candidates: &[(&str, &str)]) {
|
||||
use pgp::types::{S2kParams, SecretParams, StringToKey};
|
||||
use cfb_mode::cipher::{AsyncStreamCipher, KeyIvInit};
|
||||
use cfb_mode::Decryptor;
|
||||
use aes::Aes256;
|
||||
use sha1::Sha1;
|
||||
use sha2::Sha256;
|
||||
|
||||
// Manual SHA-256-based OpenPGP S2K (RFC 4880 §3.7.1.3).
|
||||
// Returns 32 bytes; caller slices to key_size.
|
||||
fn manual_s2k_sha256(passphrase: &str, s2k_salt: &[u8; 8], count: u64) -> [u8; 32] {
|
||||
use sha2::Digest;
|
||||
let body: Vec<u8> = s2k_salt.iter().chain(passphrase.as_bytes()).copied().collect();
|
||||
let mut hasher = Sha256::new();
|
||||
let mut total = 0u64;
|
||||
while total < count {
|
||||
let rem = (count - total) as usize;
|
||||
if rem >= body.len() {
|
||||
hasher.update(&body);
|
||||
total += body.len() as u64;
|
||||
} else {
|
||||
hasher.update(&body[..rem]);
|
||||
total = count;
|
||||
}
|
||||
}
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
fn check_sha1(enc_data: &[u8], aes_key: &[u8], iv: &[u8]) -> (bool, String) {
|
||||
use sha1::Digest;
|
||||
let mut pt = enc_data.to_vec();
|
||||
let Ok(d) = Decryptor::<Aes256>::new_from_slices(aes_key, iv) else {
|
||||
return (false, "cfb-err".into());
|
||||
};
|
||||
d.decrypt(&mut pt);
|
||||
let preview: String = pt.iter().take(6).map(|b| format!("{b:02x}")).collect();
|
||||
if pt.len() < 20 { return (false, preview); }
|
||||
let split = pt.len() - 20;
|
||||
let actual: [u8; 20] = Sha1::digest(&pt[..split]).into();
|
||||
let ok = actual.as_slice() == &pt[split..];
|
||||
(ok, preview)
|
||||
}
|
||||
|
||||
for sk in &key.inner.secret_subkeys {
|
||||
info!(" [diag] subkey {:?}", sk.key_id());
|
||||
match sk.key.secret_params() {
|
||||
SecretParams::Plain(_) => eprintln!(" [diag] subkey is unencrypted"),
|
||||
SecretParams::Encrypted(enc) => {
|
||||
let enc_data = enc.data();
|
||||
let s2k_params = enc.string_to_key_params();
|
||||
|
||||
if let S2kParams::Cfb { sym_alg, s2k, iv } = s2k_params {
|
||||
let key_size = sym_alg.key_size();
|
||||
info!(
|
||||
" [diag] enc_data ({} bytes): {}",
|
||||
enc_data.len(),
|
||||
enc_data.iter().map(|b| format!("{b:02x}")).collect::<String>()
|
||||
);
|
||||
info!(
|
||||
" [diag] iv: {}",
|
||||
iv.iter().map(|b| format!("{b:02x}")).collect::<String>()
|
||||
);
|
||||
|
||||
// Extract raw S2K params for manual comparison.
|
||||
let raw_params: Option<([u8; 8], u8)> =
|
||||
if let StringToKey::IteratedAndSalted { salt, count, .. } = s2k {
|
||||
Some((*salt, *count))
|
||||
} else { None };
|
||||
|
||||
for (label, passphrase) in candidates {
|
||||
// Path A: rpgp's derive_key
|
||||
let (rpgp_ok, rpgp_preview, rpgp_key4) = match s2k.derive_key(passphrase, key_size) {
|
||||
Ok(aes_key) => {
|
||||
let k4: String = aes_key.iter().take(4)
|
||||
.map(|b| format!("{b:02x}")).collect();
|
||||
let (ok, preview) = check_sha1(enc_data, &aes_key, iv);
|
||||
(ok, preview, k4)
|
||||
}
|
||||
Err(e) => {
|
||||
info!(" [diag] {label} → S2K error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Path B+C: manual S2K with decoded count (65536) and raw count (96)
|
||||
if let Some((s2k_salt, count_byte)) = raw_params {
|
||||
let decoded_count = (16u64 + (count_byte as u64 & 15))
|
||||
<< ((count_byte as u64 >> 4) + 6);
|
||||
let raw_count = count_byte as u64;
|
||||
|
||||
let key_dec = manual_s2k_sha256(passphrase, &s2k_salt, decoded_count);
|
||||
let key_raw_c = manual_s2k_sha256(passphrase, &s2k_salt, raw_count);
|
||||
let k4_dec: String = key_dec.iter().take(4)
|
||||
.map(|b| format!("{b:02x}")).collect();
|
||||
let k4_raw: String = key_raw_c.iter().take(4)
|
||||
.map(|b| format!("{b:02x}")).collect();
|
||||
let (ok_dec, _) = check_sha1(enc_data, &key_dec[..key_size], iv);
|
||||
let (ok_raw, _) = check_sha1(enc_data, &key_raw_c[..key_size], iv);
|
||||
|
||||
let keys_agree = rpgp_key4 == k4_dec;
|
||||
let sha1_str = if rpgp_ok { "OK ✓" } else { "FAIL" };
|
||||
info!(
|
||||
" [diag] {label} → SHA-1 {sha1_str} (pt:{rpgp_preview}) \
|
||||
rpgp={rpgp_key4} man65k={k4_dec}(ok:{ok_dec}) \
|
||||
man96={k4_raw}(ok:{ok_raw}) agree:{keys_agree}"
|
||||
);
|
||||
if rpgp_ok || ok_dec || ok_raw {
|
||||
info!(" [diag] *** CORRECT PASSPHRASE: {label} ***");
|
||||
}
|
||||
} else {
|
||||
let sha1_str = if rpgp_ok { "OK ✓" } else { "FAIL" };
|
||||
info!(" [diag] {label} → SHA-1 {sha1_str} (pt:{rpgp_preview})");
|
||||
if rpgp_ok { info!(" [diag] *** CORRECT PASSPHRASE: {label} ***"); }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!(" [diag] non-CFB S2K params: {s2k_params:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message encryption ────────────────────────────────────────────────────────
|
||||
|
||||
/// Return the encryption-capable subkey of a `SignedPublicKey`.
|
||||
///
|
||||
/// Modern ProtonMail keys have an EdDSA primary key (signing only) and an
|
||||
|
|
@ -524,97 +258,6 @@ pub fn encrypt_body_for_send(
|
|||
}
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` with a fresh random AES-256 session key.
|
||||
///
|
||||
/// Returns `(seipd_bytes, session_key_bytes)`.
|
||||
///
|
||||
/// Used for ProtonMail ClearScheme (Type 4) external sends:
|
||||
/// - `seipd_bytes` → base64-encode → `Body` in the send package
|
||||
/// - `session_key_bytes` → base64-encode → `BodyKey.Key` (cleartext session key)
|
||||
///
|
||||
/// ProtonMail receives the SEIPD + session key and uses them to relay the
|
||||
/// message to the external recipient over SMTP.
|
||||
pub fn encrypt_body_for_external_send(plaintext: &str) -> Result<(Vec<u8>, Vec<u8>), String> {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
// Serialise the plaintext as an OpenPGP literal data packet.
|
||||
let literal = Message::new_literal("", plaintext);
|
||||
let plaintext_bytes = literal.to_bytes().map_err(|e| format!("to_bytes: {e}"))?;
|
||||
|
||||
// Generate a fresh 32-byte AES-256 session key.
|
||||
let session_key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng);
|
||||
|
||||
// Symmetrically encrypt with the session key (SEIPDv1, no PKESK/SKESK).
|
||||
let seipd = SymEncryptedProtectedData::encrypt_seipdv1(
|
||||
&mut rng,
|
||||
SymmetricKeyAlgorithm::AES256,
|
||||
&session_key,
|
||||
&plaintext_bytes,
|
||||
)
|
||||
.map_err(|e| format!("encrypt_seipdv1: {e}"))?;
|
||||
|
||||
// Wrap in Edata so to_writer calls write_packet, which prepends the
|
||||
// OpenPGP packet tag (tag 18) + length before the version byte + ciphertext.
|
||||
// Calling seipd.to_writer() directly only serialises the packet body
|
||||
// (version byte 0x01 + data) without any packet framing.
|
||||
let mut seipd_bytes = Vec::new();
|
||||
Edata::SymEncryptedProtectedData(seipd)
|
||||
.to_writer(&mut seipd_bytes)
|
||||
.map_err(|e| format!("serialize seipd: {e}"))?;
|
||||
|
||||
Ok((seipd_bytes, session_key.to_vec()))
|
||||
}
|
||||
|
||||
/// Sign `plaintext` with `signing_key`, encrypt the signed payload with a fresh
|
||||
/// AES-256 session key, and return `(seipd_bytes, session_key_bytes)`.
|
||||
///
|
||||
/// Used for ProtonMail ClearScheme (Type 4) external sends where ProtonMail
|
||||
/// requires a signature inside the encrypted body (`Signature=1`):
|
||||
/// - `seipd_bytes` → base64 → `Body` in the send package
|
||||
/// - `session_key_bytes` → base64 → `BodyKey.Key` (cleartext session key)
|
||||
pub fn encrypt_sign_body_for_external_send(
|
||||
plaintext: &str,
|
||||
signing_key: &PrivateKey,
|
||||
) -> Result<(Vec<u8>, Vec<u8>), String> {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
// Sign the literal message with the sender's address key.
|
||||
let literal = Message::new_literal("", plaintext);
|
||||
let passphrase = signing_key.passphrase.clone();
|
||||
let signed = literal
|
||||
.sign(&mut rng, &signing_key.inner, || passphrase, HashAlgorithm::SHA2_256)
|
||||
.map_err(|e| format!("sign external body: {e}"))?;
|
||||
|
||||
// Serialize the signed message (OnePassSignature + LiteralData + Signature).
|
||||
let signed_bytes = signed
|
||||
.to_bytes()
|
||||
.map_err(|e| format!("signed.to_bytes: {e}"))?;
|
||||
|
||||
// Generate a fresh 32-byte AES-256 session key.
|
||||
let session_key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng);
|
||||
|
||||
// Symmetrically encrypt the signed payload (SEIPDv1, no PKESK/SKESK).
|
||||
let seipd = SymEncryptedProtectedData::encrypt_seipdv1(
|
||||
&mut rng,
|
||||
SymmetricKeyAlgorithm::AES256,
|
||||
&session_key,
|
||||
&signed_bytes,
|
||||
)
|
||||
.map_err(|e| format!("encrypt_seipdv1: {e}"))?;
|
||||
|
||||
let mut seipd_bytes = Vec::new();
|
||||
Edata::SymEncryptedProtectedData(seipd)
|
||||
.to_writer(&mut seipd_bytes)
|
||||
.map_err(|e| format!("serialize seipd: {e}"))?;
|
||||
|
||||
info!(
|
||||
" [send] signed+encrypted SEIPD: {} bytes, session_key {} bytes",
|
||||
seipd_bytes.len(),
|
||||
session_key.len()
|
||||
);
|
||||
Ok((seipd_bytes, session_key.to_vec()))
|
||||
}
|
||||
|
||||
/// Build a PGP/MIME (`multipart/signed`) body encrypted in a SEIPD.
|
||||
///
|
||||
/// Returns `(seipd_bytes, session_key_bytes)`.
|
||||
|
|
|
|||
|
|
@ -13,12 +13,6 @@ use crate::{crypto, SharedState};
|
|||
|
||||
// ── Public entry point ────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn run(state: SharedState, port: u16) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
||||
info!("IMAP listening on port {port}");
|
||||
run_with_listener(state, listener).await
|
||||
}
|
||||
|
||||
/// Accept connections on an already-bound listener. Used by the embedded bridge
|
||||
/// so ports are guaranteed to be occupied before `start()` returns.
|
||||
pub async fn run_with_listener(
|
||||
|
|
@ -149,10 +143,7 @@ async fn cmd_select(tag: &str, state: &SharedState) -> String {
|
|||
* 0 RECENT\r\n\
|
||||
* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n\
|
||||
* OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited\r\n\
|
||||
* OK [UIDVALIDITY 1] UIDs valid\r\n\
|
||||
* OK [UIDNEXT {next}] Predicted next UID\r\n\
|
||||
{tag} OK [READ-WRITE] SELECT completed\r\n",
|
||||
next = count + 1,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -300,10 +291,12 @@ async fn cmd_search(tag: &str, criteria: &str, state: &SharedState) -> String {
|
|||
.store
|
||||
.search_subject_or_from(query);
|
||||
let nums: Vec<String> = seqs.iter().map(|n| n.to_string()).collect();
|
||||
format!(
|
||||
"* SEARCH {}\r\n{tag} OK SEARCH completed\r\n",
|
||||
nums.join(" ")
|
||||
)
|
||||
let search_line = if nums.is_empty() {
|
||||
"* SEARCH\r\n".to_string()
|
||||
} else {
|
||||
format!("* SEARCH {}\r\n", nums.join(" "))
|
||||
};
|
||||
format!("{search_line}{tag} OK SEARCH completed\r\n")
|
||||
}
|
||||
|
||||
// ── STORE ─────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -15,12 +15,6 @@ use crate::{crypto, SharedState};
|
|||
|
||||
// ── Public entry point ────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn run(state: SharedState, port: u16) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
||||
info!("SMTP listening on port {port}");
|
||||
run_with_listener(state, listener).await
|
||||
}
|
||||
|
||||
/// Accept connections on an already-bound listener. Used by the embedded bridge
|
||||
/// so ports are guaranteed to be occupied before `start()` returns.
|
||||
pub async fn run_with_listener(
|
||||
|
|
|
|||
|
|
@ -101,15 +101,13 @@ impl Config {
|
|||
use crate::credentials;
|
||||
match self.provider {
|
||||
Provider::Imap => {
|
||||
if let Some(ref mut imap) = self.imap {
|
||||
if imap.password.is_none() {
|
||||
imap.password = Some(credentials::get(credentials::IMAP_PASSWORD)?);
|
||||
}
|
||||
if let Some(ref mut imap) = self.imap
|
||||
&& imap.password.is_none()
|
||||
{
|
||||
imap.password = Some(credentials::get(credentials::IMAP_PASSWORD)?);
|
||||
}
|
||||
if let Some(ref mut smtp) = self.smtp {
|
||||
if smtp.password.is_none() {
|
||||
smtp.password = Some(credentials::get(credentials::SMTP_PASSWORD)?);
|
||||
}
|
||||
if let Some(ref mut smtp) = self.smtp && smtp.password.is_none(){
|
||||
smtp.password = Some(credentials::get(credentials::SMTP_PASSWORD)?);
|
||||
}
|
||||
}
|
||||
Provider::Proton => {
|
||||
|
|
@ -122,11 +120,11 @@ impl Config {
|
|||
credentials::get(credentials::PROTON_MAILBOX_PASSWORD).ok();
|
||||
}
|
||||
}
|
||||
if let Some(ref mut bridge) = self.bridge {
|
||||
if bridge.local_password.is_none() {
|
||||
bridge.local_password =
|
||||
Some(credentials::get(credentials::BRIDGE_LOCAL_PASSWORD)?);
|
||||
}
|
||||
if let Some(ref mut bridge) = self.bridge
|
||||
&& bridge.local_password.is_none()
|
||||
{
|
||||
bridge.local_password =
|
||||
Some(credentials::get(credentials::BRIDGE_LOCAL_PASSWORD)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
src/inbox.rs
18
src/inbox.rs
|
|
@ -11,10 +11,10 @@ pub(crate) struct Inbox {
|
|||
|
||||
/// Refresh inbox (full reload). Reconnects on error.
|
||||
pub(crate) fn refresh(session: &mut Option<ImapSession>, config: &Config) -> Result<Inbox, String> {
|
||||
if let Some(s) = session.as_mut() {
|
||||
if s.noop().is_ok() {
|
||||
return fetch_latest(s);
|
||||
}
|
||||
if let Some(s) = session.as_mut()
|
||||
&& s.noop().is_ok()
|
||||
{
|
||||
return fetch_latest(s);
|
||||
}
|
||||
|
||||
*session = None;
|
||||
|
|
@ -75,13 +75,19 @@ fn fetch_range_emails(session: &mut ImapSession, range: &str) -> Result<Vec<Emai
|
|||
match session {
|
||||
ImapSession::Plain(s) => {
|
||||
let messages = s
|
||||
.fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)]")
|
||||
.fetch(
|
||||
range,
|
||||
"BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)]",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(parse_emails(&messages))
|
||||
}
|
||||
ImapSession::Tls(s) => {
|
||||
let messages = s
|
||||
.fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)]")
|
||||
.fetch(
|
||||
range,
|
||||
"BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)]",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(parse_emails(&messages))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ pub fn main(
|
|||
emails = merged;
|
||||
|
||||
oldest_seq = emails.last().map(|e| e.seq);
|
||||
has_older = oldest_seq.map_or(false, |s| s > 1);
|
||||
has_older = oldest_seq.is_some_and( |s| s > 1);
|
||||
error = None;
|
||||
loading = false;
|
||||
if !emails.is_empty() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue