use super::*;
use crate::types::response::Capability;
use crate::types::validated::{MailboxName, SequenceSet};
fn opts(literal_mode: LiteralMode, utf8_mode: bool) -> EncodeOptions {
EncodeOptions {
utf8_mode,
literal_mode,
capabilities: vec![
Capability::Imap4Rev1,
Capability::StartTls,
Capability::Idle,
Capability::Enable,
Capability::Condstore,
Capability::QResync,
Capability::Move,
Capability::Unselect,
Capability::Unauthenticate,
Capability::Namespace,
Capability::Id,
Capability::Metadata,
Capability::CompressDeflate,
Capability::Quota,
Capability::QuotaSet,
Capability::Acl,
Capability::Notify,
Capability::UidPlus,
Capability::CreateSpecialUse,
Capability::LiteralPlus,
Capability::LiteralMinus,
Capability::Sort,
],
}
}
fn default_opts() -> EncodeOptions {
opts(LiteralMode::Synchronizing, false)
}
#[test]
fn encode_simple_command() {
let mut buf = BytesMut::new();
encode_simple(&mut buf, "A001", "NOOP");
assert_eq!(&buf[..], b"A001 NOOP\r\n");
}
#[test]
fn encode_login_simple() {
let mut buf = BytesMut::new();
encode_login(
&mut buf,
"A001",
"user",
"pass",
false,
LiteralMode::Synchronizing,
)
.unwrap();
assert_eq!(&buf[..], b"A001 LOGIN \"user\" \"pass\"\r\n");
}
#[test]
fn encode_login_special_chars() {
let mut buf = BytesMut::new();
encode_login(
&mut buf,
"A001",
"user",
r#"p"a\ss"#,
false,
LiteralMode::Synchronizing,
)
.unwrap();
assert_eq!(&buf[..], b"A001 LOGIN \"user\" \"p\\\"a\\\\ss\"\r\n");
}
#[test]
fn encode_quoted_string() {
let mut buf = BytesMut::new();
encode_quoted_or_literal(&mut buf, b"hello world", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"\"hello world\"");
}
#[test]
fn encode_literal_for_binary() {
let mut buf = BytesMut::new();
encode_quoted_or_literal(&mut buf, b"line1\r\nline2", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"{12}\r\nline1\r\nline2");
}
#[test]
fn encode_select() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: None,
};
encode_command_to_buf(&mut buf, "A002", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A002 SELECT \"INBOX\"\r\n");
}
#[test]
fn encode_select_qresync() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: None,
seq_match_data: None,
}),
};
encode_command_to_buf(&mut buf, "A005", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A005 SELECT \"INBOX\" (QRESYNC (67890 12345))\r\n"
);
}
#[test]
fn encode_select_qresync_with_known_uids() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: Some("1:500".into()),
seq_match_data: None,
}),
};
encode_command_to_buf(&mut buf, "A006", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A006 SELECT \"INBOX\" (QRESYNC (67890 12345 1:500))\r\n"
);
}
#[test]
fn encode_examine_qresync() {
let mut buf = BytesMut::new();
let cmd = Command::Examine {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 100,
mod_seq: 50,
known_uids: None,
seq_match_data: None,
}),
};
encode_command_to_buf(&mut buf, "A007", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A007 EXAMINE \"INBOX\" (QRESYNC (100 50))\r\n");
}
#[test]
fn encode_select_qresync_rejects_zero_uid_validity() {
let cmd = Command::Select {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 0,
mod_seq: 1,
known_uids: None,
seq_match_data: None,
}),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"QRESYNC uid_validity=0 must be rejected per RFC 3501 Section 9 (nz-number)"
);
}
#[test]
fn encode_examine_qresync_rejects_zero_uid_validity() {
let cmd = Command::Examine {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 0,
mod_seq: 1,
known_uids: None,
seq_match_data: None,
}),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"QRESYNC uid_validity=0 must be rejected per RFC 3501 Section 9 (nz-number)"
);
}
#[test]
fn encode_uid_fetch() {
let mut buf = BytesMut::new();
let cmd = Command::UidFetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "(UID FLAGS ENVELOPE)".into(),
changed_since: None,
vanished: false,
};
encode_command_to_buf(&mut buf, "A003", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A003 UID FETCH 1:* (UID FLAGS ENVELOPE)\r\n");
}
#[test]
fn encode_store_add_flags() {
let mut buf = BytesMut::new();
let cmd = Command::UidStore {
sequence_set: SequenceSet::new("1:3").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen, crate::types::Flag::Flagged],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A004", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A004 UID STORE 1:3 +FLAGS (\\Seen \\Flagged)\r\n"
);
}
#[test]
fn encode_store_with_condstore() {
let mut buf = BytesMut::new();
let cmd = Command::UidStore {
sequence_set: SequenceSet::new("5").unwrap(),
operation: crate::types::StoreOperation::Remove,
flags: vec![crate::types::Flag::Deleted],
unchanged_since: Some(12345),
};
encode_command_to_buf(&mut buf, "A005", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A005 UID STORE 5 (UNCHANGEDSINCE 12345) -FLAGS (\\Deleted)\r\n"
);
}
#[test]
fn encode_authenticate_with_sasl_ir() {
let mut buf = BytesMut::new();
let cmd = Command::Authenticate {
mechanism: "XOAUTH2".into(),
initial_response: Some("dXNlcj1hQGIuY29tAWF1dGg9QmVhcmVyIHRva2VuAQE=".into()),
};
encode_command_to_buf(&mut buf, "A006", &cmd, &default_opts()).unwrap();
let expected = b"A006 AUTHENTICATE XOAUTH2 dXNlcj1hQGIuY29tAWF1dGg9QmVhcmVyIHRva2VuAQE=\r\n";
assert_eq!(&buf[..], &expected[..]);
}
#[test]
fn encode_authenticate_without_sasl_ir() {
let mut buf = BytesMut::new();
let cmd = Command::Authenticate {
mechanism: "PLAIN".into(),
initial_response: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 AUTHENTICATE PLAIN\r\n");
}
#[test]
fn encode_authenticate_rejects_invalid_mechanism() {
let mut buf = BytesMut::new();
let cmd = Command::Authenticate {
mechanism: "PLAIN LOGIN".into(), initial_response: None,
};
assert!(
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).is_err(),
"Mechanism with space must be rejected (RFC 3501 Section 6.2.2: auth-type = atom)"
);
let mut buf = BytesMut::new();
let cmd = Command::Authenticate {
mechanism: String::new(),
initial_response: None,
};
assert!(
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).is_err(),
"Empty mechanism must be rejected"
);
}
#[test]
fn encode_starttls() {
let mut buf = BytesMut::new();
encode_command_to_buf(&mut buf, "A001", &Command::StartTls, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 STARTTLS\r\n");
}
#[test]
fn encode_logout() {
let mut buf = BytesMut::new();
encode_command_to_buf(&mut buf, "A001", &Command::Logout, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 LOGOUT\r\n");
}
#[test]
fn encode_capability() {
let mut buf = BytesMut::new();
encode_command_to_buf(&mut buf, "A001", &Command::Capability, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 CAPABILITY\r\n");
}
#[test]
fn encode_noop() {
let mut buf = BytesMut::new();
encode_command_to_buf(&mut buf, "A001", &Command::Noop, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 NOOP\r\n");
}
#[test]
fn encode_expunge() {
let mut buf = BytesMut::new();
encode_command_to_buf(&mut buf, "A001", &Command::Expunge, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 EXPUNGE\r\n");
}
#[test]
fn encode_close() {
let mut buf = BytesMut::new();
encode_command_to_buf(&mut buf, "A001", &Command::Close, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 CLOSE\r\n");
}
#[test]
fn encode_idle() {
let mut buf = BytesMut::new();
encode_command_to_buf(&mut buf, "A001", &Command::Idle, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 IDLE\r\n");
}
#[test]
fn encode_examine() {
let mut buf = BytesMut::new();
let cmd = Command::Examine {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 EXAMINE \"INBOX\"\r\n");
}
#[test]
fn encode_create() {
let mut buf = BytesMut::new();
let cmd = Command::Create {
mailbox: MailboxName::new("Archive").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 CREATE \"Archive\"\r\n");
}
#[test]
fn encode_create_special_use_single() {
let mut buf = BytesMut::new();
let cmd = Command::CreateSpecialUse {
mailbox: MailboxName::new("Sent").unwrap(),
special_use: vec![crate::types::MailboxAttribute::Sent],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 CREATE \"Sent\" (USE (\\Sent))\r\n");
}
#[test]
fn encode_create_special_use_multiple() {
let mut buf = BytesMut::new();
let cmd = Command::CreateSpecialUse {
mailbox: MailboxName::new("Important Sent").unwrap(),
special_use: vec![
crate::types::MailboxAttribute::Sent,
crate::types::MailboxAttribute::Important,
],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 CREATE \"Important Sent\" (USE (\\Sent \\Important))\r\n"
);
}
#[test]
fn encode_create_special_use_special_char_mailbox() {
let mut buf = BytesMut::new();
let cmd = Command::CreateSpecialUse {
mailbox: MailboxName::new("My Drafts").unwrap(),
special_use: vec![crate::types::MailboxAttribute::Drafts],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 CREATE \"My Drafts\" (USE (\\Drafts))\r\n");
}
#[test]
fn encode_create_special_use_empty() {
let mut buf = BytesMut::new();
let cmd = Command::CreateSpecialUse {
mailbox: MailboxName::new("Test").unwrap(),
special_use: vec![],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 CREATE \"Test\" (USE ())\r\n");
}
#[test]
fn encode_delete() {
let mut buf = BytesMut::new();
let cmd = Command::Delete {
mailbox: MailboxName::new("OldMail").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 DELETE \"OldMail\"\r\n");
}
#[test]
fn encode_rename() {
let mut buf = BytesMut::new();
let cmd = Command::Rename {
mailbox: MailboxName::new("OldName").unwrap(),
new_name: MailboxName::new("NewName").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 RENAME \"OldName\" \"NewName\"\r\n");
}
#[test]
fn encode_list() {
let mut buf = BytesMut::new();
let cmd = Command::List {
reference: String::new(),
pattern: "*".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 LIST \"\" \"*\"\r\n");
}
#[test]
fn encode_list_extended_multiple_patterns_with_return_options() {
let mut buf = BytesMut::new();
let cmd = Command::ListExtended {
selection_options: vec!["SUBSCRIBED".into(), "RECURSIVEMATCH".into()],
reference: String::new(),
patterns: vec!["*".into(), "%".into()],
return_options: vec!["CHILDREN".into(), "STATUS (MESSAGES UNSEEN)".into()],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 LIST (SUBSCRIBED RECURSIVEMATCH) \"\" (\"*\" \"%\") RETURN (CHILDREN STATUS (MESSAGES UNSEEN))\r\n"
);
}
#[test]
fn encode_list_extended_rejects_empty_pattern_list() {
let mut buf = BytesMut::new();
let cmd = Command::ListExtended {
selection_options: Vec::new(),
reference: String::new(),
patterns: Vec::new(),
return_options: Vec::new(),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
matches!(result, Err(EncodeError::Validation(ref msg)) if msg.contains("RFC 5258 Section 3")),
"empty LIST-EXTENDED patterns must be rejected per RFC 5258 Section 3 / RFC 9051 Section 6.3.9: {result:?}"
);
}
#[test]
fn encode_list_extended_trims_outer_option_whitespace() {
let mut buf = BytesMut::new();
let cmd = Command::ListExtended {
selection_options: vec![" SUBSCRIBED ".into(), "\tRECURSIVEMATCH\t".into()],
reference: String::new(),
patterns: vec!["*".into()],
return_options: vec![" CHILDREN ".into(), " STATUS (MESSAGES) ".into()],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 LIST (SUBSCRIBED RECURSIVEMATCH) \"\" \"*\" RETURN (CHILDREN STATUS (MESSAGES))\r\n"
);
}
#[test]
fn encode_list_extended_rejects_recursivematch_without_base_option() {
let mut buf = BytesMut::new();
let cmd = Command::ListExtended {
selection_options: vec!["RECURSIVEMATCH".into()],
reference: String::new(),
patterns: vec!["*".into()],
return_options: Vec::new(),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
matches!(result, Err(EncodeError::Validation(ref msg)) if msg.contains("RECURSIVEMATCH")),
"LIST-EXTENDED must reject bare RECURSIVEMATCH per RFC 5258 Section 3 / RFC 9051 Section 6.3.9: {result:?}"
);
}
#[test]
fn encode_list_extended_rejects_bare_status_return_option() {
let mut buf = BytesMut::new();
let cmd = Command::ListExtended {
selection_options: vec!["SUBSCRIBED".into()],
reference: String::new(),
patterns: vec!["*".into()],
return_options: vec!["STATUS".into()],
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
matches!(result, Err(EncodeError::Validation(ref msg)) if msg.contains("STATUS (") || msg.contains("STATUS (<items>)")),
"LIST-EXTENDED must reject bare STATUS return option per RFC 5819 Section 4 / RFC 9051 Section 7: {result:?}"
);
}
#[test]
fn encode_status() {
let mut buf = BytesMut::new();
let cmd = Command::Status {
mailbox: MailboxName::new("INBOX").unwrap(),
items: "(MESSAGES UNSEEN)".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 STATUS \"INBOX\" (MESSAGES UNSEEN)\r\n");
}
#[test]
fn encode_uid_search() {
let mut buf = BytesMut::new();
let cmd = Command::UidSearch {
criteria: "ALL".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 UID SEARCH ALL\r\n");
}
#[test]
fn encode_uid_move() {
let mut buf = BytesMut::new();
let cmd = Command::UidMove {
sequence_set: SequenceSet::new("1:5").unwrap(),
mailbox: MailboxName::new("Trash").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 UID MOVE 1:5 \"Trash\"\r\n");
}
#[test]
fn encode_uid_copy() {
let mut buf = BytesMut::new();
let cmd = Command::UidCopy {
sequence_set: SequenceSet::new("10:20").unwrap(),
mailbox: MailboxName::new("Archive").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 UID COPY 10:20 \"Archive\"\r\n");
}
#[test]
fn encode_uid_expunge() {
let mut buf = BytesMut::new();
let cmd = Command::UidExpunge {
sequence_set: SequenceSet::new("1:3").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 UID EXPUNGE 1:3\r\n");
}
#[test]
fn encode_id() {
let mut buf = BytesMut::new();
let cmd = Command::Id(vec![
("name".into(), Some("myapp".into())),
("version".into(), Some("1.0".into())),
]);
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 ID (\"name\" \"myapp\" \"version\" \"1.0\")\r\n"
);
}
#[test]
fn encode_store_replace_flags() {
let mut buf = BytesMut::new();
let cmd = Command::UidStore {
sequence_set: SequenceSet::new("42").unwrap(),
operation: crate::types::StoreOperation::Replace,
flags: vec![crate::types::Flag::Seen],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 UID STORE 42 FLAGS (\\Seen)\r\n");
}
#[test]
fn encode_uid_store_add_silent() {
let mut buf = BytesMut::new();
let cmd = Command::UidStore {
sequence_set: SequenceSet::new("1:3").unwrap(),
operation: crate::types::StoreOperation::AddSilent,
flags: vec![crate::types::Flag::Seen],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 UID STORE 1:3 +FLAGS.SILENT (\\Seen)\r\n");
}
#[test]
fn encode_uid_store_remove_silent() {
let mut buf = BytesMut::new();
let cmd = Command::UidStore {
sequence_set: SequenceSet::new("5").unwrap(),
operation: crate::types::StoreOperation::RemoveSilent,
flags: vec![crate::types::Flag::Deleted],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 UID STORE 5 -FLAGS.SILENT (\\Deleted)\r\n");
}
#[test]
fn encode_store_replace_silent() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("10").unwrap(),
operation: crate::types::StoreOperation::ReplaceSilent,
flags: vec![crate::types::Flag::Flagged],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 STORE 10 FLAGS.SILENT (\\Flagged)\r\n");
}
#[test]
fn encode_uid_store_move_fallback_uses_silent() {
let mut buf = BytesMut::new();
let cmd = Command::UidStore {
sequence_set: SequenceSet::new("1:5").unwrap(),
operation: crate::types::StoreOperation::AddSilent,
flags: vec![crate::types::Flag::Deleted],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 UID STORE 1:5 +FLAGS.SILENT (\\Deleted)\r\n",
"MOVE fallback must use +FLAGS.SILENT per RFC 6851 Section 3.3"
);
}
#[test]
fn encode_quoted_with_nul_strips_nul_and_quotes() {
let mut buf = BytesMut::new();
encode_quoted_or_literal(&mut buf, b"has\0nul", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"\"hasnul\"");
}
#[test]
fn encode_non_ascii_falls_back_to_literal() {
let mut buf = BytesMut::new();
encode_quoted_or_literal(&mut buf, "café".as_bytes(), LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"{5}\r\ncaf\xc3\xa9");
}
#[test]
fn encode_mailbox_with_special_chars() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: MailboxName::new(r#"folder"name"#).unwrap(),
condstore: false,
qresync: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 SELECT \"folder\\\"name\"\r\n");
}
#[test]
fn encode_subscribe() {
let mut buf = BytesMut::new();
let cmd = Command::Subscribe {
mailbox: MailboxName::new("INBOX.Sent").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 SUBSCRIBE \"INBOX.Sent\"\r\n");
}
#[test]
fn encode_unsubscribe() {
let mut buf = BytesMut::new();
let cmd = Command::Unsubscribe {
mailbox: MailboxName::new("INBOX.Old").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 UNSUBSCRIBE \"INBOX.Old\"\r\n");
}
#[test]
fn encode_lsub() {
let mut buf = BytesMut::new();
let cmd = Command::Lsub {
reference: String::new(),
pattern: "*".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 LSUB \"\" \"*\"\r\n");
}
#[test]
fn encode_search() {
let mut buf = BytesMut::new();
let cmd = Command::Search {
criteria: "UNSEEN".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 SEARCH UNSEEN\r\n");
}
#[test]
fn encode_search_allows_literal_criteria() {
let mut buf = BytesMut::new();
let cmd = Command::Search {
criteria: "BODY {12}\r\nhello MODSEQ".into(),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"SEARCH criteria literals must be accepted per RFC 3501 Sections 4.3 and 6.4.4: {result:?}"
);
assert_eq!(&buf[..], b"A001 SEARCH BODY {12}\r\nhello MODSEQ\r\n");
}
#[test]
fn encode_fetch() {
let mut buf = BytesMut::new();
let cmd = Command::Fetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "(FLAGS)".into(),
changed_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 FETCH 1:* (FLAGS)\r\n");
}
#[test]
fn encode_store() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1:3").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 STORE 1:3 +FLAGS (\\Seen)\r\n");
}
#[test]
fn encode_copy() {
let mut buf = BytesMut::new();
let cmd = Command::Copy {
sequence_set: SequenceSet::new("1:5").unwrap(),
mailbox: MailboxName::new("Archive").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 COPY 1:5 \"Archive\"\r\n");
}
#[test]
fn encode_namespace() {
let mut buf = BytesMut::new();
encode_command_to_buf(&mut buf, "A001", &Command::Namespace, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 NAMESPACE\r\n");
}
#[test]
fn encode_check() {
let mut buf = BytesMut::new();
encode_command_to_buf(&mut buf, "A001", &Command::Check, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 CHECK\r\n");
}
#[test]
fn encode_search_save() {
let mut buf = BytesMut::new();
let cmd = Command::SearchSave {
criteria: "UNSEEN".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 SEARCH RETURN (SAVE) UNSEEN\r\n");
}
#[test]
fn encode_uid_search_save() {
let mut buf = BytesMut::new();
let cmd = Command::UidSearchSave {
criteria: "ALL".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 UID SEARCH RETURN (SAVE) ALL\r\n");
}
#[test]
fn encode_search_return_min_max_count() {
let mut buf = BytesMut::new();
let cmd = Command::SearchReturn {
criteria: "UNSEEN".into(),
return_opts: vec!["MIN".into(), "MAX".into(), "COUNT".into()],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 SEARCH RETURN (MIN MAX COUNT) UNSEEN\r\n");
}
#[test]
fn encode_uid_search_return_all() {
let mut buf = BytesMut::new();
let cmd = Command::UidSearchReturn {
criteria: "ALL".into(),
return_opts: vec!["ALL".into(), "COUNT".into()],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 UID SEARCH RETURN (ALL COUNT) ALL\r\n");
}
#[test]
fn encode_search_return_empty_opts() {
let mut buf = BytesMut::new();
let cmd = Command::SearchReturn {
criteria: "FLAGGED".into(),
return_opts: vec![],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 SEARCH RETURN () FLAGGED\r\n");
}
#[test]
fn encode_search_return_trims_outer_option_whitespace() {
let mut buf = BytesMut::new();
let cmd = Command::SearchReturn {
criteria: "UNSEEN".into(),
return_opts: vec![" MIN ".into(), "\tCOUNT\t".into()],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 SEARCH RETURN (MIN COUNT) UNSEEN\r\n");
}
#[test]
fn encode_search_return_rejects_embedded_whitespace_in_option() {
let mut buf = BytesMut::new();
let cmd = Command::SearchReturn {
criteria: "UNSEEN".into(),
return_opts: vec!["MIN COUNT".into()],
};
let err = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts())
.expect_err("SEARCH RETURN option with embedded whitespace must be rejected");
assert!(
matches!(err, EncodeError::Validation(ref message) if message.contains("SEARCH RETURN option")),
"expected Protocol error mentioning SEARCH RETURN option, got {err:?}"
);
}
#[test]
fn encode_list_status() {
let mut buf = BytesMut::new();
let cmd = Command::ListStatus {
reference: String::new(),
pattern: "*".into(),
status_items: "MESSAGES UNSEEN".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 LIST \"\" \"*\" RETURN (STATUS (MESSAGES UNSEEN))\r\n"
);
}
#[test]
fn encode_list_status_with_reference() {
let mut buf = BytesMut::new();
let cmd = Command::ListStatus {
reference: "INBOX".into(),
pattern: "%".into(),
status_items: "MESSAGES RECENT UNSEEN".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 LIST \"INBOX\" \"%\" RETURN (STATUS (MESSAGES RECENT UNSEEN))\r\n"
);
}
#[test]
fn encode_get_quota() {
let mut buf = BytesMut::new();
let cmd = Command::GetQuota {
root: String::new(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 GETQUOTA \"\"\r\n");
}
#[test]
fn encode_get_quota_named_root() {
let mut buf = BytesMut::new();
let cmd = Command::GetQuota {
root: "user.alice".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 GETQUOTA \"user.alice\"\r\n");
}
#[test]
fn encode_get_quota_root() {
let mut buf = BytesMut::new();
let cmd = Command::GetQuotaRoot {
mailbox: MailboxName::new("INBOX").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 GETQUOTAROOT \"INBOX\"\r\n");
}
#[test]
fn encode_get_quota_root_folder() {
let mut buf = BytesMut::new();
let cmd = Command::GetQuotaRoot {
mailbox: MailboxName::new("INBOX.Drafts").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 GETQUOTAROOT \"INBOX.Drafts\"\r\n");
}
#[test]
fn encode_setacl() {
let mut buf = BytesMut::new();
let cmd = Command::SetAcl {
mailbox: MailboxName::new("INBOX").unwrap(),
identifier: "fred".into(),
rights: "lrswipcda".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 SETACL \"INBOX\" \"fred\" \"lrswipcda\"\r\n"
);
}
#[test]
fn encode_deleteacl() {
let mut buf = BytesMut::new();
let cmd = Command::DeleteAcl {
mailbox: MailboxName::new("INBOX").unwrap(),
identifier: "fred".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 DELETEACL \"INBOX\" \"fred\"\r\n");
}
#[test]
fn encode_getacl() {
let mut buf = BytesMut::new();
let cmd = Command::GetAcl {
mailbox: MailboxName::new("INBOX").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 GETACL \"INBOX\"\r\n");
}
#[test]
fn encode_listrights() {
let mut buf = BytesMut::new();
let cmd = Command::ListRights {
mailbox: MailboxName::new("INBOX").unwrap(),
identifier: "fred".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 LISTRIGHTS \"INBOX\" \"fred\"\r\n");
}
#[test]
fn encode_myrights() {
let mut buf = BytesMut::new();
let cmd = Command::MyRights {
mailbox: MailboxName::new("INBOX").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 MYRIGHTS \"INBOX\"\r\n");
}
#[test]
fn encode_setacl_with_special_chars() {
let mut buf = BytesMut::new();
let cmd = Command::SetAcl {
mailbox: MailboxName::new("Shared Folders").unwrap(),
identifier: "user@example.com".into(),
rights: "+lrs".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 SETACL \"Shared Folders\" \"user@example.com\" \"+lrs\"\r\n"
);
}
#[test]
fn encode_getacl_nested_folder() {
let mut buf = BytesMut::new();
let cmd = Command::GetAcl {
mailbox: MailboxName::new("INBOX.Sent Items").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 GETACL \"INBOX.Sent Items\"\r\n");
}
#[test]
fn encode_getmetadata_single_entry() {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec!["/private/comment".into()],
max_size: None,
depth: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 GETMETADATA \"INBOX\" \"/private/comment\"\r\n"
);
}
#[test]
fn encode_getmetadata_multiple_entries() {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec!["/private/comment".into(), "/shared/vendor/foo".into()],
max_size: None,
depth: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 GETMETADATA \"INBOX\" (\"/private/comment\" \"/shared/vendor/foo\")\r\n"
);
}
#[test]
fn encode_setmetadata_with_values() {
let mut buf = BytesMut::new();
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![("/private/comment".into(), Some(b"my comment".to_vec()))],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 SETMETADATA \"INBOX\" (\"/private/comment\" \"my comment\")\r\n"
);
}
#[test]
fn encode_setmetadata_with_nil_delete() {
let mut buf = BytesMut::new();
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![("/private/comment".into(), None)],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 SETMETADATA \"INBOX\" (\"/private/comment\" NIL)\r\n"
);
}
#[test]
fn encode_setmetadata_multiple_entries() {
let mut buf = BytesMut::new();
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![
("/private/comment".into(), Some(b"hello".to_vec())),
("/shared/vendor/x".into(), None),
],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 SETMETADATA \"INBOX\" (\"/private/comment\" \"hello\" \"/shared/vendor/x\" NIL)\r\n"
);
}
#[test]
fn encode_setmetadata_invalid_entry_names_return_error() {
for entry in [
"comment",
"/public/comment",
"/shared/%bad",
"/shared/*bad",
"/shared//bad",
"/shared/bad/",
"/shared/r\u{00E9}sum\u{00E9}",
"/shared/\u{0019}control",
] {
let mut buf = BytesMut::new();
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![(entry.into(), Some(b"value".to_vec()))],
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"SETMETADATA entry name {entry:?} must be rejected per RFC 5464 Section 3.2"
);
}
}
#[test]
fn encode_setmetadata_literal8_preserves_nul_bytes() {
let mut buf = BytesMut::new();
let value = b"\x00\x01\x02\x03".to_vec();
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![("/private/binary".into(), Some(value))],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let output = &buf[..];
assert!(
output.windows(4).any(|w| w == b"\x00\x01\x02\x03"),
"NUL bytes must be preserved in SETMETADATA values per RFC 3516 literal8 *OCTET"
);
assert!(
output.windows(5).any(|w| w == b"~{4}\r"),
"Binary SETMETADATA value must use literal8 ~{{N}} syntax per RFC 5464 Section 5 / RFC 3516, got: {:?}",
String::from_utf8_lossy(output)
);
}
#[test]
fn encode_setmetadata_ascii_value_uses_quoted_form() {
let mut buf = BytesMut::new();
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![("/private/comment".into(), Some(b"hello".to_vec()))],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 SETMETADATA \"INBOX\" (\"/private/comment\" \"hello\")\r\n"
);
}
#[test]
fn encode_setmetadata_high_bytes_use_standard_literal() {
let mut buf = BytesMut::new();
let value = b"\x80\x81\xff".to_vec();
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![("/private/binary".into(), Some(value))],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let output = &buf[..];
assert!(
output.windows(4).any(|w| w == b"{3}\r"),
"High-byte SETMETADATA value must use classic literal {{N}} syntax per RFC 3501 Section 9 / RFC 9051 Section 9, got: {:?}",
String::from_utf8_lossy(output)
);
assert!(
!output.windows(5).any(|w| w == b"~{3}\r"),
"High-byte SETMETADATA value must not use literal8 when no NUL octet is present, got: {:?}",
String::from_utf8_lossy(output)
);
assert!(
output.windows(3).any(|w| w == b"\x80\x81\xff"),
"High bytes must be preserved in SETMETADATA literal values"
);
}
#[test]
fn metadata_value_escapes_backslash_and_quote() {
let mut buf = BytesMut::new();
encode_metadata_value(&mut buf, b"a\\b\"c", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"\"a\\\\b\\\"c\"");
}
#[test]
fn metadata_value_ascii_uses_quoted() {
let mut buf = BytesMut::new();
encode_metadata_value(&mut buf, b"hello", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"\"hello\"");
}
#[test]
fn metadata_value_crlf_uses_standard_literal() {
let mut buf = BytesMut::new();
encode_metadata_value(&mut buf, b"line1\r\nline2", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"{12}\r\nline1\r\nline2");
}
#[test]
fn metadata_value_nul_uses_literal8() {
let mut buf = BytesMut::new();
encode_metadata_value(&mut buf, b"\x00data", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"~{5}\r\n\x00data");
}
#[test]
fn metadata_value_empty() {
let mut buf = BytesMut::new();
encode_metadata_value(&mut buf, b"", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"\"\"");
}
#[test]
fn metadata_value_high_bytes_uses_standard_literal() {
let mut buf = BytesMut::new();
encode_metadata_value(&mut buf, b"\x80\xff", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"{2}\r\n\x80\xff");
}
#[test]
fn metadata_value_del_byte_uses_standard_literal() {
let mut buf = BytesMut::new();
encode_metadata_value(&mut buf, b"hello\x7Fworld", LiteralMode::Synchronizing);
assert!(
buf.starts_with(b"{"),
"DEL byte (0x7F) must trigger classic literal encoding per RFC 3501/9051 literal CHAR8 rules, got: {:?}",
std::str::from_utf8(&buf)
);
}
#[test]
fn metadata_value_control_char_uses_standard_literal() {
let mut buf = BytesMut::new();
encode_metadata_value(&mut buf, b"hello\tworld", LiteralMode::Synchronizing);
assert!(
buf.starts_with(b"{"),
"TAB (0x09) must trigger classic literal encoding, got: {:?}",
std::str::from_utf8(&buf)
);
buf.clear();
encode_metadata_value(&mut buf, b"hello world", LiteralMode::Synchronizing);
assert!(
buf.starts_with(b"\""),
"Printable ASCII should use quoted encoding, got: {:?}",
std::str::from_utf8(&buf)
);
buf.clear();
encode_metadata_value(&mut buf, b"\x00", LiteralMode::Synchronizing);
assert!(
buf.starts_with(b"~{"),
"NUL (0x00) must trigger literal8 encoding, got: {:?}",
std::str::from_utf8(&buf)
);
}
#[test]
fn encode_setmetadata_high_bytes_use_literal_plus_when_available() {
let mut buf = BytesMut::new();
let value = b"\x80\x81\xff".to_vec();
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![("/private/binary".into(), Some(value))],
};
encode_command_to_buf(
&mut buf,
"A001",
&cmd,
&opts(LiteralMode::LiteralPlus, false),
)
.unwrap();
assert_eq!(
&buf[..],
b"A001 SETMETADATA \"INBOX\" (\"/private/binary\" {3+}\r\n\x80\x81\xff)\r\n"
);
}
#[test]
fn encode_thread_command() {
let mut buf = BytesMut::new();
let cmd = Command::Thread {
algorithm: "REFERENCES".into(),
charset: "UTF-8".into(),
criteria: "ALL".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 THREAD REFERENCES UTF-8 ALL\r\n");
}
#[test]
fn encode_thread_allows_literal_criteria() {
let mut buf = BytesMut::new();
let cmd = Command::Thread {
algorithm: "REFERENCES".into(),
charset: "UTF-8".into(),
criteria: "BODY {12}\r\nhello MODSEQ".into(),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"THREAD criteria literals must be accepted per RFC 3501 Section 4.3 and RFC 5256 Section 3: {result:?}"
);
assert_eq!(
&buf[..],
b"A001 THREAD REFERENCES UTF-8 BODY {12}\r\nhello MODSEQ\r\n"
);
}
#[test]
fn encode_uid_thread_command() {
let mut buf = BytesMut::new();
let cmd = Command::UidThread {
algorithm: "ORDEREDSUBJECT".into(),
charset: "US-ASCII".into(),
criteria: "SINCE 1-Jan-2025".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 UID THREAD ORDEREDSUBJECT US-ASCII SINCE 1-Jan-2025\r\n"
);
}
#[test]
fn encode_sort_command() {
let mut buf = BytesMut::new();
let cmd = Command::Sort {
sort_criteria: "DATE".into(),
charset: "UTF-8".into(),
criteria: "ALL".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 SORT (DATE) UTF-8 ALL\r\n");
}
#[test]
fn encode_uid_sort_command() {
let mut buf = BytesMut::new();
let cmd = Command::UidSort {
sort_criteria: "SUBJECT".into(),
charset: "US-ASCII".into(),
criteria: "SINCE 1-Jan-2025".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 UID SORT (SUBJECT) US-ASCII SINCE 1-Jan-2025\r\n"
);
}
#[test]
fn encode_thread_allows_quoted_charset() {
let mut buf = BytesMut::new();
let cmd = Command::Thread {
algorithm: "REFERENCES".into(),
charset: "\"UTF-8\"".into(),
criteria: "ALL".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts())
.expect("THREAD must accept quoted charset per RFC 5256 Section 5");
assert_eq!(&buf[..], b"A001 THREAD REFERENCES \"UTF-8\" ALL\r\n");
}
#[test]
fn encode_sort_allows_quoted_charset() {
let mut buf = BytesMut::new();
let cmd = Command::Sort {
sort_criteria: "DATE".into(),
charset: "\"UTF-8\"".into(),
criteria: "ALL".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts())
.expect("SORT must accept quoted charset per RFC 5256 Section 5");
assert_eq!(&buf[..], b"A001 SORT (DATE) \"UTF-8\" ALL\r\n");
}
#[test]
fn sort_command_uses_sort_criteria_field() {
let mut buf = BytesMut::new();
let cmd = Command::Sort {
sort_criteria: "DATE".into(),
charset: "UTF-8".into(),
criteria: "ALL".into(),
};
encode_command_to_buf(&mut buf, "A1", &cmd, &default_opts()).unwrap();
assert_eq!(
std::str::from_utf8(&buf).unwrap(),
"A1 SORT (DATE) UTF-8 ALL\r\n",
"SORT must encode sort_criteria in parentheses (RFC 5256 Section 2)"
);
}
#[test]
fn encode_thread_rejects_invalid_algorithm() {
let mut buf = BytesMut::new();
let cmd = Command::Thread {
algorithm: "ORDEREDSUBJECT INVALID".into(),
charset: "UTF-8".into(),
criteria: "ALL".into(),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"THREAD algorithm with spaces must be rejected (RFC 3501 Section 9: atom)"
);
}
#[test]
fn encode_sort_rejects_invalid_charset() {
let mut buf = BytesMut::new();
let cmd = Command::Sort {
sort_criteria: "DATE".into(),
charset: "UTF(8)".into(),
criteria: "ALL".into(),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"SORT charset with parens must be rejected (RFC 3501 Section 9: atom)"
);
}
#[test]
fn encode_compress() {
let mut buf = BytesMut::new();
encode_command_to_buf(&mut buf, "A001", &Command::Compress, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 COMPRESS DEFLATE\r\n");
}
#[test]
fn encode_multi_append_first_message_no_flags() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
None,
100,
true,
LiteralMode::Synchronizing,
false,
)
.unwrap();
assert_eq!(&buf[..], b"A001 APPEND \"INBOX\" {100}\r\n");
}
#[test]
fn encode_multi_append_first_message_with_flags() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[crate::types::Flag::Seen, crate::types::Flag::Flagged],
None,
200,
true,
LiteralMode::Synchronizing,
false,
)
.unwrap();
assert_eq!(
&buf[..],
b"A001 APPEND \"INBOX\" (\\Seen \\Flagged) {200}\r\n"
);
}
#[test]
fn encode_multi_append_first_message_with_date() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
Some("17-Jul-1996 02:44:25 -0700"),
50,
true,
LiteralMode::Synchronizing,
false,
)
.unwrap();
assert_eq!(
&buf[..],
b"A001 APPEND \"INBOX\" \"17-Jul-1996 02:44:25 -0700\" {50}\r\n"
);
}
#[test]
fn encode_multi_append_first_message_with_flags_and_date() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[crate::types::Flag::Seen],
Some(" 1-Jan-2024 00:00:00 +0000"),
300,
true,
LiteralMode::Synchronizing,
false,
)
.unwrap();
assert_eq!(
&buf[..],
b"A001 APPEND \"INBOX\" (\\Seen) \" 1-Jan-2024 00:00:00 +0000\" {300}\r\n"
);
}
#[test]
fn encode_multi_append_subsequent_message() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[crate::types::Flag::Draft],
None,
75,
false,
LiteralMode::Synchronizing,
false,
)
.unwrap();
assert_eq!(&buf[..], b" (\\Draft) {75}\r\n");
}
#[test]
fn encode_multi_append_with_literal_plus() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
None,
42,
true,
LiteralMode::LiteralPlus,
false,
)
.unwrap();
assert_eq!(&buf[..], b"A001 APPEND \"INBOX\" {42+}\r\n");
}
#[test]
fn encode_multi_append_subsequent_with_literal_plus() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
None,
99,
false,
LiteralMode::LiteralPlus,
false,
)
.unwrap();
assert_eq!(&buf[..], b" {99+}\r\n");
}
#[test]
fn encode_multi_append_two_messages_mixed_flags() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A010",
"INBOX",
&[crate::types::Flag::Seen, crate::types::Flag::Answered],
Some("15-Mar-2026 10:00:00 +0000"),
50,
true,
LiteralMode::Synchronizing,
false,
)
.unwrap();
assert_eq!(
&buf[..],
b"A010 APPEND \"INBOX\" (\\Seen \\Answered) \"15-Mar-2026 10:00:00 +0000\" {50}\r\n"
);
buf.clear();
encode_multi_append_header(
&mut buf,
"A010",
"INBOX",
&[],
None,
30,
false,
LiteralMode::Synchronizing,
false,
)
.unwrap();
assert_eq!(&buf[..], b" {30}\r\n");
}
#[test]
fn encode_multi_append_three_messages_literal_plus() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A020",
"Archive",
&[crate::types::Flag::Seen],
None,
100,
true,
LiteralMode::LiteralPlus,
false,
)
.unwrap();
let expected1 = b"A020 APPEND \"Archive\" (\\Seen) {100+}\r\n";
assert_eq!(&buf[..], &expected1[..]);
buf.clear();
encode_multi_append_header(
&mut buf,
"A020",
"Archive",
&[],
Some(" 1-Jan-2025 00:00:00 +0000"),
200,
false,
LiteralMode::LiteralPlus,
false,
)
.unwrap();
assert_eq!(&buf[..], b" \" 1-Jan-2025 00:00:00 +0000\" {200+}\r\n");
buf.clear();
encode_multi_append_header(
&mut buf,
"A020",
"Archive",
&[crate::types::Flag::Flagged],
None,
300,
false,
LiteralMode::LiteralPlus,
false,
)
.unwrap();
assert_eq!(&buf[..], b" (\\Flagged) {300+}\r\n");
}
#[test]
fn encode_multi_append_subsequent_no_flags_with_date() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
Some("25-Dec-2025 12:00:00 +0000"),
500,
false,
LiteralMode::Synchronizing,
false,
)
.unwrap();
assert_eq!(&buf[..], b" \"25-Dec-2025 12:00:00 +0000\" {500}\r\n");
}
#[test]
fn encode_multi_append_special_mailbox() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
r#"folder"name"#,
&[],
None,
10,
true,
LiteralMode::Synchronizing,
false,
)
.unwrap();
assert_eq!(&buf[..], b"A001 APPEND \"folder\\\"name\" {10}\r\n");
}
#[test]
fn encode_data_with_nul_byte_strips_nul() {
let mut buf = BytesMut::new();
encode_quoted_or_literal(&mut buf, b"hello\x00world", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"\"helloworld\"");
assert!(!buf.contains(&0x00), "output must not contain NUL bytes");
}
#[test]
fn encode_data_with_non_ascii_uses_literal() {
let mut buf = BytesMut::new();
encode_quoted_or_literal(&mut buf, "café".as_bytes(), LiteralMode::Synchronizing);
assert!(buf.starts_with(b"{5}\r\n"));
}
#[test]
fn encode_empty_data_quoted() {
let mut buf = BytesMut::new();
encode_quoted_or_literal(&mut buf, b"", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"\"\"");
}
#[test]
fn encode_select_mailbox_with_spaces() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: MailboxName::new("my folder").unwrap(),
condstore: false,
qresync: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 SELECT \"my folder\"\r\n");
}
#[test]
fn encode_login_crlf_in_password_uses_literal() {
let mut buf = BytesMut::new();
encode_login(
&mut buf,
"A001",
"user",
"pass\r\nword",
false,
LiteralMode::Synchronizing,
)
.unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(
output.contains("{10}\r\n"),
"expected literal for password with CRLF"
);
}
#[test]
fn encode_long_quotable_string() {
let mut buf = BytesMut::new();
let data = "a".repeat(10_000);
encode_quoted_or_literal(&mut buf, data.as_bytes(), LiteralMode::Synchronizing);
assert!(buf.starts_with(b"\""));
assert!(buf.ends_with(b"\""));
assert_eq!(buf.len(), 10_002); }
#[test]
fn del_byte_triggers_literal_encoding() {
let mut buf = BytesMut::new();
let data_with_del = b"hello\x7Fworld";
encode_quoted_or_literal(&mut buf, data_with_del, LiteralMode::Synchronizing);
let result = std::str::from_utf8(&buf).unwrap();
assert!(
result.starts_with('{'),
"DEL byte (0x7F) must trigger literal encoding per RFC 9051 Section 9, got: {result}"
);
}
#[test]
fn control_char_triggers_literal_encoding() {
for &byte in &[0x01u8, 0x07, 0x09, 0x1B, 0x1F] {
let mut buf = BytesMut::new();
let data = [
b'h', b'e', b'l', b'l', b'o', byte, b'w', b'o', b'r', b'l', b'd',
];
encode_quoted_or_literal(&mut buf, &data, LiteralMode::Synchronizing);
let result = std::str::from_utf8(&buf).unwrap();
assert!(
result.starts_with('{'),
"Control char 0x{byte:02X} must trigger literal encoding for interoperability, got: {result}"
);
}
}
#[test]
fn encode_move() {
let mut buf = BytesMut::new();
let cmd = Command::Move {
sequence_set: SequenceSet::new("1:5").unwrap(),
mailbox: MailboxName::new("Trash").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 MOVE 1:5 \"Trash\"\r\n");
}
#[test]
fn regression_encode_id_empty_params() {
let mut buf = BytesMut::new();
let cmd = Command::Id(vec![]);
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 ID NIL\r\n",
"empty ID params should produce ID NIL per RFC 2971 Section 3.1"
);
}
#[test]
fn encode_id_rejects_more_than_30_pairs() {
let mut buf = BytesMut::new();
let params: Vec<(String, Option<String>)> = (0..31)
.map(|i| (format!("k{i}"), Some(format!("v{i}"))))
.collect();
let cmd = Command::Id(params);
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"ID with 31 pairs must be rejected per RFC 2971 Section 3.3"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("RFC 2971"),
"error message should cite RFC 2971, got: {msg}"
);
}
#[test]
fn encode_id_rejects_duplicate_field_names_case_insensitive() {
let mut buf = BytesMut::new();
let cmd = Command::Id(vec![
("name".into(), Some("daaki".into())),
("NAME".into(), Some("duplicate".into())),
]);
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"duplicate ID field names must be rejected per RFC 2971 Section 3.3"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("same field name") || msg.contains("duplicate"),
"error should mention the duplicate ID field name, got: {msg}"
);
}
#[test]
fn encode_id_rejects_key_longer_than_30_octets() {
let mut buf = BytesMut::new();
let long_key = "a".repeat(31);
let cmd = Command::Id(vec![(long_key, Some("value".into()))]);
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"ID with key > 30 octets must be rejected per RFC 2971 Section 3.3"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("RFC 2971"),
"error message should cite RFC 2971, got: {msg}"
);
}
#[test]
fn encode_id_rejects_value_longer_than_1024_octets() {
let mut buf = BytesMut::new();
let long_value = "v".repeat(1025);
let cmd = Command::Id(vec![("key".into(), Some(long_value))]);
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"ID with value > 1024 octets must be rejected per RFC 2971 Section 3.3"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("RFC 2971"),
"error message should cite RFC 2971, got: {msg}"
);
}
#[test]
fn encode_id_accepts_exactly_at_limits() {
let mut buf = BytesMut::new();
let value = "v".repeat(1024); let params: Vec<(String, Option<String>)> = (0..30)
.map(|i| (format!("{i:02}{}", "k".repeat(28)), Some(value.clone())))
.collect();
let cmd = Command::Id(params);
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"ID with exactly 30 pairs, unique 30-byte keys, and 1024-byte values \
should succeed per RFC 2971 Section 3.3, got: {:?}",
result.unwrap_err()
);
}
#[test]
fn encode_select_qresync_seq_match_data() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: Some("1:500".into()),
seq_match_data: Some(("1:3".into(), "100:102".into())),
}),
};
encode_command_to_buf(&mut buf, "A008", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A008 SELECT \"INBOX\" (QRESYNC (67890 12345 1:500 (1:3 100:102)))\r\n"
);
}
#[test]
fn encode_select_condstore() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: true,
qresync: None,
};
encode_command_to_buf(&mut buf, "A009", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A009 SELECT \"INBOX\" (CONDSTORE)\r\n");
}
#[test]
fn encode_examine_condstore() {
let mut buf = BytesMut::new();
let cmd = Command::Examine {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: true,
qresync: None,
};
encode_command_to_buf(&mut buf, "A010", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A010 EXAMINE \"INBOX\" (CONDSTORE)\r\n");
}
#[test]
fn encode_fetch_changedsince() {
let mut buf = BytesMut::new();
let cmd = Command::Fetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "(FLAGS)".into(),
changed_since: Some(12345),
};
encode_command_to_buf(&mut buf, "A011", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A011 FETCH 1:* (FLAGS) (CHANGEDSINCE 12345)\r\n");
}
#[test]
fn encode_fetch_no_changedsince() {
let mut buf = BytesMut::new();
let cmd = Command::Fetch {
sequence_set: SequenceSet::new("1:10").unwrap(),
items: "(UID)".into(),
changed_since: None,
};
encode_command_to_buf(&mut buf, "A012", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A012 FETCH 1:10 (UID)\r\n");
}
#[test]
fn encode_uid_fetch_changedsince() {
let mut buf = BytesMut::new();
let cmd = Command::UidFetch {
sequence_set: SequenceSet::new("1:500").unwrap(),
items: "(FLAGS ENVELOPE)".into(),
changed_since: Some(67890),
vanished: false,
};
encode_command_to_buf(&mut buf, "A013", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A013 UID FETCH 1:500 (FLAGS ENVELOPE) (CHANGEDSINCE 67890)\r\n"
);
}
#[test]
fn encode_uid_fetch_changedsince_vanished() {
let mut buf = BytesMut::new();
let cmd = Command::UidFetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "(FLAGS)".into(),
changed_since: Some(12345),
vanished: true,
};
encode_command_to_buf(&mut buf, "A014", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A014 UID FETCH 1:* (FLAGS) (CHANGEDSINCE 12345 VANISHED)\r\n"
);
}
#[test]
fn encode_uid_fetch_vanished_without_changedsince_rejected() {
let mut buf = BytesMut::new();
let cmd = Command::UidFetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "(FLAGS)".into(),
changed_since: None,
vanished: true,
};
let result = encode_command_to_buf(&mut buf, "A015", &cmd, &default_opts());
assert!(
result.is_err(),
"VANISHED without CHANGEDSINCE must be rejected per RFC 7162 Section 3.2.6"
);
}
#[test]
fn encode_uid_fetch_changedsince_without_vanished_unchanged() {
let mut buf = BytesMut::new();
let cmd = Command::UidFetch {
sequence_set: SequenceSet::new("1:500").unwrap(),
items: "(FLAGS ENVELOPE)".into(),
changed_since: Some(67890),
vanished: false,
};
encode_command_to_buf(&mut buf, "A016", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A016 UID FETCH 1:500 (FLAGS ENVELOPE) (CHANGEDSINCE 67890)\r\n"
);
}
#[test]
fn encode_setquota_single_resource() {
let mut buf = BytesMut::new();
let cmd = Command::SetQuota {
root: String::new(),
resources: vec![("STORAGE".into(), 51200)],
};
encode_command_to_buf(&mut buf, "A014", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A014 SETQUOTA \"\" (STORAGE 51200)\r\n");
}
#[test]
fn encode_setquota_multiple_resources() {
let mut buf = BytesMut::new();
let cmd = Command::SetQuota {
root: "user.alice".into(),
resources: vec![("STORAGE".into(), 102_400), ("MESSAGE".into(), 5000)],
};
encode_command_to_buf(&mut buf, "A015", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A015 SETQUOTA \"user.alice\" (STORAGE 102400 MESSAGE 5000)\r\n"
);
}
#[test]
fn encode_setquota_empty_resources() {
let mut buf = BytesMut::new();
let cmd = Command::SetQuota {
root: String::new(),
resources: vec![],
};
encode_command_to_buf(&mut buf, "A016", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A016 SETQUOTA \"\" ()\r\n");
}
#[test]
fn spec_audit_m4_nul_bytes_in_literal() {
let mut buf = BytesMut::new();
encode_quoted_or_literal(&mut buf, b"has\0nul", LiteralMode::Synchronizing);
assert!(
!buf.contains(&0x00),
"encoder must reject or strip NUL bytes per RFC 3501 Section 9 (CHAR8 = %x01-ff), \
but the output contains NUL: {:?}",
&buf[..]
);
}
#[test]
fn spec_audit_l2_recent_in_store() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1:5").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen, crate::types::Flag::Recent],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
!output.contains("\\Recent"),
"encoder must skip \\Recent in STORE per RFC 3501 Section 9, \
but output contains it: {output}"
);
assert!(
output.contains("\\Seen"),
"\\Seen should still be present in output: {output}"
);
}
#[test]
fn spec_audit_l14_invalid_sequence_set() {
assert!(SequenceSet::new("abc").is_err());
}
#[test]
fn audit_finding1_append_header_with_utf8_extension() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
None,
100,
true, LiteralMode::Synchronizing, true, )
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output, "A001 APPEND \"INBOX\" UTF8 (~{100}\r\n",
"RFC 6855 Section 4: UTF8 APPEND data extension"
);
}
#[test]
fn audit_finding1_append_header_classic_without_utf8() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
None,
100,
true,
LiteralMode::Synchronizing,
false, )
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(output, "A001 APPEND \"INBOX\" {100}\r\n");
assert!(!output.contains("UTF8"));
}
#[test]
fn audit_finding6_append_header_uses_literal8_for_binary_append() {
let mut buf = BytesMut::new();
encode_multi_append_header_with_literal8(
&mut buf,
"A001",
"INBOX",
&[],
None,
12,
true,
LiteralMode::LiteralPlus,
false,
true,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(output, "A001 APPEND \"INBOX\" ~{12}\r\n");
}
#[test]
fn audit_finding3_qresync_rejects_seq_match_without_known_uids() {
use crate::types::response::QresyncParams;
let params = QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: None,
seq_match_data: Some(("1:100".into(), "1:100".into())),
};
let mut buf = BytesMut::new();
let result = encode_select_or_examine(
&mut buf,
"A001",
"SELECT",
"INBOX",
false,
Some(¶ms),
false,
LiteralMode::Synchronizing,
);
assert!(
result.is_err(),
"seq-match-data without known-uids must return Err (RFC 7162 Section 3.2.5.2)"
);
}
#[test]
fn audit_finding3_qresync_valid_known_uids_with_seq_match() {
use crate::types::response::QresyncParams;
let params = QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: Some("1:500".into()),
seq_match_data: Some(("1:100".into(), "1:100".into())),
};
let mut buf = BytesMut::new();
encode_select_or_examine(
&mut buf,
"A001",
"SELECT",
"INBOX",
false,
Some(¶ms),
false,
LiteralMode::Synchronizing,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("1:500"),
"known_uids should appear in output: {output}"
);
assert!(
output.contains("(1:100 1:100)"),
"seq_match_data should appear in output: {output}"
);
assert!(
!output.contains("1:*"),
"should NOT contain fabricated 1:* when known_uids is provided: {output}"
);
}
#[test]
fn audit_finding3_qresync_known_uids_without_seq_match() {
use crate::types::response::QresyncParams;
let params = QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: Some("1:500".into()),
seq_match_data: None,
};
let mut buf = BytesMut::new();
encode_select_or_examine(
&mut buf,
"A001",
"SELECT",
"INBOX",
false,
Some(¶ms),
false,
LiteralMode::Synchronizing,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output, "A001 SELECT \"INBOX\" (QRESYNC (67890 12345 1:500))\r\n",
"QRESYNC with known_uids but no seq_match_data"
);
}
#[test]
fn audit_finding7_getmetadata_without_options() {
let mut buf = BytesMut::new();
encode_getmetadata(
&mut buf,
"A001",
"INBOX",
&["/private/comment".to_owned()],
None,
None,
false,
LiteralMode::Synchronizing,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output,
"A001 GETMETADATA \"INBOX\" \"/private/comment\"\r\n",
);
}
#[test]
fn audit_finding7_getmetadata_with_maxsize_and_depth() {
let mut buf = BytesMut::new();
encode_getmetadata(
&mut buf,
"A001",
"INBOX",
&["/private/comment".to_owned()],
Some(1024),
Some("infinity"),
false,
LiteralMode::Synchronizing,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output,
"A001 GETMETADATA (MAXSIZE 1024 DEPTH infinity) \"INBOX\" \"/private/comment\"\r\n",
);
}
#[test]
fn audit_finding7_getmetadata_with_maxsize_only() {
let mut buf = BytesMut::new();
encode_getmetadata(
&mut buf,
"A001",
"INBOX",
&["/private/comment".to_owned()],
Some(2048),
None,
false,
LiteralMode::Synchronizing,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output,
"A001 GETMETADATA (MAXSIZE 2048) \"INBOX\" \"/private/comment\"\r\n",
);
}
#[test]
fn audit_finding7_getmetadata_with_depth_only() {
let mut buf = BytesMut::new();
encode_getmetadata(
&mut buf,
"A001",
"INBOX",
&["/private/comment".to_owned()],
None,
Some("1"),
false,
LiteralMode::Synchronizing,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output,
"A001 GETMETADATA (DEPTH 1) \"INBOX\" \"/private/comment\"\r\n",
);
}
#[test]
fn audit_getmetadata_options_before_mailbox_per_verified_errata() {
let mut buf = BytesMut::new();
encode_getmetadata(
&mut buf,
"A001",
"INBOX",
&["/private/comment".to_owned()],
Some(1024),
Some("infinity"),
false,
LiteralMode::Synchronizing,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output, "A001 GETMETADATA (MAXSIZE 1024 DEPTH infinity) \"INBOX\" \"/private/comment\"\r\n",
"RFC 5464 Section 5 / errata 2785 and 2786: GETMETADATA options must precede the mailbox",
);
}
#[test]
fn audit_finding8_sort_command_encodes_correctly() {
let mut buf = BytesMut::new();
let cmd = Command::Sort {
sort_criteria: "DATE".into(),
charset: "UTF-8".into(),
criteria: "ALL".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output, "A001 SORT (DATE) UTF-8 ALL\r\n",
"SORT command should encode correctly (RFC 5256 Section 2)"
);
}
#[test]
fn audit_finding8_uid_sort_command_encodes_correctly() {
let mut buf = BytesMut::new();
let cmd = Command::UidSort {
sort_criteria: "REVERSE DATE".into(),
charset: "UTF-8".into(),
criteria: "SINCE 1-Jan-2024".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output, "A001 UID SORT (REVERSE DATE) UTF-8 SINCE 1-Jan-2024\r\n",
"UID SORT command should encode correctly (RFC 5256 Section 2)"
);
}
#[test]
fn audit_finding8_setquota_command_encodes_correctly() {
let mut buf = BytesMut::new();
let cmd = Command::SetQuota {
root: String::new(),
resources: vec![("STORAGE".into(), 51200)],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output, "A001 SETQUOTA \"\" (STORAGE 51200)\r\n",
"SETQUOTA command should encode correctly (RFC 2087 Section 4.1)"
);
}
#[test]
fn audit_h3_single_append_filters_recent_and_wildcard() {
let flags = vec![
crate::types::Flag::Seen,
crate::types::Flag::Recent,
crate::types::Flag::Wildcard,
crate::types::Flag::Flagged,
];
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&flags,
None,
10,
true,
LiteralMode::Synchronizing,
false,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
!output.contains("\\Recent"),
"must filter \\Recent: {output}"
);
assert!(!output.contains("\\*"), "must filter \\*: {output}");
assert!(output.contains("\\Seen"), "must keep \\Seen");
assert!(output.contains("\\Flagged"), "must keep \\Flagged");
}
#[test]
fn audit_l8_store_all_flags_filtered() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1:5").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Recent, crate::types::Flag::Wildcard],
unchanged_since: None,
};
assert!(
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).is_err(),
"STORE with all flags filtered out must return an error \
per RFC 9051 Section 6.4.6"
);
}
#[test]
fn audit_l10_authenticate_empty_initial_response() {
let mut buf = BytesMut::new();
let cmd = Command::Authenticate {
mechanism: "PLAIN".into(),
initial_response: Some(String::new()),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains(" =\r\n"),
"empty initial response must be '=': '{output}'"
);
}
#[test]
fn audit_l9_sequence_set_rejects_invalid() {
assert!(SequenceSet::new("abc").is_err());
assert!(SequenceSet::new("0").is_err());
assert!(SequenceSet::new("").is_err());
assert!(SequenceSet::new("1,,2").is_err());
assert!(SequenceSet::new("1:*").is_ok());
assert!(SequenceSet::new("1,2,3").is_ok());
}
#[test]
fn sequence_set_rejects_overflow_u32() {
assert!(SequenceSet::new("99999999999").is_err());
assert!(SequenceSet::new("4294967295").is_ok());
assert!(SequenceSet::new("4294967296").is_err());
assert!(SequenceSet::new("1:99999999999").is_err());
assert!(SequenceSet::new("99999999999:1").is_err());
}
#[test]
fn sequence_set_rejects_zero_via_newtype() {
assert!(
SequenceSet::new("0").is_err(),
"\"0\" must be rejected per RFC 3501 Section 9 (nz-number)"
);
}
#[test]
fn sequence_set_rejects_empty() {
assert!(
SequenceSet::new("").is_err(),
"Empty string must be rejected per RFC 3501 Section 9"
);
}
#[test]
fn sequence_set_rejects_alphabetic() {
assert!(
SequenceSet::new("abc").is_err(),
"Alphabetic string must be rejected per RFC 3501 Section 9"
);
}
#[test]
fn sequence_set_rejects_overflow() {
assert!(
SequenceSet::new("99999999999").is_err(),
"u32 overflow must be rejected per RFC 3501 Section 9"
);
}
#[test]
fn sequence_set_rejects_zero() {
assert!(
SequenceSet::new("0").is_err(),
"Zero must be rejected per RFC 3501 Section 9 (nz-number)"
);
}
#[test]
fn encode_id_nil_value() {
let mut buf = BytesMut::new();
let cmd = Command::Id(vec![
("name".into(), Some("myapp".into())),
("version".into(), None),
]);
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 ID (\"name\" \"myapp\" \"version\" NIL)\r\n",
"None value must encode as NIL per RFC 2971 Section 3.1"
);
}
#[test]
fn encode_setquota_limit_at_u32_max_succeeds() {
let mut buf = BytesMut::new();
let cmd = Command::SetQuota {
root: String::new(),
resources: vec![("STORAGE".into(), u64::from(u32::MAX))],
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"SETQUOTA with limit = u32::MAX must succeed (RFC 2087 Section 4.1)"
);
assert_eq!(&buf[..], b"A001 SETQUOTA \"\" (STORAGE 4294967295)\r\n");
}
#[test]
fn encode_setquota_limit_exceeding_u32_max_returns_error() {
let mut buf = BytesMut::new();
let cmd = Command::SetQuota {
root: String::new(),
resources: vec![("STORAGE".into(), u64::from(u32::MAX) + 1)],
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"SETQUOTA with limit > u32::MAX must fail (RFC 2087 Section 4.1)"
);
}
#[test]
fn encode_setmetadata_empty_entries_returns_error() {
let mut buf = BytesMut::new();
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![],
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"SETMETADATA with empty entries must fail (RFC 5464 Section 5)"
);
}
#[test]
fn encode_getmetadata_empty_entries_returns_error() {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![],
max_size: None,
depth: None,
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"GETMETADATA with empty entries must fail (RFC 5464 Section 4.2)"
);
}
#[test]
fn encode_getmetadata_invalid_depth_returns_error() {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec!["/private/comment".into()],
max_size: None,
depth: Some("2".into()),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"GETMETADATA with invalid DEPTH \"2\" must fail (RFC 5464 Section 4.2.2)"
);
}
#[test]
fn encode_getmetadata_invalid_entry_names_return_error() {
for entry in [
"comment",
"/public/comment",
"/private/%bad",
"/private/*bad",
"/private//bad",
"/private/bad/",
"/private/r\u{00E9}sum\u{00E9}",
"/private/\u{0019}control",
] {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![entry.into()],
max_size: None,
depth: None,
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"GETMETADATA entry name {entry:?} must be rejected per RFC 5464 Section 3.2"
);
}
}
#[test]
fn encode_getmetadata_valid_depth_values() {
for depth in &["0", "1", "infinity"] {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec!["/private/comment".into()],
max_size: None,
depth: Some((*depth).to_string()),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"GETMETADATA with valid DEPTH \"{depth}\" must succeed"
);
}
}
#[test]
fn regression_custom_flag_with_spaces_rejected() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Custom("has space".into())],
unchanged_since: None,
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"custom flag with space must be rejected (RFC 3501 Section 9: ATOM-CHAR)"
);
}
#[test]
fn regression_custom_flag_with_parens_rejected() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Custom("bad(flag".into())],
unchanged_since: None,
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"custom flag with parenthesis must be rejected (RFC 3501 Section 9: ATOM-CHAR)"
);
}
#[test]
fn regression_empty_custom_flag_rejected() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Custom(String::new())],
unchanged_since: None,
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"empty custom flag must be rejected (RFC 3501 Section 9: atom = 1*ATOM-CHAR)"
);
}
#[test]
fn regression_valid_custom_flags_accepted() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![
crate::types::Flag::Custom("$Important".into()),
crate::types::Flag::Custom("$Junk".into()),
crate::types::Flag::Custom("NonJunk".into()),
],
unchanged_since: None,
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"valid custom flags must be accepted; got: {result:?}"
);
}
#[test]
fn regression_getmetadata_options_before_mailbox() {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec!["/shared/comment".into(), "/private/comment".into()],
max_size: Some(1024),
depth: None,
};
encode_command_to_buf(&mut buf, "a", &cmd, &default_opts()).unwrap();
assert_eq!(
std::str::from_utf8(&buf).unwrap(),
"a GETMETADATA (MAXSIZE 1024) \"INBOX\" (\"/shared/comment\" \"/private/comment\")\r\n",
"GETMETADATA options must come BEFORE the mailbox (RFC 5464 Section 5 / errata 2785)"
);
}
#[test]
fn regression_getmetadata_depth_before_mailbox() {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec!["/private/filters/values".into()],
max_size: None,
depth: Some("1".into()),
};
encode_command_to_buf(&mut buf, "a", &cmd, &default_opts()).unwrap();
assert_eq!(
std::str::from_utf8(&buf).unwrap(),
"a GETMETADATA (DEPTH 1) \"INBOX\" \"/private/filters/values\"\r\n",
"GETMETADATA DEPTH must come BEFORE the mailbox (RFC 5464 Section 5 / errata 2786)"
);
}
#[test]
fn regression_getmetadata_both_options_before_mailbox() {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec!["/private/comment".into()],
max_size: Some(2048),
depth: Some("infinity".into()),
};
encode_command_to_buf(&mut buf, "a", &cmd, &default_opts()).unwrap();
assert_eq!(
std::str::from_utf8(&buf).unwrap(),
"a GETMETADATA (MAXSIZE 2048 DEPTH infinity) \"INBOX\" \"/private/comment\"\r\n",
"GETMETADATA MAXSIZE + DEPTH must come BEFORE the mailbox (RFC 5464 Section 5 / errata 2785/2786)"
);
}
#[test]
fn regression_qresync_seq_match_without_known_uids_returns_err() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: None,
seq_match_data: Some(("1:100".into(), "1:100".into())),
}),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"seq-match-data without known-uids must return Err (RFC 7162 Section 3.2.5.2)"
);
}
#[test]
fn regression_sequence_set_dollar_in_comma_list() {
assert!(
SequenceSet::new("1:5,$").is_ok(),
"\"1:5,$\" must be valid per RFC 5182 Section 5"
);
assert!(
SequenceSet::new("$,1:*").is_ok(),
"\"$,1:*\" must be valid per RFC 5182 Section 5"
);
assert!(
SequenceSet::new("42,$").is_ok(),
"\"42,$\" must be valid per RFC 5182 Section 5"
);
assert!(
SequenceSet::new("$").is_ok(),
"bare \"$\" must still be valid per RFC 5182 Section 2"
);
assert!(
SequenceSet::new("$:5").is_err(),
"\"$:5\" must be rejected ($ is not a seq-number, cannot form a range)"
);
}
#[test]
fn regression_qresync_known_uids_rejects_wildcard() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: Some("1:*".into()),
seq_match_data: None,
}),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"known-uids containing '*' must return Err (RFC 7162 Section 3.2.5.2)"
);
}
#[test]
fn regression_qresync_seq_match_data_rejects_wildcard_in_seq_set() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: Some("1:500".into()),
seq_match_data: Some(("1:*".into(), "1:100".into())),
}),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"known-sequence-set containing '*' must return Err (RFC 7162 Section 3.2.5.2)"
);
}
#[test]
fn regression_qresync_seq_match_data_rejects_wildcard_in_uid_set() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: Some("1:500".into()),
seq_match_data: Some(("1:100".into(), "1:*".into())),
}),
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"known-uid-set containing '*' must return Err (RFC 7162 Section 3.2.5.2)"
);
}
#[test]
fn store_all_flags_filtered_returns_error_recent_only() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1:5").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Recent],
unchanged_since: None,
};
assert!(
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).is_err(),
"STORE with only \\Recent (filtered out) must return an error \
per RFC 9051 Section 6.4.6"
);
}
#[test]
fn store_filters_wildcard_flag() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![
crate::types::Flag::Seen,
crate::types::Flag::Wildcard,
crate::types::Flag::Flagged,
],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 STORE 1 +FLAGS (\\Seen \\Flagged)\r\n",
"Wildcard must be filtered from STORE flags"
);
}
#[test]
fn store_filters_recent_flag() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("2").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![
crate::types::Flag::Seen,
crate::types::Flag::Recent,
crate::types::Flag::Flagged,
],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 STORE 2 +FLAGS (\\Seen \\Flagged)\r\n",
"Recent must be filtered from STORE flags"
);
}
#[test]
fn store_accepts_valid_custom_flag() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![
crate::types::Flag::Seen,
crate::types::Flag::Custom("$Important".into()),
],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 STORE 1 +FLAGS (\\Seen $Important)\r\n");
}
#[test]
fn store_rejects_invalid_custom_flag() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Custom("bad flag".into())],
unchanged_since: None,
};
assert!(
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).is_err(),
"custom flag with space must be rejected"
);
}
#[test]
fn store_all_flags_filtered_returns_error() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1:5").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Recent],
unchanged_since: None,
};
assert!(
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).is_err(),
"STORE with all flags filtered out must return an error \
per RFC 9051 Section 6.4.6 (flag-list requires at least one flag)"
);
}
#[test]
fn imap_003_store_replace_empty_flags_produces_flags_empty() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1:5").unwrap(),
operation: crate::types::StoreOperation::Replace,
flags: vec![],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 STORE 1:5 FLAGS ()\r\n",
"STORE Replace with empty flags must produce FLAGS () to clear all flags"
);
}
#[test]
fn imap_003_store_replace_silent_empty_flags_produces_flags_silent_empty() {
let mut buf = BytesMut::new();
let cmd = Command::UidStore {
sequence_set: SequenceSet::new("42").unwrap(),
operation: crate::types::StoreOperation::ReplaceSilent,
flags: vec![],
unchanged_since: None,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 UID STORE 42 FLAGS.SILENT ()\r\n",
"STORE ReplaceSilent with empty flags must produce FLAGS.SILENT () to clear all flags"
);
}
#[test]
fn imap_003_store_add_empty_flags_returns_error() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![],
unchanged_since: None,
};
assert!(
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).is_err(),
"STORE +FLAGS with empty flags must return an error"
);
}
#[test]
fn imap_003_store_remove_empty_flags_returns_error() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Remove,
flags: vec![],
unchanged_since: None,
};
assert!(
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).is_err(),
"STORE -FLAGS with empty flags must return an error"
);
}
#[test]
fn append_filters_recent_and_wildcard_flags() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[
crate::types::Flag::Seen,
crate::types::Flag::Recent,
crate::types::Flag::Wildcard,
crate::types::Flag::Flagged,
],
None,
42,
true,
LiteralMode::Synchronizing,
false,
)
.unwrap();
assert_eq!(
&buf[..],
b"A001 APPEND \"INBOX\" (\\Seen \\Flagged) {42}\r\n",
"Recent and Wildcard must be filtered from APPEND flags"
);
}
#[test]
fn append_accepts_valid_custom_flag() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[crate::types::Flag::Custom("$MailFlagBit0".into())],
None,
10,
true,
LiteralMode::Synchronizing,
false,
)
.unwrap();
assert_eq!(&buf[..], b"A001 APPEND \"INBOX\" ($MailFlagBit0) {10}\r\n");
}
#[test]
fn append_rejects_invalid_custom_flag() {
let mut buf = BytesMut::new();
let result = encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[crate::types::Flag::Custom("bad{flag}".into())],
None,
10,
true,
LiteralMode::Synchronizing,
false,
);
assert!(result.is_err(), "custom flag with braces must be rejected");
}
#[test]
fn encode_fetch_changedsince_minimum_valid() {
let mut buf = BytesMut::new();
let cmd = Command::Fetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "(FLAGS)".into(),
changed_since: Some(1),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 FETCH 1:* (FLAGS) (CHANGEDSINCE 1)\r\n");
}
#[test]
fn encode_uid_fetch_changedsince_minimum_valid() {
let mut buf = BytesMut::new();
let cmd = Command::UidFetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "(FLAGS)".into(),
changed_since: Some(1),
vanished: false,
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 UID FETCH 1:* (FLAGS) (CHANGEDSINCE 1)\r\n");
}
#[test]
fn spec_audit_utf8_mode_ascii_produces_quoted() {
let mut buf = BytesMut::new();
encode_quoted_or_literal_utf8(&mut buf, b"Brouillons", true, LiteralMode::Synchronizing);
assert_eq!(
&buf[..],
b"\"Brouillons\"",
"ASCII mailbox must be quoted in UTF-8 mode per RFC 6855 Section 3"
);
}
#[test]
fn spec_audit_utf8_mode_non_ascii_produces_quoted() {
let mut buf = BytesMut::new();
encode_quoted_or_literal_utf8(
&mut buf,
"日本語".as_bytes(),
true,
LiteralMode::Synchronizing,
);
assert!(
buf.starts_with(b"\""),
"UTF-8 mailbox name must be quoted when UTF8=ACCEPT is active \
per RFC 6855 Section 3, got literal form instead"
);
let expected_bytes = b"\"\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e\"";
assert_eq!(
&buf[..],
&expected_bytes[..],
"UTF-8 mailbox name must be quoted per RFC 6855 Section 3"
);
}
#[test]
fn spec_audit_no_utf8_mode_non_ascii_produces_literal() {
let mut buf = BytesMut::new();
encode_quoted_or_literal_utf8(
&mut buf,
"日本語".as_bytes(),
false,
LiteralMode::Synchronizing,
);
assert!(
buf.starts_with(b"{"),
"Non-ASCII mailbox name must use literal form when UTF8=ACCEPT is not active \
per RFC 3501 Section 9, got quoted form instead"
);
assert_eq!(
&buf[..],
b"{9}\r\n\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e",
"Non-ASCII mailbox name must use literal form per RFC 3501 Section 9"
);
}
#[test]
fn spec_audit_utf8_mode_crlf_produces_literal() {
let mut buf = BytesMut::new();
encode_quoted_or_literal_utf8(
&mut buf,
b"line1\r\nline2",
true,
LiteralMode::Synchronizing,
);
assert!(
buf.starts_with(b"{"),
"Data with CR/LF must use literal form even when UTF8=ACCEPT is active \
per RFC 9051 Section 9 TEXT-CHAR definition"
);
assert_eq!(
&buf[..],
b"{12}\r\nline1\r\nline2",
"CR/LF data must produce literal form per RFC 9051 Section 9"
);
}
#[test]
fn spec_audit_utf8_mode_invalid_utf8_produces_literal() {
let mut buf = BytesMut::new();
let invalid = &[0x80, 0x81, 0x82];
encode_quoted_or_literal_utf8(&mut buf, invalid, true, LiteralMode::Synchronizing);
assert!(
buf.starts_with(b"{"),
"Invalid UTF-8 must use literal form even when UTF8=ACCEPT is active \
per RFC 6855 Section 3 (only valid UTF-8 is allowed in quoted strings)"
);
}
#[test]
fn spec_audit_utf8_mode_del_byte_produces_literal() {
let mut buf = BytesMut::new();
encode_quoted_or_literal_utf8(
&mut buf,
b"hello\x7Fworld",
true,
LiteralMode::Synchronizing,
);
assert!(
buf.starts_with(b"{"),
"DEL byte (0x7F) must trigger literal encoding in UTF-8 mode \
per RFC 9051 Section 9 (CHAR = %x01-7E / UTF8-2/3/4), got: {:?}",
std::str::from_utf8(&buf)
);
}
#[test]
fn encode_quoted_escapes_backslash_and_dquote() {
let mut buf = BytesMut::new();
encode_quoted_or_literal(&mut buf, b"a\\b\"c", LiteralMode::Synchronizing);
assert_eq!(&buf[..], b"\"a\\\\b\\\"c\"");
}
#[test]
fn encode_utf8_mode_strips_nul_bytes() {
let mut buf = BytesMut::new();
encode_quoted_or_literal_utf8(
&mut buf,
b"hello\x00world",
true,
LiteralMode::Synchronizing,
);
assert_eq!(
&buf[..],
b"\"helloworld\"",
"NUL bytes must be stripped in UTF-8 mode per RFC 3501 Section 9"
);
assert!(!buf.contains(&0x00), "output must not contain NUL bytes");
}
#[test]
fn encode_utf8_mode_escapes_backslash_and_dquote() {
let mut buf = BytesMut::new();
encode_quoted_or_literal_utf8(&mut buf, b"a\\b\"c", true, LiteralMode::Synchronizing);
assert_eq!(
&buf[..],
b"\"a\\\\b\\\"c\"",
"backslash and double-quote must be escaped in UTF-8 mode"
);
}
#[test]
fn test_append_accepts_zero_padded_day() {
let mut buf = BytesMut::new();
let result = encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
Some("07-Jul-1996 02:44:25 -0700"),
100,
true,
LiteralMode::Synchronizing,
false,
);
assert!(
result.is_ok(),
"zero-padded day '07' is valid per RFC 3501 Section 9 \
(date-day-fixed = (SP DIGIT) / 2DIGIT): {result:?}"
);
}
#[test]
fn test_append_accepts_all_zero_padded_days() {
for day in 1..=9u8 {
let date = format!("0{day}-Jan-2024 12:00:00 +0000");
let result = validate_append_datetime(&date);
assert!(
result.is_ok(),
"zero-padded day '0{day}' must be accepted per RFC 3501 Section 9 \
date-day-fixed 2DIGIT: {result:?}"
);
}
}
#[test]
fn test_append_rejects_day_zero() {
let result = validate_append_datetime("00-Jan-2024 12:00:00 +0000");
assert!(
result.is_err(),
"day '00' is not a valid calendar day and must be rejected"
);
}
#[test]
fn test_append_rejects_invalid_month() {
let mut buf = BytesMut::new();
let result = encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
Some("31-Foo-2024 12:00:00 +0000"),
100,
true,
LiteralMode::Synchronizing,
false,
);
assert!(
result.is_err(),
"invalid month name should be rejected per RFC 3501 Section 9 date-month"
);
}
#[test]
fn test_append_rejects_garbage_date() {
let mut buf = BytesMut::new();
let result = encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
Some("not-a-date"),
100,
true,
LiteralMode::Synchronizing,
false,
);
assert!(
result.is_err(),
"garbage date string should be rejected per RFC 3501 Section 9"
);
}
#[test]
fn test_append_accepts_case_insensitive_month() {
let mut buf = BytesMut::new();
let result = encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
Some(" 7-JAN-2024 12:00:00 +0000"),
100,
true,
LiteralMode::Synchronizing,
false,
);
assert!(
result.is_ok(),
"uppercase month must be accepted per RFC 3501 Section 9 paragraph (1): {result:?}"
);
let mut buf2 = BytesMut::new();
let result2 = encode_multi_append_header(
&mut buf2,
"A001",
"INBOX",
&[],
Some("15-jul-2024 12:00:00 +0000"),
100,
true,
LiteralMode::Synchronizing,
false,
);
assert!(
result2.is_ok(),
"lowercase month must be accepted per RFC 3501 Section 9 paragraph (1): {result2:?}"
);
let mut buf3 = BytesMut::new();
let result3 = encode_multi_append_header(
&mut buf3,
"A001",
"INBOX",
&[],
Some("20-sEp-2024 12:00:00 +0000"),
100,
true,
LiteralMode::Synchronizing,
false,
);
assert!(
result3.is_ok(),
"mixed-case month must be accepted per RFC 3501 Section 9 paragraph (1): {result3:?}"
);
}
#[test]
fn test_append_accepts_valid_datetime() {
let mut buf = BytesMut::new();
let result = encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
Some(" 7-Jul-1996 02:44:25 -0700"),
100,
true,
LiteralMode::Synchronizing,
false,
);
assert!(
result.is_ok(),
"valid space-padded day should be accepted: {result:?}"
);
let mut buf2 = BytesMut::new();
let result2 = encode_multi_append_header(
&mut buf2,
"A001",
"INBOX",
&[],
Some("17-Jul-1996 02:44:25 -0700"),
100,
true,
LiteralMode::Synchronizing,
false,
);
assert!(
result2.is_ok(),
"valid two-digit day should be accepted: {result2:?}"
);
}
#[test]
fn spec_audit_time_range_validation() {
assert!(
validate_append_datetime("01-Jan-2024 25:00:00 +0000").is_err(),
"hour 25 must be rejected — valid range is 00-23 (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("01-Jan-2024 12:61:00 +0000").is_err(),
"minute 61 must be rejected — valid range is 00-59 (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("01-Jan-2024 12:00:61 +0000").is_err(),
"second 61 must be rejected — valid range is 00-60 (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("01-Jan-2024 12:00:60 +0000").is_ok(),
"second 60 (leap second) must be accepted per RFC 5322 Section 3.3"
);
assert!(
validate_append_datetime("01-Jan-2024 23:59:59 +0000").is_ok(),
"23:59:59 is the maximum valid non-leap-second time"
);
assert!(
validate_append_datetime("01-Jan-2024 00:00:00 +0000").is_ok(),
"00:00:00 is the minimum valid time"
);
}
#[test]
fn spec_audit_day_month_cross_check() {
assert!(
validate_append_datetime("31-Feb-2024 00:00:00 +0000").is_err(),
"31-Feb must be rejected — February has at most 29 days (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("30-Feb-2024 00:00:00 +0000").is_err(),
"30-Feb must be rejected — February has at most 29 days (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("29-Feb-2024 00:00:00 +0000").is_ok(),
"29-Feb-2024 must be accepted — 2024 is a leap year (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("29-Feb-2023 00:00:00 +0000").is_err(),
"29-Feb-2023 must be rejected — 2023 is not a leap year (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("29-Feb-2000 00:00:00 +0000").is_ok(),
"29-Feb-2000 must be accepted — 2000 is a leap year (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("29-Feb-1900 00:00:00 +0000").is_err(),
"29-Feb-1900 must be rejected — 1900 is not a leap year (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("31-Apr-2024 00:00:00 +0000").is_err(),
"31-Apr must be rejected — April has at most 30 days (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("31-Jun-2024 00:00:00 +0000").is_err(),
"31-Jun must be rejected — June has at most 30 days (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("30-Apr-2024 00:00:00 +0000").is_ok(),
"30-Apr must be accepted — April has 30 days (RFC 3501 Section 9)"
);
assert!(
validate_append_datetime("31-Jan-2024 00:00:00 +0000").is_ok(),
"31-Jan must be accepted — January has 31 days (RFC 3501 Section 9)"
);
}
#[test]
fn spec_audit_multi_append_utf8_mailbox_quoted() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"日本語",
&[],
None,
42,
true,
LiteralMode::Synchronizing,
true, )
.unwrap();
let output = std::str::from_utf8(&buf[..]).unwrap_or("");
assert!(
output.contains("APPEND \""),
"UTF-8 mailbox in MULTIAPPEND must be quoted when UTF8=ACCEPT is active \
per RFC 6855 Section 3, got: '{output}'"
);
}
#[test]
fn encode_fetch_changedsince_rejects_zero() {
let cmd = Command::Fetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "FLAGS".into(),
changed_since: Some(0),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"CHANGEDSINCE 0 must be rejected per RFC 7162 Section 7"
);
}
#[test]
fn encode_fetch_changedsince_rejects_overflow() {
let cmd = Command::Fetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "FLAGS".into(),
changed_since: Some(u64::MAX),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"CHANGEDSINCE > i64::MAX must be rejected per RFC 7162 Section 7"
);
}
#[test]
fn encode_store_unchangedsince_allows_zero() {
let cmd = Command::Store {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen],
unchanged_since: Some(0),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"UNCHANGEDSINCE 0 must be allowed per RFC 7162 Section 7"
);
}
#[test]
fn encode_store_unchangedsince_rejects_overflow() {
let cmd = Command::Store {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen],
unchanged_since: Some(u64::MAX),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"UNCHANGEDSINCE > i64::MAX must be rejected per RFC 7162 Section 7"
);
}
#[test]
fn encode_uid_fetch_changedsince_rejects_zero() {
let cmd = Command::UidFetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "FLAGS".into(),
changed_since: Some(0),
vanished: false,
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"UID FETCH CHANGEDSINCE 0 must be rejected per RFC 7162 Section 7"
);
}
#[test]
fn encode_uid_store_unchangedsince_rejects_overflow() {
let cmd = Command::UidStore {
sequence_set: SequenceSet::new("1").unwrap(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen],
unchanged_since: Some(u64::MAX),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"UID STORE UNCHANGEDSINCE > i64::MAX must be rejected per RFC 7162 Section 7"
);
}
#[test]
fn known_sequence_set_accepts_valid() {
assert!(SequenceSet::new_known("1").is_ok());
assert!(SequenceSet::new_known("1:100").is_ok());
assert!(SequenceSet::new_known("1,2,3").is_ok());
assert!(SequenceSet::new_known("1:5,10:20").is_ok());
assert!(SequenceSet::new_known("4294967295").is_ok()); }
#[test]
fn known_sequence_set_rejects_invalid() {
assert!(SequenceSet::new_known("").is_err());
assert!(SequenceSet::new_known("abc").is_err());
assert!(SequenceSet::new_known("1 2").is_err());
assert!(SequenceSet::new_known("1:*").is_err());
assert!(SequenceSet::new_known("*").is_err());
assert!(SequenceSet::new_known("$").is_err());
assert!(SequenceSet::new_known("1,$").is_err());
assert!(SequenceSet::new_known("0").is_err());
assert!(SequenceSet::new_known("01").is_err());
assert!(SequenceSet::new_known("4294967296").is_err());
assert!(SequenceSet::new_known("1,").is_err());
assert!(SequenceSet::new_known("1::2").is_err());
}
#[test]
fn getmetadata_maxsize_zero_accepted() {
let mut buf = BytesMut::new();
let result = encode_getmetadata(
&mut buf,
"A001",
"INBOX",
&["/private/comment".to_owned()],
Some(0),
None,
false,
LiteralMode::Synchronizing,
);
assert!(result.is_ok(), "max_size 0 should be accepted");
}
#[test]
fn getmetadata_maxsize_u32_max_accepted() {
let mut buf = BytesMut::new();
let result = encode_getmetadata(
&mut buf,
"A001",
"INBOX",
&["/private/comment".to_owned()],
Some(u64::from(u32::MAX)),
None,
false,
LiteralMode::Synchronizing,
);
assert!(result.is_ok(), "max_size u32::MAX should be accepted");
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("MAXSIZE 4294967295"),
"should contain u32::MAX value, got: {output}"
);
}
#[test]
fn getmetadata_maxsize_u32_max_plus_one_rejected() {
let mut buf = BytesMut::new();
let result = encode_getmetadata(
&mut buf,
"A001",
"INBOX",
&["/private/comment".to_owned()],
Some(u64::from(u32::MAX) + 1),
None,
false,
LiteralMode::Synchronizing,
);
assert!(
result.is_err(),
"max_size u32::MAX + 1 must be rejected per RFC 5464 Section 5 / RFC 3501 Section 9"
);
}
#[test]
fn getmetadata_maxsize_u64_max_rejected() {
let mut buf = BytesMut::new();
let result = encode_getmetadata(
&mut buf,
"A001",
"INBOX",
&["/private/comment".to_owned()],
Some(u64::MAX),
None,
false,
LiteralMode::Synchronizing,
);
assert!(
result.is_err(),
"max_size u64::MAX must be rejected per RFC 5464 Section 5 / RFC 3501 Section 9"
);
}
#[test]
fn encode_select_qresync_rejects_zero_modseq() {
let cmd = Command::Select {
mailbox: MailboxName::new("INBOX").unwrap(),
condstore: false,
qresync: Some(crate::types::QresyncParams {
uid_validity: 1,
mod_seq: 0,
known_uids: None,
seq_match_data: None,
}),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"QRESYNC mod_seq 0 must be rejected per RFC 7162 Section 7"
);
}
#[test]
fn encode_setquota_rejects_resource_name_with_space() {
let mut buf = BytesMut::new();
let cmd = Command::SetQuota {
root: String::new(),
resources: vec![("BAD RESOURCE".into(), 1024)],
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"SETQUOTA resource name with space must be rejected \
(RFC 2087 Section 4.1: resource is atom; RFC 3501 Section 9: SP is atom-special)"
);
}
#[test]
fn encode_setquota_rejects_empty_resource_name() {
let mut buf = BytesMut::new();
let cmd = Command::SetQuota {
root: String::new(),
resources: vec![(String::new(), 1024)],
};
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"SETQUOTA with empty resource name must be rejected \
(RFC 2087 Section 4.1: resource is atom; RFC 3501 Section 9: atom = 1*ATOM-CHAR)"
);
}
#[test]
fn encode_list_non_ascii_reference() {
let mut buf = BytesMut::new();
let cmd = Command::List {
reference: "café".into(),
pattern: "*".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 LIST \"caf&AOk-\" \"*\"\r\n");
}
#[test]
fn encode_lsub_non_ascii_pattern() {
let mut buf = BytesMut::new();
let cmd = Command::Lsub {
reference: String::new(),
pattern: "日本語".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 LSUB \"\" \"&ZeVnLIqe-\"\r\n");
}
#[test]
fn encode_rename_non_ascii_both_args() {
let mut buf = BytesMut::new();
let cmd = Command::Rename {
mailbox: MailboxName::new("Ünread").unwrap(),
new_name: MailboxName::new("Gelöscht").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.starts_with("A001 RENAME "),
"command prefix mismatch: {output}"
);
assert!(
!output.contains('{'),
"modified UTF-7 encoded names should not need literals: {output}"
);
}
#[test]
fn encode_list_empty_reference_percent_pattern() {
let mut buf = BytesMut::new();
let cmd = Command::List {
reference: String::new(),
pattern: "%".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 LIST \"\" \"%\"\r\n");
}
#[test]
fn encode_lsub_with_reference_and_pattern() {
let mut buf = BytesMut::new();
let cmd = Command::Lsub {
reference: "INBOX.".into(),
pattern: "*".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 LSUB \"INBOX.\" \"*\"\r\n");
}
#[test]
fn sequence_set_newtype_rejects_invalid_values() {
assert!(SequenceSet::new("0").is_err());
assert!(SequenceSet::new("abc").is_err());
assert!(SequenceSet::new("").is_err());
}
#[test]
fn encode_non_uid_store_with_condstore_typical_value() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: SequenceSet::new("1:3").unwrap(),
operation: crate::types::StoreOperation::Remove,
flags: vec![crate::types::Flag::Seen],
unchanged_since: Some(12345),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 STORE 1:3 (UNCHANGEDSINCE 12345) -FLAGS (\\Seen)\r\n"
);
}
#[test]
fn encode_copy_with_special_char_mailbox() {
let mut buf = BytesMut::new();
let cmd = Command::Copy {
sequence_set: SequenceSet::new("1:5").unwrap(),
mailbox: MailboxName::new("folder\"name").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 COPY 1:5 \"folder\\\"name\"\r\n",
"mailbox with double-quote should use quoted form with escaping"
);
}
#[test]
fn encode_uid_move_with_non_ascii_mailbox() {
let mut buf = BytesMut::new();
let cmd = Command::UidMove {
sequence_set: SequenceSet::new("1:5").unwrap(),
mailbox: MailboxName::new("caf\u{00E9}").unwrap(), };
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 UID MOVE 1:5 \"caf&AOk-\"\r\n",
"non-ASCII mailbox must be modified UTF-7 encoded on the wire"
);
}
#[test]
fn encode_deleteacl_mailbox_with_spaces() {
let mut buf = BytesMut::new();
let cmd = Command::DeleteAcl {
mailbox: MailboxName::new("Shared Folders").unwrap(),
identifier: "user@example.com".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 DELETEACL \"Shared Folders\" \"user@example.com\"\r\n"
);
}
#[test]
fn encode_deleteacl_non_ascii_mailbox() {
let mut buf = BytesMut::new();
let cmd = Command::DeleteAcl {
mailbox: MailboxName::new("café").unwrap(),
identifier: "fred".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 DELETEACL \"caf&AOk-\" \"fred\"\r\n");
}
#[test]
fn encode_listrights_mailbox_with_spaces() {
let mut buf = BytesMut::new();
let cmd = Command::ListRights {
mailbox: MailboxName::new("Shared Folders").unwrap(),
identifier: "user@example.com".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 LISTRIGHTS \"Shared Folders\" \"user@example.com\"\r\n"
);
}
#[test]
fn encode_listrights_non_ascii_mailbox() {
let mut buf = BytesMut::new();
let cmd = Command::ListRights {
mailbox: MailboxName::new("café").unwrap(),
identifier: "fred".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 LISTRIGHTS \"caf&AOk-\" \"fred\"\r\n");
}
#[test]
fn encode_get_quota_non_ascii_root() {
let mut buf = BytesMut::new();
let cmd = Command::GetQuota {
root: "café".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 GETQUOTA {5}\r\ncaf\xc3\xa9\r\n");
}
#[test]
fn encode_get_quota_root_non_ascii() {
let mut buf = BytesMut::new();
let cmd = Command::GetQuotaRoot {
mailbox: MailboxName::new("café").unwrap(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 GETQUOTAROOT \"caf&AOk-\"\r\n");
}
#[test]
fn literal8_must_not_use_non_sync_plus() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[], None, 100, true, LiteralMode::LiteralPlus, true, )
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
!output.contains("+}"),
"literal8 must not contain non-synchronizing '+' suffix per RFC 9051 Section 9; got: {output}"
);
assert!(
output.contains("~{100}\r\n"),
"literal8 must use synchronizing form ~{{100}} per RFC 9051 Section 9; got: {output}"
);
}
#[test]
fn regular_literal_plus_still_works_without_utf8() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[], None, 200, true, LiteralMode::LiteralPlus, false, )
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("{200+}\r\n"),
"regular literal with LITERAL+ must use non-synchronizing form {{200+}}; got: {output}"
);
}
#[test]
fn synchronizing_literal_without_utf8() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[], None, 300, true, LiteralMode::Synchronizing, false, )
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("{300}\r\n"),
"synchronizing literal must use {{300}}; got: {output}"
);
assert!(
!output.contains('~'),
"non-UTF8 literal must not contain literal8 prefix '~'; got: {output}"
);
}
#[test]
fn validate_datetime_rejects_invalid_day_high_digit() {
let result = validate_append_datetime("40-Jan-2024 12:00:00 +0000");
assert!(
result.is_err(),
"day '40' must be rejected — first byte '4' is not in SP/'0'-'3' \
per RFC 3501 Section 9 date-day-fixed"
);
}
#[test]
fn validate_datetime_rejects_non_digit_day() {
let result = validate_append_datetime("X1-Jan-2024 12:00:00 +0000");
assert!(
result.is_err(),
"day 'X1' must be rejected — first byte 'X' is not valid \
per RFC 3501 Section 9 date-day-fixed"
);
}
#[test]
fn validate_datetime_rejects_day_starting_with_nine() {
let result = validate_append_datetime("91-Jan-2024 12:00:00 +0000");
assert!(
result.is_err(),
"day '91' must be rejected — first byte '9' falls through \
per RFC 3501 Section 9 date-day-fixed"
);
}
#[test]
fn validate_datetime_bad_separator_at_position_2() {
let result = validate_append_datetime("01/Jan-2024 12:00:00 +0000");
assert!(
result.is_err(),
"non '-' at position 2 must be rejected per RFC 3501 Section 9"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("position 2"),
"error should mention position 2, got: {err_msg}"
);
}
#[test]
fn validate_datetime_bad_separator_at_position_6() {
let result = validate_append_datetime("01-Jan/2024 12:00:00 +0000");
assert!(
result.is_err(),
"non '-' at position 6 must be rejected per RFC 3501 Section 9"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("position 6"),
"error should mention position 6, got: {err_msg}"
);
}
#[test]
fn validate_datetime_invalid_year() {
let result = validate_append_datetime("01-Jan-ABCD 12:00:00 +0000");
assert!(
result.is_err(),
"non-digit year 'ABCD' must be rejected per RFC 3501 Section 9 date-year = 4DIGIT"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("year"),
"error should mention year, got: {err_msg}"
);
}
#[test]
fn validate_datetime_bad_separator_at_position_11() {
let result = validate_append_datetime("01-Jan-2024X12:00:00 +0000");
assert!(
result.is_err(),
"non SP at position 11 must be rejected per RFC 3501 Section 9"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("position 11"),
"error should mention position 11, got: {err_msg}"
);
}
#[test]
fn validate_datetime_invalid_time_format() {
let result = validate_append_datetime("01-Jan-2024 12-00-00 +0000");
assert!(
result.is_err(),
"time 'HH-MM-SS' must be rejected — colons are required per RFC 3501 Section 9"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("time"),
"error should mention time, got: {err_msg}"
);
}
#[test]
fn validate_datetime_invalid_time_non_digit() {
let result = validate_append_datetime("01-Jan-2024 AB:CD:EF +0000");
assert!(
result.is_err(),
"non-digit time must be rejected per RFC 3501 Section 9 time = 2DIGIT \":\" 2DIGIT \":\" 2DIGIT"
);
}
#[test]
fn validate_datetime_bad_separator_at_position_20() {
let result = validate_append_datetime("01-Jan-2024 12:00:00X+0000");
assert!(
result.is_err(),
"non SP at position 20 must be rejected per RFC 3501 Section 9"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("position 20"),
"error should mention position 20, got: {err_msg}"
);
}
#[test]
fn validate_datetime_invalid_timezone_format() {
let result = validate_append_datetime("01-Jan-2024 12:00:00 X0000");
assert!(
result.is_err(),
"zone without +/- prefix must be rejected per RFC 3501 Section 9 zone format"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("zone"),
"error should mention zone, got: {err_msg}"
);
}
#[test]
fn validate_datetime_invalid_timezone_non_digit() {
let result = validate_append_datetime("01-Jan-2024 12:00:00 +ABCD");
assert!(
result.is_err(),
"zone with non-digit HHMM must be rejected per RFC 3501 Section 9"
);
}
#[test]
fn validate_datetime_timezone_hour_exceeds_14() {
let result = validate_append_datetime("01-Jan-2024 12:00:00 +1500");
assert!(
result.is_err(),
"timezone hour 15 must be rejected — maximum is 14 (RFC 3501 Section 9)"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("zone hour"),
"error should mention zone hour, got: {err_msg}"
);
}
#[test]
fn validate_datetime_timezone_hour_14_is_valid() {
let result = validate_append_datetime("01-Jan-2024 12:00:00 +1400");
assert!(
result.is_ok(),
"timezone hour 14 must be accepted — it is the maximum valid offset: {result:?}"
);
}
#[test]
fn validate_datetime_timezone_minute_exceeds_59() {
let result = validate_append_datetime("01-Jan-2024 12:00:00 +0060");
assert!(
result.is_err(),
"timezone minute 60 must be rejected — maximum is 59 (RFC 3501 Section 9)"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("zone minute"),
"error should mention zone minute, got: {err_msg}"
);
}
#[test]
fn validate_datetime_timezone_minute_59_is_valid() {
let result = validate_append_datetime("01-Jan-2024 12:00:00 +0059");
assert!(
result.is_ok(),
"timezone minute 59 must be accepted — it is the maximum valid value: {result:?}"
);
}
#[test]
fn validate_datetime_timezone_hour_14_allows_any_minute() {
assert!(
validate_append_datetime("01-Jan-2024 12:00:00 +1400").is_ok(),
"+1400 must be accepted — it is the maximum real UTC offset"
);
assert!(
validate_append_datetime("01-Jan-2024 12:00:00 +1430").is_ok(),
"+1430 must be accepted — RFC 3501 Section 9 zone grammar allows any 4DIGIT"
);
assert!(
validate_append_datetime("01-Jan-2024 12:00:00 -1400").is_ok(),
"-1400 must be accepted — symmetric with +1400"
);
assert!(
validate_append_datetime("01-Jan-2024 12:00:00 -1430").is_ok(),
"-1430 must be accepted — RFC 3501 Section 9 zone grammar allows any 4DIGIT"
);
}
#[test]
fn validate_datetime_timezone_hour_14_with_nonzero_minutes() {
assert!(
validate_append_datetime("01-Jan-2024 12:00:00 +1430").is_ok(),
"+1430 must be accepted — RFC 3501 Section 9 zone grammar allows any 4DIGIT"
);
assert!(
validate_append_datetime("01-Jan-2024 12:00:00 +1445").is_ok(),
"+1445 must be accepted — RFC 3501 Section 9 zone grammar allows any 4DIGIT"
);
assert!(
validate_append_datetime("01-Jan-2024 12:00:00 -1430").is_ok(),
"-1430 must be accepted — RFC 3501 Section 9 zone grammar allows any 4DIGIT"
);
}
#[test]
fn from_utf8_lossy_handles_non_utf8_encoded_output() {
let mut buf = BytesMut::new();
let value = b"\x80\x81\xfe\xff".to_vec();
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![("/private/binary".into(), Some(value))],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let output = &buf[..];
let lossy = String::from_utf8_lossy(output);
assert!(
lossy.contains('\u{FFFD}'),
"from_utf8_lossy must replace non-UTF8 bytes with U+FFFD; got: {lossy}"
);
assert!(
lossy.contains("SETMETADATA"),
"lossy output must preserve ASCII command keyword; got: {lossy}"
);
assert!(
lossy.contains("INBOX"),
"lossy output must preserve ASCII mailbox name; got: {lossy}"
);
}
#[test]
fn from_utf8_lossy_with_nul_bytes_in_output() {
let mut buf = BytesMut::new();
let value = b"\x00\x00".to_vec();
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![("/private/binary".into(), Some(value))],
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let output = &buf[..];
let lossy = String::from_utf8_lossy(output);
assert!(
lossy.contains("SETMETADATA"),
"lossy output must preserve command keyword even with NUL bytes; got: {lossy}"
);
let nul_count = output
.iter()
.fold(0usize, |acc, &b| acc + usize::from(b == 0x00));
assert_eq!(
nul_count, 2,
"both NUL bytes must be preserved in the encoded output"
);
}
#[test]
fn encode_command_login_literal_plus() {
let mut buf = BytesMut::new();
let cmd = Command::Login {
user: "alice".into(),
pass: "pass\r\nword".into(),
};
encode_command_to_buf(
&mut buf,
"A001",
&cmd,
&opts(LiteralMode::LiteralPlus, false),
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("{10+}\r\n"),
"with literal_plus=true, literal must use non-synchronizing form {{N+}}; got: {output}"
);
}
#[test]
fn encode_command_login_synchronizing_literal() {
let mut buf = BytesMut::new();
let cmd = Command::Login {
user: "alice".into(),
pass: "pass\r\nword".into(),
};
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("{10}\r\n"),
"with literal_plus=false, literal must use synchronizing form {{N}}; got: {output}"
);
assert!(
!output.contains("{10+}"),
"with literal_plus=false, literal must NOT use non-synchronizing form; got: {output}"
);
}
#[test]
fn encode_login_sync_literal_splits_into_segments() {
let cmd = Command::Login {
user: "alice".into(),
pass: "pass\r\nword".into(),
};
let encoded = encode_command("A001", &cmd, &default_opts()).unwrap();
let segments = encoded.segments();
assert_eq!(
segments.len(),
2,
"LOGIN with synchronizing literal must produce 2 segments \
(RFC 3501 Section 4.3); got {} segment(s): {:?}",
segments.len(),
segments
.iter()
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect::<Vec<_>>()
);
let seg0 = std::str::from_utf8(&segments[0]).unwrap();
assert!(
seg0.starts_with("A001 LOGIN \"alice\" {10}\r\n"),
"first segment should be the command prefix ending with {{10}}\\r\\n; \
got: {seg0:?}"
);
assert_eq!(
&segments[1][..],
b"pass\r\nword\r\n",
"second segment should be the literal body followed by command CRLF"
);
}
#[test]
fn encode_login_literal_plus_single_segment() {
let cmd = Command::Login {
user: "alice".into(),
pass: "pass\r\nword".into(),
};
let encoded = encode_command("A001", &cmd, &opts(LiteralMode::LiteralPlus, false)).unwrap();
let segments = encoded.segments();
assert_eq!(
segments.len(),
1,
"LOGIN with literal_plus=true must be a single segment \
(RFC 7888 Section 4); got {} segments",
segments.len()
);
}
#[test]
fn encode_login_two_sync_literals_three_segments() {
let cmd = Command::Login {
user: "us\r\ner".into(),
pass: "pass\r\nword".into(),
};
let encoded = encode_command("A001", &cmd, &default_opts()).unwrap();
let segments = encoded.segments();
assert_eq!(
segments.len(),
3,
"LOGIN with two synchronizing literals must produce 3 segments \
(RFC 3501 Section 4.3); got {} segment(s): {:?}",
segments.len(),
segments
.iter()
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect::<Vec<_>>()
);
let seg0 = std::str::from_utf8(&segments[0]).unwrap();
assert!(
seg0.ends_with("{6}\r\n"),
"first segment should end with the user literal marker {{6}}\\r\\n; \
got: {seg0:?}"
);
let seg1_bytes = &segments[1][..];
assert_eq!(
&seg1_bytes[..6],
b"us\r\ner",
"second segment should start with the user literal body"
);
let seg1_rest = std::str::from_utf8(&seg1_bytes[6..]).unwrap();
assert_eq!(
seg1_rest, " {10}\r\n",
"second segment should end with space + password literal marker"
);
assert_eq!(
&segments[2][..],
b"pass\r\nword\r\n",
"third segment should be the password literal body + command CRLF"
);
}
#[test]
fn regression_setmetadata_literal8_produces_two_segments_without_literal_plus() {
let cmd = Command::SetMetadata {
mailbox: MailboxName::new("INBOX").unwrap(),
entries: vec![("/private/binary".into(), Some(b"\x00\x01\x02".to_vec()))],
};
let encoded = encode_command("A001", &cmd, &default_opts()).unwrap();
let segments = encoded.segments();
assert_eq!(
segments.len(),
2,
"SETMETADATA with literal8 and literal_plus=false must produce 2 segments \
(RFC 3516 Section 4 / RFC 9051 Section 9: literal8 is synchronizing); \
got {} segment(s): {:?}",
segments.len(),
segments
.iter()
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect::<Vec<_>>()
);
let seg0 = &segments[0];
assert!(
seg0.ends_with(b"~{3}\r\n"),
"first segment should end with literal8 marker ~{{3}}\\r\\n; \
got: {:?}",
String::from_utf8_lossy(seg0)
);
assert!(
segments[1].starts_with(b"\x00\x01\x02"),
"second segment should start with the literal8 body"
);
}
#[test]
fn encode_command_no_literal_single_segment() {
let cmd = Command::Login {
user: "alice".into(),
pass: "secret".into(),
};
let encoded = encode_command("A001", &cmd, &default_opts()).unwrap();
assert_eq!(
encoded.segments().len(),
1,
"command without literals should be a single segment"
);
assert_eq!(
&encoded.into_buf()[..],
b"A001 LOGIN \"alice\" \"secret\"\r\n"
);
}
#[test]
fn encoded_command_into_buf_round_trips() {
let cmd = Command::Login {
user: "alice".into(),
pass: "pass\r\nword".into(),
};
let encoded = encode_command("A001", &cmd, &default_opts()).unwrap();
let concatenated = encoded.into_buf();
let mut flat = BytesMut::new();
encode_command_to_buf(&mut flat, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&concatenated[..],
&flat[..],
"into_buf() must produce the same bytes as the flat encoder"
);
}
#[test]
fn encode_select_utf8_mailbox_quoted_when_utf8_enabled() {
let cmd = Command::Select {
mailbox: MailboxName::new("日本語").unwrap(),
condstore: false,
qresync: None,
};
let encoded = encode_command("A001", &cmd, &opts(LiteralMode::Synchronizing, true)).unwrap();
let wire = encoded.into_buf();
let wire_str = std::str::from_utf8(&wire).unwrap();
assert_eq!(
wire_str, "A001 SELECT \"日本語\"\r\n",
"Non-ASCII mailbox should be quoted when UTF8=ACCEPT is active \
(RFC 6855 Section 3); got literal instead"
);
}
#[test]
fn encode_select_utf8_mailbox_literal_when_utf8_disabled() {
let cmd = Command::Select {
mailbox: MailboxName::new("日本語").unwrap(),
condstore: false,
qresync: None,
};
let encoded = encode_command("A001", &cmd, &default_opts()).unwrap();
let wire = encoded.into_buf();
let wire_str = std::str::from_utf8(&wire).unwrap();
assert!(
!wire_str.contains('{'),
"modified UTF-7 encoded mailbox should be quoted, not literal: {wire_str}"
);
assert!(
wire_str.starts_with("A001 SELECT \""),
"expected quoted mailbox name: {wire_str}"
);
}
#[test]
fn encode_login_rejects_utf8_credentials_even_when_utf8_enabled() {
let cmd = Command::Login {
user: "ユーザー".into(),
pass: "пароль".into(),
};
let result = encode_command("A001", &cmd, &opts(LiteralMode::Synchronizing, true));
assert!(
matches!(result, Err(EncodeError::Validation(ref msg)) if msg.contains("RFC 6855 Section 5")),
"LOGIN with non-ASCII credentials must be rejected even when UTF8=ACCEPT is active \
(RFC 6855 Section 5); got: {result:?}"
);
}
#[test]
fn encode_mailbox_cmds_utf8_quoted() {
for (variant, keyword) in [
("create", "CREATE"),
("delete", "DELETE"),
("subscribe", "SUBSCRIBE"),
("unsubscribe", "UNSUBSCRIBE"),
] {
let cmd = match variant {
"create" => Command::Create {
mailbox: MailboxName::new("Ångström").unwrap(),
},
"delete" => Command::Delete {
mailbox: MailboxName::new("Ångström").unwrap(),
},
"subscribe" => Command::Subscribe {
mailbox: MailboxName::new("Ångström").unwrap(),
},
"unsubscribe" => Command::Unsubscribe {
mailbox: MailboxName::new("Ångström").unwrap(),
},
_ => unreachable!(),
};
let encoded = encode_command("T1", &cmd, &opts(LiteralMode::Synchronizing, true)).unwrap();
let buf = encoded.into_buf();
let wire = std::str::from_utf8(&buf).unwrap();
assert_eq!(
wire,
format!("T1 {keyword} \"Ångström\"\r\n"),
"{keyword} with UTF-8 mailbox should produce quoted string \
when UTF8=ACCEPT is active (RFC 6855 Section 3)"
);
}
}
#[test]
fn literal_minus_large_literal_uses_synchronizing_form() {
let large_pass = format!("pass\r\n{}", "x".repeat(4100));
let cmd = Command::Login {
user: "alice".into(),
pass: large_pass.clone(),
};
let mut buf = BytesMut::new();
encode_command_to_buf(
&mut buf,
"A001",
&cmd,
&opts(LiteralMode::LiteralMinus, false),
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
let expected_size = large_pass.len();
assert!(
output.contains(&format!("{{{expected_size}}}\r\n")),
"LITERAL- with literal > 4096 bytes must use synchronizing form \
{{N}}\\r\\n (RFC 7888 Section 5); got: {output}"
);
assert!(
!output.contains(&format!("{{{expected_size}+}}")),
"LITERAL- with literal > 4096 bytes must NOT use non-synchronizing \
form {{N+}} (RFC 7888 Section 5); got: {output}"
);
}
#[test]
fn literal_minus_small_literal_uses_non_synchronizing_form() {
let cmd = Command::Login {
user: "alice".into(),
pass: "pass\r\nword".into(),
};
let mut buf = BytesMut::new();
encode_command_to_buf(
&mut buf,
"A001",
&cmd,
&opts(LiteralMode::LiteralMinus, false),
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("{10+}\r\n"),
"LITERAL- with literal <= 4096 bytes must use non-synchronizing form \
{{N+}}\\r\\n (RFC 7888 Section 5); got: {output}"
);
}
#[test]
fn literal_minus_boundary_4096_uses_non_synchronizing() {
let pass = format!("x\r\n{}", "a".repeat(4093));
assert_eq!(pass.len(), 4096);
let cmd = Command::Login {
user: "u".into(),
pass,
};
let mut buf = BytesMut::new();
encode_command_to_buf(
&mut buf,
"A001",
&cmd,
&opts(LiteralMode::LiteralMinus, false),
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("{4096+}\r\n"),
"LITERAL- with literal of exactly 4096 bytes must use non-synchronizing \
form (RFC 7888 Section 5); got: {output}"
);
}
#[test]
fn literal_minus_boundary_4097_uses_synchronizing() {
let pass = format!("x\r\n{}", "a".repeat(4094));
assert_eq!(pass.len(), 4097);
let cmd = Command::Login {
user: "u".into(),
pass,
};
let mut buf = BytesMut::new();
encode_command_to_buf(
&mut buf,
"A001",
&cmd,
&opts(LiteralMode::LiteralMinus, false),
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("{4097}\r\n"),
"LITERAL- with literal of 4097 bytes must use synchronizing form \
(RFC 7888 Section 5); got: {output}"
);
assert!(
!output.contains("{4097+}"),
"LITERAL- with literal of 4097 bytes must NOT use non-synchronizing \
form (RFC 7888 Section 5); got: {output}"
);
}
#[test]
fn literal_plus_large_literal_uses_non_synchronizing() {
let large_pass = format!("pass\r\n{}", "x".repeat(10_000));
let cmd = Command::Login {
user: "alice".into(),
pass: large_pass.clone(),
};
let mut buf = BytesMut::new();
encode_command_to_buf(
&mut buf,
"A001",
&cmd,
&opts(LiteralMode::LiteralPlus, false),
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
let expected_size = large_pass.len();
assert!(
output.contains(&format!("{{{expected_size}+}}\r\n")),
"LITERAL+ must use non-synchronizing form {{N+}}\\r\\n for all sizes \
(RFC 7888 Section 4); got: {output}"
);
}
#[test]
fn literal_minus_large_literal_produces_segments() {
let large_pass = format!("pass\r\n{}", "x".repeat(5000));
let cmd = Command::Login {
user: "alice".into(),
pass: large_pass,
};
let encoded = encode_command("A001", &cmd, &opts(LiteralMode::LiteralMinus, false)).unwrap();
let segments = encoded.segments();
assert!(
segments.len() > 1,
"LITERAL- with literal > 4096 bytes must produce multiple segments \
for synchronizing literal handling; got {} segment(s)",
segments.len()
);
}
#[test]
fn literal_minus_small_literal_produces_single_segment() {
let cmd = Command::Login {
user: "alice".into(),
pass: "pass\r\nword".into(),
};
let encoded = encode_command("A001", &cmd, &opts(LiteralMode::LiteralMinus, false)).unwrap();
let segments = encoded.segments();
assert_eq!(
segments.len(),
1,
"LITERAL- with literal <= 4096 bytes must produce a single segment \
(non-synchronizing, RFC 7888 Section 5); got {} segments",
segments.len()
);
}
#[test]
fn literal_minus_multi_append_large_message_synchronizing() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
None,
5000,
true,
LiteralMode::LiteralMinus,
false,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("{5000}\r\n"),
"LITERAL- with message > 4096 bytes must use synchronizing literal \
in MULTIAPPEND (RFC 7888 Section 5); got: {output}"
);
assert!(
!output.contains("{5000+}"),
"LITERAL- with message > 4096 bytes must NOT use non-synchronizing \
literal in MULTIAPPEND (RFC 7888 Section 5); got: {output}"
);
}
#[test]
fn literal_minus_multi_append_small_message_non_synchronizing() {
let mut buf = BytesMut::new();
encode_multi_append_header(
&mut buf,
"A001",
"INBOX",
&[],
None,
100,
true,
LiteralMode::LiteralMinus,
false,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("{100+}\r\n"),
"LITERAL- with message <= 4096 bytes must use non-synchronizing \
literal in MULTIAPPEND (RFC 7888 Section 5); got: {output}"
);
}
#[test]
fn crlf_injection_search_criteria_rejected() {
let cmd = Command::Search {
criteria: "ALL\r\nA002 DELETE INBOX".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"SEARCH criteria with CRLF must be rejected (RFC 3501 Section 2.2)"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("CR or LF"),
"error message should mention CR or LF: {err_msg}"
);
}
#[test]
fn crlf_injection_uid_search_criteria_rejected() {
let cmd = Command::UidSearch {
criteria: "ALL\r\nA002 DELETE INBOX".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"UID SEARCH criteria with CRLF must be rejected (RFC 3501 Section 2.2)"
);
}
#[test]
fn crlf_injection_search_return_opts_rejected() {
let cmd = Command::SearchReturn {
criteria: "ALL".into(),
return_opts: vec!["MIN\r\nA002 DELETE INBOX".into()],
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"SEARCH RETURN option with CRLF must be rejected (RFC 3501 Section 2.2)"
);
}
#[test]
fn crlf_injection_status_items_rejected() {
let cmd = Command::Status {
mailbox: MailboxName::new("INBOX").unwrap(),
items: "(MESSAGES)\r\nA002 DELETE INBOX".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"STATUS items with CRLF must be rejected (RFC 3501 Section 2.2)"
);
}
#[test]
fn crlf_injection_fetch_items_rejected() {
let cmd = Command::Fetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "FLAGS\r\nA002 DELETE INBOX".into(),
changed_since: None,
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"FETCH items with CRLF must be rejected (RFC 3501 Section 2.2)"
);
}
#[test]
fn crlf_injection_uid_fetch_items_rejected() {
let cmd = Command::UidFetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "FLAGS\r\nA002 DELETE INBOX".into(),
changed_since: None,
vanished: false,
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"UID FETCH items with CRLF must be rejected (RFC 3501 Section 2.2)"
);
}
#[test]
fn crlf_injection_list_status_items_rejected() {
let cmd = Command::ListStatus {
reference: String::new(),
pattern: "*".into(),
status_items: "MESSAGES\r\nA002 DELETE INBOX".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"LIST-STATUS items with CRLF must be rejected (RFC 3501 Section 2.2)"
);
}
#[test]
fn crlf_injection_thread_criteria_rejected() {
let cmd = Command::Thread {
algorithm: "REFERENCES".into(),
charset: "UTF-8".into(),
criteria: "ALL\r\nA002 DELETE INBOX".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"THREAD criteria with CRLF must be rejected (RFC 3501 Section 2.2)"
);
}
#[test]
fn crlf_injection_sort_criteria_rejected() {
let cmd = Command::Sort {
sort_criteria: "DATE".into(),
charset: "UTF-8".into(),
criteria: "ALL\r\nA002 DELETE INBOX".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"SORT criteria with CRLF must be rejected (RFC 3501 Section 2.2)"
);
}
#[test]
fn crlf_injection_bare_lf_rejected() {
let cmd = Command::Search {
criteria: "ALL\nA002 DELETE INBOX".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"bare LF in SEARCH criteria must be rejected (RFC 3501 Section 2.2)"
);
}
#[test]
fn crlf_injection_bare_cr_rejected() {
let cmd = Command::Search {
criteria: "ALL\rA002 DELETE INBOX".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"bare CR in SEARCH criteria must be rejected (RFC 3501 Section 2.2)"
);
}
#[test]
fn crlf_injection_normal_search_succeeds() {
let cmd = Command::Search {
criteria: "ALL".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"normal SEARCH criteria must succeed: {result:?}"
);
assert_eq!(&buf[..], b"A001 SEARCH ALL\r\n");
}
#[test]
fn search_rejects_non_synchronizing_literal_without_extension() {
let cmd = Command::Search {
criteria: "TEXT {3+}\r\nfoo".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"SEARCH must reject non-synchronizing literals without negotiated \
LITERAL+/LITERAL- support (RFC 7888 Section 3)"
);
}
#[test]
fn search_allows_non_synchronizing_literal_with_literal_plus() {
let cmd = Command::Search {
criteria: "TEXT {3+}\r\nfoo".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(
&mut buf,
"A001",
&cmd,
&opts(LiteralMode::LiteralPlus, false),
);
assert!(
result.is_ok(),
"SEARCH should allow non-synchronizing literals once LITERAL+ is active: {result:?}"
);
assert_eq!(&buf[..], b"A001 SEARCH TEXT {3+}\r\nfoo\r\n");
}
#[test]
fn crlf_injection_normal_status_succeeds() {
let cmd = Command::Status {
mailbox: MailboxName::new("INBOX").unwrap(),
items: "(MESSAGES UNSEEN)".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"normal STATUS items must succeed: {result:?}"
);
}
#[test]
fn status_raw_items_are_parenthesized() {
let cmd = Command::Status {
mailbox: MailboxName::new("INBOX").unwrap(),
items: "MESSAGES UNSEEN".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"raw STATUS items should be normalized to a valid status-att-list: {result:?}"
);
assert_eq!(&buf[..], b"A001 STATUS \"INBOX\" (MESSAGES UNSEEN)\r\n");
}
#[test]
fn status_rejects_empty_items_list() {
let cmd = Command::Status {
mailbox: MailboxName::new("INBOX").unwrap(),
items: "()".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"empty STATUS item list must be rejected (RFC 3501 Section 6.3.10)"
);
}
#[test]
fn list_status_rejects_empty_status_items() {
let cmd = Command::ListStatus {
reference: String::new(),
pattern: "*".into(),
status_items: String::new(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"LIST-STATUS must reject an empty status data item list (RFC 5819 Section 2)"
);
}
#[test]
fn search_rejects_empty_criteria() {
let cmd = Command::Search {
criteria: String::new(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"empty SEARCH criteria must be rejected (RFC 3501 Section 6.4.4)"
);
}
#[test]
fn thread_rejects_empty_criteria() {
let cmd = Command::Thread {
algorithm: "REFERENCES".into(),
charset: "UTF-8".into(),
criteria: " ".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"empty THREAD criteria must be rejected (RFC 5256 Section 6)"
);
}
#[test]
fn sort_rejects_empty_criteria() {
let cmd = Command::Sort {
sort_criteria: "DATE".into(),
charset: "UTF-8".into(),
criteria: String::new(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"empty SORT criteria must be rejected (RFC 5256 Section 6)"
);
}
#[test]
fn thread_rejects_oversized_non_synchronizing_literal_in_literal_minus_mode() {
let oversized = "a".repeat(4097);
let cmd = Command::Thread {
algorithm: "REFERENCES".into(),
charset: "UTF-8".into(),
criteria: format!("TEXT {{4097+}}\r\n{oversized}"),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(
&mut buf,
"A001",
&cmd,
&opts(LiteralMode::LiteralMinus, false),
);
assert!(
result.is_err(),
"THREAD must reject non-synchronizing literals larger than 4096 octets \
in LITERAL- mode (RFC 7888 Section 5 / RFC 9051 Section 4.3)"
);
}
#[test]
fn crlf_injection_normal_fetch_succeeds() {
let cmd = Command::Fetch {
sequence_set: SequenceSet::new("1:*").unwrap(),
items: "(FLAGS ENVELOPE)".into(),
changed_since: None,
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"normal FETCH items must succeed: {result:?}"
);
}
#[test]
fn fetch_rejects_empty_items() {
let cmd = Command::Fetch {
sequence_set: SequenceSet::new("1").unwrap(),
items: "()".into(),
changed_since: None,
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"empty FETCH item list must be rejected (RFC 3501 Section 6.4.5)"
);
}
#[test]
fn uid_fetch_rejects_empty_items() {
let cmd = Command::UidFetch {
sequence_set: SequenceSet::new("1").unwrap(),
items: " ".into(),
changed_since: None,
vanished: false,
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"empty UID FETCH item list must be rejected (RFC 3501 Section 6.4.5)"
);
}
#[test]
fn fetch_rejects_unbalanced_inner_delimiters() {
let cmd = Command::Fetch {
sequence_set: SequenceSet::new("1").unwrap(),
items: "BODY[HEADER.FIELDS (SUBJECT)".into(),
changed_since: None,
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"malformed inner FETCH delimiters must be rejected (RFC 3501 Section 6.4.5)"
);
}
#[test]
fn uid_fetch_rejects_unbalanced_inner_delimiters() {
let cmd = Command::UidFetch {
sequence_set: SequenceSet::new("1").unwrap(),
items: "BODY.PEEK[HEADER.FIELDS (SUBJECT)".into(),
changed_since: None,
vanished: false,
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"malformed inner UID FETCH delimiters must be rejected \
(RFC 3501 Section 6.4.5 / RFC 9051 Section 6.4.5)"
);
}
#[test]
fn crlf_injection_normal_list_status_succeeds() {
let cmd = Command::ListStatus {
reference: String::new(),
pattern: "*".into(),
status_items: "MESSAGES UNSEEN".into(),
};
let mut buf = BytesMut::new();
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"normal LIST-STATUS items must succeed: {result:?}"
);
}
#[test]
fn validate_no_crlf_rejects_cr() {
let result = validate_no_crlf("foo\rbar", "test");
assert!(result.is_err());
}
#[test]
fn validate_no_crlf_rejects_lf() {
let result = validate_no_crlf("foo\nbar", "test");
assert!(result.is_err());
}
#[test]
fn validate_no_crlf_rejects_crlf() {
let result = validate_no_crlf("foo\r\nbar", "test");
assert!(result.is_err());
}
#[test]
fn validate_no_crlf_accepts_clean_input() {
let result = validate_no_crlf("ALL UNSEEN", "test");
assert!(result.is_ok());
}
#[test]
fn encode_authenticate_rejects_crlf_in_initial_response() {
let cmd = Command::Authenticate {
mechanism: "PLAIN".into(),
initial_response: Some("dXNlcg==\r\nA002 LOGOUT".into()),
};
let result = encode_command("A001", &cmd, &default_opts());
assert!(
result.is_err(),
"CRLF in SASL-IR initial response must be rejected"
);
}
#[test]
fn encode_authenticate_rejects_non_base64_initial_response() {
let cmd = Command::Authenticate {
mechanism: "PLAIN".into(),
initial_response: Some("not base64! @#".into()),
};
let result = encode_command("A001", &cmd, &default_opts());
assert!(result.is_err(), "Non-base64 SASL-IR must be rejected");
}
#[test]
fn encode_authenticate_accepts_valid_base64_initial_response() {
let cmd = Command::Authenticate {
mechanism: "PLAIN".into(),
initial_response: Some("dXNlcgBwYXNz".into()),
};
let result = encode_command("A001", &cmd, &default_opts());
assert!(result.is_ok());
}
#[test]
fn encode_authenticate_rejects_malformed_base64_initial_response() {
let cmd = Command::Authenticate {
mechanism: "PLAIN".into(),
initial_response: Some("==".into()),
};
let result = encode_command("A001", &cmd, &default_opts());
assert!(result.is_err(), "Malformed base64 SASL-IR must be rejected");
}
#[test]
fn encode_search_rejects_charset_when_utf8_enabled() {
let cmd = Command::Search {
criteria: "CHARSET UTF-8 ALL".into(),
};
let result = encode_command("A001", &cmd, &opts(LiteralMode::Synchronizing, true));
assert!(
matches!(result, Err(EncodeError::Validation(ref msg)) if msg.contains("RFC 6855 Section 3")),
"SEARCH with CHARSET must be rejected when UTF8=ACCEPT is active \
(RFC 6855 Section 3); got: {result:?}"
);
}
#[test]
fn encode_authenticate_accepts_empty_initial_response_marker() {
let cmd = Command::Authenticate {
mechanism: "PLAIN".into(),
initial_response: Some(String::new()),
};
let result = encode_command("A001", &cmd, &default_opts());
assert!(result.is_ok());
let encoded = result.unwrap();
let s = String::from_utf8(encoded.into_buf().to_vec()).unwrap();
assert!(
s.contains(" =\r\n"),
"Empty initial response should encode as '='"
);
}
mod prop_roundtrip {
use super::*;
use crate::codec::decode;
use crate::types::response::{GreetingStatus, Response, StatusKind};
use proptest::prelude::*;
fn arb_tag() -> impl Strategy<Value = String> {
prop::string::string_regex("[A-Za-z][A-Za-z0-9]{0,10}").expect("valid regex")
}
fn arb_mailbox() -> impl Strategy<Value = MailboxName> {
prop_oneof![
Just("INBOX".to_string()),
prop::string::string_regex("[A-Za-z][A-Za-z0-9./-]{0,20}").expect("valid regex"),
]
.prop_map(|s| MailboxName::new(s).expect("generated mailbox must be valid"))
}
fn arb_sequence_set() -> impl Strategy<Value = SequenceSet> {
prop_oneof![
(1u32..=99999).prop_map(|n| n.to_string()),
(1u32..=999, 1u32..=99999).prop_map(|(a, b)| format!("{a}:{b}")),
Just("1:*".to_string()),
]
.prop_map(|s| SequenceSet::new(s).expect("generated sequence set must be valid"))
}
fn arb_simple_command() -> impl Strategy<Value = Command> {
prop_oneof![
Just(Command::Noop),
Just(Command::Capability),
Just(Command::Logout),
Just(Command::StartTls),
Just(Command::Close),
Just(Command::Expunge),
Just(Command::Check),
Just(Command::Namespace),
Just(Command::Idle),
Just(Command::Compress),
arb_mailbox().prop_map(|mailbox| Command::Select {
mailbox,
condstore: false,
qresync: None,
}),
arb_mailbox().prop_map(|mailbox| Command::Examine {
mailbox,
condstore: false,
qresync: None,
}),
arb_mailbox().prop_map(|mailbox| Command::Create { mailbox }),
arb_mailbox().prop_map(|mailbox| Command::Delete { mailbox }),
arb_mailbox().prop_map(|mailbox| Command::Subscribe { mailbox }),
arb_mailbox().prop_map(|mailbox| Command::Unsubscribe { mailbox }),
arb_mailbox().prop_map(|mailbox| Command::Status {
mailbox,
items: "(MESSAGES RECENT UNSEEN)".into(),
}),
(arb_sequence_set(), arb_mailbox()).prop_map(|(ss, mb)| Command::Copy {
sequence_set: ss,
mailbox: mb,
}),
(arb_sequence_set(), arb_mailbox()).prop_map(|(ss, mb)| Command::UidCopy {
sequence_set: ss,
mailbox: mb,
}),
(arb_sequence_set(), arb_mailbox()).prop_map(|(ss, mb)| Command::Move {
sequence_set: ss,
mailbox: mb,
}),
arb_sequence_set().prop_map(|ss| Command::Fetch {
sequence_set: ss,
items: "(FLAGS)".into(),
changed_since: None,
}),
arb_sequence_set().prop_map(|ss| Command::UidFetch {
sequence_set: ss,
items: "(FLAGS UID)".into(),
changed_since: None,
vanished: false,
}),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(300))]
#[test]
fn command_well_formed(
tag in arb_tag(),
cmd in arb_simple_command(),
) {
let result = encode_command(&tag, &cmd, &default_opts());
if let Ok(encoded) = result {
let buf = encoded.into_buf();
let bytes = &buf[..];
prop_assert!(
bytes.ends_with(b"\r\n"),
"command must end with CRLF"
);
let tag_prefix = format!("{tag} ");
prop_assert!(
bytes.starts_with(tag_prefix.as_bytes()),
"command must start with '{tag} ', got: {:?}",
String::from_utf8_lossy(&bytes[..bytes.len().min(40)])
);
prop_assert!(
!bytes.contains(&0u8),
"command must not contain NUL bytes"
);
}
}
#[test]
fn literal_plus_single_segment(
tag in arb_tag(),
cmd in arb_simple_command(),
) {
let result = encode_command(&tag, &cmd, &opts(LiteralMode::LiteralPlus, false));
if let Ok(encoded) = result {
prop_assert_eq!(
encoded.segments().len(),
1,
"LITERAL+ commands must be single-segment"
);
}
}
#[test]
fn select_contains_mailbox(mailbox in arb_mailbox()) {
let cmd = Command::Select {
mailbox,
condstore: false,
qresync: None,
};
let encoded = encode_command("T1", &cmd, &opts(LiteralMode::LiteralPlus, false))
.expect("SELECT encoding must succeed");
let s = String::from_utf8(encoded.into_buf().to_vec())
.expect("SELECT must produce valid UTF-8");
prop_assert!(
s.starts_with("T1 SELECT "),
"SELECT must start with tag and keyword"
);
prop_assert!(
s.ends_with("\r\n"),
"SELECT must end with CRLF"
);
}
#[test]
fn fetch_contains_sequence_set(seq in arb_sequence_set()) {
let cmd = Command::Fetch {
sequence_set: seq.clone(),
items: "(FLAGS)".into(),
changed_since: None,
};
let encoded = encode_command("T1", &cmd, &opts(LiteralMode::LiteralPlus, false))
.expect("FETCH encoding must succeed");
let s = String::from_utf8(encoded.into_buf().to_vec())
.expect("FETCH must produce valid UTF-8");
prop_assert!(
s.starts_with("T1 FETCH "),
"FETCH must start with tag and keyword"
);
prop_assert!(
s.contains(seq.as_str()),
"FETCH must contain the sequence set '{}'",
seq
);
}
}
fn format_tagged_response(tag: &str, status: &str, text: &str) -> Vec<u8> {
format!("{tag} {status} {text}\r\n").into_bytes()
}
fn format_greeting(status: &str, text: &str) -> Vec<u8> {
format!("* {status} {text}\r\n").into_bytes()
}
fn arb_response_text() -> impl Strategy<Value = String> {
prop::string::string_regex("[A-Za-z][A-Za-z0-9 _.,-]{0,50}").expect("valid regex")
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn tagged_response_roundtrip(
tag in arb_tag(),
status_idx in 0u8..3,
text in arb_response_text(),
) {
let (status_str, expected_status) = match status_idx {
0 => ("OK", StatusKind::Ok),
1 => ("NO", StatusKind::No),
_ => ("BAD", StatusKind::Bad),
};
let wire = format_tagged_response(&tag, status_str, &text);
match decode::parse_response(&wire) {
Ok((remaining, Response::Tagged(resp))) => {
prop_assert!(
remaining.is_empty(),
"parser left unconsumed bytes"
);
prop_assert_eq!(&resp.tag, &tag, "tag mismatch");
prop_assert_eq!(resp.status, expected_status, "status mismatch");
prop_assert_eq!(resp.text.trim(), text.trim(), "text mismatch");
}
Ok((_, other)) => {
prop_assert!(
false,
"expected Tagged response, got: {:?}",
std::mem::discriminant(&other)
);
}
Err(e) => {
prop_assert!(
false,
"parse_response failed: {:?}\nwire: {:?}",
e,
String::from_utf8_lossy(&wire)
);
}
}
}
#[test]
fn greeting_roundtrip(
status_idx in 0u8..3,
text in arb_response_text(),
) {
let (status_str, expected_status) = match status_idx {
0 => ("OK", GreetingStatus::Ok),
1 => ("PREAUTH", GreetingStatus::PreAuth),
_ => ("BYE", GreetingStatus::Bye),
};
let wire = format_greeting(status_str, &text);
match decode::parse_greeting(&wire) {
Ok((remaining, Response::Greeting(resp))) => {
prop_assert!(
remaining.is_empty(),
"parser left unconsumed bytes"
);
prop_assert_eq!(resp.status, expected_status, "greeting status mismatch");
prop_assert_eq!(resp.text.trim(), text.trim(), "greeting text mismatch");
}
Ok((_, other)) => {
prop_assert!(
false,
"expected Greeting response, got: {:?}",
std::mem::discriminant(&other)
);
}
Err(e) => {
prop_assert!(
false,
"parse_greeting failed: {:?}\nwire: {:?}",
e,
String::from_utf8_lossy(&wire)
);
}
}
}
#[test]
fn untagged_exists_recent_roundtrip(
count in 0u32..100_000,
kind_idx in 0u8..2,
) {
let kind = if kind_idx == 0 { "EXISTS" } else { "RECENT" };
let wire = format!("* {count} {kind}\r\n").into_bytes();
match decode::parse_response(&wire) {
Ok((remaining, resp)) => {
prop_assert!(
remaining.is_empty(),
"parser left unconsumed bytes"
);
prop_assert!(
matches!(resp, Response::Untagged(_)),
"expected Untagged response for * {count} {kind}"
);
}
Err(e) => {
prop_assert!(
false,
"parse_response failed for * {count} {kind}: {:?}",
e
);
}
}
}
#[test]
fn continuation_roundtrip(text in arb_response_text()) {
let wire = format!("+ {text}\r\n").into_bytes();
match decode::parse_response(&wire) {
Ok((remaining, Response::Continuation(cont))) => {
prop_assert!(
remaining.is_empty(),
"parser left unconsumed bytes"
);
prop_assert_eq!(
cont.data.trim(), text.trim(),
"continuation data mismatch"
);
}
Ok((_, other)) => {
prop_assert!(
false,
"expected Continuation, got: {:?}",
std::mem::discriminant(&other)
);
}
Err(e) => {
prop_assert!(
false,
"parse_response failed for continuation: {:?}",
e
);
}
}
}
}
}
use crate::types::notify::{MailboxFilter, NotifyEvent, NotifyEventGroup, NotifySetParams};
#[test]
fn encode_notify_none() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifyNone;
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 NOTIFY NONE\r\n");
}
#[test]
fn encode_notify_set_selected_basic() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
],
}],
});
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 NOTIFY SET (selected (MessageNew MessageExpunge))\r\n"
);
}
#[test]
fn encode_notify_set_with_fetch_attrs() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![
"uid".into(),
"body.peek[header.fields (from to subject)]".into(),
],
},
NotifyEvent::MessageExpunge,
],
}],
});
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 NOTIFY SET (selected (MessageNew \
(uid body.peek[header.fields (from to subject)]) MessageExpunge))\r\n"
);
}
#[test]
fn encode_notify_set_with_status_indicator() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: true,
event_groups: vec![
NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
],
},
NotifyEventGroup {
filter: MailboxFilter::Subtree(vec!["Lists".into()]),
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
],
},
],
});
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 NOTIFY SET STATUS (selected (MessageNew MessageExpunge)) \
(subtree \"Lists\" (MessageNew MessageExpunge))\r\n"
);
}
#[test]
fn encode_notify_set_selected_delayed() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::SelectedDelayed,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
NotifyEvent::FlagChange,
],
}],
});
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 NOTIFY SET (selected-delayed (MessageNew MessageExpunge FlagChange))\r\n"
);
}
#[test]
fn encode_notify_set_simple_filters() {
for (filter, expected) in [
(MailboxFilter::Inboxes, "inboxes"),
(MailboxFilter::Personal, "personal"),
(MailboxFilter::Subscribed, "subscribed"),
] {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter,
events: vec![NotifyEvent::MailboxName],
}],
});
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
let expected_str = format!("A001 NOTIFY SET ({expected} (MailboxName))\r\n");
assert_eq!(&buf[..], expected_str.as_bytes(), "filter: {expected}");
}
}
#[test]
fn encode_notify_set_mailboxes_multiple() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Mailboxes(vec!["INBOX".into(), "Sent Items".into()]),
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
],
}],
});
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 NOTIFY SET (mailboxes (\"INBOX\" \"Sent Items\") (MessageNew MessageExpunge))\r\n"
);
}
#[test]
fn encode_notify_set_subtree_single() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Subtree(vec!["Lists".into()]),
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
],
}],
});
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 NOTIFY SET (subtree \"Lists\" (MessageNew MessageExpunge))\r\n"
);
}
#[test]
fn encode_notify_set_events_none() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![],
}],
});
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(&buf[..], b"A001 NOTIFY SET (selected NONE)\r\n");
}
#[test]
fn encode_notify_set_all_event_types() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![
NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
NotifyEvent::FlagChange,
NotifyEvent::AnnotationChange,
],
},
NotifyEventGroup {
filter: MailboxFilter::Personal,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
NotifyEvent::MailboxName,
NotifyEvent::SubscriptionChange,
NotifyEvent::MailboxMetadataChange,
NotifyEvent::ServerMetadataChange,
],
},
],
});
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 NOTIFY SET \
(selected (MessageNew MessageExpunge FlagChange AnnotationChange)) \
(personal (MessageNew MessageExpunge MailboxName SubscriptionChange \
MailboxMetadataChange ServerMetadataChange))\r\n"
);
}
#[test]
fn encode_notify_set_extension_event() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Personal,
events: vec![NotifyEvent::Other("VendorSpecificEvent".into())],
}],
});
encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts()).unwrap();
assert_eq!(
&buf[..],
b"A001 NOTIFY SET (personal (VendorSpecificEvent))\r\n"
);
}
#[test]
fn encode_notify_set_empty_event_groups_returns_error() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"NOTIFY SET with empty event_groups must fail (RFC 5465 Section 8)"
);
}
#[test]
fn encode_notify_set_subtree_empty_mailboxes_returns_error() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Subtree(vec![]),
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"subtree with empty mailbox list must fail (RFC 5465 Section 8)"
);
}
#[test]
fn encode_notify_set_mailboxes_empty_returns_error() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Mailboxes(vec![]),
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"mailboxes with empty mailbox list must fail (RFC 5465 Section 8)"
);
}
#[test]
fn encode_notify_set_rejects_invalid_other_event() {
for invalid in [
"",
"has space",
"has\ttab",
"(parens)",
"cr\rlf",
"lf\nonly",
] {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Personal,
events: vec![NotifyEvent::Other(invalid.into())],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"Other(\"{invalid}\") must be rejected as invalid IMAP atom"
);
}
}
#[test]
fn encode_notify_set_rejects_crlf_in_fetch_attrs() {
for payload in ["uid\r\nA002 LOGOUT", "flags\r", "body\n"] {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![payload.into()],
},
NotifyEvent::MessageExpunge,
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"fetch-att containing CRLF must be rejected: {payload:?}"
);
}
}
#[test]
fn encode_notify_set_rejects_malformed_fetch_attrs() {
for malformed in [
"BODY[TEXT", "FLAGS)", "BODY.PEEK]", "BODY.PEEK[HEADER.FIELDS (From To)", "(UID FLAGS", "\"unterminated quote", ] {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![malformed.into()],
},
NotifyEvent::MessageExpunge,
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"malformed fetch-att must be rejected: {malformed:?}"
);
}
}
#[test]
fn encode_notify_set_accepts_valid_fetch_attrs() {
for valid in [
"UID",
"FLAGS",
"ENVELOPE",
"BODY.PEEK[HEADER.FIELDS (From To Subject)]",
"BODY[TEXT]<0.100>",
"BODYSTRUCTURE",
] {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![valid.into()],
},
NotifyEvent::MessageExpunge,
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_ok(),
"valid fetch-att must be accepted: {valid:?}, got: {result:?}"
);
}
}
#[test]
fn encode_notify_set_rejects_non_message_event_on_selected() {
for (filter, filter_name) in [
(MailboxFilter::Selected, "selected"),
(MailboxFilter::SelectedDelayed, "selected-delayed"),
] {
for non_msg_event in [
NotifyEvent::MailboxName,
NotifyEvent::SubscriptionChange,
NotifyEvent::MailboxMetadataChange,
NotifyEvent::ServerMetadataChange,
] {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: filter.clone(),
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
non_msg_event.clone(),
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"{filter_name} must reject non-message events (RFC 5465 Section 6.1)"
);
}
}
}
#[test]
fn encode_notify_set_rejects_expunge_without_new() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Personal,
events: vec![NotifyEvent::MessageExpunge],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"MessageExpunge without MessageNew must fail (RFC 5465 Section 5)"
);
}
#[test]
fn encode_notify_set_rejects_new_without_expunge_on_selected() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![NotifyEvent::MessageNew {
fetch_attrs: vec![],
}],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"MessageNew without MessageExpunge must fail on selected filter (RFC 5465 Section 5)"
);
}
#[test]
fn encode_notify_set_rejects_new_without_expunge_on_non_selected() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Personal,
events: vec![
NotifyEvent::MailboxName,
NotifyEvent::SubscriptionChange,
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"MessageNew without MessageExpunge must fail on non-selected filters (RFC 5465 Section 5)"
);
}
#[test]
fn encode_notify_set_rejects_empty_fetch_attr() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec!["UID".into(), String::new()],
},
NotifyEvent::MessageExpunge,
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"empty fetch-att string must be rejected (RFC 3501 Section 9)"
);
}
#[test]
fn encode_notify_set_rejects_flag_change_without_new_and_expunge() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Personal,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::FlagChange,
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"FlagChange without MessageExpunge must fail (RFC 5465 Section 5)"
);
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Personal,
events: vec![NotifyEvent::FlagChange],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"FlagChange alone must fail (RFC 5465 Section 5)"
);
}
#[test]
fn encode_notify_set_rejects_annotation_change_without_new_and_expunge() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: MailboxFilter::Personal,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::AnnotationChange,
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"AnnotationChange without MessageExpunge must fail (RFC 5465 Section 5)"
);
}
#[test]
fn encode_notify_set_rejects_both_selected_and_selected_delayed() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![
NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
],
},
NotifyEventGroup {
filter: MailboxFilter::SelectedDelayed,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
],
},
],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"selected + selected-delayed must fail (RFC 5465 Section 3)"
);
}
#[test]
fn encode_notify_set_rejects_duplicate_selected() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![
NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
],
},
NotifyEventGroup {
filter: MailboxFilter::Selected,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
NotifyEvent::FlagChange,
],
},
],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"duplicate selected event-groups must fail (RFC 5465 Section 3)"
);
}
#[test]
fn encode_notify_set_rejects_duplicate_selected_delayed() {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![
NotifyEventGroup {
filter: MailboxFilter::SelectedDelayed,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
],
},
NotifyEventGroup {
filter: MailboxFilter::SelectedDelayed,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
NotifyEvent::FlagChange,
],
},
],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"duplicate selected-delayed event-groups must fail (RFC 5465 Section 3)"
);
}
#[test]
fn encode_notify_set_rejects_fetch_attrs_on_non_selected_filter() {
for filter in [
MailboxFilter::Inboxes,
MailboxFilter::Personal,
MailboxFilter::Subscribed,
MailboxFilter::Subtree(vec!["INBOX".into()]),
MailboxFilter::Mailboxes(vec!["INBOX".into()]),
] {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter,
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec!["UID".into()],
},
NotifyEvent::MessageExpunge,
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"fetch-attrs on non-selected filter must fail (RFC 5465 Section 8)"
);
}
}
#[test]
fn encode_notify_set_rejects_extension_event_on_selected() {
for (filter, filter_name) in [
(MailboxFilter::Selected, "selected"),
(MailboxFilter::SelectedDelayed, "selected-delayed"),
] {
let mut buf = BytesMut::new();
let cmd = crate::types::Command::NotifySet(NotifySetParams {
status: false,
event_groups: vec![NotifyEventGroup {
filter: filter.clone(),
events: vec![
NotifyEvent::MessageNew {
fetch_attrs: vec![],
},
NotifyEvent::MessageExpunge,
NotifyEvent::Other("X-CUSTOM".into()),
],
}],
});
let result = encode_command_to_buf(&mut buf, "A001", &cmd, &default_opts());
assert!(
result.is_err(),
"{filter_name} must reject extension events (RFC 5465 Section 6.1, Section 8)"
);
}
}