Plain-TCP listener on 0.0.0.0 (handles both localhost and 127.0.0.1). LOGIN, NOOP, SELECT (reloads inbox), FETCH header+body, SEARCH, STORE, EXPUNGE (deletes on ProtonMail), LOGOUT. FETCH body decrypts messages on demand: brief lock for ID lookup, API call without lock, brief lock again for crypto. RFC 3501 literal format with exact byte counts for imap-crate compatibility. Also: update store.expunge() to return (ids, seqs) in descending order for correct IMAP EXPUNGE response ordering; add chrono for RFC 2822 dates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
109 lines
3.9 KiB
Rust
109 lines
3.9 KiB
Rust
/// Step 5: In-memory message store.
|
|
///
|
|
/// Maps IMAP sequence numbers (1-based, contiguous) ↔ ProtonMail message IDs.
|
|
/// Messages are stored oldest-first so that seq 1 = oldest, seq N = newest.
|
|
/// The API returns messages newest-first, so `load_all` reverses the slice.
|
|
///
|
|
/// After EXPUNGE the Vec automatically renumbers: the element that was at
|
|
/// index i is now at some new index j, and its seq number is j + 1.
|
|
use std::collections::HashSet;
|
|
|
|
use crate::api::MessageMeta;
|
|
|
|
pub struct MessageStore {
|
|
/// Oldest-first. `messages[i]` has IMAP sequence number `i + 1`.
|
|
messages: Vec<MessageMeta>,
|
|
/// Proton IDs of messages marked `\Deleted`, waiting for EXPUNGE.
|
|
deleted_pending: HashSet<String>,
|
|
}
|
|
|
|
impl MessageStore {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
messages: Vec::new(),
|
|
deleted_pending: HashSet::new(),
|
|
}
|
|
}
|
|
|
|
/// Replace the store contents with a fresh API response (newest-first).
|
|
/// Reverses to oldest-first and clears any pending deletions.
|
|
pub fn load_all(&mut self, mut messages: Vec<MessageMeta>) {
|
|
messages.reverse(); // API is newest-first; store oldest-first
|
|
self.messages = messages;
|
|
self.deleted_pending.clear();
|
|
}
|
|
|
|
/// Number of messages currently in the store (the IMAP EXISTS count).
|
|
pub fn count(&self) -> u32 {
|
|
self.messages.len() as u32
|
|
}
|
|
|
|
/// Look up the ProtonMail message ID for a given IMAP sequence number.
|
|
pub fn proton_id(&self, seq: u32) -> Option<&str> {
|
|
self.messages
|
|
.get(seq.wrapping_sub(1) as usize)
|
|
.map(|m| m.id.as_str())
|
|
}
|
|
|
|
/// Look up message metadata for a given IMAP sequence number.
|
|
pub fn get_meta(&self, seq: u32) -> Option<&MessageMeta> {
|
|
self.messages.get(seq.wrapping_sub(1) as usize)
|
|
}
|
|
|
|
/// Return all (seq, meta) pairs in the inclusive range [from, to].
|
|
/// Clamps to the actual message count.
|
|
pub fn get_range(&self, from: u32, to: u32) -> Vec<(u32, &MessageMeta)> {
|
|
let max = self.count();
|
|
let from = from.max(1);
|
|
let to = to.min(max);
|
|
if from > to {
|
|
return vec![];
|
|
}
|
|
(from..=to)
|
|
.filter_map(|seq| self.get_meta(seq).map(|m| (seq, m)))
|
|
.collect()
|
|
}
|
|
|
|
/// Mark a message for deletion (STORE … +FLAGS (\Deleted)).
|
|
pub fn mark_deleted(&mut self, seq: u32) {
|
|
if let Some(m) = self.get_meta(seq) {
|
|
self.deleted_pending.insert(m.id.clone());
|
|
}
|
|
}
|
|
|
|
/// Remove all pending-deleted messages.
|
|
/// Returns `(proton_ids, seqs)` where `seqs` are the pre-expunge IMAP
|
|
/// sequence numbers in **descending** order (correct for IMAP EXPUNGE
|
|
/// responses — highest seq first so client renumbering stays consistent).
|
|
pub fn expunge(&mut self) -> (Vec<String>, Vec<u32>) {
|
|
let mut removed_ids = Vec::new();
|
|
let mut removed_seqs = Vec::new();
|
|
for (i, m) in self.messages.iter().enumerate() {
|
|
if self.deleted_pending.contains(&m.id) {
|
|
removed_ids.push(m.id.clone());
|
|
removed_seqs.push(i as u32 + 1);
|
|
}
|
|
}
|
|
self.messages
|
|
.retain(|m| !self.deleted_pending.contains(&m.id));
|
|
self.deleted_pending.clear();
|
|
removed_seqs.sort_unstable_by(|a, b| b.cmp(a)); // descending
|
|
(removed_ids, removed_seqs)
|
|
}
|
|
|
|
/// Return the seq numbers of messages whose subject, sender name, or
|
|
/// sender address contains `query` (case-insensitive substring match).
|
|
pub fn search_subject_or_from(&self, query: &str) -> Vec<u32> {
|
|
let q = query.to_lowercase();
|
|
self.messages
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, m)| {
|
|
m.subject.to_lowercase().contains(&q)
|
|
|| m.sender.address.to_lowercase().contains(&q)
|
|
|| m.sender.name.to_lowercase().contains(&q)
|
|
})
|
|
.map(|(i, _)| i as u32 + 1)
|
|
.collect()
|
|
}
|
|
}
|