mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat(imap): handle UIDVALIDITY changes via Message-ID comparison instead of full rebuild
This commit is contained in:
+965
-58
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,25 @@ use std::ops::DerefMut;
|
|||||||
use tokio::io::BufWriter;
|
use tokio::io::BufWriter;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Classify an `io::Error` (from TLS stream I/O) for IMAP connection errors.
|
||||||
|
/// `UnexpectedEof` is treated as a network error because many servers skip
|
||||||
|
/// the TLS `close_notify` alert, causing rustls to emit this error when the
|
||||||
|
/// TCP connection is dropped normally.
|
||||||
|
fn classify_io_error(e: &std::io::Error) -> ErrorCode {
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
matches!(
|
||||||
|
e.kind(),
|
||||||
|
ErrorKind::BrokenPipe
|
||||||
|
| ErrorKind::ConnectionReset
|
||||||
|
| ErrorKind::ConnectionAborted
|
||||||
|
| ErrorKind::TimedOut
|
||||||
|
| ErrorKind::UnexpectedEof
|
||||||
|
| ErrorKind::NotConnected
|
||||||
|
)
|
||||||
|
.then_some(ErrorCode::NetworkError)
|
||||||
|
.unwrap_or(ErrorCode::ImapCommandFailed)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct Client {
|
pub(crate) struct Client {
|
||||||
inner: ImapClient<Box<dyn SessionStream>>,
|
inner: ImapClient<Box<dyn SessionStream>>,
|
||||||
@@ -141,7 +160,7 @@ impl Client {
|
|||||||
let _greeting = client
|
let _greeting = client
|
||||||
.read_response()
|
.read_response()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
.map_err(|e| raise_error!(format!("{:#?}", e), classify_io_error(&e)))?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
raise_error!(
|
raise_error!(
|
||||||
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is SSL.".into(),
|
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is SSL.".into(),
|
||||||
@@ -171,7 +190,7 @@ impl Client {
|
|||||||
let _greeting = client
|
let _greeting = client
|
||||||
.read_response()
|
.read_response()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
.map_err(|e| raise_error!(format!("{:#?}", e), classify_io_error(&e)))?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
raise_error!(
|
raise_error!(
|
||||||
"failed to read greeting".into(),
|
"failed to read greeting".into(),
|
||||||
@@ -202,7 +221,7 @@ impl Client {
|
|||||||
let _greeting = client
|
let _greeting = client
|
||||||
.read_response()
|
.read_response()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
.map_err(|e| raise_error!(format!("{:#?}", e), classify_io_error(&e)))?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
raise_error!(
|
raise_error!(
|
||||||
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is STARTTLS.".into(),
|
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is STARTTLS.".into(),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use crate::{error::BichonResult, imap::manager::ImapConnectionManager};
|
|||||||
use async_imap::types::Name;
|
use async_imap::types::Name;
|
||||||
use async_imap::Session;
|
use async_imap::Session;
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
@@ -260,7 +260,9 @@ impl ImapExecutor {
|
|||||||
let mut count = 0u64;
|
let mut count = 0u64;
|
||||||
let mut skipped = 0u64;
|
let mut skipped = 0u64;
|
||||||
let mut max_uid: Option<u32> = None;
|
let mut max_uid: Option<u32> = None;
|
||||||
let size_limit = account.max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
let size_limit = account
|
||||||
|
.max_email_size_bytes
|
||||||
|
.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||||
while let Some(fetch) = stream
|
while let Some(fetch) = stream
|
||||||
.try_next()
|
.try_next()
|
||||||
.await
|
.await
|
||||||
@@ -363,14 +365,14 @@ impl ImapExecutor {
|
|||||||
let mut size_stream = session
|
let mut size_stream = session
|
||||||
.fetch(sequence_set.as_str(), SIZE_ONLY_FETCH)
|
.fetch(sequence_set.as_str(), SIZE_ONLY_FETCH)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut uids: Vec<u32> = Vec::new();
|
let mut uids: Vec<u32> = Vec::new();
|
||||||
while let Some(fetch) = size_stream.try_next().await.map_err(|e| {
|
while let Some(fetch) = size_stream
|
||||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
.try_next()
|
||||||
})? {
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||||
|
{
|
||||||
let uid = fetch.uid.unwrap_or(0);
|
let uid = fetch.uid.unwrap_or(0);
|
||||||
let msg_size = fetch.size.unwrap_or(0) as u64;
|
let msg_size = fetch.size.unwrap_or(0) as u64;
|
||||||
if msg_size == 0 || msg_size <= limit {
|
if msg_size == 0 || msg_size <= limit {
|
||||||
@@ -437,14 +439,14 @@ impl ImapExecutor {
|
|||||||
let mut size_stream = session
|
let mut size_stream = session
|
||||||
.uid_fetch(uid_set, SIZE_ONLY_FETCH)
|
.uid_fetch(uid_set, SIZE_ONLY_FETCH)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut uids: Vec<u32> = Vec::new();
|
let mut uids: Vec<u32> = Vec::new();
|
||||||
while let Some(fetch) = size_stream.try_next().await.map_err(|e| {
|
while let Some(fetch) = size_stream
|
||||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
.try_next()
|
||||||
})? {
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||||
|
{
|
||||||
let uid = fetch.uid.unwrap_or(0);
|
let uid = fetch.uid.unwrap_or(0);
|
||||||
let msg_size = fetch.size.unwrap_or(0) as u64;
|
let msg_size = fetch.size.unwrap_or(0) as u64;
|
||||||
if msg_size == 0 || msg_size <= limit {
|
if msg_size == 0 || msg_size <= limit {
|
||||||
@@ -550,6 +552,37 @@ impl ImapExecutor {
|
|||||||
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||||
ImapConnectionManager::build(account_id).await
|
ImapConnectionManager::build(account_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch UID → Message-ID mapping without downloading bodies.
|
||||||
|
/// `uid_set` is an IMAP sequence-set string (e.g. "1:100" or "1,3,5").
|
||||||
|
pub async fn fetch_uid_metadata(
|
||||||
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
|
uid_set: &str,
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<HashMap<u32, Option<String>>> {
|
||||||
|
let mut stream = session
|
||||||
|
.uid_fetch(uid_set, "(UID BODY.PEEK[HEADER])")
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||||
|
|
||||||
|
let mut result = HashMap::new();
|
||||||
|
while let Some(fetch) = stream
|
||||||
|
.try_next()
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||||
|
{
|
||||||
|
if token.is_cancelled() {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Stream cancelled".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let uid = fetch.uid.unwrap_or(0);
|
||||||
|
let msg_id = fetch.header().and_then(parse_message_id_header);
|
||||||
|
result.insert(uid, msg_id);
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const DEFAULT_BATCH_SIZE: u32 = 30;
|
pub const DEFAULT_BATCH_SIZE: u32 = 30;
|
||||||
@@ -613,6 +646,27 @@ pub fn generate_uid_sequence_hashset(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_message_id_header(header_bytes: &[u8]) -> Option<String> {
|
||||||
|
let header = std::str::from_utf8(header_bytes).ok()?;
|
||||||
|
for line in header.lines() {
|
||||||
|
if let Some(value) = line
|
||||||
|
.strip_prefix("Message-ID:")
|
||||||
|
.or_else(|| line.strip_prefix("Message-Id:"))
|
||||||
|
.or_else(|| line.strip_prefix("Message-id:"))
|
||||||
|
{
|
||||||
|
// mail_parser strips angle brackets, so we must do the same
|
||||||
|
// to ensure comparisons against the Tantivy index match.
|
||||||
|
let trimmed = value.trim();
|
||||||
|
let stripped = trimmed.strip_prefix('<').unwrap_or(trimmed);
|
||||||
|
let stripped = stripped.strip_suffix('>').unwrap_or(stripped);
|
||||||
|
if !stripped.is_empty() {
|
||||||
|
return Some(stripped.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -668,4 +722,80 @@ mod test {
|
|||||||
assert_eq!(batches[2].0, "5");
|
assert_eq!(batches[2].0, "5");
|
||||||
assert_eq!(batches[2].1, 1);
|
assert_eq!(batches[2].1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── parse_message_id_header ─────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_standard_message_id() {
|
||||||
|
let header = b"Message-ID: <abc123@example.com>\r\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_message_id_header(header),
|
||||||
|
Some("abc123@example.com".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_message_id_lowercase() {
|
||||||
|
let header = b"Message-Id: <foo@bar.com>\r\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_message_id_header(header),
|
||||||
|
Some("foo@bar.com".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_message_id_extra_whitespace() {
|
||||||
|
let header = b"Message-ID: <spaces@test.com> \r\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_message_id_header(header),
|
||||||
|
Some("spaces@test.com".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_message_id_returns_none() {
|
||||||
|
let header = b"Message-ID: <>\r\n";
|
||||||
|
assert_eq!(parse_message_id_header(header), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_missing_header_returns_none() {
|
||||||
|
let header = b"X-Custom: something\r\n";
|
||||||
|
assert_eq!(parse_message_id_header(header), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_body_returns_none() {
|
||||||
|
assert_eq!(parse_message_id_header(b""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_message_id_in_full_header() {
|
||||||
|
// The Message-ID line is in the middle, not at the start.
|
||||||
|
let header = b"From: sender@example.com\r\n\
|
||||||
|
Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
|
||||||
|
Subject: test\r\n\
|
||||||
|
Message-ID: <mid@example.com>\r\n\
|
||||||
|
To: recipient@example.com\r\n\r\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_message_id_header(header),
|
||||||
|
Some("mid@example.com".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_message_id_only_in_full_header() {
|
||||||
|
// Only a few headers, Message-ID is among them.
|
||||||
|
let header = b"From: a@b.com\r\nMessage-ID: <x@y.com>\r\n\r\n";
|
||||||
|
assert_eq!(parse_message_id_header(header), Some("x@y.com".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_message_id_no_brackets_still_works() {
|
||||||
|
let header = b"Message-ID: plain@example.com\r\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_message_id_header(header),
|
||||||
|
Some("plain@example.com".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,16 +95,40 @@ impl ImapConnectionManager {
|
|||||||
|
|
||||||
pub async fn build(account_id: u64) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
pub async fn build(account_id: u64) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||||
let account = AccountModel::get(account_id)?;
|
let account = AccountModel::get(account_id)?;
|
||||||
let client = match Self::create_client(&account).await {
|
let account_email = account.email.clone();
|
||||||
Ok(client) => client,
|
|
||||||
Err(error) => {
|
let mut client = None;
|
||||||
error!(
|
for attempt in 0..3u32 {
|
||||||
"Failed to create IMAP {}'s client: {:#?}",
|
match Self::create_client(&account).await {
|
||||||
&account.email, error
|
Ok(c) => {
|
||||||
);
|
client = Some(c);
|
||||||
return Err(error);
|
break;
|
||||||
|
}
|
||||||
|
Err(error) if error.code() == ErrorCode::NetworkError && attempt < 2 => {
|
||||||
|
warn!(
|
||||||
|
"IMAP connection attempt {}/3 to {} failed (network error), retrying...",
|
||||||
|
attempt + 1,
|
||||||
|
account_email
|
||||||
|
);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
error!(
|
||||||
|
"Failed to create IMAP {}'s client: {:#?}",
|
||||||
|
account_email, error
|
||||||
|
);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
let client = client.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("Failed to create IMAP {}'s client after 3 attempts", account_email),
|
||||||
|
ErrorCode::NetworkError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut session = match Self::authenticate(client, &account).await {
|
let mut session = match Self::authenticate(client, &account).await {
|
||||||
Ok(session) => session,
|
Ok(session) => session,
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//! A minimal scriptable IMAP server for integration testing.
|
||||||
|
//!
|
||||||
|
//! Each instance listens on a random localhost port and responds to a
|
||||||
|
//! pre-configured script of (expected_command, response) pairs. Commands
|
||||||
|
//! are matched by substring — the first matching pattern wins.
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//! ```ignore
|
||||||
|
//! let server = MockImapServer::new()
|
||||||
|
//! .greeting("* OK ready\r\n")
|
||||||
|
//! .respond("LOGIN", "A0 OK logged in\r\n")
|
||||||
|
//! .respond("CAPABILITY", "* CAPABILITY IMAP4rev1\r\nA0 OK done\r\n")
|
||||||
|
//! .respond("STATUS", "* STATUS INBOX (MESSAGES 10 UIDVALIDITY 42)\r\nA0 OK\r\n")
|
||||||
|
//! .respond("LOGOUT", "* BYE\r\nA0 OK\r\n")
|
||||||
|
//! .start()
|
||||||
|
//! .await;
|
||||||
|
//!
|
||||||
|
//! let (host, port) = server.addr();
|
||||||
|
//! // connect to host:port with Encryption::None
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
type Response = Vec<u8>;
|
||||||
|
|
||||||
|
pub struct MockImapServer {
|
||||||
|
greeting: Vec<u8>,
|
||||||
|
script: Vec<(String, Response)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockImapServer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
greeting: b"* OK Mock IMAP server ready\r\n".to_vec(),
|
||||||
|
script: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the greeting banner sent immediately after connection.
|
||||||
|
pub fn greeting(mut self, banner: impl Into<Vec<u8>>) -> Self {
|
||||||
|
self.greeting = banner.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a script step: when a client command *contains* `pattern` (case-insensitive),
|
||||||
|
/// respond with `response`. Steps are checked in insertion order.
|
||||||
|
pub fn respond(mut self, pattern: impl Into<String>, response: impl Into<Vec<u8>>) -> Self {
|
||||||
|
self.script.push((pattern.into(), response.into()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the server on a random port. Returns a handle whose `addr()` gives
|
||||||
|
/// the `(host, port)` to connect to.
|
||||||
|
pub async fn start(self) -> MockImapServerHandle {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("local_addr");
|
||||||
|
|
||||||
|
let server = Arc::new(self);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match listener.accept().await {
|
||||||
|
Ok((stream, _)) => {
|
||||||
|
let srv = server.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
srv.handle_connection(stream).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
MockImapServerHandle { addr }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_connection(&self, mut stream: TcpStream) {
|
||||||
|
let (reader, mut writer) = stream.split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
// Send greeting
|
||||||
|
if writer.write_all(&self.greeting).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut line = String::new();
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
match reader.read_line(&mut line).await {
|
||||||
|
Ok(0) => break, // EOF
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag = extract_tag(&line).unwrap_or("A0");
|
||||||
|
let matched = self.find_match(&line);
|
||||||
|
if let Some(response) = matched {
|
||||||
|
let substituted = substitute_tag(response, tag);
|
||||||
|
if writer.write_all(&substituted).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: send tagged OK for commands we don't handle
|
||||||
|
let fallback = format!("{tag} OK done\r\n");
|
||||||
|
if writer.write_all(fallback.as_bytes()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_match(&self, line: &str) -> Option<&[u8]> {
|
||||||
|
let line_lower = line.to_lowercase();
|
||||||
|
for (pattern, response) in &self.script {
|
||||||
|
if line_lower.contains(&pattern.to_lowercase()) {
|
||||||
|
return Some(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MockImapServer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle to a running mock IMAP server. The server stops when this handle
|
||||||
|
/// is dropped.
|
||||||
|
pub struct MockImapServerHandle {
|
||||||
|
addr: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockImapServerHandle {
|
||||||
|
pub fn host(&self) -> String {
|
||||||
|
self.addr.ip().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn port(&self) -> u16 {
|
||||||
|
self.addr.port()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tag(line: &str) -> Option<&str> {
|
||||||
|
line.split_whitespace().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace `{TAG}` placeholders in `response` with `tag`.
|
||||||
|
fn substitute_tag(response: &[u8], tag: &str) -> Vec<u8> {
|
||||||
|
let placeholder = b"{TAG}";
|
||||||
|
if response.is_empty() || !contains_slice(response, placeholder) {
|
||||||
|
return response.to_vec();
|
||||||
|
}
|
||||||
|
let tag_bytes = tag.as_bytes();
|
||||||
|
let mut result = Vec::with_capacity(response.len());
|
||||||
|
let mut pos = 0;
|
||||||
|
while let Some(idx) = find_slice(&response[pos..], placeholder) {
|
||||||
|
result.extend_from_slice(&response[pos..pos + idx]);
|
||||||
|
result.extend_from_slice(tag_bytes);
|
||||||
|
pos += idx + placeholder.len();
|
||||||
|
}
|
||||||
|
result.extend_from_slice(&response[pos..]);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_slice(haystack: &[u8], needle: &[u8]) -> bool {
|
||||||
|
haystack.windows(needle.len()).any(|w| w == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_slice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
|
haystack
|
||||||
|
.windows(needle.len())
|
||||||
|
.position(|w| w == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Pre-built response helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// Build a tagged OK response.
|
||||||
|
pub fn ok(tag: impl AsRef<str>, msg: impl AsRef<str>) -> Vec<u8> {
|
||||||
|
format!("{} OK {}\r\n", tag.as_ref(), msg.as_ref()).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a STATUS response line.
|
||||||
|
pub fn status_response(
|
||||||
|
mailbox: &str,
|
||||||
|
messages: u32,
|
||||||
|
unseen: u32,
|
||||||
|
uid_next: u32,
|
||||||
|
uid_validity: Option<u32>,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let uv = uid_validity
|
||||||
|
.map(|v| format!(" UIDVALIDITY {v}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let text = format!(
|
||||||
|
"* STATUS \"{mailbox}\" (MESSAGES {messages} UNSEEN {unseen} UIDNEXT {uid_next}{uv})\r\n"
|
||||||
|
);
|
||||||
|
// Clients expect a tagged response after the untagged STATUS line.
|
||||||
|
// We produce a generic OK that works for any tag.
|
||||||
|
let mut out = text.into_bytes();
|
||||||
|
out.extend_from_slice(b"{TAG} OK STATUS completed\r\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an EXAMINE response with mailbox data.
|
||||||
|
pub fn examine_response(
|
||||||
|
_mailbox: &str,
|
||||||
|
exists: u32,
|
||||||
|
uid_validity: u32,
|
||||||
|
uid_next: u32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
format!(
|
||||||
|
"* FLAGS (\\Seen \\Answered \\Flagged \\Deleted \\Draft)\r\n\
|
||||||
|
* OK [PERMANENTFLAGS ()]\r\n\
|
||||||
|
* {exists} EXISTS\r\n\
|
||||||
|
* 0 RECENT\r\n\
|
||||||
|
* OK [UIDVALIDITY {uid_validity}]\r\n\
|
||||||
|
* OK [UIDNEXT {uid_next}]\r\n\
|
||||||
|
* OK [HIGHESTMODSEQ 1]\r\n\
|
||||||
|
{{TAG}} OK [READ-ONLY] EXAMINE completed\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a UID SEARCH response for the given UID list.
|
||||||
|
pub fn uid_search_response(uids: &[u32]) -> Vec<u8> {
|
||||||
|
let uid_str = uids
|
||||||
|
.iter()
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
format!("* SEARCH {uid_str}\r\n{{TAG}} OK SEARCH completed\r\n").into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a UID FETCH response returning full headers (for BODY[HEADER]).
|
||||||
|
/// Each entry: (uid, message_id)
|
||||||
|
pub fn uid_fetch_metadata_response(entries: &[(u32, &str)]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (uid, msg_id) in entries {
|
||||||
|
// Build a minimal header that contains the Message-ID line.
|
||||||
|
let header_data = format!(
|
||||||
|
"From: sender@example.com\r\n\
|
||||||
|
To: recipient@example.com\r\n\
|
||||||
|
Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
|
||||||
|
Subject: test\r\n\
|
||||||
|
Message-ID: {msg_id}\r\n\r\n"
|
||||||
|
);
|
||||||
|
let header_len = header_data.len();
|
||||||
|
let line = format!(
|
||||||
|
"* {uid} FETCH (UID {uid} BODY[HEADER] {{{header_len}}}\r\n\
|
||||||
|
{header_data}\
|
||||||
|
)\r\n",
|
||||||
|
);
|
||||||
|
out.extend_from_slice(line.as_bytes());
|
||||||
|
}
|
||||||
|
out.extend_from_slice(b"{TAG} OK FETCH completed\r\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a UID FETCH RFC822 response with a full email body.
|
||||||
|
pub fn uid_fetch_rfc822_response(uid: u32, eml: &[u8]) -> Vec<u8> {
|
||||||
|
let header = format!(
|
||||||
|
"* {uid} FETCH (UID {uid} RFC822 {{{len}}}\r\n",
|
||||||
|
len = eml.len()
|
||||||
|
);
|
||||||
|
let mut out = header.into_bytes();
|
||||||
|
out.extend_from_slice(eml);
|
||||||
|
out.extend_from_slice(b")\r\n{TAG} OK FETCH completed\r\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal RFC822 email fixture for testing.
|
||||||
|
pub fn minimal_eml(subject: &str, message_id: &str) -> Vec<u8> {
|
||||||
|
format!(
|
||||||
|
"From: sender@example.com\r\n\
|
||||||
|
To: recipient@example.com\r\n\
|
||||||
|
Subject: {subject}\r\n\
|
||||||
|
Message-ID: <{message_id}>\r\n\
|
||||||
|
Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
|
||||||
|
MIME-Version: 1.0\r\n\
|
||||||
|
Content-Type: text/plain; charset=utf-8\r\n\
|
||||||
|
\r\n\
|
||||||
|
This is a test email: {subject}.\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Self-tests for the mock server itself
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
async fn connect_and_read_greeting(host: &str, port: u16) -> String {
|
||||||
|
let mut stream = TcpStream::connect((host, port)).await.unwrap();
|
||||||
|
let (reader, _writer) = stream.split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).await.unwrap();
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_and_recv(host: &str, port: u16, cmd: &str) -> String {
|
||||||
|
let mut stream = TcpStream::connect((host, port)).await.unwrap();
|
||||||
|
let (reader, mut writer) = stream.split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
// Read greeting
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).await.unwrap();
|
||||||
|
|
||||||
|
// Send command
|
||||||
|
writer.write_all(cmd.as_bytes()).await.unwrap();
|
||||||
|
writer.write_all(b"\r\n").await.unwrap();
|
||||||
|
|
||||||
|
// Read response (may be multi-line; read until tagged response)
|
||||||
|
let mut out = String::new();
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
reader.read_line(&mut line).await.unwrap();
|
||||||
|
out.push_str(&line);
|
||||||
|
if line.starts_with("A0") || line.starts_with("A1") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mock_greeting() {
|
||||||
|
let handle = MockImapServer::new().start().await;
|
||||||
|
let greeting = connect_and_read_greeting(&handle.host(), handle.port()).await;
|
||||||
|
assert!(greeting.starts_with("* OK"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mock_scripted_response() {
|
||||||
|
let handle = MockImapServer::new()
|
||||||
|
.respond(
|
||||||
|
"LOGIN",
|
||||||
|
"A0 OK LOGIN completed\r\n",
|
||||||
|
)
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let resp = send_and_recv(&handle.host(), handle.port(), "A0 LOGIN u p").await;
|
||||||
|
assert!(resp.contains("LOGIN completed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mock_fallback_on_unmatched() {
|
||||||
|
let handle = MockImapServer::new().start().await;
|
||||||
|
|
||||||
|
// Send a command that has no scripted response
|
||||||
|
let resp = send_and_recv(&handle.host(), handle.port(), "A0 NOOP").await;
|
||||||
|
assert!(resp.contains("OK done"), "unmatched command should get fallback OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_status_response_helper() {
|
||||||
|
let resp = status_response("INBOX", 10, 2, 11, Some(42));
|
||||||
|
let text = String::from_utf8(resp).unwrap();
|
||||||
|
assert!(text.contains("MESSAGES 10"));
|
||||||
|
assert!(text.contains("UNSEEN 2"));
|
||||||
|
assert!(text.contains("UIDNEXT 11"));
|
||||||
|
assert!(text.contains("UIDVALIDITY 42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_status_response_without_uidvalidity() {
|
||||||
|
let resp = status_response("INBOX", 10, 2, 11, None);
|
||||||
|
let text = String::from_utf8(resp).unwrap();
|
||||||
|
assert!(!text.contains("UIDVALIDITY"));
|
||||||
|
assert!(text.contains("MESSAGES 10"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_examine_response() {
|
||||||
|
let resp = examine_response("INBOX", 10, 42, 11);
|
||||||
|
let text = String::from_utf8(resp).unwrap();
|
||||||
|
assert!(text.contains("UIDVALIDITY 42"));
|
||||||
|
assert!(text.contains("10 EXISTS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_uid_search_response() {
|
||||||
|
let resp = uid_search_response(&[1, 3, 5]);
|
||||||
|
let text = String::from_utf8(resp).unwrap();
|
||||||
|
assert!(text.contains("SEARCH 1 3 5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_uid_fetch_metadata_response() {
|
||||||
|
let resp = uid_fetch_metadata_response(&[(1, "msg-a@x.com"), (2, "msg-b@x.com")]);
|
||||||
|
let text = String::from_utf8(resp).unwrap();
|
||||||
|
assert!(text.contains("Message-ID: msg-a@x.com"));
|
||||||
|
assert!(text.contains("Message-ID: msg-b@x.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multiple_commands_in_sequence() {
|
||||||
|
let handle = MockImapServer::new()
|
||||||
|
.respond("LOGIN", "A0 OK LOGIN\r\n")
|
||||||
|
.respond("STATUS", status_response("INBOX", 5, 1, 6, Some(99)))
|
||||||
|
.respond("LOGOUT", "* BYE\r\nA0 OK\r\n")
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut stream = TcpStream::connect((handle.host(), handle.port()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (reader, mut writer) = stream.split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
// Read greeting
|
||||||
|
let mut buf = String::new();
|
||||||
|
reader.read_line(&mut buf).await.unwrap();
|
||||||
|
|
||||||
|
// LOGIN
|
||||||
|
writer.write_all(b"A0 LOGIN u p\r\n").await.unwrap();
|
||||||
|
buf.clear();
|
||||||
|
reader.read_line(&mut buf).await.unwrap();
|
||||||
|
assert!(buf.contains("LOGIN"));
|
||||||
|
|
||||||
|
// STATUS
|
||||||
|
writer
|
||||||
|
.write_all(b"A0 STATUS INBOX (MESSAGES UNSEEN UIDNEXT UIDVALIDITY)\r\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
buf.clear();
|
||||||
|
// Read multi-line STATUS response (untagged line + tagged OK)
|
||||||
|
loop {
|
||||||
|
reader.read_line(&mut buf).await.unwrap();
|
||||||
|
if buf.contains("UIDVALIDITY 99") {
|
||||||
|
// Consume the tagged OK line that follows
|
||||||
|
buf.clear();
|
||||||
|
reader.read_line(&mut buf).await.unwrap();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOGOUT
|
||||||
|
writer.write_all(b"A0 LOGOUT\r\n").await.unwrap();
|
||||||
|
buf.clear();
|
||||||
|
reader.read_line(&mut buf).await.unwrap();
|
||||||
|
assert!(buf.contains("BYE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tag_substitution_in_response() {
|
||||||
|
// Use {TAG} placeholder in the response and verify it gets the
|
||||||
|
// client's actual tag ("A5") substituted in.
|
||||||
|
let handle = MockImapServer::new()
|
||||||
|
.respond("LOGIN", "{TAG} OK LOGIN succeeded\r\n")
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut stream = TcpStream::connect((handle.host(), handle.port()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (reader, mut writer) = stream.split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
// Read greeting
|
||||||
|
let mut buf = String::new();
|
||||||
|
reader.read_line(&mut buf).await.unwrap();
|
||||||
|
|
||||||
|
// Send LOGIN with non-standard tag
|
||||||
|
writer.write_all(b"A5 LOGIN u p\r\n").await.unwrap();
|
||||||
|
buf.clear();
|
||||||
|
reader.read_line(&mut buf).await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
buf.contains("A5 OK LOGIN succeeded"),
|
||||||
|
"expected 'A5 OK LOGIN succeeded', got '{buf}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tag_substitution_multiple_placeholders() {
|
||||||
|
let handle = MockImapServer::new()
|
||||||
|
.respond("NOOP", "* 0 RECENT\r\n{TAG} OK NOOP done\r\n")
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut stream = TcpStream::connect((handle.host(), handle.port()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (reader, mut writer) = stream.split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
// Read greeting
|
||||||
|
let mut buf = String::new();
|
||||||
|
reader.read_line(&mut buf).await.unwrap();
|
||||||
|
|
||||||
|
// Send with tag "B99"
|
||||||
|
writer.write_all(b"B99 NOOP\r\n").await.unwrap();
|
||||||
|
|
||||||
|
// Read all lines
|
||||||
|
let mut all = String::new();
|
||||||
|
loop {
|
||||||
|
buf.clear();
|
||||||
|
reader.read_line(&mut buf).await.unwrap();
|
||||||
|
all.push_str(&buf);
|
||||||
|
if buf.starts_with("B99") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(all.contains("* 0 RECENT\r\n"));
|
||||||
|
assert!(all.contains("B99 OK NOOP done\r\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,3 +26,5 @@ pub mod session;
|
|||||||
pub mod stats;
|
pub mod stats;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod mock_server;
|
||||||
|
|||||||
@@ -278,6 +278,80 @@ impl IndexManager {
|
|||||||
Box::new(boolean_query)
|
Box::new(boolean_query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return all Message-IDs stored in Tantivy for a given mailbox.
|
||||||
|
/// Prefer `mailbox_contains_message_id` for existence checks on large
|
||||||
|
/// mailboxes — this method loads everything into a HashSet.
|
||||||
|
pub fn get_message_ids_for_mailbox(
|
||||||
|
&self,
|
||||||
|
account_id: u64,
|
||||||
|
mailbox_id: u64,
|
||||||
|
) -> BichonResult<HashSet<String>> {
|
||||||
|
let query = self.mailbox_query(account_id, mailbox_id);
|
||||||
|
let fields = SchemaTools::email_fields();
|
||||||
|
let searcher = self.create_searcher()?;
|
||||||
|
|
||||||
|
let docs = searcher
|
||||||
|
.search(&query, &DocSetCollector)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
let mut result = HashSet::new();
|
||||||
|
for doc_address in docs {
|
||||||
|
let doc = searcher
|
||||||
|
.doc::<TantivyDocument>(doc_address)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
if let Some(v) = doc.get_first(fields.f_message_id) {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
if !s.is_empty() {
|
||||||
|
result.insert(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a specific Message-ID exists in a mailbox.
|
||||||
|
/// Uses a TermQuery — O(1) per call, no allocation proportional to
|
||||||
|
/// mailbox size. Suitable for large mailboxes where
|
||||||
|
/// `get_message_ids_for_mailbox` would allocate too much memory.
|
||||||
|
pub fn mailbox_contains_message_id(
|
||||||
|
&self,
|
||||||
|
account_id: u64,
|
||||||
|
mailbox_id: u64,
|
||||||
|
message_id: &str,
|
||||||
|
) -> BichonResult<bool> {
|
||||||
|
let fields = SchemaTools::email_fields();
|
||||||
|
let query = BooleanQuery::new(vec![
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(fields.f_account_id, account_id),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(fields.f_mailbox_id, mailbox_id),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_text(fields.f_message_id, message_id),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let searcher = self.create_searcher()?;
|
||||||
|
let count = searcher
|
||||||
|
.search(&query, &Count)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
fn envelope_query(&self, account_id: u64, eid: &str) -> Box<dyn Query> {
|
fn envelope_query(&self, account_id: u64, eid: &str) -> Box<dyn Query> {
|
||||||
let account_id_query = TermQuery::new(
|
let account_id_query = TermQuery::new(
|
||||||
Term::from_field_u64(SchemaTools::email_fields().f_account_id, account_id),
|
Term::from_field_u64(SchemaTools::email_fields().f_account_id, account_id),
|
||||||
@@ -2069,4 +2143,362 @@ mod tests {
|
|||||||
"body should be absent when EML is missing"
|
"body should be absent when EML is missing"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── get_message_ids_for_mailbox ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_message_ids_returns_stored_ids() {
|
||||||
|
let f = SchemaTools::email_fields();
|
||||||
|
let index = Index::create_in_ram(SchemaTools::email_schema());
|
||||||
|
index.tokenizers().register("euro", EuroTokenizer::new());
|
||||||
|
|
||||||
|
// Insert two docs for mailbox 10, one for mailbox 20
|
||||||
|
{
|
||||||
|
let mut writer = index
|
||||||
|
.writer_with_num_threads(1, 15_000_000)
|
||||||
|
.expect("writer");
|
||||||
|
|
||||||
|
let mut doc1 = TantivyDocument::new();
|
||||||
|
doc1.add_u64(f.f_account_id, 1);
|
||||||
|
doc1.add_u64(f.f_mailbox_id, 10);
|
||||||
|
doc1.add_text(f.f_message_id, "<msg-a@test>");
|
||||||
|
doc1.add_text(f.f_id, "id-a");
|
||||||
|
doc1.add_u64(f.f_uid, 1);
|
||||||
|
doc1.add_text(f.f_content_hash, "hash-a");
|
||||||
|
writer.add_document(doc1).unwrap();
|
||||||
|
|
||||||
|
let mut doc2 = TantivyDocument::new();
|
||||||
|
doc2.add_u64(f.f_account_id, 1);
|
||||||
|
doc2.add_u64(f.f_mailbox_id, 10);
|
||||||
|
doc2.add_text(f.f_message_id, "<msg-b@test>");
|
||||||
|
doc2.add_text(f.f_id, "id-b");
|
||||||
|
doc2.add_u64(f.f_uid, 2);
|
||||||
|
doc2.add_text(f.f_content_hash, "hash-b");
|
||||||
|
writer.add_document(doc2).unwrap();
|
||||||
|
|
||||||
|
let mut doc3 = TantivyDocument::new();
|
||||||
|
doc3.add_u64(f.f_account_id, 1);
|
||||||
|
doc3.add_u64(f.f_mailbox_id, 20);
|
||||||
|
doc3.add_text(f.f_message_id, "<msg-c@test>");
|
||||||
|
doc3.add_text(f.f_id, "id-c");
|
||||||
|
doc3.add_u64(f.f_uid, 3);
|
||||||
|
doc3.add_text(f.f_content_hash, "hash-c");
|
||||||
|
writer.add_document(doc3).unwrap();
|
||||||
|
|
||||||
|
writer.commit().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let reader = index.reader().unwrap();
|
||||||
|
reader.reload().unwrap();
|
||||||
|
let searcher = reader.searcher();
|
||||||
|
|
||||||
|
// We can't easily call ENVELOPE_MANAGER.get_message_ids_for_mailbox
|
||||||
|
// because it reads from ENVELOPE_MANAGER's own index, not our in-memory one.
|
||||||
|
// Instead, test the query pattern directly.
|
||||||
|
let query: Box<dyn Query> = {
|
||||||
|
let account_query = TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_account_id, 1),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
);
|
||||||
|
let mailbox_query = TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_mailbox_id, 10),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
);
|
||||||
|
Box::new(BooleanQuery::new(vec![
|
||||||
|
(Occur::Must, Box::new(account_query)),
|
||||||
|
(Occur::Must, Box::new(mailbox_query)),
|
||||||
|
]))
|
||||||
|
};
|
||||||
|
|
||||||
|
let docs = searcher
|
||||||
|
.search(&query, &DocSetCollector)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut ids: Vec<String> = Vec::new();
|
||||||
|
for addr in docs {
|
||||||
|
let doc: TantivyDocument = searcher.doc(addr).unwrap();
|
||||||
|
if let Some(v) = doc.get_first(f.f_message_id) {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
ids.push(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids.sort();
|
||||||
|
|
||||||
|
assert_eq!(ids, vec!["<msg-a@test>", "<msg-b@test>"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_message_ids_empty_mailbox_returns_empty() {
|
||||||
|
let f = SchemaTools::email_fields();
|
||||||
|
let index = Index::create_in_ram(SchemaTools::email_schema());
|
||||||
|
index.tokenizers().register("euro", EuroTokenizer::new());
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut writer = index
|
||||||
|
.writer_with_num_threads(1, 15_000_000)
|
||||||
|
.expect("writer");
|
||||||
|
|
||||||
|
// Doc for a different mailbox
|
||||||
|
let mut doc = TantivyDocument::new();
|
||||||
|
doc.add_u64(f.f_account_id, 1);
|
||||||
|
doc.add_u64(f.f_mailbox_id, 99);
|
||||||
|
doc.add_text(f.f_message_id, "<other@test>");
|
||||||
|
doc.add_text(f.f_id, "id-other");
|
||||||
|
doc.add_u64(f.f_uid, 1);
|
||||||
|
doc.add_text(f.f_content_hash, "hash-other");
|
||||||
|
writer.add_document(doc).unwrap();
|
||||||
|
writer.commit().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let reader = index.reader().unwrap();
|
||||||
|
reader.reload().unwrap();
|
||||||
|
let searcher = reader.searcher();
|
||||||
|
|
||||||
|
let query: Box<dyn Query> = {
|
||||||
|
let account_query = TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_account_id, 1),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
);
|
||||||
|
let mailbox_query = TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_mailbox_id, 10),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
);
|
||||||
|
Box::new(BooleanQuery::new(vec![
|
||||||
|
(Occur::Must, Box::new(account_query)),
|
||||||
|
(Occur::Must, Box::new(mailbox_query)),
|
||||||
|
]))
|
||||||
|
};
|
||||||
|
|
||||||
|
let docs = searcher.search(&query, &DocSetCollector).unwrap();
|
||||||
|
assert!(docs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mailbox_contains_message_id ───────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mailbox_contains_message_id_finds_existing() {
|
||||||
|
let f = SchemaTools::email_fields();
|
||||||
|
let index = Index::create_in_ram(SchemaTools::email_schema());
|
||||||
|
index.tokenizers().register("euro", EuroTokenizer::new());
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut writer = index
|
||||||
|
.writer_with_num_threads(1, 15_000_000)
|
||||||
|
.expect("writer");
|
||||||
|
|
||||||
|
let mut doc = TantivyDocument::new();
|
||||||
|
doc.add_u64(f.f_account_id, 1);
|
||||||
|
doc.add_u64(f.f_mailbox_id, 10);
|
||||||
|
doc.add_text(f.f_message_id, "abc@example.com");
|
||||||
|
doc.add_text(f.f_id, "id-1");
|
||||||
|
doc.add_u64(f.f_uid, 1);
|
||||||
|
doc.add_text(f.f_content_hash, "hash-1");
|
||||||
|
writer.add_document(doc).unwrap();
|
||||||
|
writer.commit().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// We test the query pattern directly (can't call ENVELOPE_MANAGER
|
||||||
|
// which uses a different index).
|
||||||
|
let reader = index.reader().unwrap();
|
||||||
|
reader.reload().unwrap();
|
||||||
|
let searcher = reader.searcher();
|
||||||
|
|
||||||
|
let query = BooleanQuery::new(vec![
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_account_id, 1),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_mailbox_id, 10),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_text(f.f_message_id, "abc@example.com"),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let count = searcher.search(&query, &Count).unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mailbox_contains_message_id_returns_zero_for_missing() {
|
||||||
|
let f = SchemaTools::email_fields();
|
||||||
|
let index = Index::create_in_ram(SchemaTools::email_schema());
|
||||||
|
index.tokenizers().register("euro", EuroTokenizer::new());
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut writer = index
|
||||||
|
.writer_with_num_threads(1, 15_000_000)
|
||||||
|
.expect("writer");
|
||||||
|
|
||||||
|
let mut doc = TantivyDocument::new();
|
||||||
|
doc.add_u64(f.f_account_id, 1);
|
||||||
|
doc.add_u64(f.f_mailbox_id, 10);
|
||||||
|
doc.add_text(f.f_message_id, "existing@example.com");
|
||||||
|
doc.add_text(f.f_id, "id-1");
|
||||||
|
doc.add_u64(f.f_uid, 1);
|
||||||
|
doc.add_text(f.f_content_hash, "hash-1");
|
||||||
|
writer.add_document(doc).unwrap();
|
||||||
|
writer.commit().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let reader = index.reader().unwrap();
|
||||||
|
reader.reload().unwrap();
|
||||||
|
let searcher = reader.searcher();
|
||||||
|
|
||||||
|
let query = BooleanQuery::new(vec![
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_account_id, 1),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_mailbox_id, 10),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_text(f.f_message_id, "nonexistent@example.com"),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let count = searcher.search(&query, &Count).unwrap();
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mailbox_contains_message_id_respects_mailbox_boundary() {
|
||||||
|
let f = SchemaTools::email_fields();
|
||||||
|
let index = Index::create_in_ram(SchemaTools::email_schema());
|
||||||
|
index.tokenizers().register("euro", EuroTokenizer::new());
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut writer = index
|
||||||
|
.writer_with_num_threads(1, 15_000_000)
|
||||||
|
.expect("writer");
|
||||||
|
|
||||||
|
// Same Message-ID in mailbox 10
|
||||||
|
let mut doc1 = TantivyDocument::new();
|
||||||
|
doc1.add_u64(f.f_account_id, 1);
|
||||||
|
doc1.add_u64(f.f_mailbox_id, 10);
|
||||||
|
doc1.add_text(f.f_message_id, "shared@example.com");
|
||||||
|
doc1.add_text(f.f_id, "id-1");
|
||||||
|
doc1.add_u64(f.f_uid, 1);
|
||||||
|
doc1.add_text(f.f_content_hash, "hash-1");
|
||||||
|
writer.add_document(doc1).unwrap();
|
||||||
|
|
||||||
|
// Same Message-ID in mailbox 20 (different mailbox)
|
||||||
|
let mut doc2 = TantivyDocument::new();
|
||||||
|
doc2.add_u64(f.f_account_id, 1);
|
||||||
|
doc2.add_u64(f.f_mailbox_id, 20);
|
||||||
|
doc2.add_text(f.f_message_id, "shared@example.com");
|
||||||
|
doc2.add_text(f.f_id, "id-2");
|
||||||
|
doc2.add_u64(f.f_uid, 2);
|
||||||
|
doc2.add_text(f.f_content_hash, "hash-2");
|
||||||
|
writer.add_document(doc2).unwrap();
|
||||||
|
writer.commit().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let reader = index.reader().unwrap();
|
||||||
|
reader.reload().unwrap();
|
||||||
|
let searcher = reader.searcher();
|
||||||
|
|
||||||
|
// Query mailbox 10: should find 1
|
||||||
|
let q10 = BooleanQuery::new(vec![
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_account_id, 1),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_mailbox_id, 10),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_text(f.f_message_id, "shared@example.com"),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(searcher.search(&q10, &Count).unwrap(), 1);
|
||||||
|
|
||||||
|
// Query mailbox 20: should find 1
|
||||||
|
let q20 = BooleanQuery::new(vec![
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_account_id, 1),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_mailbox_id, 20),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_text(f.f_message_id, "shared@example.com"),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(searcher.search(&q20, &Count).unwrap(), 1);
|
||||||
|
|
||||||
|
// Query mailbox 99 (no docs): should find 0
|
||||||
|
let q99 = BooleanQuery::new(vec![
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_account_id, 1),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_u64(f.f_mailbox_id, 99),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Occur::Must,
|
||||||
|
Box::new(TermQuery::new(
|
||||||
|
Term::from_field_text(f.f_message_id, "shared@example.com"),
|
||||||
|
IndexRecordOption::Basic,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(searcher.search(&q99, &Count).unwrap(), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user