use bytes::BytesMut;
use crate::types::{Command, MailboxAttribute, QresyncParams};
fn validate_sequence_set(s: &str) -> bool {
if s.is_empty() {
return false;
}
s.split(',').all(|part| {
if part.is_empty() {
return false;
}
if part == "$" {
return true;
}
part.split(':').all(|num| {
if num == "*" {
return true;
}
if num.is_empty() || num.starts_with('0') || !num.chars().all(|c| c.is_ascii_digit()) {
return false;
}
num.parse::<u32>().is_ok()
}) && part.matches(':').count() <= 1
})
}
fn validate_sequence_set_or_err(s: &str) -> Result<(), crate::Error> {
if !validate_sequence_set(s) {
return Err(crate::Error::Protocol(format!(
"invalid sequence set per RFC 3501 Section 9: {s:?}"
)));
}
Ok(())
}
const MOD_SEQ_MAX: u64 = i64::MAX as u64;
fn validate_mod_sequence_value(val: u64, context: &str) -> Result<(), crate::Error> {
if val == 0 {
return Err(crate::Error::Protocol(format!(
"{context} mod-sequence-value must be >= 1 per RFC 7162 Section 7, got 0"
)));
}
if val > MOD_SEQ_MAX {
return Err(crate::Error::Protocol(format!(
"{context} mod-sequence-value must be <= {MOD_SEQ_MAX} per RFC 7162 Section 7, got {val}"
)));
}
Ok(())
}
fn validate_mod_sequence_valzer(val: u64, context: &str) -> Result<(), crate::Error> {
if val > MOD_SEQ_MAX {
return Err(crate::Error::Protocol(format!(
"{context} mod-sequence-valzer must be <= {MOD_SEQ_MAX} per RFC 7162 Section 7, got {val}"
)));
}
Ok(())
}
fn encode_changedsince_modifier(
buf: &mut BytesMut,
modseq: u64,
vanished: bool,
) -> Result<(), crate::Error> {
validate_mod_sequence_value(modseq, "CHANGEDSINCE")?;
buf.extend_from_slice(b" (CHANGEDSINCE ");
buf.extend_from_slice(modseq.to_string().as_bytes());
if vanished {
buf.extend_from_slice(b" VANISHED");
}
buf.extend_from_slice(b")");
Ok(())
}
#[allow(clippy::too_many_lines)]
pub(crate) fn encode_command(
buf: &mut BytesMut,
tag: &str,
command: &Command,
) -> Result<(), crate::Error> {
match command {
Command::Login { user, pass } => {
encode_login(buf, tag, user, pass);
}
Command::Authenticate {
mechanism,
initial_response,
} => {
encode_authenticate(buf, tag, mechanism, initial_response.as_deref());
}
Command::StartTls => {
encode_simple(buf, tag, "STARTTLS");
}
Command::Logout => {
encode_simple(buf, tag, "LOGOUT");
}
Command::List { reference, pattern } => {
encode_two_quoted_args(buf, tag, "LIST", reference, pattern);
}
Command::ListStatus {
reference,
pattern,
status_items,
} => {
encode_list_status(buf, tag, reference, pattern, status_items);
}
Command::Select {
mailbox,
condstore,
qresync,
} => {
encode_select_or_examine(buf, tag, "SELECT", mailbox, *condstore, qresync.as_ref())?;
}
Command::Examine {
mailbox,
condstore,
qresync,
} => {
encode_select_or_examine(buf, tag, "EXAMINE", mailbox, *condstore, qresync.as_ref())?;
}
Command::Create { mailbox } => {
encode_mailbox_cmd(buf, tag, "CREATE", mailbox);
}
Command::CreateSpecialUse {
mailbox,
special_use,
} => {
encode_create_special_use(buf, tag, mailbox, special_use);
}
Command::Delete { mailbox } => {
encode_mailbox_cmd(buf, tag, "DELETE", mailbox);
}
Command::Rename { mailbox, new_name } => {
encode_two_quoted_args(buf, tag, "RENAME", mailbox, new_name);
}
Command::Subscribe { mailbox } => {
encode_mailbox_cmd(buf, tag, "SUBSCRIBE", mailbox);
}
Command::Unsubscribe { mailbox } => {
encode_mailbox_cmd(buf, tag, "UNSUBSCRIBE", mailbox);
}
Command::Lsub { reference, pattern } => {
encode_two_quoted_args(buf, tag, "LSUB", reference, pattern);
}
Command::Close => {
encode_simple(buf, tag, "CLOSE");
}
Command::Unselect => {
encode_simple(buf, tag, "UNSELECT");
}
Command::Unauthenticate => {
encode_simple(buf, tag, "UNAUTHENTICATE");
}
Command::Status { mailbox, items } => {
encode_status(buf, tag, mailbox, items);
}
Command::Search { criteria } => {
encode_search(buf, tag, "SEARCH", criteria, None);
}
Command::SearchReturn {
criteria,
return_opts,
} => {
encode_search(buf, tag, "SEARCH", criteria, Some(return_opts));
}
Command::SearchSave { criteria } => {
encode_search(buf, tag, "SEARCH RETURN (SAVE)", criteria, None);
}
Command::Fetch {
sequence_set,
items,
changed_since,
} => {
validate_sequence_set_or_err(sequence_set)?;
encode_fetch(buf, tag, sequence_set, items, *changed_since)?;
}
Command::Store {
sequence_set,
operation,
flags,
unchanged_since,
} => {
validate_sequence_set_or_err(sequence_set)?;
encode_store(
buf,
tag,
false,
sequence_set,
*operation,
flags,
*unchanged_since,
)?;
}
Command::Copy {
sequence_set,
mailbox,
} => {
validate_sequence_set_or_err(sequence_set)?;
encode_two_arg(buf, tag, false, "COPY", sequence_set, mailbox);
}
Command::Move {
sequence_set,
mailbox,
} => {
validate_sequence_set_or_err(sequence_set)?;
encode_two_arg(buf, tag, false, "MOVE", sequence_set, mailbox);
}
Command::UidSearch { criteria } => {
encode_search(buf, tag, "UID SEARCH", criteria, None);
}
Command::UidSearchReturn {
criteria,
return_opts,
} => {
encode_search(buf, tag, "UID SEARCH", criteria, Some(return_opts));
}
Command::UidSearchSave { criteria } => {
encode_search(buf, tag, "UID SEARCH RETURN (SAVE)", criteria, None);
}
Command::UidFetch {
sequence_set,
items,
changed_since,
vanished,
} => {
validate_sequence_set_or_err(sequence_set)?;
encode_uid_fetch(buf, tag, sequence_set, items, *changed_since, *vanished)?;
}
Command::UidStore {
sequence_set,
operation,
flags,
unchanged_since,
} => {
validate_sequence_set_or_err(sequence_set)?;
encode_store(
buf,
tag,
true,
sequence_set,
*operation,
flags,
*unchanged_since,
)?;
}
Command::UidMove {
sequence_set,
mailbox,
} => {
validate_sequence_set_or_err(sequence_set)?;
encode_two_arg(buf, tag, true, "MOVE", sequence_set, mailbox);
}
Command::UidCopy {
sequence_set,
mailbox,
} => {
validate_sequence_set_or_err(sequence_set)?;
encode_two_arg(buf, tag, true, "COPY", sequence_set, mailbox);
}
Command::UidExpunge { sequence_set } => {
validate_sequence_set_or_err(sequence_set)?;
encode_uid_expunge(buf, tag, sequence_set);
}
Command::Namespace => {
encode_simple(buf, tag, "NAMESPACE");
}
Command::Check => {
encode_simple(buf, tag, "CHECK");
}
Command::Expunge => {
encode_simple(buf, tag, "EXPUNGE");
}
Command::Idle => {
encode_simple(buf, tag, "IDLE");
}
Command::Done => {
buf.extend_from_slice(b"DONE\r\n");
}
Command::Capability => {
encode_simple(buf, tag, "CAPABILITY");
}
Command::Noop => {
encode_simple(buf, tag, "NOOP");
}
Command::Enable(caps) => {
encode_enable(buf, tag, caps)?;
}
Command::Id(params) => {
encode_id(buf, tag, params)?;
}
Command::GetMetadata {
mailbox,
entries,
max_size,
depth,
} => {
encode_getmetadata(buf, tag, mailbox, entries, *max_size, depth.as_deref())?;
}
Command::SetMetadata { mailbox, entries } => {
encode_setmetadata(buf, tag, mailbox, entries)?;
}
Command::Thread {
algorithm,
charset,
criteria,
} => {
encode_thread_or_sort_cmd(buf, tag, "THREAD", algorithm, charset, criteria, false);
}
Command::UidThread {
algorithm,
charset,
criteria,
} => {
encode_thread_or_sort_cmd(buf, tag, "UID THREAD", algorithm, charset, criteria, false);
}
Command::Sort {
algorithm,
charset,
criteria,
} => {
encode_thread_or_sort_cmd(buf, tag, "SORT", algorithm, charset, criteria, true);
}
Command::UidSort {
algorithm,
charset,
criteria,
} => {
encode_thread_or_sort_cmd(buf, tag, "UID SORT", algorithm, charset, criteria, true);
}
Command::Compress => {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" COMPRESS DEFLATE\r\n");
}
Command::GetQuota { root } => {
encode_mailbox_cmd(buf, tag, "GETQUOTA", root);
}
Command::GetQuotaRoot { mailbox } => {
encode_mailbox_cmd(buf, tag, "GETQUOTAROOT", mailbox);
}
Command::SetQuota { root, resources } => {
encode_set_quota(buf, tag, root, resources)?;
}
Command::SetAcl {
mailbox,
identifier,
rights,
} => {
encode_set_acl(buf, tag, mailbox, identifier, rights);
}
Command::DeleteAcl {
mailbox,
identifier,
} => {
encode_two_quoted_args(buf, tag, "DELETEACL", mailbox, identifier);
}
Command::GetAcl { mailbox } => {
encode_mailbox_cmd(buf, tag, "GETACL", mailbox);
}
Command::ListRights {
mailbox,
identifier,
} => {
encode_two_quoted_args(buf, tag, "LISTRIGHTS", mailbox, identifier);
}
Command::MyRights { mailbox } => {
encode_mailbox_cmd(buf, tag, "MYRIGHTS", mailbox);
}
}
Ok(())
}
fn encode_simple(buf: &mut BytesMut, tag: &str, command: &str) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(command.as_bytes());
buf.extend_from_slice(b"\r\n");
}
fn encode_login(buf: &mut BytesMut, tag: &str, user: &str, pass: &str) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" LOGIN ");
encode_quoted_or_literal(buf, user.as_bytes());
buf.extend_from_slice(b" ");
encode_quoted_or_literal(buf, pass.as_bytes());
buf.extend_from_slice(b"\r\n");
}
fn encode_authenticate(
buf: &mut BytesMut,
tag: &str,
mechanism: &str,
initial_response: Option<&str>,
) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" AUTHENTICATE ");
buf.extend_from_slice(mechanism.as_bytes());
if let Some(ir) = initial_response {
if ir.is_empty() {
buf.extend_from_slice(b" =");
} else {
buf.extend_from_slice(b" ");
buf.extend_from_slice(ir.as_bytes());
}
}
buf.extend_from_slice(b"\r\n");
}
fn encode_status(buf: &mut BytesMut, tag: &str, mailbox: &str, items: &str) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" STATUS ");
encode_quoted_or_literal(buf, mailbox.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(items.as_bytes());
buf.extend_from_slice(b"\r\n");
}
fn encode_search(
buf: &mut BytesMut,
tag: &str,
cmd: &str,
criteria: &str,
return_opts: Option<&[String]>,
) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(cmd.as_bytes());
if let Some(opts) = return_opts {
buf.extend_from_slice(b" RETURN (");
for (i, opt) in opts.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(b" ");
}
buf.extend_from_slice(opt.as_bytes());
}
buf.extend_from_slice(b")");
}
buf.extend_from_slice(b" ");
buf.extend_from_slice(criteria.as_bytes());
buf.extend_from_slice(b"\r\n");
}
fn encode_fetch(
buf: &mut BytesMut,
tag: &str,
sequence_set: &str,
items: &str,
changed_since: Option<u64>,
) -> Result<(), crate::Error> {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" FETCH ");
buf.extend_from_slice(sequence_set.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(items.as_bytes());
if let Some(modseq) = changed_since {
encode_changedsince_modifier(buf, modseq, false)?;
}
buf.extend_from_slice(b"\r\n");
Ok(())
}
fn encode_store(
buf: &mut BytesMut,
tag: &str,
uid: bool,
sequence_set: &str,
operation: crate::types::StoreOperation,
flags: &[crate::types::Flag],
unchanged_since: Option<u64>,
) -> Result<(), crate::Error> {
buf.extend_from_slice(tag.as_bytes());
if uid {
buf.extend_from_slice(b" UID STORE ");
} else {
buf.extend_from_slice(b" STORE ");
}
buf.extend_from_slice(sequence_set.as_bytes());
if let Some(modseq) = unchanged_since {
validate_mod_sequence_valzer(modseq, "UNCHANGEDSINCE")?;
buf.extend_from_slice(b" (UNCHANGEDSINCE ");
buf.extend_from_slice(modseq.to_string().as_bytes());
buf.extend_from_slice(b")");
}
encode_store_flags(buf, operation, flags)
}
fn encode_two_arg(buf: &mut BytesMut, tag: &str, uid: bool, cmd: &str, arg1: &str, arg2: &str) {
buf.extend_from_slice(tag.as_bytes());
if uid {
buf.extend_from_slice(b" UID ");
} else {
buf.extend_from_slice(b" ");
}
buf.extend_from_slice(cmd.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(arg1.as_bytes());
buf.extend_from_slice(b" ");
encode_quoted_or_literal(buf, arg2.as_bytes());
buf.extend_from_slice(b"\r\n");
}
fn encode_two_quoted_args(buf: &mut BytesMut, tag: &str, cmd: &str, arg1: &str, arg2: &str) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(cmd.as_bytes());
buf.extend_from_slice(b" ");
encode_quoted_or_literal(buf, arg1.as_bytes());
buf.extend_from_slice(b" ");
encode_quoted_or_literal(buf, arg2.as_bytes());
buf.extend_from_slice(b"\r\n");
}
fn encode_list_status(
buf: &mut BytesMut,
tag: &str,
reference: &str,
pattern: &str,
status_items: &str,
) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" LIST ");
encode_quoted_or_literal(buf, reference.as_bytes());
buf.extend_from_slice(b" ");
encode_quoted_or_literal(buf, pattern.as_bytes());
buf.extend_from_slice(b" RETURN (STATUS (");
buf.extend_from_slice(status_items.as_bytes());
buf.extend_from_slice(b"))\r\n");
}
fn encode_create_special_use(
buf: &mut BytesMut,
tag: &str,
mailbox: &str,
special_use: &[MailboxAttribute],
) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" CREATE ");
encode_quoted_or_literal(buf, mailbox.as_bytes());
buf.extend_from_slice(b" (USE (");
for (i, attr) in special_use.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(b" ");
}
buf.extend_from_slice(attr.as_imap_str().as_bytes());
}
buf.extend_from_slice(b"))\r\n");
}
fn encode_mailbox_cmd(buf: &mut BytesMut, tag: &str, cmd: &str, mailbox: &str) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(cmd.as_bytes());
buf.extend_from_slice(b" ");
encode_quoted_or_literal(buf, mailbox.as_bytes());
buf.extend_from_slice(b"\r\n");
}
fn validate_known_sequence_set(s: &str) -> Result<(), crate::Error> {
if s.contains('*') {
return Err(crate::Error::Protocol(
"\"*\" is not allowed in QRESYNC known-uids/sequence-set (RFC 7162 Section 3.2.5.2)"
.into(),
));
}
if s.contains('$') {
return Err(crate::Error::Protocol(
"\"$\" (search result reference) is not allowed in QRESYNC \
known-uids/sequence-set (RFC 7162 Section 7)"
.into(),
));
}
if !validate_sequence_set(s) {
return Err(crate::Error::Protocol(
"QRESYNC known-uids/sequence-set is not a valid sequence-set \
(RFC 7162 Section 7: known-uids = sequence-set)"
.into(),
));
}
Ok(())
}
fn validate_atom(s: &str, context: &str) -> Result<(), crate::Error> {
if s.is_empty() {
return Err(crate::Error::Protocol(format!(
"{context} must be at least one character \
(RFC 3501 Section 9: atom = 1*ATOM-CHAR)"
)));
}
for b in s.bytes() {
let is_atom_special = matches!(
b,
b'(' | b')' | b'{' | b' ' | b'%' | b'*' | b'"' | b'\\' | b']'
);
let is_ctl = b < 0x20 || b == 0x7F;
let is_outside_char = b == 0 || b > 0x7F;
if is_atom_special || is_ctl || is_outside_char {
return Err(crate::Error::Protocol(format!(
"{context} contains invalid byte 0x{b:02X} — must be an atom \
(RFC 3501 Section 9: ATOM-CHAR excludes atom-specials, CTL, non-ASCII)"
)));
}
}
Ok(())
}
pub(crate) fn validate_flag_keyword(s: &str) -> Result<(), crate::Error> {
validate_atom(s, "flag keyword")
}
fn validate_and_filter_flags<'a>(
flags: &'a [crate::types::Flag],
context: &str,
) -> Result<Vec<&'a crate::types::Flag>, crate::Error> {
let valid_flags: Vec<_> = flags
.iter()
.filter(|f| {
if matches!(f, crate::types::Flag::Recent | crate::types::Flag::Wildcard) {
tracing::debug!(
flag = %f,
"Skipping flag not permitted in {context} per RFC 3501 Section 9 (flag production)"
);
false
} else {
true
}
})
.collect();
for flag in &valid_flags {
if let crate::types::Flag::Custom(s) = flag {
validate_flag_keyword(s)?;
}
}
Ok(valid_flags)
}
#[allow(clippy::too_many_lines)]
pub(crate) fn validate_append_datetime(date: &str) -> Result<(), crate::Error> {
const MONTHS: [&[u8]; 12] = [
b"Jan", b"Feb", b"Mar", b"Apr", b"May", b"Jun", b"Jul", b"Aug", b"Sep", b"Oct", b"Nov",
b"Dec",
];
let b = date.as_bytes();
if b.len() != 26 {
return Err(crate::Error::InvalidAppendDate(format!(
"expected 26 characters, got {} — \
date-time must be date-day-fixed \"-\" date-month \"-\" date-year \
SP time SP zone (RFC 3501 Section 9)",
b.len()
)));
}
let day_hi = b[0];
let day_lo = b[1];
let valid_day = match day_hi {
b' ' => day_lo.is_ascii_digit() && day_lo != b'0',
b'0' => (b'1'..=b'9').contains(&day_lo),
b'1'..=b'2' => day_lo.is_ascii_digit(),
b'3' => day_lo == b'0' || day_lo == b'1',
_ => false,
};
if !valid_day {
return Err(crate::Error::InvalidAppendDate(format!(
"invalid day {:?} — date-day-fixed must be (SP DIGIT) / 2DIGIT \
with values 1-31 (RFC 3501 Section 9)",
std::str::from_utf8(&b[0..2]).unwrap_or("??")
)));
}
if b[2] != b'-' {
return Err(crate::Error::InvalidAppendDate(format!(
"expected '-' at position 2, got {:?} (RFC 3501 Section 9)",
b[2] as char
)));
}
let month = &b[3..6];
if !MONTHS.iter().any(|m| m.eq_ignore_ascii_case(month)) {
return Err(crate::Error::InvalidAppendDate(format!(
"invalid month {:?} — date-month must be Jan/Feb/.../Dec (RFC 3501 Section 9)",
std::str::from_utf8(month).unwrap_or("???")
)));
}
let day: u8 = match day_hi {
b' ' => day_lo - b'0',
_ => (day_hi - b'0') * 10 + (day_lo - b'0'),
};
let max_day: u8 = match month[0].to_ascii_lowercase() {
b'f' => 29,
b'a' if month[1].eq_ignore_ascii_case(&b'p') => 30,
b'j' if month[1].eq_ignore_ascii_case(&b'u') && month[2].eq_ignore_ascii_case(&b'n') => 30,
b's' | b'n' => 30,
_ => 31,
};
if day > max_day {
return Err(crate::Error::InvalidAppendDate(format!(
"invalid day {} for month {:?} — maximum is {} (RFC 3501 Section 9)",
day,
std::str::from_utf8(month).unwrap_or("???"),
max_day
)));
}
if b[6] != b'-' {
return Err(crate::Error::InvalidAppendDate(format!(
"expected '-' at position 6, got {:?} (RFC 3501 Section 9)",
b[6] as char
)));
}
if !b[7..11].iter().all(u8::is_ascii_digit) {
return Err(crate::Error::InvalidAppendDate(format!(
"invalid year {:?} — date-year must be 4DIGIT (RFC 3501 Section 9)",
std::str::from_utf8(&b[7..11]).unwrap_or("????")
)));
}
if b[11] != b' ' {
return Err(crate::Error::InvalidAppendDate(format!(
"expected SP at position 11, got {:?} (RFC 3501 Section 9)",
b[11] as char
)));
}
let time = &b[12..20];
let valid_time = time[0].is_ascii_digit()
&& time[1].is_ascii_digit()
&& time[2] == b':'
&& time[3].is_ascii_digit()
&& time[4].is_ascii_digit()
&& time[5] == b':'
&& time[6].is_ascii_digit()
&& time[7].is_ascii_digit();
if !valid_time {
return Err(crate::Error::InvalidAppendDate(format!(
"invalid time {:?} — time must be 2DIGIT \":\" 2DIGIT \":\" 2DIGIT \
(RFC 3501 Section 9)",
std::str::from_utf8(time).unwrap_or("????????")
)));
}
let hour = (time[0] - b'0') * 10 + (time[1] - b'0');
let minute = (time[3] - b'0') * 10 + (time[4] - b'0');
let second = (time[6] - b'0') * 10 + (time[7] - b'0');
if hour > 23 {
return Err(crate::Error::InvalidAppendDate(format!(
"invalid hour {hour:02} — must be 00-23 (RFC 3501 Section 9, \
RFC 5322 Section 3.3)"
)));
}
if minute > 59 {
return Err(crate::Error::InvalidAppendDate(format!(
"invalid minute {minute:02} — must be 00-59 (RFC 3501 Section 9, \
RFC 5322 Section 3.3)"
)));
}
if second > 60 {
return Err(crate::Error::InvalidAppendDate(format!(
"invalid second {second:02} — must be 00-60 (RFC 3501 Section 9, \
RFC 5322 Section 3.3; 60 permits leap seconds)"
)));
}
if b[20] != b' ' {
return Err(crate::Error::InvalidAppendDate(format!(
"expected SP at position 20, got {:?} (RFC 3501 Section 9)",
b[20] as char
)));
}
let zone = &b[21..26];
let valid_zone =
(zone[0] == b'+' || zone[0] == b'-') && zone[1..].iter().all(u8::is_ascii_digit);
if !valid_zone {
return Err(crate::Error::InvalidAppendDate(format!(
"invalid zone {:?} — zone must be (\"+\" / \"-\") 4DIGIT (RFC 3501 Section 9)",
std::str::from_utf8(zone).unwrap_or("?????")
)));
}
let zone_hh = (zone[1] - b'0') * 10 + (zone[2] - b'0');
let zone_mm = (zone[3] - b'0') * 10 + (zone[4] - b'0');
if zone_hh > 14 {
return Err(crate::Error::InvalidAppendDate(format!(
"invalid zone hour {zone_hh:02} — must be 00-14 \
(maximum real UTC offset is ±14:00, RFC 3501 Section 9)"
)));
}
if zone_mm > 59 {
return Err(crate::Error::InvalidAppendDate(format!(
"invalid zone minute {zone_mm:02} — must be 00-59 \
(RFC 3501 Section 9)"
)));
}
Ok(())
}
fn encode_select_or_examine(
buf: &mut BytesMut,
tag: &str,
cmd: &str,
mailbox: &str,
condstore: bool,
qresync: Option<&QresyncParams>,
) -> Result<(), crate::Error> {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(cmd.as_bytes());
buf.extend_from_slice(b" ");
encode_quoted_or_literal(buf, mailbox.as_bytes());
if condstore && qresync.is_none() {
buf.extend_from_slice(b" (CONDSTORE)");
}
if let Some(params) = qresync {
if params.uid_validity == 0 {
return Err(crate::Error::Protocol(
"QRESYNC uid_validity must be non-zero (nz-number per RFC 3501 Section 9)".into(),
));
}
validate_mod_sequence_value(params.mod_seq, "QRESYNC mod_seq")?;
buf.extend_from_slice(b" (QRESYNC (");
buf.extend_from_slice(params.uid_validity.to_string().as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(params.mod_seq.to_string().as_bytes());
if let Some(known_uids) = ¶ms.known_uids {
validate_known_sequence_set(known_uids)?;
buf.extend_from_slice(b" ");
buf.extend_from_slice(known_uids.as_bytes());
}
if let Some((seq_set, uid_set)) = ¶ms.seq_match_data {
if params.known_uids.is_none() {
return Err(crate::Error::Protocol(
"seq-match-data requires known-uids (RFC 7162 Section 3.2.5.2)".into(),
));
}
validate_known_sequence_set(seq_set)?;
validate_known_sequence_set(uid_set)?;
buf.extend_from_slice(b" (");
buf.extend_from_slice(seq_set.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(uid_set.as_bytes());
buf.extend_from_slice(b")");
}
buf.extend_from_slice(b"))");
}
buf.extend_from_slice(b"\r\n");
Ok(())
}
fn encode_uid_fetch(
buf: &mut BytesMut,
tag: &str,
sequence_set: &str,
items: &str,
changed_since: Option<u64>,
vanished: bool,
) -> Result<(), crate::Error> {
if vanished && changed_since.is_none() {
return Err(crate::Error::Protocol(
"VANISHED modifier requires CHANGEDSINCE per RFC 7162 Section 3.2.6".into(),
));
}
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" UID FETCH ");
buf.extend_from_slice(sequence_set.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(items.as_bytes());
if let Some(modseq) = changed_since {
encode_changedsince_modifier(buf, modseq, vanished)?;
}
buf.extend_from_slice(b"\r\n");
Ok(())
}
fn encode_store_flags(
buf: &mut BytesMut,
operation: crate::types::StoreOperation,
flags: &[crate::types::Flag],
) -> Result<(), crate::Error> {
buf.extend_from_slice(b" ");
match operation {
crate::types::StoreOperation::Add => buf.extend_from_slice(b"+FLAGS"),
crate::types::StoreOperation::Remove => buf.extend_from_slice(b"-FLAGS"),
crate::types::StoreOperation::Replace => buf.extend_from_slice(b"FLAGS"),
crate::types::StoreOperation::AddSilent => buf.extend_from_slice(b"+FLAGS.SILENT"),
crate::types::StoreOperation::RemoveSilent => buf.extend_from_slice(b"-FLAGS.SILENT"),
crate::types::StoreOperation::ReplaceSilent => buf.extend_from_slice(b"FLAGS.SILENT"),
}
let valid_flags = validate_and_filter_flags(flags, "STORE")?;
buf.extend_from_slice(b" (");
for (i, flag) in valid_flags.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(b" ");
}
buf.extend_from_slice(flag.as_imap_str().as_bytes());
}
buf.extend_from_slice(b")\r\n");
Ok(())
}
fn encode_uid_expunge(buf: &mut BytesMut, tag: &str, sequence_set: &str) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" UID EXPUNGE ");
buf.extend_from_slice(sequence_set.as_bytes());
buf.extend_from_slice(b"\r\n");
}
fn encode_enable(buf: &mut BytesMut, tag: &str, caps: &[String]) -> Result<(), crate::Error> {
if caps.is_empty() {
return Err(crate::Error::Protocol(
"ENABLE requires at least one capability (RFC 5161)".into(),
));
}
for cap in caps {
validate_atom(cap, "ENABLE capability")?;
}
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" ENABLE");
for cap in caps {
buf.extend_from_slice(b" ");
buf.extend_from_slice(cap.as_bytes());
}
buf.extend_from_slice(b"\r\n");
Ok(())
}
fn encode_getmetadata(
buf: &mut BytesMut,
tag: &str,
mailbox: &str,
entries: &[String],
max_size: Option<u64>,
depth: Option<&str>,
) -> Result<(), crate::Error> {
if let Some(n) = max_size {
if n > u64::from(u32::MAX) {
return Err(crate::Error::Protocol(format!(
"GETMETADATA MAXSIZE must fit in number (u32) per RFC 5464 Section 5 / RFC 3501 Section 9, got {n}"
)));
}
}
if entries.is_empty() {
return Err(crate::Error::Protocol(
"GETMETADATA requires at least one entry (RFC 5464 Section 4.2)".into(),
));
}
if let Some(d) = depth {
if d != "0" && d != "1" && d != "infinity" {
return Err(crate::Error::Protocol(format!(
"GETMETADATA DEPTH must be \"0\", \"1\", or \"infinity\" \
(RFC 5464 Section 4.2.2), got: {d:?}"
)));
}
}
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" GETMETADATA");
buf.extend_from_slice(b" ");
encode_quoted_or_literal(buf, mailbox.as_bytes());
if max_size.is_some() || depth.is_some() {
buf.extend_from_slice(b" (");
let first_opt = if let Some(n) = max_size {
buf.extend_from_slice(b"MAXSIZE ");
buf.extend_from_slice(n.to_string().as_bytes());
false
} else {
true
};
if let Some(d) = depth {
if !first_opt {
buf.extend_from_slice(b" ");
}
buf.extend_from_slice(b"DEPTH ");
buf.extend_from_slice(d.as_bytes());
}
buf.extend_from_slice(b")");
}
buf.extend_from_slice(b" ");
if entries.len() == 1 {
encode_quoted_or_literal(buf, entries[0].as_bytes());
} else {
buf.extend_from_slice(b"(");
for (i, entry) in entries.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(b" ");
}
encode_quoted_or_literal(buf, entry.as_bytes());
}
buf.extend_from_slice(b")");
}
buf.extend_from_slice(b"\r\n");
Ok(())
}
fn encode_setmetadata(
buf: &mut BytesMut,
tag: &str,
mailbox: &str,
entries: &[(String, Option<Vec<u8>>)],
) -> Result<(), crate::Error> {
if entries.is_empty() {
return Err(crate::Error::Protocol(
"SETMETADATA requires at least one entry (RFC 5464 Section 5)".into(),
));
}
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" SETMETADATA ");
encode_quoted_or_literal(buf, mailbox.as_bytes());
buf.extend_from_slice(b" (");
for (i, (name, value)) in entries.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(b" ");
}
encode_quoted_or_literal(buf, name.as_bytes());
buf.extend_from_slice(b" ");
match value {
Some(v) => encode_nstring_or_literal8(buf, v),
None => buf.extend_from_slice(b"NIL"),
}
}
buf.extend_from_slice(b")\r\n");
Ok(())
}
fn encode_thread_or_sort_cmd(
buf: &mut BytesMut,
tag: &str,
cmd: &str,
algorithm: &str,
charset: &str,
criteria: &str,
parenthesize_algo: bool,
) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(cmd.as_bytes());
if parenthesize_algo {
buf.extend_from_slice(b" (");
buf.extend_from_slice(algorithm.as_bytes());
buf.extend_from_slice(b") ");
} else {
buf.extend_from_slice(b" ");
buf.extend_from_slice(algorithm.as_bytes());
buf.extend_from_slice(b" ");
}
buf.extend_from_slice(charset.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(criteria.as_bytes());
buf.extend_from_slice(b"\r\n");
}
fn encode_id(
buf: &mut BytesMut,
tag: &str,
params: &[(String, Option<String>)],
) -> Result<(), crate::Error> {
if params.len() > 30 {
return Err(crate::Error::Protocol(format!(
"ID command has {} field-value pairs, but RFC 2971 Section 3.3 \
allows at most 30",
params.len()
)));
}
for (key, value) in params {
if key.len() > 30 {
return Err(crate::Error::Protocol(format!(
"ID field name is {} octets, but RFC 2971 Section 3.3 \
allows at most 30",
key.len()
)));
}
if let Some(v) = value {
if v.len() > 1024 {
return Err(crate::Error::Protocol(format!(
"ID value is {} octets, but RFC 2971 Section 3.3 \
allows at most 1024",
v.len()
)));
}
}
}
buf.extend_from_slice(tag.as_bytes());
if params.is_empty() {
buf.extend_from_slice(b" ID NIL\r\n");
} else {
buf.extend_from_slice(b" ID (");
for (i, (key, value)) in params.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(b" ");
}
encode_quoted_or_literal(buf, key.as_bytes());
buf.extend_from_slice(b" ");
match value {
Some(v) => encode_quoted_or_literal(buf, v.as_bytes()),
None => buf.extend_from_slice(b"NIL"),
}
}
buf.extend_from_slice(b")\r\n");
}
Ok(())
}
pub(crate) fn encode_quoted_or_literal(buf: &mut BytesMut, data: &[u8]) {
let data = strip_nul_bytes(data);
let quotable = data.iter().all(|&b| b != b'\r' && b != b'\n' && b <= 0x7F);
emit_quoted_or_literal(buf, &data, quotable);
}
pub(crate) fn encode_quoted_or_literal_utf8(buf: &mut BytesMut, data: &[u8], utf8_mode: bool) {
if !utf8_mode {
encode_quoted_or_literal(buf, data);
return;
}
let data = strip_nul_bytes(data);
let quotable =
std::str::from_utf8(&data).is_ok() && data.iter().all(|&b| b != b'\r' && b != b'\n');
emit_quoted_or_literal(buf, &data, quotable);
}
fn strip_nul_bytes(data: &[u8]) -> std::borrow::Cow<'_, [u8]> {
if data.contains(&0x00) {
tracing::warn!(
"Stripped NUL bytes from IMAP string data — RFC 3501 Section 9 forbids %x00"
);
std::borrow::Cow::Owned(data.iter().copied().filter(|&b| b != 0x00).collect())
} else {
std::borrow::Cow::Borrowed(data)
}
}
fn emit_quoted_string(buf: &mut BytesMut, data: &[u8]) {
buf.extend_from_slice(b"\"");
for &byte in data {
if byte == b'\\' || byte == b'"' {
buf.extend_from_slice(b"\\");
}
buf.extend_from_slice(&[byte]);
}
buf.extend_from_slice(b"\"");
}
fn emit_quoted_or_literal(buf: &mut BytesMut, data: &[u8], quotable: bool) {
if quotable {
emit_quoted_string(buf, data);
} else {
buf.extend_from_slice(b"{");
buf.extend_from_slice(data.len().to_string().as_bytes());
buf.extend_from_slice(b"}\r\n");
buf.extend_from_slice(data);
}
}
fn encode_literal8(buf: &mut BytesMut, data: &[u8]) {
buf.extend_from_slice(b"~{");
buf.extend_from_slice(data.len().to_string().as_bytes());
buf.extend_from_slice(b"}\r\n");
buf.extend_from_slice(data);
}
fn encode_nstring_or_literal8(buf: &mut BytesMut, data: &[u8]) {
let quotable = data
.iter()
.all(|&b| b != 0x00 && b != b'\r' && b != b'\n' && b <= 0x7F);
if quotable {
emit_quoted_string(buf, data);
} else {
encode_literal8(buf, data);
}
}
fn encode_set_quota(
buf: &mut BytesMut,
tag: &str,
root: &str,
resources: &[(String, u64)],
) -> Result<(), crate::Error> {
for (resource, limit) in resources {
validate_atom(resource, "SETQUOTA resource name")?;
if *limit > u64::from(u32::MAX) {
return Err(crate::Error::Protocol(format!(
"SETQUOTA resource limit {limit} for \"{resource}\" exceeds u32::MAX \
(RFC 2087 Section 4.1: number is constrained to 32 bits per RFC 3501 Section 9)"
)));
}
}
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" SETQUOTA ");
encode_quoted_or_literal(buf, root.as_bytes());
buf.extend_from_slice(b" (");
for (i, (resource, limit)) in resources.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(b" ");
}
buf.extend_from_slice(resource.as_bytes());
buf.extend_from_slice(b" ");
buf.extend_from_slice(limit.to_string().as_bytes());
}
buf.extend_from_slice(b")\r\n");
Ok(())
}
fn encode_set_acl(buf: &mut BytesMut, tag: &str, mailbox: &str, identifier: &str, rights: &str) {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" SETACL ");
encode_quoted_or_literal(buf, mailbox.as_bytes());
buf.extend_from_slice(b" ");
encode_quoted_or_literal(buf, identifier.as_bytes());
buf.extend_from_slice(b" ");
encode_quoted_or_literal(buf, rights.as_bytes());
buf.extend_from_slice(b"\r\n");
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn encode_multi_append_header(
buf: &mut BytesMut,
tag: &str,
mailbox: &str,
flags: &[crate::types::Flag],
date: Option<&str>,
message_len: usize,
first: bool,
literal_plus: bool,
utf8: bool,
) -> Result<(), crate::Error> {
if first {
buf.extend_from_slice(tag.as_bytes());
buf.extend_from_slice(b" APPEND ");
encode_quoted_or_literal_utf8(buf, mailbox.as_bytes(), utf8);
}
let valid_flags = validate_and_filter_flags(flags, "APPEND")?;
if !valid_flags.is_empty() {
buf.extend_from_slice(b" (");
for (i, flag) in valid_flags.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(b" ");
}
buf.extend_from_slice(flag.as_imap_str().as_bytes());
}
buf.extend_from_slice(b")");
}
if let Some(d) = date {
validate_append_datetime(d)?;
buf.extend_from_slice(b" ");
encode_quoted_or_literal(buf, d.as_bytes());
}
if utf8 {
buf.extend_from_slice(b" UTF8 (~{");
} else {
buf.extend_from_slice(b" {");
}
buf.extend_from_slice(message_len.to_string().as_bytes());
if literal_plus && !utf8 {
buf.extend_from_slice(b"+");
}
buf.extend_from_slice(b"}\r\n");
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[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");
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"#);
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");
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");
assert_eq!(&buf[..], b"{12}\r\nline1\r\nline2");
}
#[test]
fn encode_select() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: "INBOX".into(),
condstore: false,
qresync: None,
};
encode_command(&mut buf, "A002", &cmd).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: "INBOX".into(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: None,
seq_match_data: None,
}),
};
encode_command(&mut buf, "A005", &cmd).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: "INBOX".into(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: Some("1:500".into()),
seq_match_data: None,
}),
};
encode_command(&mut buf, "A006", &cmd).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: "INBOX".into(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 100,
mod_seq: 50,
known_uids: None,
seq_match_data: None,
}),
};
encode_command(&mut buf, "A007", &cmd).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: "INBOX".into(),
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(&mut buf, "A001", &cmd);
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: "INBOX".into(),
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(&mut buf, "A001", &cmd);
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: "1:*".into(),
items: "(UID FLAGS ENVELOPE)".into(),
changed_since: None,
vanished: false,
};
encode_command(&mut buf, "A003", &cmd).unwrap();
assert_eq!(&buf[..], b"A003 UID FETCH 1:* (UID FLAGS ENVELOPE)\r\n");
}
#[test]
fn encode_done_has_no_tag() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A999", &Command::Done).unwrap();
assert_eq!(&buf[..], b"DONE\r\n");
}
#[test]
fn encode_store_add_flags() {
let mut buf = BytesMut::new();
let cmd = Command::UidStore {
sequence_set: "1:3".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen, crate::types::Flag::Flagged],
unchanged_since: None,
};
encode_command(&mut buf, "A004", &cmd).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: "5".into(),
operation: crate::types::StoreOperation::Remove,
flags: vec![crate::types::Flag::Deleted],
unchanged_since: Some(12345),
};
encode_command(&mut buf, "A005", &cmd).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(&mut buf, "A006", &cmd).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(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 AUTHENTICATE PLAIN\r\n");
}
#[test]
fn encode_starttls() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A001", &Command::StartTls).unwrap();
assert_eq!(&buf[..], b"A001 STARTTLS\r\n");
}
#[test]
fn encode_logout() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A001", &Command::Logout).unwrap();
assert_eq!(&buf[..], b"A001 LOGOUT\r\n");
}
#[test]
fn encode_capability() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A001", &Command::Capability).unwrap();
assert_eq!(&buf[..], b"A001 CAPABILITY\r\n");
}
#[test]
fn encode_noop() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A001", &Command::Noop).unwrap();
assert_eq!(&buf[..], b"A001 NOOP\r\n");
}
#[test]
fn encode_expunge() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A001", &Command::Expunge).unwrap();
assert_eq!(&buf[..], b"A001 EXPUNGE\r\n");
}
#[test]
fn encode_close() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A001", &Command::Close).unwrap();
assert_eq!(&buf[..], b"A001 CLOSE\r\n");
}
#[test]
fn encode_unselect() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A001", &Command::Unselect).unwrap();
assert_eq!(&buf[..], b"A001 UNSELECT\r\n");
}
#[test]
fn encode_idle() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A001", &Command::Idle).unwrap();
assert_eq!(&buf[..], b"A001 IDLE\r\n");
}
#[test]
fn encode_examine() {
let mut buf = BytesMut::new();
let cmd = Command::Examine {
mailbox: "INBOX".into(),
condstore: false,
qresync: None,
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 EXAMINE \"INBOX\"\r\n");
}
#[test]
fn encode_create() {
let mut buf = BytesMut::new();
let cmd = Command::Create {
mailbox: "Archive".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "Sent".into(),
special_use: vec![crate::types::MailboxAttribute::Sent],
};
encode_command(&mut buf, "A001", &cmd).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: "Important Sent".into(),
special_use: vec![
crate::types::MailboxAttribute::Sent,
crate::types::MailboxAttribute::Important,
],
};
encode_command(&mut buf, "A001", &cmd).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: "My Drafts".into(),
special_use: vec![crate::types::MailboxAttribute::Drafts],
};
encode_command(&mut buf, "A001", &cmd).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: "Test".into(),
special_use: vec![],
};
encode_command(&mut buf, "A001", &cmd).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: "OldMail".into(),
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 DELETE \"OldMail\"\r\n");
}
#[test]
fn encode_rename() {
let mut buf = BytesMut::new();
let cmd = Command::Rename {
mailbox: "OldName".into(),
new_name: "NewName".into(),
};
encode_command(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 LIST \"\" \"*\"\r\n");
}
#[test]
fn encode_status() {
let mut buf = BytesMut::new();
let cmd = Command::Status {
mailbox: "INBOX".into(),
items: "(MESSAGES UNSEEN)".into(),
};
encode_command(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).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: "1:5".into(),
mailbox: "Trash".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "10:20".into(),
mailbox: "Archive".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "1:3".into(),
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 UID EXPUNGE 1:3\r\n");
}
#[test]
fn encode_enable() {
let mut buf = BytesMut::new();
let cmd = Command::Enable(vec!["CONDSTORE".into(), "UTF8=ACCEPT".into()]);
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 ENABLE CONDSTORE UTF8=ACCEPT\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(&mut buf, "A001", &cmd).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: "42".into(),
operation: crate::types::StoreOperation::Replace,
flags: vec![crate::types::Flag::Seen],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).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: "1:3".into(),
operation: crate::types::StoreOperation::AddSilent,
flags: vec![crate::types::Flag::Seen],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).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: "5".into(),
operation: crate::types::StoreOperation::RemoveSilent,
flags: vec![crate::types::Flag::Deleted],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).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: "10".into(),
operation: crate::types::StoreOperation::ReplaceSilent,
flags: vec![crate::types::Flag::Flagged],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).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: "1:5".into(),
operation: crate::types::StoreOperation::AddSilent,
flags: vec![crate::types::Flag::Deleted],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).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");
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());
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: r#"folder"name"#.into(),
condstore: false,
qresync: None,
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX.Sent".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX.Old".into(),
};
encode_command(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 SEARCH UNSEEN\r\n");
}
#[test]
fn encode_fetch() {
let mut buf = BytesMut::new();
let cmd = Command::Fetch {
sequence_set: "1:*".into(),
items: "(FLAGS)".into(),
changed_since: None,
};
encode_command(&mut buf, "A001", &cmd).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: "1:3".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).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: "1:5".into(),
mailbox: "Archive".into(),
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 COPY 1:5 \"Archive\"\r\n");
}
#[test]
fn encode_namespace() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A001", &Command::Namespace).unwrap();
assert_eq!(&buf[..], b"A001 NAMESPACE\r\n");
}
#[test]
fn encode_check() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A001", &Command::Check).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(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 SEARCH RETURN () FLAGGED\r\n");
}
#[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(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).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: "INBOX".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX.Drafts".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX".into(),
identifier: "fred".into(),
rights: "lrswipcda".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX".into(),
identifier: "fred".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX".into(),
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 GETACL \"INBOX\"\r\n");
}
#[test]
fn encode_listrights() {
let mut buf = BytesMut::new();
let cmd = Command::ListRights {
mailbox: "INBOX".into(),
identifier: "fred".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "Shared Folders".into(),
identifier: "user@example.com".into(),
rights: "+lrs".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX.Sent Items".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX".into(),
entries: vec!["/private/comment".into()],
max_size: None,
depth: None,
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX".into(),
entries: vec!["/private/comment".into(), "/shared/vendor/foo".into()],
max_size: None,
depth: None,
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX".into(),
entries: vec![("/private/comment".into(), Some(b"my comment".to_vec()))],
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX".into(),
entries: vec![("/private/comment".into(), None)],
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX".into(),
entries: vec![
("/private/comment".into(), Some(b"hello".to_vec())),
("/shared/vendor/x".into(), None),
],
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(
&buf[..],
b"A001 SETMETADATA \"INBOX\" (\"/private/comment\" \"hello\" \"/shared/vendor/x\" NIL)\r\n"
);
}
#[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: "INBOX".into(),
entries: vec![("/private/binary".into(), Some(value))],
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX".into(),
entries: vec![("/private/comment".into(), Some(b"hello".to_vec()))],
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(
&buf[..],
b"A001 SETMETADATA \"INBOX\" (\"/private/comment\" \"hello\")\r\n"
);
}
#[test]
fn encode_setmetadata_high_bytes_use_literal8() {
let mut buf = BytesMut::new();
let value = b"\x80\x81\xff".to_vec();
let cmd = Command::SetMetadata {
mailbox: "INBOX".into(),
entries: vec![("/private/binary".into(), Some(value))],
};
encode_command(&mut buf, "A001", &cmd).unwrap();
let output = &buf[..];
assert!(
output.windows(5).any(|w| w == b"~{3}\r"),
"High-byte SETMETADATA value must use literal8 ~{{N}} syntax per RFC 5464 Section 5, got: {:?}",
String::from_utf8_lossy(output)
);
assert!(
output.windows(3).any(|w| w == b"\x80\x81\xff"),
"High bytes must be preserved in SETMETADATA literal8 values"
);
}
#[test]
fn nstring_or_literal8_escapes_backslash_and_quote() {
let mut buf = BytesMut::new();
encode_nstring_or_literal8(&mut buf, b"a\\b\"c");
assert_eq!(&buf[..], b"\"a\\\\b\\\"c\"");
}
#[test]
fn nstring_or_literal8_ascii_uses_quoted() {
let mut buf = BytesMut::new();
encode_nstring_or_literal8(&mut buf, b"hello");
assert_eq!(&buf[..], b"\"hello\"");
}
#[test]
fn nstring_or_literal8_crlf_uses_literal8() {
let mut buf = BytesMut::new();
encode_nstring_or_literal8(&mut buf, b"line1\r\nline2");
assert_eq!(&buf[..], b"~{12}\r\nline1\r\nline2");
}
#[test]
fn nstring_or_literal8_nul_uses_literal8() {
let mut buf = BytesMut::new();
encode_nstring_or_literal8(&mut buf, b"\x00data");
assert_eq!(&buf[..], b"~{5}\r\n\x00data");
}
#[test]
fn nstring_or_literal8_empty() {
let mut buf = BytesMut::new();
encode_nstring_or_literal8(&mut buf, b"");
assert_eq!(&buf[..], b"\"\"");
}
#[test]
fn nstring_or_literal8_high_bytes_uses_literal8() {
let mut buf = BytesMut::new();
encode_nstring_or_literal8(&mut buf, b"\x80\xff");
assert_eq!(&buf[..], b"~{2}\r\n\x80\xff");
}
#[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(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 THREAD REFERENCES UTF-8 ALL\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(&mut buf, "A001", &cmd).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 {
algorithm: "DATE".into(),
charset: "UTF-8".into(),
criteria: "ALL".into(),
};
encode_command(&mut buf, "A001", &cmd).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 {
algorithm: "SUBJECT".into(),
charset: "US-ASCII".into(),
criteria: "SINCE 1-Jan-2025".into(),
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(
&buf[..],
b"A001 UID SORT (SUBJECT) US-ASCII SINCE 1-Jan-2025\r\n"
);
}
#[test]
fn encode_compress() {
let mut buf = BytesMut::new();
encode_command(&mut buf, "A001", &Command::Compress).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,
false,
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,
false,
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,
false,
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,
false,
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,
false,
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, true, 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, true, 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,
false,
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,
false,
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,
true,
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,
true,
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,
true,
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,
false,
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,
false,
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");
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());
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"");
assert_eq!(&buf[..], b"\"\"");
}
#[test]
fn encode_select_mailbox_with_spaces() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: "my folder".into(),
condstore: false,
qresync: None,
};
encode_command(&mut buf, "A001", &cmd).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");
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());
assert!(buf.starts_with(b"\""));
assert!(buf.ends_with(b"\""));
assert_eq!(buf.len(), 10_002); }
#[test]
fn encode_del_char_uses_quoted_form() {
let mut buf = BytesMut::new();
encode_quoted_or_literal(&mut buf, &[0x7F]);
assert!(
buf.starts_with(b"\""),
"DEL (0x7F) is a valid TEXT-CHAR per RFC 3501 Section 9 and should use quoted form, \
got literal form instead"
);
}
#[test]
fn encode_move() {
let mut buf = BytesMut::new();
let cmd = Command::Move {
sequence_set: "1:5".into(),
mailbox: "Trash".into(),
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 MOVE 1:5 \"Trash\"\r\n");
}
#[test]
fn encode_enable_empty_args() {
let mut buf = BytesMut::new();
let cmd = Command::Enable(vec![]);
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"ENABLE with empty capabilities must fail (RFC 5161)"
);
}
#[test]
fn regression_encode_id_empty_params() {
let mut buf = BytesMut::new();
let cmd = Command::Id(vec![]);
encode_command(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd);
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_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(&mut buf, "A001", &cmd);
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(&mut buf, "A001", &cmd);
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 key = "k".repeat(30); let value = "v".repeat(1024); let params: Vec<(String, Option<String>)> = (0..30)
.map(|_| (key.clone(), Some(value.clone())))
.collect();
let cmd = Command::Id(params);
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_ok(),
"ID with exactly 30 pairs, 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: "INBOX".into(),
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(&mut buf, "A008", &cmd).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: "INBOX".into(),
condstore: true,
qresync: None,
};
encode_command(&mut buf, "A009", &cmd).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: "INBOX".into(),
condstore: true,
qresync: None,
};
encode_command(&mut buf, "A010", &cmd).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: "1:*".into(),
items: "(FLAGS)".into(),
changed_since: Some(12345),
};
encode_command(&mut buf, "A011", &cmd).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: "1:10".into(),
items: "(UID)".into(),
changed_since: None,
};
encode_command(&mut buf, "A012", &cmd).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: "1:500".into(),
items: "(FLAGS ENVELOPE)".into(),
changed_since: Some(67890),
vanished: false,
};
encode_command(&mut buf, "A013", &cmd).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: "1:*".into(),
items: "(FLAGS)".into(),
changed_since: Some(12345),
vanished: true,
};
encode_command(&mut buf, "A014", &cmd).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: "1:*".into(),
items: "(FLAGS)".into(),
changed_since: None,
vanished: true,
};
let result = encode_command(&mut buf, "A015", &cmd);
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: "1:500".into(),
items: "(FLAGS ENVELOPE)".into(),
changed_since: Some(67890),
vanished: false,
};
encode_command(&mut buf, "A016", &cmd).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(&mut buf, "A014", &cmd).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(&mut buf, "A015", &cmd).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(&mut buf, "A016", &cmd).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");
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: "1:5".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen, crate::types::Flag::Recent],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).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]
#[should_panic(expected = "invalid sequence set")]
fn spec_audit_l14_invalid_sequence_set() {
let mut buf = BytesMut::new();
let cmd = Command::Fetch {
sequence_set: "abc".into(),
items: "(UID)".into(),
changed_since: None,
};
encode_command(&mut buf, "A001", &cmd).unwrap();
}
#[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, false, 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,
false,
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_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));
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))
.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))
.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,
)
.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"),
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output,
"A001 GETMETADATA \"INBOX\" (MAXSIZE 1024 DEPTH infinity) \"/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,
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output,
"A001 GETMETADATA \"INBOX\" (MAXSIZE 2048) \"/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"),
)
.unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert_eq!(
output,
"A001 GETMETADATA \"INBOX\" (DEPTH 1) \"/private/comment\"\r\n",
);
}
#[test]
fn audit_finding8_sort_command_encodes_correctly() {
let mut buf = BytesMut::new();
let cmd = Command::Sort {
algorithm: "DATE".into(),
charset: "UTF-8".into(),
criteria: "ALL".into(),
};
encode_command(&mut buf, "A001", &cmd).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 {
algorithm: "REVERSE DATE".into(),
charset: "UTF-8".into(),
criteria: "SINCE 1-Jan-2024".into(),
};
encode_command(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).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, false, 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: "1:5".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Recent, crate::types::Flag::Wildcard],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("+FLAGS ()"),
"all-filtered STORE must produce empty flag-list '()' per RFC 3501 Section 9: '{output}'"
);
}
#[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(&mut buf, "A001", &cmd).unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains(" =\r\n"),
"empty initial response must be '=': '{output}'"
);
}
#[test]
fn audit_l9_validate_sequence_set_rejects_invalid() {
assert!(!validate_sequence_set("abc"));
assert!(!validate_sequence_set("0"));
assert!(!validate_sequence_set(""));
assert!(!validate_sequence_set("1,,2"));
assert!(validate_sequence_set("1:*"));
assert!(validate_sequence_set("1,2,3"));
}
#[test]
fn validate_sequence_set_rejects_overflow_u32() {
assert!(!validate_sequence_set("99999999999"));
assert!(validate_sequence_set("4294967295"));
assert!(!validate_sequence_set("4294967296"));
assert!(!validate_sequence_set("1:99999999999"));
assert!(!validate_sequence_set("99999999999:1"));
}
#[test]
#[should_panic(expected = "invalid sequence set per RFC 3501 Section 9")]
fn encode_fetch_with_zero_sequence_set_panics() {
let mut buf = BytesMut::new();
let cmd = Command::Fetch {
sequence_set: "0".into(),
items: "(FLAGS)".into(),
changed_since: None,
};
encode_command(&mut buf, "A001", &cmd).unwrap();
}
#[test]
#[should_panic(expected = "invalid sequence set per RFC 3501 Section 9")]
fn encode_uid_store_with_empty_sequence_set_panics() {
let mut buf = BytesMut::new();
let cmd = Command::UidStore {
sequence_set: String::new(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).unwrap();
}
#[test]
#[should_panic(expected = "invalid sequence set per RFC 3501 Section 9")]
fn encode_copy_with_alphabetic_sequence_set_panics() {
let mut buf = BytesMut::new();
let cmd = Command::Copy {
sequence_set: "abc".into(),
mailbox: "Trash".into(),
};
encode_command(&mut buf, "A001", &cmd).unwrap();
}
#[test]
#[should_panic(expected = "invalid sequence set per RFC 3501 Section 9")]
fn encode_uid_fetch_with_overflow_sequence_set_panics() {
let mut buf = BytesMut::new();
let cmd = Command::UidFetch {
sequence_set: "99999999999".into(),
items: "(UID)".into(),
changed_since: None,
vanished: false,
};
encode_command(&mut buf, "A001", &cmd).unwrap();
}
#[test]
fn encode_command_rejects_invalid_sequence_set_without_panic() {
let mut buf = BytesMut::new();
let cmd = Command::Fetch {
sequence_set: "0".into(), items: "(FLAGS)".into(),
changed_since: None,
};
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"Invalid sequence set '0' should return Err"
);
}
#[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(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd);
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(&mut buf, "A001", &cmd);
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: "INBOX".into(),
entries: vec![],
};
let result = encode_command(&mut buf, "A001", &cmd);
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: "INBOX".into(),
entries: vec![],
max_size: None,
depth: None,
};
let result = encode_command(&mut buf, "A001", &cmd);
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: "INBOX".into(),
entries: vec!["/private/comment".into()],
max_size: None,
depth: Some("2".into()),
};
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"GETMETADATA with invalid DEPTH \"2\" must fail (RFC 5464 Section 4.2.2)"
);
}
#[test]
fn encode_getmetadata_valid_depth_values() {
for depth in &["0", "1", "infinity"] {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: "INBOX".into(),
entries: vec!["/private/comment".into()],
max_size: None,
depth: Some((*depth).to_string()),
};
let result = encode_command(&mut buf, "A001", &cmd);
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: "1".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Custom("has space".into())],
unchanged_since: None,
};
let result = encode_command(&mut buf, "A001", &cmd);
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: "1".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Custom("bad(flag".into())],
unchanged_since: None,
};
let result = encode_command(&mut buf, "A001", &cmd);
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: "1".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Custom(String::new())],
unchanged_since: None,
};
let result = encode_command(&mut buf, "A001", &cmd);
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: "1".into(),
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(&mut buf, "A001", &cmd);
assert!(
result.is_ok(),
"valid custom flags must be accepted; got: {result:?}"
);
}
#[test]
fn regression_getmetadata_options_after_mailbox() {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: "INBOX".into(),
entries: vec!["/shared/comment".into(), "/private/comment".into()],
max_size: Some(1024),
depth: None,
};
encode_command(&mut buf, "a", &cmd).unwrap();
assert_eq!(
std::str::from_utf8(&buf).unwrap(),
"a GETMETADATA \"INBOX\" (MAXSIZE 1024) (\"/shared/comment\" \"/private/comment\")\r\n",
"GETMETADATA options must come AFTER the mailbox (RFC 5464 Section 4.2.1 examples)"
);
}
#[test]
fn regression_getmetadata_depth_after_mailbox() {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: "INBOX".into(),
entries: vec!["/private/filters/values".into()],
max_size: None,
depth: Some("1".into()),
};
encode_command(&mut buf, "a", &cmd).unwrap();
assert_eq!(
std::str::from_utf8(&buf).unwrap(),
"a GETMETADATA \"INBOX\" (DEPTH 1) \"/private/filters/values\"\r\n",
"GETMETADATA DEPTH must come AFTER the mailbox (RFC 5464 Section 4.2.2 examples)"
);
}
#[test]
fn regression_getmetadata_both_options_after_mailbox() {
let mut buf = BytesMut::new();
let cmd = Command::GetMetadata {
mailbox: "INBOX".into(),
entries: vec!["/private/comment".into()],
max_size: Some(2048),
depth: Some("infinity".into()),
};
encode_command(&mut buf, "a", &cmd).unwrap();
assert_eq!(
std::str::from_utf8(&buf).unwrap(),
"a GETMETADATA \"INBOX\" (MAXSIZE 2048 DEPTH infinity) \"/private/comment\"\r\n",
"GETMETADATA MAXSIZE + DEPTH must come AFTER the mailbox (RFC 5464 Section 4.2)"
);
}
#[test]
fn encode_enable_empty_caps_returns_error() {
let mut buf = BytesMut::new();
let cmd = Command::Enable(vec![]);
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"ENABLE with empty capabilities must fail (RFC 5161)"
);
}
#[test]
fn regression_qresync_seq_match_without_known_uids_returns_err() {
let mut buf = BytesMut::new();
let cmd = Command::Select {
mailbox: "INBOX".into(),
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(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"seq-match-data without known-uids must return Err (RFC 7162 Section 3.2.5.2)"
);
}
#[test]
fn regression_validate_sequence_set_dollar_in_comma_list() {
assert!(
validate_sequence_set("1:5,$"),
"\"1:5,$\" must be valid per RFC 5182 Section 5"
);
assert!(
validate_sequence_set("$,1:*"),
"\"$,1:*\" must be valid per RFC 5182 Section 5"
);
assert!(
validate_sequence_set("42,$"),
"\"42,$\" must be valid per RFC 5182 Section 5"
);
assert!(
validate_sequence_set("$"),
"bare \"$\" must still be valid per RFC 5182 Section 2"
);
assert!(
!validate_sequence_set("$:5"),
"\"$: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: "INBOX".into(),
condstore: false,
qresync: Some(QresyncParams {
uid_validity: 67890,
mod_seq: 12345,
known_uids: Some("1:*".into()),
seq_match_data: None,
}),
};
let result = encode_command(&mut buf, "A001", &cmd);
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: "INBOX".into(),
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(&mut buf, "A001", &cmd);
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: "INBOX".into(),
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(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"known-uid-set containing '*' must return Err (RFC 7162 Section 3.2.5.2)"
);
}
#[test]
fn store_all_flags_filtered_produces_empty_flag_list() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: "1:5".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Recent],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("+FLAGS ()"),
"all-filtered STORE must produce empty flag-list '()' per RFC 3501 Section 9, got: '{output}'"
);
assert!(
output.ends_with("+FLAGS ()\r\n"),
"must end with valid CRLF after empty flag-list, got: '{output}'"
);
}
#[test]
fn store_filters_wildcard_flag() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: "1".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![
crate::types::Flag::Seen,
crate::types::Flag::Wildcard,
crate::types::Flag::Flagged,
],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).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: "2".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![
crate::types::Flag::Seen,
crate::types::Flag::Recent,
crate::types::Flag::Flagged,
],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).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: "1".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![
crate::types::Flag::Seen,
crate::types::Flag::Custom("$Important".into()),
],
unchanged_since: None,
};
encode_command(&mut buf, "A001", &cmd).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: "1".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Custom("bad flag".into())],
unchanged_since: None,
};
assert!(
encode_command(&mut buf, "A001", &cmd).is_err(),
"custom flag with space must be rejected"
);
}
#[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,
false,
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,
false,
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,
false,
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: "1:*".into(),
items: "(FLAGS)".into(),
changed_since: Some(1),
};
encode_command(&mut buf, "A001", &cmd).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: "1:*".into(),
items: "(FLAGS)".into(),
changed_since: Some(1),
vanished: false,
};
encode_command(&mut buf, "A001", &cmd).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);
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);
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);
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);
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);
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 encode_quoted_escapes_backslash_and_dquote() {
let mut buf = BytesMut::new();
encode_quoted_or_literal(&mut buf, b"a\\b\"c");
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);
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);
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,
false,
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,
false,
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,
false,
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,
false,
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,
false,
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,
false,
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,
false,
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,
false,
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 must be accepted — we allow Feb 29 for all years (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,
false,
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: "1:*".into(),
items: "FLAGS".into(),
changed_since: Some(0),
};
let mut buf = BytesMut::new();
let result = encode_command(&mut buf, "A001", &cmd);
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: "1:*".into(),
items: "FLAGS".into(),
changed_since: Some(u64::MAX),
};
let mut buf = BytesMut::new();
let result = encode_command(&mut buf, "A001", &cmd);
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: "1".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen],
unchanged_since: Some(0),
};
let mut buf = BytesMut::new();
let result = encode_command(&mut buf, "A001", &cmd);
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: "1".into(),
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(&mut buf, "A001", &cmd);
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: "1:*".into(),
items: "FLAGS".into(),
changed_since: Some(0),
vanished: false,
};
let mut buf = BytesMut::new();
let result = encode_command(&mut buf, "A001", &cmd);
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: "1".into(),
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(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"UID STORE UNCHANGEDSINCE > i64::MAX must be rejected per RFC 7162 Section 7"
);
}
#[test]
fn validate_known_sequence_set_accepts_valid() {
assert!(validate_known_sequence_set("1").is_ok());
assert!(validate_known_sequence_set("1:100").is_ok());
assert!(validate_known_sequence_set("1,2,3").is_ok());
assert!(validate_known_sequence_set("1:5,10:20").is_ok());
assert!(validate_known_sequence_set("4294967295").is_ok()); }
#[test]
fn validate_known_sequence_set_rejects_invalid() {
assert!(validate_known_sequence_set("").is_err());
assert!(validate_known_sequence_set("abc").is_err());
assert!(validate_known_sequence_set("1 2").is_err());
assert!(validate_known_sequence_set("1:*").is_err());
assert!(validate_known_sequence_set("*").is_err());
assert!(validate_known_sequence_set("$").is_err());
assert!(validate_known_sequence_set("1,$").is_err());
assert!(validate_known_sequence_set("0").is_err());
assert!(validate_known_sequence_set("01").is_err());
assert!(validate_known_sequence_set("4294967296").is_err());
assert!(validate_known_sequence_set("1,").is_err());
assert!(validate_known_sequence_set("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,
);
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,
);
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,
);
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,
);
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: "INBOX".into(),
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(&mut buf, "A001", &cmd);
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(&mut buf, "A001", &cmd);
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_enable_rejects_invalid_capability_name() {
let mut buf = BytesMut::new();
let cmd = Command::Enable(vec!["BAD NAME".into()]);
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"ENABLE with space in capability name must be rejected \
(RFC 5161 Section 4: capability = atom)"
);
}
#[test]
fn encode_enable_accepts_valid_capabilities() {
let mut buf = BytesMut::new();
let cmd = Command::Enable(vec!["UTF8=ACCEPT".into(), "CONDSTORE".into()]);
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_ok(),
"ENABLE with valid atom capabilities must succeed, got: {result:?}"
);
let s = String::from_utf8_lossy(&buf);
assert!(s.contains("ENABLE UTF8=ACCEPT CONDSTORE"));
}
#[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(&mut buf, "A001", &cmd);
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(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 LIST {5}\r\ncaf\xc3\xa9 \"*\"\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(&mut buf, "A001", &cmd).unwrap();
assert_eq!(
&buf[..],
b"A001 LSUB \"\" {9}\r\n\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e\r\n"
);
}
#[test]
fn encode_rename_non_ascii_both_args() {
let mut buf = BytesMut::new();
let cmd = Command::Rename {
mailbox: "Ünread".into(),
new_name: "Gelöscht".into(),
};
encode_command(&mut buf, "A001", &cmd).unwrap();
let expected_old = "Ünread".as_bytes(); let expected_new = "Gelöscht".as_bytes(); let expected = format!(
"A001 RENAME {{{}}}\r\n{} {{{}}}\r\n{}\r\n",
expected_old.len(),
"Ünread",
expected_new.len(),
"Gelöscht",
);
assert_eq!(&buf[..], expected.as_bytes());
}
#[test]
fn encode_list_empty_reference_percent_pattern() {
let mut buf = BytesMut::new();
let cmd = Command::List {
reference: String::new(),
pattern: "%".into(),
};
encode_command(&mut buf, "A001", &cmd).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(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 LSUB \"INBOX.\" \"*\"\r\n");
}
#[test]
fn encode_store_rejects_invalid_sequence_set() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: "0".into(),
operation: crate::types::StoreOperation::Add,
flags: vec![crate::types::Flag::Seen],
unchanged_since: None,
};
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"expected error for invalid sequence set '0' in STORE"
);
}
#[test]
fn encode_move_rejects_invalid_sequence_set() {
let mut buf = BytesMut::new();
let cmd = Command::Move {
sequence_set: "abc".into(),
mailbox: "Trash".into(),
};
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"expected error for invalid sequence set 'abc' in MOVE"
);
}
#[test]
fn encode_uid_move_rejects_invalid_sequence_set() {
let mut buf = BytesMut::new();
let cmd = Command::UidMove {
sequence_set: String::new(),
mailbox: "Archive".into(),
};
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"expected error for empty sequence set in UID MOVE"
);
}
#[test]
fn encode_uid_copy_rejects_invalid_sequence_set() {
let mut buf = BytesMut::new();
let cmd = Command::UidCopy {
sequence_set: "0".into(),
mailbox: "INBOX".into(),
};
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"expected error for invalid sequence set '0' in UID COPY"
);
}
#[test]
fn encode_uid_expunge_rejects_invalid_sequence_set() {
let mut buf = BytesMut::new();
let cmd = Command::UidExpunge {
sequence_set: "abc".into(),
};
let result = encode_command(&mut buf, "A001", &cmd);
assert!(
result.is_err(),
"expected error for invalid sequence set 'abc' in UID EXPUNGE"
);
}
#[test]
fn encode_non_uid_store_with_condstore_typical_value() {
let mut buf = BytesMut::new();
let cmd = Command::Store {
sequence_set: "1:3".into(),
operation: crate::types::StoreOperation::Remove,
flags: vec![crate::types::Flag::Seen],
unchanged_since: Some(12345),
};
encode_command(&mut buf, "A001", &cmd).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: "1:5".into(),
mailbox: "folder\"name".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "1:5".into(),
mailbox: "caf\u{00E9}".into(), };
encode_command(&mut buf, "A001", &cmd).unwrap();
assert!(
buf.starts_with(b"A001 UID MOVE 1:5 "),
"command prefix mismatch"
);
let s = String::from_utf8_lossy(&buf);
assert!(
s.contains("{5}\r\n"),
"should use literal for non-ASCII mailbox, got: {s}"
);
}
#[test]
fn encode_deleteacl_mailbox_with_spaces() {
let mut buf = BytesMut::new();
let cmd = Command::DeleteAcl {
mailbox: "Shared Folders".into(),
identifier: "user@example.com".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "café".into(),
identifier: "fred".into(),
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 DELETEACL {5}\r\ncaf\xc3\xa9 \"fred\"\r\n");
}
#[test]
fn encode_listrights_mailbox_with_spaces() {
let mut buf = BytesMut::new();
let cmd = Command::ListRights {
mailbox: "Shared Folders".into(),
identifier: "user@example.com".into(),
};
encode_command(&mut buf, "A001", &cmd).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: "café".into(),
identifier: "fred".into(),
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 LISTRIGHTS {5}\r\ncaf\xc3\xa9 \"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(&mut buf, "A001", &cmd).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: "café".into(),
};
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(&buf[..], b"A001 GETQUOTAROOT {5}\r\ncaf\xc3\xa9\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, true, 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, true, 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, false, 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 encode_unauthenticate_command() {
let mut buf = BytesMut::new();
let cmd = Command::Unauthenticate;
encode_command(&mut buf, "A001", &cmd).unwrap();
assert_eq!(
&buf[..],
b"A001 UNAUTHENTICATE\r\n",
"UNAUTHENTICATE must encode as 'tag UNAUTHENTICATE\\r\\n' per RFC 8437 Section 2"
);
}
#[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 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: "INBOX".into(),
entries: vec![("/private/binary".into(), Some(value))],
};
encode_command(&mut buf, "A001", &cmd).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: "INBOX".into(),
entries: vec![("/private/binary".into(), Some(value))],
};
encode_command(&mut buf, "A001", &cmd).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"
);
}
}