tuimail/proton-bridge/src/store.rs
Shautvast fa1d1a6cb7 Add IMAP server (step 6) — all 9 commands implemented
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>
2026-02-22 19:13:20 +01:00

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()
}
}