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:
Shautvast 2026-02-25 17:35:58 +01:00
parent 687e8d028b
commit e858236f51
7 changed files with 31 additions and 401 deletions

View file

@ -72,8 +72,6 @@ struct MessageListPayload {
/// Full message with (still-encrypted) body. /// Full message with (still-encrypted) body.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct Message { pub struct Message {
#[serde(rename = "ID")]
pub id: String,
#[serde(rename = "Subject")] #[serde(rename = "Subject")]
pub subject: String, pub subject: String,
#[serde(rename = "Sender")] #[serde(rename = "Sender")]
@ -250,8 +248,6 @@ pub struct UserKey {
#[serde(rename = "PrivateKey")] #[serde(rename = "PrivateKey")]
pub private_key: String, pub private_key: String,
/// 1 = primary key. /// 1 = primary key.
#[serde(rename = "Primary")]
pub primary: u32,
#[serde(rename = "Active")] #[serde(rename = "Active")]
pub active: u32, pub active: u32,
} }

View file

@ -25,10 +25,7 @@ use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData, SymE
use pgp::ser::Serialize; use pgp::ser::Serialize;
use pgp::types::{KeyVersion, PublicKeyTrait}; use pgp::types::{KeyVersion, PublicKeyTrait};
use pgp::{Deserializable, Message, SignedPublicKey, SignedSecretKey}; use pgp::{Deserializable, Message, SignedPublicKey, SignedSecretKey};
use tracing::{error, info, warn}; use tracing::info;
use crate::srp::bcrypt_base64_encode;
// ── Key passphrase derivation ─────────────────────────────────────────────────
/// Derive the passphrase used to unlock a user private key. /// 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()) 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. /// A parsed, ready-to-use private key.
/// ///
/// `SignedSecretKey` is `Clone`, so `PrivateKey` derives it too. We clone the /// `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. /// 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 /// 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}")) 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`. /// Return the encryption-capable subkey of a `SignedPublicKey`.
/// ///
/// Modern ProtonMail keys have an EdDSA primary key (signing only) and an /// 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. /// Build a PGP/MIME (`multipart/signed`) body encrypted in a SEIPD.
/// ///
/// Returns `(seipd_bytes, session_key_bytes)`. /// Returns `(seipd_bytes, session_key_bytes)`.

View file

@ -13,12 +13,6 @@ use crate::{crypto, SharedState};
// ── Public entry point ──────────────────────────────────────────────────────── // ── 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 /// Accept connections on an already-bound listener. Used by the embedded bridge
/// so ports are guaranteed to be occupied before `start()` returns. /// so ports are guaranteed to be occupied before `start()` returns.
pub async fn run_with_listener( pub async fn run_with_listener(
@ -149,10 +143,7 @@ async fn cmd_select(tag: &str, state: &SharedState) -> String {
* 0 RECENT\r\n\ * 0 RECENT\r\n\
* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n\ * FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n\
* OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited\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", {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 .store
.search_subject_or_from(query); .search_subject_or_from(query);
let nums: Vec<String> = seqs.iter().map(|n| n.to_string()).collect(); let nums: Vec<String> = seqs.iter().map(|n| n.to_string()).collect();
format!( let search_line = if nums.is_empty() {
"* SEARCH {}\r\n{tag} OK SEARCH completed\r\n", "* SEARCH\r\n".to_string()
nums.join(" ") } else {
) format!("* SEARCH {}\r\n", nums.join(" "))
};
format!("{search_line}{tag} OK SEARCH completed\r\n")
} }
// ── STORE ───────────────────────────────────────────────────────────────────── // ── STORE ─────────────────────────────────────────────────────────────────────

View file

@ -15,12 +15,6 @@ use crate::{crypto, SharedState};
// ── Public entry point ──────────────────────────────────────────────────────── // ── 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 /// Accept connections on an already-bound listener. Used by the embedded bridge
/// so ports are guaranteed to be occupied before `start()` returns. /// so ports are guaranteed to be occupied before `start()` returns.
pub async fn run_with_listener( pub async fn run_with_listener(

View file

@ -101,17 +101,15 @@ impl Config {
use crate::credentials; use crate::credentials;
match self.provider { match self.provider {
Provider::Imap => { Provider::Imap => {
if let Some(ref mut imap) = self.imap { if let Some(ref mut imap) = self.imap
if imap.password.is_none() { && imap.password.is_none()
{
imap.password = Some(credentials::get(credentials::IMAP_PASSWORD)?); imap.password = Some(credentials::get(credentials::IMAP_PASSWORD)?);
} }
} if let Some(ref mut smtp) = self.smtp && smtp.password.is_none(){
if let Some(ref mut smtp) = self.smtp {
if smtp.password.is_none() {
smtp.password = Some(credentials::get(credentials::SMTP_PASSWORD)?); smtp.password = Some(credentials::get(credentials::SMTP_PASSWORD)?);
} }
} }
}
Provider::Proton => { Provider::Proton => {
if let Some(ref mut proton) = self.proton { if let Some(ref mut proton) = self.proton {
if proton.password.is_none() { if proton.password.is_none() {
@ -122,14 +120,14 @@ impl Config {
credentials::get(credentials::PROTON_MAILBOX_PASSWORD).ok(); credentials::get(credentials::PROTON_MAILBOX_PASSWORD).ok();
} }
} }
if let Some(ref mut bridge) = self.bridge { if let Some(ref mut bridge) = self.bridge
if bridge.local_password.is_none() { && bridge.local_password.is_none()
{
bridge.local_password = bridge.local_password =
Some(credentials::get(credentials::BRIDGE_LOCAL_PASSWORD)?); Some(credentials::get(credentials::BRIDGE_LOCAL_PASSWORD)?);
} }
} }
} }
}
Ok(()) Ok(())
} }

View file

@ -11,11 +11,11 @@ pub(crate) struct Inbox {
/// Refresh inbox (full reload). Reconnects on error. /// Refresh inbox (full reload). Reconnects on error.
pub(crate) fn refresh(session: &mut Option<ImapSession>, config: &Config) -> Result<Inbox, String> { pub(crate) fn refresh(session: &mut Option<ImapSession>, config: &Config) -> Result<Inbox, String> {
if let Some(s) = session.as_mut() { if let Some(s) = session.as_mut()
if s.noop().is_ok() { && s.noop().is_ok()
{
return fetch_latest(s); return fetch_latest(s);
} }
}
*session = None; *session = None;
let mut new_session = connect::connect(config)?; let mut new_session = connect::connect(config)?;
@ -75,13 +75,19 @@ fn fetch_range_emails(session: &mut ImapSession, range: &str) -> Result<Vec<Emai
match session { match session {
ImapSession::Plain(s) => { ImapSession::Plain(s) => {
let messages = 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())?; .map_err(|e| e.to_string())?;
Ok(parse_emails(&messages)) Ok(parse_emails(&messages))
} }
ImapSession::Tls(s) => { ImapSession::Tls(s) => {
let messages = 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())?; .map_err(|e| e.to_string())?;
Ok(parse_emails(&messages)) Ok(parse_emails(&messages))
} }

View file

@ -234,7 +234,7 @@ pub fn main(
emails = merged; emails = merged;
oldest_seq = emails.last().map(|e| e.seq); 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; error = None;
loading = false; loading = false;
if !emails.is_empty() { if !emails.is_empty() {