feat(imap): key messages by their server object id #416
No files matched your search
@@ -350,17 +350,30 @@ impl Email {
|
||||
/// The surrogate tier is folder-scoped and therefore *not* stable across a
|
||||
/// move; it is the honest answer when the server offered nothing better.
|
||||
///
|
||||
/// # Keys do not yet converge across engine types
|
||||
/// # What convergence across engine types does and does not follow from
|
||||
///
|
||||
/// Precedence makes a key reproducible from the same inputs, not from the
|
||||
/// same *message*. The IMAP engine does not currently request `EMAILID` or
|
||||
/// `X-GM-MSGID`, so it can only ever reach the `Message-ID`+content tier or
|
||||
/// the surrogate, while JMAP supplies a server id and enters at the top
|
||||
/// tier. Two engines syncing one account therefore produce disjoint key
|
||||
/// spaces: the same message is two identities, one per engine. This is a
|
||||
/// known gap in the convergent multi-engine model, not a property to rely
|
||||
/// on -- do not assume a key derived by one engine addresses the row
|
||||
/// another engine wrote.
|
||||
/// same *message*. Two engines converge exactly when they enter at the same
|
||||
/// tier with the same value.
|
||||
///
|
||||
/// They do when the server publishes a stable object id and both engines
|
||||
/// ask for it. The IMAP engine requests RFC 8474 `EMAILID` from an
|
||||
/// `OBJECTID` server and `X-GM-MSGID` from an `X-GM-EXT-1` one, and JMAP's
|
||||
/// Email object id is the same identifier RFC 8474 exposes to IMAP -- so
|
||||
/// one message synced through both protocols yields one key. That is the
|
||||
/// whole point of the OBJECTID extension.
|
||||
///
|
||||
/// They do not when the server offers neither. IMAP then falls to the
|
||||
/// `Message-ID`+content tier or the surrogate, both derived from what the
|
||||
/// message or the mailbox happened to contain, while a JMAP engine against
|
||||
/// the same account still enters at the top tier from a server-assigned id.
|
||||
/// Those key spaces are disjoint, and no comparison across them means
|
||||
/// anything. The surrogate additionally is not stable across a *move*, even
|
||||
/// within one engine.
|
||||
///
|
||||
/// Object ids are also only comparable within one account on one server:
|
||||
/// RFC 8474 section 4 scopes uniqueness that way, which is why every tier
|
||||
/// here is account-scoped.
|
||||
pub fn derive_message_key(
|
||||
account_id: &str,
|
||||
remote: RemoteIdentity<'_>,
|
||||
|
||||
+149
-51
@@ -175,6 +175,13 @@ pub(crate) struct MailboxSyncCapabilities {
|
||||
/// `NOMODSEQ` has no mod-sequences to compare against no matter what the
|
||||
/// server advertises.
|
||||
pub mailbox_has_modseq: bool,
|
||||
/// Server advertises `OBJECTID` (RFC 8474), so `EMAILID` may be asked for.
|
||||
/// Asking a server that did not advertise it is not merely rude: an
|
||||
/// unrecognised FETCH item is answered `BAD`, costing a round trip per
|
||||
/// batch to learn nothing.
|
||||
pub objectid: bool,
|
||||
/// Server advertises `X-GM-EXT-1`, so `X-GM-MSGID` may be asked for.
|
||||
pub gmail_extensions: bool,
|
||||
}
|
||||
|
||||
/// One folder-sync pass, resolved before any body is fetched.
|
||||
@@ -182,6 +189,11 @@ pub(crate) struct MailboxSyncCapabilities {
|
||||
pub(crate) struct FolderSyncPlan {
|
||||
/// The mechanism this pass negotiated.
|
||||
pub rung: SyncRung,
|
||||
/// What the negotiation found the server able to do. Carried past the
|
||||
/// planning stage because the identity extensions decide what the *fetch*
|
||||
/// asks for, and a fetch that asks for an unadvertised item is answered
|
||||
/// `BAD`.
|
||||
pub caps: MailboxSyncCapabilities,
|
||||
pub uid_validity: Option<u32>,
|
||||
pub uid_next: Option<u32>,
|
||||
pub highest_modseq: Option<u64>,
|
||||
@@ -326,6 +338,24 @@ pub fn build_fetch_command_query() -> &'static str {
|
||||
"(FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODY.PEEK[])"
|
||||
}
|
||||
|
||||
/// Add `X-GM-MSGID` to a FETCH item list when, and only when, the server
|
||||
/// advertised `X-GM-EXT-1`.
|
||||
///
|
||||
/// Gmail's message id is the second identity tier, and unlike RFC 8474's
|
||||
/// `EMAILID` it can be asked for through the typed client: `imap-proto` parses
|
||||
/// the attribute and `Fetch::gmail_msg_id` reads it back. So it rides along on
|
||||
/// the fetch the sync already issues and costs no extra round trip.
|
||||
///
|
||||
/// A server that advertises nothing gets a query byte-identical to the one it
|
||||
/// got before this existed. That is the point: capability negotiation must not
|
||||
/// change what an unextended server sees.
|
||||
fn with_gmail_msgid(items: &str, advertised: bool) -> String {
|
||||
match items.strip_suffix(')').filter(|_| advertised) {
|
||||
Some(head) => format!("{head} X-GM-MSGID)"),
|
||||
None => items.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `UID FETCH` query for the cheap enumeration pass: UID plus `RFC822.SIZE`
|
||||
/// only, deliberately WITHOUT a `BODY.PEEK[]`. This lets the sync discover every
|
||||
/// message's UID and size (to decide per-message whether the body is safe to
|
||||
@@ -906,15 +936,24 @@ impl ImapEngine {
|
||||
drop(enumeration);
|
||||
|
||||
let mut emails = Vec::new();
|
||||
let body_query = with_gmail_msgid(build_fetch_command_query(), plan.caps.gmail_extensions);
|
||||
let header_query = with_gmail_msgid(
|
||||
build_headers_only_command_query(),
|
||||
plan.caps.gmail_extensions,
|
||||
);
|
||||
|
||||
// Phase 2a: normal messages -- full bodies, in bounded UID-set batches.
|
||||
for chunk in body_uids.chunks(batch_size) {
|
||||
let set = join_uid_batch(chunk);
|
||||
// Read the batch's object ids first. It has to be its own command:
|
||||
// `EMAILID` cannot ride on the body fetch, because that response
|
||||
// goes through a parser which treats the item as a fatal error.
|
||||
// Issuing it here is also what keeps the raw layer's precondition
|
||||
// true -- the last thing on this connection was a tagged
|
||||
// completion, so the client holds no buffered bytes.
|
||||
let object_ids = self.fetch_object_ids(session, plan.caps, &set).await?;
|
||||
let items = {
|
||||
let mut fetch_stream = session
|
||||
.uid_fetch(&set, build_fetch_command_query())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let mut fetch_stream = session.uid_fetch(&set, &body_query).await.map_err(|e| {
|
||||
MailError::ImapError(format!(
|
||||
"UID FETCH failed for folder '{}': {}",
|
||||
folder_id, e
|
||||
@@ -927,11 +966,16 @@ impl ImapEngine {
|
||||
// `UID FETCH` must carry one (RFC 3501 section 6.4.8), so this
|
||||
// only fires for a non-conforming server. Recording it anyway
|
||||
// would write a placement at the meaningless UID 0.
|
||||
if item.uid.filter(|u| *u >= 1).is_none() {
|
||||
let Some(uid) = item.uid.filter(|u| *u >= 1) else {
|
||||
skipped_no_uid += 1;
|
||||
continue;
|
||||
}
|
||||
emails.push(self.build_email_from_fetch(folder_id, server_uid_validity, item)?);
|
||||
};
|
||||
emails.push(self.build_email_from_fetch(
|
||||
folder_id,
|
||||
server_uid_validity,
|
||||
item,
|
||||
object_ids.get(&uid).map(String::as_str),
|
||||
)?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,11 +984,13 @@ impl ImapEngine {
|
||||
// buffer an unbounded number of header fetches at once.
|
||||
for chunk in oversized_uids.chunks(batch_size) {
|
||||
let set = join_uid_batch(chunk);
|
||||
// An oversized message needs the object id most: its body is never
|
||||
// fetched, so there is no content hash and the alternative is the
|
||||
// folder-scoped surrogate.
|
||||
let object_ids = self.fetch_object_ids(session, plan.caps, &set).await?;
|
||||
let items = {
|
||||
let mut fetch_stream = session
|
||||
.uid_fetch(&set, build_headers_only_command_query())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let mut fetch_stream =
|
||||
session.uid_fetch(&set, &header_query).await.map_err(|e| {
|
||||
MailError::ImapError(format!(
|
||||
"UID FETCH (headers) failed for folder '{}': {}",
|
||||
folder_id, e
|
||||
@@ -955,11 +1001,16 @@ impl ImapEngine {
|
||||
for item in &items {
|
||||
// Same guard as the body pass: an item with no UID cannot be
|
||||
// placed, and UID 0 is not an address.
|
||||
if item.uid.filter(|u| *u >= 1).is_none() {
|
||||
let Some(uid) = item.uid.filter(|u| *u >= 1) else {
|
||||
skipped_no_uid += 1;
|
||||
continue;
|
||||
}
|
||||
emails.push(self.build_headers_only_email(folder_id, server_uid_validity, item));
|
||||
};
|
||||
emails.push(self.build_headers_only_email(
|
||||
folder_id,
|
||||
server_uid_validity,
|
||||
item,
|
||||
object_ids.get(&uid).map(String::as_str),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,12 +1118,19 @@ impl ImapEngine {
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + Debug + 'static,
|
||||
{
|
||||
let (has_qresync, has_condstore) = {
|
||||
let (has_qresync, has_condstore, has_objectid, has_gmail) = {
|
||||
let caps = session
|
||||
.capabilities()
|
||||
.await
|
||||
.map_err(|e| MailError::ImapError(format!("CAPABILITY query failed: {e}")))?;
|
||||
(caps.has_str("QRESYNC"), caps.has_str("CONDSTORE"))
|
||||
(
|
||||
caps.has_str("QRESYNC"),
|
||||
caps.has_str("CONDSTORE"),
|
||||
// RFC 8474 names the capability OBJECTID; EMAILID is the FETCH
|
||||
// item it brings, and asking for it otherwise earns a BAD.
|
||||
caps.has_str("OBJECTID"),
|
||||
caps.has_str("X-GM-EXT-1"),
|
||||
)
|
||||
};
|
||||
|
||||
let checkpoint = since_state.and_then(parse_checkpoint);
|
||||
@@ -1127,6 +1185,8 @@ impl ImapEngine {
|
||||
qresync_enabled,
|
||||
condstore: has_condstore,
|
||||
mailbox_has_modseq: selected.highest_modseq.is_some(),
|
||||
objectid: has_objectid,
|
||||
gmail_extensions: has_gmail,
|
||||
};
|
||||
let mut rung = choose_rung(checkpoint, selected.uid_validity, caps);
|
||||
// The QRESYNC SELECT was issued optimistically; if the server answered
|
||||
@@ -1144,6 +1204,7 @@ impl ImapEngine {
|
||||
|
||||
let mut plan = FolderSyncPlan {
|
||||
rung,
|
||||
caps,
|
||||
uid_validity: selected.uid_validity,
|
||||
uid_next: selected.uid_next,
|
||||
highest_modseq: selected.highest_modseq,
|
||||
@@ -1202,6 +1263,29 @@ impl ImapEngine {
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// The batch's `uid -> EMAILID` map, or an empty one when the server never
|
||||
/// advertised RFC 8474 `OBJECTID`.
|
||||
///
|
||||
/// The capability gate is the whole reason this is a separate method: a
|
||||
/// server without `OBJECTID` must see exactly the traffic it saw before
|
||||
/// object ids existed -- no extra command, no extra round trip -- and a
|
||||
/// server that has it must be asked through the raw layer rather than the
|
||||
/// typed client, which cannot parse the reply without dying.
|
||||
async fn fetch_object_ids<S>(
|
||||
&self,
|
||||
session: &mut async_imap::Session<S>,
|
||||
caps: MailboxSyncCapabilities,
|
||||
uid_set: &str,
|
||||
) -> Result<std::collections::BTreeMap<u32, String>, MailError>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + Debug + 'static,
|
||||
{
|
||||
if !caps.objectid {
|
||||
return Ok(std::collections::BTreeMap::new());
|
||||
}
|
||||
crate::imap_raw::uid_fetch_email_ids(session, uid_set).await
|
||||
}
|
||||
|
||||
/// List the folder's UIDs, optionally narrowed to those changed since
|
||||
/// `changed_since`. Requests no bodies and no flags, so the cost is one
|
||||
/// short line per message rather than a download.
|
||||
@@ -1267,12 +1351,15 @@ impl ImapEngine {
|
||||
/// and spreading them out buys no clarity while pushing this function past
|
||||
/// the argument-count lint.
|
||||
///
|
||||
/// In practice every IMAP call site passes `email_id: None` and
|
||||
/// `gm_msgid: None` today, because the FETCH never asks for them, so this
|
||||
/// engine reaches only the `Message-ID`+content tier or the surrogate. The
|
||||
/// JMAP engine enters at the top tier, which means an IMAP-derived key and
|
||||
/// a JMAP-derived key for the *same* message will not match. Syncing one
|
||||
/// account over both engines does not converge on a shared identity yet.
|
||||
/// Both server-assigned tiers are reachable from here. `EMAILID` comes from
|
||||
/// a raw `UID FETCH` issued only against an `OBJECTID` server
|
||||
/// ([`crate::imap_raw::uid_fetch_email_ids`]) and `X-GM-MSGID` from the
|
||||
/// typed fetch against an `X-GM-EXT-1` server, so an IMAP-derived key and a
|
||||
/// JMAP-derived key for the same message on a server that publishes object
|
||||
/// ids through both protocols are the same key -- which is what RFC 8474
|
||||
/// exists to make possible. Against a server that advertises neither, this
|
||||
/// engine still lands on the `Message-ID`+content tier or the surrogate,
|
||||
/// and those do not converge with a JMAP engine's server-assigned ids.
|
||||
fn place(
|
||||
&self,
|
||||
folder_id: &str,
|
||||
@@ -1298,11 +1385,16 @@ impl ImapEngine {
|
||||
/// when it does not (a server that returned only the ENVELOPE), a minimal
|
||||
/// record is synthesised from the envelope so the message is still surfaced
|
||||
/// honestly.
|
||||
///
|
||||
/// `email_id` is this UID's RFC 8474 object id when the folder pass read
|
||||
/// one; `X-GM-MSGID` is read from the item itself, since the typed parser
|
||||
/// understands that attribute and the raw layer never needs to see it.
|
||||
fn build_email_from_fetch(
|
||||
&self,
|
||||
folder_id: &str,
|
||||
server_uid_validity: Option<u32>,
|
||||
fetch_data: &async_imap::types::Fetch,
|
||||
email_id: Option<&str>,
|
||||
) -> Result<PlacedMessage, MailError> {
|
||||
let uid_num = fetch_data.uid.unwrap_or(0);
|
||||
// The message is addressed on the wire by its UID within the folder's
|
||||
@@ -1318,19 +1410,11 @@ impl ImapEngine {
|
||||
.flags()
|
||||
.any(|flag| matches!(flag, async_imap::types::Flag::Seen));
|
||||
|
||||
// EMAILID (RFC 8474) and X-GM-MSGID are not requested by the current
|
||||
// FETCH, so identity rests on the Message-ID/content pair where the
|
||||
// message carried a Message-ID and on the folder-scoped surrogate
|
||||
// otherwise.
|
||||
//
|
||||
// Adding those FETCH items is not a local change. Every already-stored
|
||||
// message would derive a DIFFERENT key on its next resync, so each
|
||||
// re-reported placement repoints to the new key -- and the old message
|
||||
// row, now referenced by nothing, is never reaped. Its decrypted body
|
||||
// stays in the plaintext FTS index forever, which is exactly the leak
|
||||
// the reap on placement removal exists to prevent. The FETCH items can
|
||||
// only be requested once the repoint path reaps the message it
|
||||
// abandons; until then, requesting them leaks plaintext.
|
||||
// Gmail's id is a 64-bit integer on the wire; it is rendered decimal
|
||||
// here because identity inputs are hashed as strings and the rendering
|
||||
// has to be the one every engine would produce from the same number.
|
||||
let gm_msgid = fetch_data.gmail_msg_id().map(u64::to_string);
|
||||
|
||||
if let Some(raw_bytes) = fetch_data.body() {
|
||||
let mut email = MimeParserAdapter::parse_mime(&self.account_id, raw_bytes)?;
|
||||
let (identity, placement) = self.place(
|
||||
@@ -1338,8 +1422,8 @@ impl ImapEngine {
|
||||
&uid_validity,
|
||||
&remote_id,
|
||||
RemoteIdentity {
|
||||
email_id: None,
|
||||
gm_msgid: None,
|
||||
email_id,
|
||||
gm_msgid: gm_msgid.as_deref(),
|
||||
message_id: email.message_id.as_deref(),
|
||||
content_hash: email.content_hash.as_deref(),
|
||||
},
|
||||
@@ -1383,16 +1467,16 @@ impl ImapEngine {
|
||||
.and_then(|raw| Email::normalize_message_id(&String::from_utf8_lossy(raw)));
|
||||
|
||||
// With no octets there is no content hash, and the `Message-ID` alone
|
||||
// is never enough to key on, so this path lands on the surrogate tier.
|
||||
// That is the honest answer: nothing was fetched that could prove two
|
||||
// folders hold the same message.
|
||||
// is never enough to key on, so absent a server-assigned id this path
|
||||
// lands on the surrogate tier. That is the honest answer: nothing was
|
||||
// fetched that could prove two folders hold the same message.
|
||||
let (identity, placement) = self.place(
|
||||
folder_id,
|
||||
&uid_validity,
|
||||
&remote_id,
|
||||
RemoteIdentity {
|
||||
email_id: None,
|
||||
gm_msgid: None,
|
||||
email_id,
|
||||
gm_msgid: gm_msgid.as_deref(),
|
||||
message_id: message_id.as_deref(),
|
||||
content_hash: None,
|
||||
},
|
||||
@@ -1429,6 +1513,7 @@ impl ImapEngine {
|
||||
folder_id: &str,
|
||||
server_uid_validity: Option<u32>,
|
||||
fetch_data: &async_imap::types::Fetch,
|
||||
email_id: Option<&str>,
|
||||
) -> PlacedMessage {
|
||||
let uid_num = fetch_data.uid.unwrap_or(0);
|
||||
let remote_id = uid_num.to_string();
|
||||
@@ -1438,6 +1523,7 @@ impl ImapEngine {
|
||||
let is_read = fetch_data
|
||||
.flags()
|
||||
.any(|flag| matches!(flag, async_imap::types::Flag::Seen));
|
||||
let gm_msgid = fetch_data.gmail_msg_id().map(u64::to_string);
|
||||
|
||||
if let Some(header_bytes) = fetch_data.header() {
|
||||
if let Ok(mut email) = MimeParserAdapter::parse_mime(&self.account_id, header_bytes) {
|
||||
@@ -1452,16 +1538,17 @@ impl ImapEngine {
|
||||
// something else, so drop it; the `Message-ID` it recovered
|
||||
// from those headers is still correct and is kept. Dropping it
|
||||
// also costs this message the content tier -- a `Message-ID`
|
||||
// never keys on its own -- so an oversized message identifies
|
||||
// by its folder-scoped surrogate until the octets are fetched.
|
||||
// never keys on its own -- so absent a server-assigned id an
|
||||
// oversized message identifies by its folder-scoped surrogate
|
||||
// until the octets are fetched.
|
||||
email.content_hash = None;
|
||||
let (identity, placement) = self.place(
|
||||
folder_id,
|
||||
&uid_validity,
|
||||
&remote_id,
|
||||
RemoteIdentity {
|
||||
email_id: None,
|
||||
gm_msgid: None,
|
||||
email_id,
|
||||
gm_msgid: gm_msgid.as_deref(),
|
||||
message_id: email.message_id.as_deref(),
|
||||
content_hash: None,
|
||||
},
|
||||
@@ -1479,13 +1566,17 @@ impl ImapEngine {
|
||||
// No usable header section: fall back to whatever the envelope offers.
|
||||
// `build_email_from_fetch` cannot error on this path (no body to parse),
|
||||
// but if it ever did, surface a minimal honest record rather than panic.
|
||||
self.build_email_from_fetch(folder_id, server_uid_validity, fetch_data)
|
||||
self.build_email_from_fetch(folder_id, server_uid_validity, fetch_data, email_id)
|
||||
.unwrap_or_else(|_| {
|
||||
let (identity, placement) = self.place(
|
||||
folder_id,
|
||||
&uid_validity,
|
||||
&remote_id,
|
||||
RemoteIdentity::default(),
|
||||
RemoteIdentity {
|
||||
email_id,
|
||||
gm_msgid: gm_msgid.as_deref(),
|
||||
..RemoteIdentity::default()
|
||||
},
|
||||
is_read,
|
||||
);
|
||||
PlacedMessage {
|
||||
@@ -2141,6 +2232,10 @@ mod tests {
|
||||
qresync_enabled: true,
|
||||
condstore: true,
|
||||
mailbox_has_modseq: true,
|
||||
// The identity extensions decide what a FETCH asks for, never
|
||||
// which rung a pass runs on; they are set for completeness.
|
||||
objectid: true,
|
||||
gmail_extensions: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2217,7 +2312,8 @@ mod tests {
|
||||
MailboxSyncCapabilities {
|
||||
qresync_enabled: false,
|
||||
condstore: true,
|
||||
mailbox_has_modseq: true
|
||||
mailbox_has_modseq: true,
|
||||
..MailboxSyncCapabilities::default()
|
||||
}
|
||||
),
|
||||
SyncRung::Condstore
|
||||
@@ -2231,7 +2327,8 @@ mod tests {
|
||||
MailboxSyncCapabilities {
|
||||
qresync_enabled: false,
|
||||
condstore: false,
|
||||
mailbox_has_modseq: false
|
||||
mailbox_has_modseq: false,
|
||||
..MailboxSyncCapabilities::default()
|
||||
}
|
||||
),
|
||||
SyncRung::UidSetDiff
|
||||
@@ -2247,7 +2344,8 @@ mod tests {
|
||||
MailboxSyncCapabilities {
|
||||
qresync_enabled: true,
|
||||
condstore: true,
|
||||
mailbox_has_modseq: false
|
||||
mailbox_has_modseq: false,
|
||||
..MailboxSyncCapabilities::default()
|
||||
}
|
||||
),
|
||||
SyncRung::Full
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Raw IMAP command layer: the responses `async-imap`'s typed API discards.
|
||||
//!
|
||||
//! The engine needs four things the typed client cannot express, and all four
|
||||
//! decide whether a sync saw everything or whether a mutation actually
|
||||
//! happened:
|
||||
//! The engine needs five things the typed client cannot express, and they all
|
||||
//! decide whether a sync saw everything, whether a mutation actually happened,
|
||||
//! or whether two engines can agree on what a message *is*:
|
||||
//!
|
||||
//! | Needed | Why the typed API cannot give it |
|
||||
//! | --- | --- |
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | `SELECT (QRESYNC …)` | no such method, and `select()`'s parser drops `VANISHED` |
|
||||
//! | `COPYUID` | `uid_copy`/`uid_mv` return `Result<()>`; the tagged response *code* is discarded |
|
||||
//! | `[MODIFIED …]` | same discard, and `imap-proto` has no `ResponseCode` variant for it at all |
|
||||
//! | `EMAILID` | `imap-proto` cannot *parse* it, and failing to parse kills the connection -- see [`uid_fetch_email_ids`] |
|
||||
//!
|
||||
//! ## Why this is hand-rolled rather than a fork
|
||||
//!
|
||||
@@ -32,13 +33,15 @@
|
||||
//! against a server that emits the real syntax, and the alternative is having
|
||||
//! no conflict detection at all.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Debug;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use async_imap::Session;
|
||||
// Reached through `async-imap`'s re-export rather than a direct dependency,
|
||||
// so the parser types can never version-skew against the client using them.
|
||||
use async_imap::imap_proto::{Response, ResponseCode, Status, UidSetMember};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
use crate::parser::MailError;
|
||||
|
||||
@@ -386,10 +389,326 @@ where
|
||||
Ok(exchange)
|
||||
}
|
||||
|
||||
/// Upper bound on the bytes one raw exchange will accumulate before giving up.
|
||||
///
|
||||
/// The raw reader has no framing beyond "a line beginning with our tag", so a
|
||||
/// server that answers with an endless stream and never completes the command
|
||||
/// would otherwise grow this buffer without limit. One `EMAILID` line is a few
|
||||
/// dozen bytes and a batch is capped at a few hundred messages, so this is
|
||||
/// orders of magnitude above any legitimate reply.
|
||||
const MAX_RAW_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Tag prefix for commands this module writes straight to the transport.
|
||||
///
|
||||
/// `async-imap` allocates its own tags from an `IdGenerator` that emits
|
||||
/// `format!("A{:04}", n % 10_000)` -- always the letter `A` followed by four
|
||||
/// decimal digits. A tag starting with `NX` therefore cannot collide with one
|
||||
/// the typed client will ever issue, no matter how many commands a session
|
||||
/// runs or how often the generator wraps. Colliding would be worse than
|
||||
/// untidy: two commands sharing a tag makes each one's completion look like
|
||||
/// the other's, and a reader would stop at the wrong response.
|
||||
const RAW_TAG_PREFIX: &str = "NX";
|
||||
|
||||
/// Sequence behind [`RAW_TAG_PREFIX`]. Process-wide rather than per-session
|
||||
/// because it costs nothing and makes a tag unique in a packet capture across
|
||||
/// every connection, which is the only thing anyone reads these tags for.
|
||||
static RAW_TAG_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn next_raw_tag() -> String {
|
||||
let n = RAW_TAG_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
format!("{RAW_TAG_PREFIX}{n}")
|
||||
}
|
||||
|
||||
/// `UID FETCH <set> (UID EMAILID)` (RFC 8474 section 5.2), read straight off
|
||||
/// the transport, returning `uid -> EMAILID`.
|
||||
///
|
||||
/// # Why this bypasses the client entirely
|
||||
///
|
||||
/// `imap-proto` 0.16.7 cannot parse `EMAILID`: its `msg_att` parser is a closed
|
||||
/// `alt(...)` of known attributes with no catch-all, so an `EMAILID` item is a
|
||||
/// hard nom error rather than an unknown-but-tolerated one. `async-imap`'s
|
||||
/// decoder turns any non-`Incomplete` parse error into a fatal `io::Error` and
|
||||
/// then latches `read_closed`, after which the stream yields `None` forever --
|
||||
/// the session is dead, not degraded. So `EMAILID` must never reach
|
||||
/// `parse_response`, which rules out `Session::uid_fetch` **and**
|
||||
/// [`run_collected`], since the latter reads through `Session::read_response`
|
||||
/// and therefore through the same decoder.
|
||||
///
|
||||
/// What is left is the transport underneath: `Session` exposes `&mut T`
|
||||
/// (`AsMut`, and `Connection::get_mut` through `Deref`), so the command is
|
||||
/// written and its reply scanned here, by hand, and no byte of it is offered
|
||||
/// to nom.
|
||||
///
|
||||
/// # The two conditions this is only correct under
|
||||
///
|
||||
/// 1. **The client's read buffer must be empty.** `ImapStream` owns a buffer
|
||||
/// that can hold bytes already pulled off the socket. Raw reads bypass it,
|
||||
/// so anything sitting there is invisible to this scanner. Call this only
|
||||
/// at a quiescent point -- immediately after a previous command's *tagged*
|
||||
/// completion has been read -- and never interleaved with an in-flight
|
||||
/// typed command or a live response stream.
|
||||
/// 2. **The tag must not collide** with the client's own. See
|
||||
/// [`RAW_TAG_PREFIX`].
|
||||
///
|
||||
/// # Degrading rather than failing
|
||||
///
|
||||
/// A tagged `NO`/`BAD` yields an empty map, not an error. The object id is an
|
||||
/// identity *upgrade*: without it a message still syncs under the
|
||||
/// `Message-ID`+content tier or the surrogate, so a server that advertises
|
||||
/// `OBJECTID` and then refuses the fetch must cost the sync precision, never
|
||||
/// the mail. A stalled read is different and does surface, because a server
|
||||
/// that stopped answering mid-command has left the session unusable.
|
||||
pub async fn uid_fetch_email_ids<S>(
|
||||
session: &mut Session<S>,
|
||||
uid_set: &str,
|
||||
) -> Result<BTreeMap<u32, String>, MailError>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + Debug + 'static,
|
||||
{
|
||||
if uid_set.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
|
||||
let tag = next_raw_tag();
|
||||
let command = format!("{tag} UID FETCH {uid_set} (UID EMAILID)\r\n");
|
||||
|
||||
let stream: &mut S = session.as_mut();
|
||||
stream
|
||||
.write_all(command.as_bytes())
|
||||
.await
|
||||
.map_err(|e| MailError::ImapError(format!("failed to issue '{command:?}': {e}")))?;
|
||||
stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| MailError::ImapError(format!("failed to flush '{command:?}': {e}")))?;
|
||||
|
||||
let (lines, tagged) = read_until_tagged(stream, &tag).await?;
|
||||
|
||||
if !tagged_is_ok(&tagged) {
|
||||
tracing::warn!(
|
||||
response = %tagged,
|
||||
"server refused UID FETCH (UID EMAILID) despite advertising OBJECTID; \
|
||||
this pass identifies its messages by a weaker tier"
|
||||
);
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
|
||||
let mut ids = BTreeMap::new();
|
||||
for line in &lines {
|
||||
if let Some((uid, email_id)) = parse_email_id_line(line) {
|
||||
ids.insert(uid, email_id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Read complete CRLF-terminated lines until one begins with `tag`, returning
|
||||
/// the lines before it and the tagged line itself.
|
||||
///
|
||||
/// Line-oriented scanning is sound *for this command only*: an `EMAILID`
|
||||
/// response carries no literal (`{n}`) section, so no line body can contain an
|
||||
/// embedded CRLF. It must not be reused for a command whose reply can carry
|
||||
/// literals -- a body fetch -- without adding literal handling.
|
||||
async fn read_until_tagged<S>(stream: &mut S, tag: &str) -> Result<(Vec<String>, String), MailError>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
let mut scanned = 0usize;
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let tag_prefix = format!("{tag} ");
|
||||
|
||||
loop {
|
||||
let read = tokio::time::timeout(RESPONSE_READ_TIMEOUT, stream.read(&mut chunk))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MailError::FetchStalled(format!(
|
||||
"no response to raw '{tag}' within {RESPONSE_READ_TIMEOUT:?}"
|
||||
))
|
||||
})?
|
||||
.map_err(|e| MailError::ImapError(format!("reading response to raw '{tag}': {e}")))?;
|
||||
if read == 0 {
|
||||
return Err(MailError::ImapError(format!(
|
||||
"connection closed before raw '{tag}' completed"
|
||||
)));
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if buffer.len() > MAX_RAW_RESPONSE_BYTES {
|
||||
return Err(MailError::ImapError(format!(
|
||||
"raw '{tag}' response exceeded {MAX_RAW_RESPONSE_BYTES} bytes without completing"
|
||||
)));
|
||||
}
|
||||
|
||||
// Consume every line that is now complete. `scanned` marks how far the
|
||||
// buffer has already been split, so a reply arriving in many chunks is
|
||||
// not re-scanned from the start each time.
|
||||
while let Some(offset) = buffer[scanned..].iter().position(|b| *b == b'\n') {
|
||||
let end = scanned + offset;
|
||||
let line = String::from_utf8_lossy(&buffer[scanned..end])
|
||||
.trim_end_matches('\r')
|
||||
.to_string();
|
||||
scanned = end + 1;
|
||||
if line.starts_with(&tag_prefix) {
|
||||
return Ok((lines, line));
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a tagged completion line reports `OK`.
|
||||
fn tagged_is_ok(tagged: &str) -> bool {
|
||||
tagged
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.is_some_and(|status| status.eq_ignore_ascii_case("OK"))
|
||||
}
|
||||
|
||||
/// Pull `(uid, EMAILID)` out of one untagged `FETCH` line.
|
||||
///
|
||||
/// Both wire spellings are accepted. RFC 8474 section 5.2 defines the response
|
||||
/// as `"EMAILID" SP "(" objectid ")"`, but the bare `EMAILID <objectid>` form
|
||||
/// is what a hand-rolled scanner meets often enough that rejecting it would
|
||||
/// mean silently dropping to a weaker identity tier against a server that
|
||||
/// answered the question correctly enough.
|
||||
///
|
||||
/// Returns `None` for anything that is not an untagged `FETCH` carrying both a
|
||||
/// UID and a non-empty object id -- there is nothing to key on without both.
|
||||
fn parse_email_id_line(line: &str) -> Option<(u32, String)> {
|
||||
let upper = line.to_ascii_uppercase();
|
||||
if !upper.starts_with("* ") || !upper.contains(" FETCH ") {
|
||||
return None;
|
||||
}
|
||||
// `to_ascii_uppercase` is byte-length preserving, so offsets found in the
|
||||
// uppercased copy index the original exactly. Item *names* are
|
||||
// case-insensitive (RFC 3501 section 9); an object id is not, so the value
|
||||
// is always taken from the original line.
|
||||
let uid = find_item_value(&upper, line, "UID ")?.parse::<u32>().ok()?;
|
||||
let email_id = find_item_value(&upper, line, "EMAILID ")?;
|
||||
let email_id = email_id
|
||||
.trim_start_matches('(')
|
||||
.trim_end_matches(')')
|
||||
.trim_matches('"')
|
||||
.to_string();
|
||||
if email_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((uid, email_id))
|
||||
}
|
||||
|
||||
/// The token following FETCH item `name` in `line`, located via its uppercased
|
||||
/// twin `upper`.
|
||||
///
|
||||
/// The name must sit on a token boundary, so `UID ` never matches the tail of
|
||||
/// another attribute name. A parenthesised value is returned whole, brackets
|
||||
/// included, for the caller to strip.
|
||||
fn find_item_value(upper: &str, line: &str, name: &str) -> Option<String> {
|
||||
let mut from = 0usize;
|
||||
loop {
|
||||
let at = from + upper[from..].find(name)?;
|
||||
let boundary = at == 0
|
||||
|| upper[..at]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(|c| c == '(' || c == ' ');
|
||||
if boundary {
|
||||
let value_start = at + name.len();
|
||||
let rest = &line[value_start..];
|
||||
let end = if rest.starts_with('(') {
|
||||
rest.find(')').map_or(rest.len(), |i| i + 1)
|
||||
} else {
|
||||
rest.find([' ', ')']).unwrap_or(rest.len())
|
||||
};
|
||||
let value = rest[..end].trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
from = at + name.len();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_object_id_is_read_from_both_wire_spellings() {
|
||||
// RFC 8474 5.2 parenthesises the id; the bare form appears in the wild.
|
||||
// Both must key the same message, or which spelling a server chose
|
||||
// would decide whether identity converges.
|
||||
assert_eq!(
|
||||
parse_email_id_line("* 1 FETCH (UID 7 EMAILID (M00000001))"),
|
||||
Some((7, "M00000001".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_email_id_line("* 1 FETCH (UID 7 EMAILID M00000001)"),
|
||||
Some((7, "M00000001".to_string()))
|
||||
);
|
||||
// Item names are case-insensitive; the id itself is not.
|
||||
assert_eq!(
|
||||
parse_email_id_line("* 1 fetch (uid 7 emailid (Mixed-Case_01))"),
|
||||
Some((7, "Mixed-Case_01".to_string()))
|
||||
);
|
||||
// Order is not fixed, and other attributes may sit between them.
|
||||
assert_eq!(
|
||||
parse_email_id_line("* 3 FETCH (EMAILID (M9) FLAGS (\\Seen) UID 12)"),
|
||||
Some((12, "M9".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_missing_either_half_yields_nothing() {
|
||||
// Half an answer is not a weaker identity, it is no identity: without
|
||||
// the UID there is nothing to attach the id to, and without the id
|
||||
// there is nothing to attach.
|
||||
assert_eq!(
|
||||
parse_email_id_line("* 1 FETCH (UID 7 FLAGS (\\Seen))"),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_email_id_line("* 1 FETCH (EMAILID (M1))"), None);
|
||||
assert_eq!(parse_email_id_line("* 1 FETCH (UID 7 EMAILID ())"), None);
|
||||
assert_eq!(parse_email_id_line("NX0 OK UID FETCH completed"), None);
|
||||
assert_eq!(parse_email_id_line("* 4 EXPUNGE"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_name_is_only_matched_on_a_token_boundary() {
|
||||
// RFC 8474 defines THREADID alongside EMAILID, so a line carrying both
|
||||
// is ordinary. Every occurrence of `ID ` in it is the tail of a longer
|
||||
// attribute name; without the boundary rule a search would read
|
||||
// THREADID's value as the item's own and key the message on it.
|
||||
let line = "* 1 FETCH (THREADID (T1) EMAILID (M1) UID 7)";
|
||||
let upper = line.to_ascii_uppercase();
|
||||
assert_eq!(find_item_value(&upper, line, "ID "), None);
|
||||
assert_eq!(
|
||||
find_item_value(&upper, line, "EMAILID "),
|
||||
Some("(M1)".to_string())
|
||||
);
|
||||
assert_eq!(find_item_value(&upper, line, "UID "), Some("7".to_string()));
|
||||
assert_eq!(parse_email_id_line(line), Some((7, "M1".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_tagged_ok_counts_as_a_usable_answer() {
|
||||
assert!(tagged_is_ok("NX7 OK UID FETCH completed"));
|
||||
assert!(tagged_is_ok("NX7 ok uid fetch completed"));
|
||||
assert!(!tagged_is_ok("NX7 NO server does not do that"));
|
||||
assert!(!tagged_is_ok("NX7 BAD unknown item"));
|
||||
assert!(!tagged_is_ok("NX7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_tags_can_never_collide_with_the_clients_own() {
|
||||
// async-imap's IdGenerator emits `A` + four digits and wraps at 10_000,
|
||||
// so a shared tag is only impossible if the prefix differs.
|
||||
let tag = next_raw_tag();
|
||||
assert!(tag.starts_with(RAW_TAG_PREFIX));
|
||||
assert_ne!(&tag[..1], "A");
|
||||
assert_ne!(next_raw_tag(), tag, "each raw command gets its own tag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_response_code_is_recovered_from_the_tagged_text() {
|
||||
// imap-proto has no ResponseCode::Modified, and its opt(resp_text_code)
|
||||
|
||||
@@ -48,21 +48,6 @@ pub enum ServerProfile {
|
||||
}
|
||||
|
||||
impl ServerProfile {
|
||||
/// The `CAPABILITY` atom list for this profile. `UIDPLUS` and `MOVE` are
|
||||
/// advertised by all three because every profile we model supports them.
|
||||
fn capability_line(self) -> String {
|
||||
let mut caps = vec!["IMAP4rev1", "UIDPLUS", "MOVE", "ENABLE", "LIST-STATUS"];
|
||||
match self {
|
||||
ServerProfile::Basic => {}
|
||||
ServerProfile::Condstore => caps.push("CONDSTORE"),
|
||||
ServerProfile::Qresync => {
|
||||
caps.push("CONDSTORE");
|
||||
caps.push("QRESYNC");
|
||||
}
|
||||
}
|
||||
caps.join(" ")
|
||||
}
|
||||
|
||||
fn supports_condstore(self) -> bool {
|
||||
matches!(self, ServerProfile::Condstore | ServerProfile::Qresync)
|
||||
}
|
||||
@@ -72,6 +57,57 @@ impl ServerProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which server-assigned message-id extensions this server publishes.
|
||||
///
|
||||
/// Independent of [`ServerProfile`] because the two axes genuinely are
|
||||
/// independent in the field: Gmail advertises `X-GM-EXT-1` with CONDSTORE and
|
||||
/// no QRESYNC, Fastmail advertises `OBJECTID` with QRESYNC, and Exchange
|
||||
/// advertises neither. Selectable per instance so a test can pin the
|
||||
/// behaviour of a server that offers *nothing*, which is the case that must
|
||||
/// stay byte-identical to how the engine behaved before object ids existed.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct IdentityExtensions {
|
||||
/// RFC 8474 `OBJECTID`, so `EMAILID` is served when asked for.
|
||||
pub objectid: bool,
|
||||
/// Gmail's `X-GM-EXT-1`, so `X-GM-MSGID` is served when asked for.
|
||||
pub gmail: bool,
|
||||
}
|
||||
|
||||
impl IdentityExtensions {
|
||||
/// A server that publishes no stable message id at all.
|
||||
pub fn none() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// RFC 8474 only.
|
||||
pub fn objectid() -> Self {
|
||||
Self {
|
||||
objectid: true,
|
||||
gmail: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gmail's vendor extension only, which is what Gmail itself does.
|
||||
pub fn gmail() -> Self {
|
||||
Self {
|
||||
objectid: false,
|
||||
gmail: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `CAPABILITY` atoms these extensions contribute.
|
||||
fn atoms(self) -> Vec<&'static str> {
|
||||
let mut atoms = Vec::new();
|
||||
if self.objectid {
|
||||
atoms.push("OBJECTID");
|
||||
}
|
||||
if self.gmail {
|
||||
atoms.push("X-GM-EXT-1");
|
||||
}
|
||||
atoms
|
||||
}
|
||||
}
|
||||
|
||||
/// One message as the server holds it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockMessage {
|
||||
@@ -80,6 +116,23 @@ pub struct MockMessage {
|
||||
pub flags: BTreeSet<String>,
|
||||
/// Full RFC822 octets served for `BODY[]`.
|
||||
pub body: String,
|
||||
/// The server-assigned ids this message keeps wherever it sits.
|
||||
pub object_ids: ObjectIds,
|
||||
}
|
||||
|
||||
/// The message-level ids a server assigns once and reports identically from
|
||||
/// every mailbox holding the message.
|
||||
///
|
||||
/// That invariant is the entire point of RFC 8474: a UID is per-mailbox, an
|
||||
/// `EMAILID` is not, so a copy in a second folder reports the same one. A mock
|
||||
/// that minted a fresh id per placement would model a server that does not
|
||||
/// exist and would let a broken client pass.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ObjectIds {
|
||||
/// RFC 8474 `EMAILID`.
|
||||
pub email_id: String,
|
||||
/// Gmail `X-GM-MSGID`, a 64-bit integer on the wire.
|
||||
pub gm_msgid: u64,
|
||||
}
|
||||
|
||||
/// A mailbox: its UID space, its mod-sequence, and the tombstones needed to
|
||||
@@ -113,7 +166,7 @@ impl MockMailbox {
|
||||
self.highest_modseq
|
||||
}
|
||||
|
||||
fn append(&mut self, body: &str, flags: &[&str]) -> u32 {
|
||||
fn append(&mut self, body: &str, flags: &[&str], object_ids: ObjectIds) -> u32 {
|
||||
let uid = self.uid_next;
|
||||
self.uid_next += 1;
|
||||
let modseq = self.next_modseq();
|
||||
@@ -122,6 +175,7 @@ impl MockMailbox {
|
||||
modseq,
|
||||
flags: flags.iter().map(|f| (*f).to_string()).collect(),
|
||||
body: body.to_string(),
|
||||
object_ids,
|
||||
});
|
||||
uid
|
||||
}
|
||||
@@ -150,21 +204,57 @@ impl MockMailbox {
|
||||
#[derive(Debug)]
|
||||
pub struct ServerState {
|
||||
pub profile: ServerProfile,
|
||||
/// Which server-assigned id extensions this instance publishes.
|
||||
pub extensions: IdentityExtensions,
|
||||
pub mailboxes: BTreeMap<String, MockMailbox>,
|
||||
/// Every command line received, across all connections, in arrival order.
|
||||
/// Tests assert on this to prove *which* protocol path ran -- e.g. that a
|
||||
/// second sync narrowed its FETCH rather than re-fetching everything.
|
||||
pub commands: Vec<String>,
|
||||
next_uid_validity: u32,
|
||||
next_object_id: u64,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
fn new(profile: ServerProfile) -> Self {
|
||||
fn new(profile: ServerProfile, extensions: IdentityExtensions) -> Self {
|
||||
Self {
|
||||
profile,
|
||||
extensions,
|
||||
mailboxes: BTreeMap::new(),
|
||||
commands: Vec::new(),
|
||||
next_uid_validity: 1000,
|
||||
next_object_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `CAPABILITY` atom list. `UIDPLUS` and `MOVE` are advertised by every
|
||||
/// profile because every server we model supports them; the sync-ladder and
|
||||
/// identity atoms are what varies.
|
||||
fn capability_line(&self) -> String {
|
||||
let mut caps = vec!["IMAP4rev1", "UIDPLUS", "MOVE", "ENABLE", "LIST-STATUS"];
|
||||
match self.profile {
|
||||
ServerProfile::Basic => {}
|
||||
ServerProfile::Condstore => caps.push("CONDSTORE"),
|
||||
ServerProfile::Qresync => {
|
||||
caps.push("CONDSTORE");
|
||||
caps.push("QRESYNC");
|
||||
}
|
||||
}
|
||||
caps.extend(self.extensions.atoms());
|
||||
caps.join(" ")
|
||||
}
|
||||
|
||||
/// Mint the ids for a message the test did not name explicitly.
|
||||
fn mint_object_ids(&mut self) -> ObjectIds {
|
||||
let n = self.next_object_id;
|
||||
self.next_object_id += 1;
|
||||
ObjectIds {
|
||||
// RFC 8474 section 4 restricts an objectid to 1*255 of
|
||||
// ALPHA/DIGIT/`_`/`-`, so this shape is representative rather than
|
||||
// decorative -- a scanner that mishandles it mishandles the real
|
||||
// thing.
|
||||
email_id: format!("M{n:08}"),
|
||||
gm_msgid: 1_278_455_344_230_334_000 + n,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,11 +285,20 @@ fn lock_state(state: &Arc<Mutex<ServerState>>) -> MutexGuard<'_, ServerState> {
|
||||
}
|
||||
|
||||
impl MockImapServer {
|
||||
/// Bind an ephemeral loopback port and start accepting connections.
|
||||
/// Bind an ephemeral loopback port and start accepting connections, with
|
||||
/// no server-assigned message-id extensions.
|
||||
pub async fn start(profile: ServerProfile) -> std::io::Result<Self> {
|
||||
Self::start_with_identity(profile, IdentityExtensions::none()).await
|
||||
}
|
||||
|
||||
/// As [`Self::start`], additionally publishing `extensions`.
|
||||
pub async fn start_with_identity(
|
||||
profile: ServerProfile,
|
||||
extensions: IdentityExtensions,
|
||||
) -> std::io::Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
let state = Arc::new(Mutex::new(ServerState::new(profile)));
|
||||
let state = Arc::new(Mutex::new(ServerState::new(profile, extensions)));
|
||||
|
||||
let accept_state = Arc::clone(&state);
|
||||
let accept_task = tokio::spawn(async move {
|
||||
@@ -241,8 +340,26 @@ impl MockImapServer {
|
||||
.insert(name.to_string(), MockMailbox::new(uid_validity));
|
||||
}
|
||||
|
||||
/// Append a message, creating the mailbox if absent. Returns its UID.
|
||||
/// Append a message under freshly minted object ids, creating the mailbox
|
||||
/// if absent. Returns its UID.
|
||||
pub fn append_message(&self, mailbox: &str, body: &str, flags: &[&str]) -> u32 {
|
||||
let ids = lock_state(&self.state).mint_object_ids();
|
||||
self.append_message_with_object_ids(mailbox, body, flags, ids)
|
||||
}
|
||||
|
||||
/// Append a message under *given* object ids.
|
||||
///
|
||||
/// This is how a test puts the same message in two mailboxes: a real
|
||||
/// OBJECTID server reports one `EMAILID` for both copies, and only that
|
||||
/// makes the cross-folder identity claim testable. Passing distinct ids to
|
||||
/// two appends models two genuinely different messages.
|
||||
pub fn append_message_with_object_ids(
|
||||
&self,
|
||||
mailbox: &str,
|
||||
body: &str,
|
||||
flags: &[&str],
|
||||
object_ids: ObjectIds,
|
||||
) -> u32 {
|
||||
let mut state = lock_state(&self.state);
|
||||
let uid_validity = state.next_uid_validity;
|
||||
let entry = state.mailboxes.entry(mailbox.to_string());
|
||||
@@ -251,13 +368,24 @@ impl MockImapServer {
|
||||
MockMailbox::new(uid_validity)
|
||||
});
|
||||
let fresh = mailbox.uid_next == 1 && mailbox.messages.is_empty();
|
||||
let uid = mailbox.append(body, flags);
|
||||
let uid = mailbox.append(body, flags, object_ids);
|
||||
if fresh {
|
||||
state.next_uid_validity += 1;
|
||||
}
|
||||
uid
|
||||
}
|
||||
|
||||
/// The object ids the server assigned to `uid` in `mailbox`.
|
||||
pub fn object_ids(&self, mailbox: &str, uid: u32) -> Option<ObjectIds> {
|
||||
let state = lock_state(&self.state);
|
||||
state.mailboxes.get(mailbox).and_then(|mb| {
|
||||
mb.messages
|
||||
.iter()
|
||||
.find(|m| m.uid == uid)
|
||||
.map(|m| m.object_ids.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Simulate another client removing a message -- the case a forward-only
|
||||
/// high-water-mark sync can never observe.
|
||||
pub fn expunge(&self, mailbox: &str, uid: u32) -> bool {
|
||||
@@ -331,7 +459,7 @@ async fn handle_connection(
|
||||
let st = lock_state(&state);
|
||||
format!(
|
||||
"* OK [CAPABILITY {}] Nuncio mock IMAP ready\r\n",
|
||||
st.profile.capability_line()
|
||||
st.capability_line()
|
||||
)
|
||||
};
|
||||
socket.write_all(greeting.as_bytes()).await?;
|
||||
@@ -381,11 +509,11 @@ fn dispatch(line: &str, session: &mut Session, st: &mut ServerState) -> Reply {
|
||||
match command.as_str() {
|
||||
"CAPABILITY" => Reply::open(format!(
|
||||
"* CAPABILITY {}\r\n{tag} OK CAPABILITY completed\r\n",
|
||||
st.profile.capability_line()
|
||||
st.capability_line()
|
||||
)),
|
||||
"LOGIN" => Reply::open(format!(
|
||||
"{tag} OK [CAPABILITY {}] Logged in\r\n",
|
||||
st.profile.capability_line()
|
||||
st.capability_line()
|
||||
)),
|
||||
"ENABLE" => handle_enable(&tag, &rest, session, st),
|
||||
"NOOP" => Reply::open(format!("{tag} OK NOOP completed\r\n")),
|
||||
@@ -499,7 +627,7 @@ fn handle_select(
|
||||
out.push_str(&format!("* VANISHED (EARLIER) {}\r\n", gone.join(",")));
|
||||
}
|
||||
for msg in mb.messages.iter().filter(|m| m.modseq > since) {
|
||||
out.push_str(&fetch_line(mb, msg, false));
|
||||
out.push_str(&fetch_line(mb, msg, FetchItems::none()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,7 +704,39 @@ fn format_flags(flags: &BTreeSet<String>) -> String {
|
||||
flags.iter().cloned().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn fetch_line(mb: &MockMailbox, msg: &MockMessage, include_body: bool) -> String {
|
||||
/// Which optional items a `FETCH` reply should carry.
|
||||
///
|
||||
/// A server returns what was asked for. That is not pedantry here: `EMAILID`
|
||||
/// is unparseable by the client's own parser, so serving it unasked would kill
|
||||
/// every connection that ran a typed fetch -- exactly the failure the raw
|
||||
/// layer exists to route around.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct FetchItems {
|
||||
body: bool,
|
||||
email_id: bool,
|
||||
gm_msgid: bool,
|
||||
}
|
||||
|
||||
impl FetchItems {
|
||||
/// Read the request off the command line, honouring what this server
|
||||
/// actually advertised: an item the client asked for without the
|
||||
/// capability is simply not served.
|
||||
fn requested(upper: &str, extensions: IdentityExtensions) -> Self {
|
||||
Self {
|
||||
body: upper.contains("BODY[]") || upper.contains("RFC822"),
|
||||
email_id: extensions.objectid && upper.contains("EMAILID"),
|
||||
gm_msgid: extensions.gmail && upper.contains("X-GM-MSGID"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape a non-FETCH path (a QRESYNC `SELECT`, a non-silent `STORE`)
|
||||
/// volunteers: flags and mod-sequence only.
|
||||
fn none() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_line(mb: &MockMailbox, msg: &MockMessage, items_wanted: FetchItems) -> String {
|
||||
let seq = mb.seq_of(msg.uid);
|
||||
let mut items = format!(
|
||||
"UID {} FLAGS ({}) MODSEQ ({})",
|
||||
@@ -584,7 +744,14 @@ fn fetch_line(mb: &MockMailbox, msg: &MockMessage, include_body: bool) -> String
|
||||
format_flags(&msg.flags),
|
||||
msg.modseq
|
||||
);
|
||||
if include_body {
|
||||
if items_wanted.email_id {
|
||||
// RFC 8474 section 5.2 parenthesises the value.
|
||||
items.push_str(&format!(" EMAILID ({})", msg.object_ids.email_id));
|
||||
}
|
||||
if items_wanted.gm_msgid {
|
||||
items.push_str(&format!(" X-GM-MSGID {}", msg.object_ids.gm_msgid));
|
||||
}
|
||||
if items_wanted.body {
|
||||
items.push_str(&format!(" BODY[] {{{}}}\r\n{}", msg.body.len(), msg.body));
|
||||
}
|
||||
format!("* {seq} FETCH ({items})\r\n")
|
||||
@@ -598,6 +765,7 @@ fn handle_uid_fetch(
|
||||
st: &mut ServerState,
|
||||
) -> Reply {
|
||||
let condstore = st.profile.supports_condstore();
|
||||
let extensions = st.extensions;
|
||||
let Some(mb) = st.mailboxes.get(selected) else {
|
||||
return Reply::open(format!("{tag} NO mailbox vanished\r\n"));
|
||||
};
|
||||
@@ -606,7 +774,14 @@ fn handle_uid_fetch(
|
||||
};
|
||||
|
||||
let upper = line.to_ascii_uppercase();
|
||||
let include_body = upper.contains("BODY[]") || upper.contains("RFC822");
|
||||
// An unrecognised FETCH item is a syntax error (RFC 3501 section 6.4.5), so
|
||||
// a server that never advertised OBJECTID rejects the whole command rather
|
||||
// than quietly answering without the item. Modelling that is what makes the
|
||||
// client's capability gate load-bearing instead of decorative.
|
||||
if upper.contains("EMAILID") && !extensions.objectid {
|
||||
return Reply::open(format!("{tag} BAD unknown FETCH item EMAILID\r\n"));
|
||||
}
|
||||
let items_wanted = FetchItems::requested(&upper, extensions);
|
||||
let changed_since = if condstore {
|
||||
parse_changed_since(&upper)
|
||||
} else {
|
||||
@@ -623,7 +798,7 @@ fn handle_uid_fetch(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push_str(&fetch_line(mb, msg, include_body));
|
||||
out.push_str(&fetch_line(mb, msg, items_wanted));
|
||||
}
|
||||
|
||||
// RFC 7162 3.2.6: with CHANGEDSINCE ... VANISHED the server reports
|
||||
@@ -724,7 +899,7 @@ fn handle_uid_store(
|
||||
if !silent {
|
||||
for uid in &touched {
|
||||
if let Some(msg) = mb.messages.iter().find(|m| m.uid == *uid) {
|
||||
out.push_str(&fetch_line(mb, msg, false));
|
||||
out.push_str(&fetch_line(mb, msg, FetchItems::none()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -784,11 +959,14 @@ fn handle_uid_copy(
|
||||
};
|
||||
let present: Vec<u32> = mb.messages.iter().map(|m| m.uid).collect();
|
||||
let uids = expand_uid_set(spec, &present, mb.uid_next);
|
||||
let bodies: Vec<(u32, String, BTreeSet<String>)> = mb
|
||||
// The copies keep the source's object ids. RFC 8474 section 4 makes
|
||||
// EMAILID a property of the message, so a server that minted a new one
|
||||
// per mailbox would be reporting two messages where there is one.
|
||||
let bodies: Vec<(String, BTreeSet<String>, ObjectIds)> = mb
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| uids.contains(&m.uid))
|
||||
.map(|m| (m.uid, m.body.clone(), m.flags.clone()))
|
||||
.map(|m| (m.body.clone(), m.flags.clone(), m.object_ids.clone()))
|
||||
.collect();
|
||||
(uids, mb.uid_validity, bodies)
|
||||
};
|
||||
@@ -806,9 +984,9 @@ fn handle_uid_copy(
|
||||
let Some(dst) = st.mailboxes.get_mut(&dest) else {
|
||||
return Reply::open(format!("{tag} NO destination vanished\r\n"));
|
||||
};
|
||||
for (_, body, flags) in &bodies {
|
||||
for (body, flags, object_ids) in &bodies {
|
||||
let refs: Vec<&str> = flags.iter().map(String::as_str).collect();
|
||||
dest_uids.push(dst.append(body, &refs));
|
||||
dest_uids.push(dst.append(body, &refs, object_ids.clone()));
|
||||
}
|
||||
dst.uid_validity
|
||||
};
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Offline tests for server-assigned message identity over IMAP.
|
||||
//!
|
||||
//! The claim under test is the one ADR 0002 rests on: a message has one
|
||||
//! account-scoped key, and which protocol observed it does not change that key.
|
||||
//! RFC 8474 exists precisely so a server implementing both IMAP and JMAP
|
||||
//! reports the same object id through both, so the last test here compares an
|
||||
//! IMAP-derived key against a JMAP-derived one and requires them to be equal.
|
||||
//!
|
||||
//! Everything runs against [`MockImapServer`] over loopback and against a JMAP
|
||||
//! response parsed from a literal; no test here reaches a network.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use nuncio_core::model::IdentitySource;
|
||||
use nuncio_mail::backend::FolderChanges;
|
||||
use nuncio_mail::test_server::{IdentityExtensions, MockImapServer, ObjectIds, ServerProfile};
|
||||
use nuncio_mail::{JmapEngine, MailBackend};
|
||||
|
||||
/// Sync one folder through the real engine against the mock.
|
||||
async fn sync(server: &MockImapServer, account_id: &str, folder: &str) -> FolderChanges {
|
||||
let engine = nuncio_mail::ImapEngine::with_credentials(
|
||||
account_id,
|
||||
server.host(),
|
||||
server.port(),
|
||||
nuncio_core::TlsMode::Plain,
|
||||
"user",
|
||||
"pass",
|
||||
);
|
||||
engine
|
||||
.sync_changes(folder, None)
|
||||
.await
|
||||
.expect("folder sync succeeds")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_objectid_server_keys_messages_by_their_emailid() {
|
||||
// Tier 1. Reaching it at all is the difference between an identity the
|
||||
// server vouches for and one derived from whatever the message happened to
|
||||
// contain.
|
||||
let server =
|
||||
MockImapServer::start_with_identity(ServerProfile::Qresync, IdentityExtensions::objectid())
|
||||
.await
|
||||
.expect("server starts");
|
||||
server.create_mailbox("INBOX");
|
||||
let uid = server.append_message("INBOX", "Subject: one\r\n\r\nbody", &[]);
|
||||
|
||||
let changes = sync(&server, "acct-objectid", "INBOX").await;
|
||||
|
||||
assert_eq!(changes.upserts.len(), 1);
|
||||
assert_eq!(
|
||||
changes.upserts[0].source,
|
||||
IdentitySource::EmailId,
|
||||
"an OBJECTID server must be asked for EMAILID and the answer used"
|
||||
);
|
||||
assert_eq!(changes.upserts[0].placement.remote_id, uid.to_string());
|
||||
assert!(
|
||||
server
|
||||
.commands_matching("EMAILID")
|
||||
.iter()
|
||||
.any(|c| c.to_ascii_uppercase().contains("UID FETCH")),
|
||||
"the object id must come from a real UID FETCH, got {:?}",
|
||||
server.commands()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_same_message_in_a_second_folder_keys_identically() {
|
||||
// The property a folder-scoped surrogate can never have, and the reason
|
||||
// tier 1 is worth a separate round trip: two placements, one message.
|
||||
let server =
|
||||
MockImapServer::start_with_identity(ServerProfile::Qresync, IdentityExtensions::objectid())
|
||||
.await
|
||||
.expect("server starts");
|
||||
server.create_mailbox("INBOX");
|
||||
server.create_mailbox("Archive");
|
||||
|
||||
let shared = ObjectIds {
|
||||
email_id: "M00000042".to_string(),
|
||||
gm_msgid: 1_278_455_344_230_334_865,
|
||||
};
|
||||
let body = "Subject: filed\r\n\r\nbody";
|
||||
server.append_message_with_object_ids("INBOX", body, &[], shared.clone());
|
||||
server.append_message_with_object_ids("Archive", body, &[], shared);
|
||||
|
||||
let inbox = sync(&server, "acct-shared", "INBOX").await;
|
||||
let archive = sync(&server, "acct-shared", "Archive").await;
|
||||
|
||||
assert_eq!(inbox.upserts.len(), 1);
|
||||
assert_eq!(archive.upserts.len(), 1);
|
||||
assert_eq!(
|
||||
inbox.upserts[0].email.id, archive.upserts[0].email.id,
|
||||
"one message in two folders must be one identity"
|
||||
);
|
||||
assert_eq!(inbox.upserts[0].source, IdentitySource::EmailId);
|
||||
assert_eq!(archive.upserts[0].source, IdentitySource::EmailId);
|
||||
assert_ne!(
|
||||
inbox.upserts[0].placement, archive.upserts[0].placement,
|
||||
"the two occupancies are still distinct; only the message is shared"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_gmail_shaped_server_keys_messages_by_x_gm_msgid() {
|
||||
// Gmail advertises X-GM-EXT-1 and not OBJECTID, so tier 2 is the best it
|
||||
// offers -- and it is reachable through the typed client, with no extra
|
||||
// round trip, because `imap-proto` can parse that attribute.
|
||||
let server =
|
||||
MockImapServer::start_with_identity(ServerProfile::Condstore, IdentityExtensions::gmail())
|
||||
.await
|
||||
.expect("server starts");
|
||||
server.create_mailbox("INBOX");
|
||||
server.append_message("INBOX", "Subject: gmail\r\n\r\nbody", &[]);
|
||||
|
||||
let changes = sync(&server, "acct-gmail", "INBOX").await;
|
||||
|
||||
assert_eq!(changes.upserts.len(), 1);
|
||||
assert_eq!(
|
||||
changes.upserts[0].source,
|
||||
IdentitySource::GmailMsgId,
|
||||
"X-GM-EXT-1 must lift identity to the Gmail message id"
|
||||
);
|
||||
assert!(
|
||||
server.commands_matching("EMAILID").is_empty(),
|
||||
"a server without OBJECTID must never be asked for EMAILID, got {:?}",
|
||||
server.commands()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_server_advertising_neither_extension_is_left_exactly_as_it_was() {
|
||||
// The regression that matters most: capability negotiation must be
|
||||
// invisible to a server that has neither extension. No new command, and
|
||||
// the same tier it reached before any of this existed.
|
||||
for profile in [
|
||||
ServerProfile::Qresync,
|
||||
ServerProfile::Condstore,
|
||||
ServerProfile::Basic,
|
||||
] {
|
||||
let server = MockImapServer::start(profile).await.expect("server starts");
|
||||
server.create_mailbox("INBOX");
|
||||
server.append_message("INBOX", "Subject: plain\r\n\r\nbody", &[]);
|
||||
|
||||
let changes = sync(&server, "acct-plain", "INBOX").await;
|
||||
|
||||
assert_eq!(changes.upserts.len(), 1, "{profile:?}");
|
||||
assert_eq!(
|
||||
changes.upserts[0].source,
|
||||
IdentitySource::Surrogate,
|
||||
"{profile:?}: with no server id and no octets, the surrogate is the honest answer"
|
||||
);
|
||||
assert!(
|
||||
server.commands_matching("EMAILID").is_empty(),
|
||||
"{profile:?}: no EMAILID command may be issued, got {:?}",
|
||||
server.commands()
|
||||
);
|
||||
assert!(
|
||||
server.commands_matching("X-GM-MSGID").is_empty(),
|
||||
"{profile:?}: no X-GM-MSGID item may be requested, got {:?}",
|
||||
server.commands()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_imap_emailid_and_a_jmap_object_id_produce_the_same_key() {
|
||||
// The convergence assertion. One account, one message, one server-assigned
|
||||
// id -- reported as RFC 8474 EMAILID over IMAP and as the Email object id
|
||||
// over JMAP. If either engine derived its key from a different tier, or
|
||||
// hashed under a different domain label, these two strings would differ.
|
||||
const ACCOUNT: &str = "acct-converge";
|
||||
const OBJECT_ID: &str = "M00000001";
|
||||
|
||||
let server =
|
||||
MockImapServer::start_with_identity(ServerProfile::Qresync, IdentityExtensions::objectid())
|
||||
.await
|
||||
.expect("server starts");
|
||||
server.create_mailbox("INBOX");
|
||||
server.append_message_with_object_ids(
|
||||
"INBOX",
|
||||
"Subject: both\r\n\r\nbody",
|
||||
&[],
|
||||
ObjectIds {
|
||||
email_id: OBJECT_ID.to_string(),
|
||||
gm_msgid: 1,
|
||||
},
|
||||
);
|
||||
let imap = sync(&server, ACCOUNT, "INBOX").await;
|
||||
|
||||
let jmap_response = format!(
|
||||
r#"{{"methodResponses":[["Email/get",{{"accountId":"{ACCOUNT}",
|
||||
"state":"s-1","list":[{{"id":"{OBJECT_ID}","subject":"both",
|
||||
"from":[{{"email":"a@nuncio.mx"}}],"to":[{{"email":"b@nuncio.mx"}}],
|
||||
"receivedAt":1700001000,"isUnread":true}}]}},"c1"]]}}"#
|
||||
);
|
||||
let (jmap, _state) = JmapEngine::new(ACCOUNT)
|
||||
.parse_email_get_response(&jmap_response)
|
||||
.expect("the JMAP response parses");
|
||||
|
||||
assert_eq!(imap.upserts.len(), 1);
|
||||
assert_eq!(jmap.len(), 1);
|
||||
assert_eq!(
|
||||
imap.upserts[0].source,
|
||||
IdentitySource::EmailId,
|
||||
"the IMAP side must have entered at the object-id tier"
|
||||
);
|
||||
assert_eq!(
|
||||
jmap[0].source,
|
||||
IdentitySource::EmailId,
|
||||
"the JMAP side must have entered at the object-id tier"
|
||||
);
|
||||
assert_eq!(
|
||||
imap.upserts[0].email.id, jmap[0].email.id,
|
||||
"two engines syncing one account must agree on what the message is"
|
||||
);
|
||||
|
||||
// Equality alone would survive both engines moving to a different tier or
|
||||
// a different domain label together, since they share one derivation. The
|
||||
// golden pins the actual value: SHA-256 over the length-prefixed
|
||||
// (tier, account, object id) triple. It is a tripwire, not a spec -- if it
|
||||
// moves, every stored message_key has moved with it, and
|
||||
// `IDENTITY_SCHEMA_VERSION` must move in the same commit.
|
||||
const EXPECTED_KEY: &str = "ba2eb228c6b973c548837e32b84e95cf06b73664b3e350f5dfa30614b2e38bd0";
|
||||
assert_eq!(
|
||||
imap.upserts[0].email.id, EXPECTED_KEY,
|
||||
"the object-id tier's derivation changed; bump IDENTITY_SCHEMA_VERSION with it"
|
||||
);
|
||||
|
||||
// And the two engines still describe *where* it sits in their own terms:
|
||||
// convergence is a claim about identity, never about placement.
|
||||
assert_ne!(imap.upserts[0].placement, jmap[0].placement);
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
use nuncio_core::model::PlacementKey;
|
||||
use nuncio_mail::imap_raw;
|
||||
use nuncio_mail::test_server::{MockImapServer, ServerProfile};
|
||||
use nuncio_mail::test_server::{IdentityExtensions, MockImapServer, ObjectIds, ServerProfile};
|
||||
|
||||
/// Connect the real client to the mock server and authenticate.
|
||||
async fn login(
|
||||
@@ -301,6 +301,89 @@ async fn a_qresync_select_without_enable_is_refused() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_ids_are_read_off_the_transport_and_the_session_survives_it() {
|
||||
// The whole reason this bypasses the client: `imap-proto` has no parser arm
|
||||
// for EMAILID, and `async-imap` turns a parse error into a fatal I/O error
|
||||
// that latches the stream closed. So the test that matters is not only that
|
||||
// the ids come back, but that the *typed* client still works afterwards --
|
||||
// proving the raw exchange left the connection and the client's read buffer
|
||||
// in a state the next command can use.
|
||||
let server =
|
||||
MockImapServer::start_with_identity(ServerProfile::Qresync, IdentityExtensions::objectid())
|
||||
.await
|
||||
.expect("server starts");
|
||||
server.create_mailbox("INBOX");
|
||||
let first = server.append_message_with_object_ids(
|
||||
"INBOX",
|
||||
"Subject: one\r\n\r\nx",
|
||||
&[],
|
||||
ObjectIds {
|
||||
email_id: "M-first".to_string(),
|
||||
gm_msgid: 11,
|
||||
},
|
||||
);
|
||||
let second = server.append_message_with_object_ids(
|
||||
"INBOX",
|
||||
"Subject: two\r\n\r\nx",
|
||||
&[],
|
||||
ObjectIds {
|
||||
email_id: "M-second".to_string(),
|
||||
gm_msgid: 12,
|
||||
},
|
||||
);
|
||||
|
||||
let mut session = login(&server).await;
|
||||
imap_raw::run_collected(&mut session, "SELECT INBOX")
|
||||
.await
|
||||
.expect("select");
|
||||
|
||||
let ids = imap_raw::uid_fetch_email_ids(&mut session, &format!("{first},{second}"))
|
||||
.await
|
||||
.expect("the raw EMAILID fetch succeeds");
|
||||
|
||||
assert_eq!(ids.get(&first).map(String::as_str), Some("M-first"));
|
||||
assert_eq!(ids.get(&second).map(String::as_str), Some("M-second"));
|
||||
|
||||
// The session is still alive: a typed command after the raw exchange must
|
||||
// still see the mailbox.
|
||||
let after = imap_raw::run_collected(&mut session, "UID FETCH 1:* (UID)")
|
||||
.await
|
||||
.expect("the session survives the raw exchange");
|
||||
assert_eq!(after.fetched_uids, vec![first, second]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_object_id_fetch_costs_precision_not_the_sync() {
|
||||
// A server that rejects the item leaves the message identifiable by a
|
||||
// weaker tier. Erroring here would fail an entire folder sync over an
|
||||
// identity *upgrade*, which is the wrong trade in both directions.
|
||||
let server = MockImapServer::start(ServerProfile::Qresync)
|
||||
.await
|
||||
.expect("server starts");
|
||||
server.create_mailbox("INBOX");
|
||||
let uid = server.append_message("INBOX", "Subject: one\r\n\r\nx", &[]);
|
||||
|
||||
let mut session = login(&server).await;
|
||||
imap_raw::run_collected(&mut session, "SELECT INBOX")
|
||||
.await
|
||||
.expect("select");
|
||||
|
||||
let ids = imap_raw::uid_fetch_email_ids(&mut session, &uid.to_string())
|
||||
.await
|
||||
.expect("a BAD answer is not a transport failure");
|
||||
assert!(
|
||||
ids.is_empty(),
|
||||
"nothing was learned, and that is the answer"
|
||||
);
|
||||
|
||||
// And the connection is still usable, which a `BAD` must not change.
|
||||
let after = imap_raw::run_collected(&mut session, "UID FETCH 1:* (UID)")
|
||||
.await
|
||||
.expect("the session survives a refusal");
|
||||
assert_eq!(after.fetched_uids, vec![uid]);
|
||||
}
|
||||
|
||||
/// Drive a real folder sync against the mock and return what it observed.
|
||||
async fn sync_folder(
|
||||
server: &MockImapServer,
|
||||
|
||||
@@ -628,7 +628,17 @@ impl DatabaseEngine {
|
||||
///
|
||||
/// Bump this only for a change of that kind, and only together with the
|
||||
/// reset it implies (see [`DatabaseEngine::reconcile_schema_version`]).
|
||||
pub const IDENTITY_SCHEMA_VERSION: i64 = 2;
|
||||
///
|
||||
/// Generation 3 is where the IMAP engine began asking for RFC 8474
|
||||
/// `EMAILID` and `X-GM-MSGID`. Every message a capable server holds now
|
||||
/// derives its key from a stronger identity tier, so no key stored under
|
||||
/// generation 2 still names the message it named. Letting that happen a row
|
||||
/// at a time -- as each resync repointed a placement -- would work, but the
|
||||
/// repoint reap is a safety net against stranded plaintext, not a migration
|
||||
/// mechanism, and it would leave the store in two identity models for as
|
||||
/// long as any folder went unsynced. Rebuilding once is the honest form of
|
||||
/// the same change.
|
||||
pub const IDENTITY_SCHEMA_VERSION: i64 = 3;
|
||||
|
||||
/// Reconcile the on-disk identity-schema generation with this binary's.
|
||||
///
|
||||
|
||||
@@ -175,6 +175,24 @@ sound without a sequence number and resumption.
|
||||
Precedence: `EMAILID` (RFC 8474 OBJECTID) → `X-GM-MSGID` → `(account_id,
|
||||
normalized Message-ID, content_hash)` → `(account, folder, uidvalidity, uid)`.
|
||||
|
||||
**Both engines negotiate the top two tiers, so keys converge exactly where the
|
||||
server publishes an object id.** The IMAP engine requests `EMAILID` from a
|
||||
server advertising `OBJECTID` and `X-GM-MSGID` from one advertising
|
||||
`X-GM-EXT-1`; JMAP's Email object id is the same identifier RFC 8474 exposes to
|
||||
IMAP. One message synced over both protocols therefore lands on one key, which
|
||||
is what makes the multi-engine model's identity claim true across protocols and
|
||||
not only within one.
|
||||
|
||||
Be precise about where it stops. Against a server advertising neither
|
||||
extension, IMAP reaches only the Message-ID+content tier or the folder-scoped
|
||||
surrogate, while a JMAP engine on the same account still enters at the top tier
|
||||
from a server-assigned id — those key spaces are disjoint and cross-engine
|
||||
comparison means nothing there. Object ids are unique within one account on one
|
||||
server (RFC 8474 §4), never globally, which is why every tier is
|
||||
account-scoped. And `EMAILID` cannot be requested through the typed IMAP client
|
||||
at all: `imap-proto` has no parser arm for the attribute and a parse failure is
|
||||
fatal to the connection, so it is read through the raw command layer instead.
|
||||
|
||||
Bodies are keyed by a content hash of the full RFC822 octets, first-write-wins.
|
||||
**A Message-ID hash alone must not be the key**: Message-IDs are public, echoed
|
||||
in every `References:` header, and forgeable, so with `INSERT OR REPLACE` a
|
||||
@@ -238,7 +256,11 @@ work is chunked per folder so it yields to user actions.
|
||||
- **`async-imap` 0.11.3 cannot express any of this**: no `ENABLE`, its `SELECT`
|
||||
parser drops `VANISHED`, and `check_status_ok` discards the tagged response
|
||||
code where `COPYUID` and `[MODIFIED …]` live. A fork-versus-hand-rolled
|
||||
decision gates the protocol work.
|
||||
decision gates the protocol work. `EMAILID` is worse than discarded:
|
||||
`imap-proto`'s `msg_att` is a closed alternation with no catch-all, and the
|
||||
decoder turns the resulting parse error into a fatal I/O error that latches
|
||||
the connection closed — so the object id must be read off the transport
|
||||
underneath the client, never through it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
Reference in new issue
Block a user