use crate::types::ResponseCode;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("authentication failed: {text}")]
Auth {
text: String,
code: Option<ResponseCode>,
},
#[error("server rejected command: {text}")]
No {
text: String,
code: Option<ResponseCode>,
},
#[error("server reported bad command: {text}")]
Bad {
text: String,
code: Option<ResponseCode>,
},
#[error("server closing connection: {text}")]
Bye {
text: String,
code: Option<ResponseCode>,
},
#[error("protocol error: {0}")]
Protocol(String),
#[error("parse error: {0}")]
Parse(String),
#[error("operation timed out")]
Timeout,
#[error("connection closed")]
Closed,
#[error("STARTTLS not supported by server")]
StartTlsUnavailable,
#[error("missing required capability: {0}")]
MissingCapability(String),
#[error("message size {size} exceeds server APPENDLIMIT of {limit}")]
AppendLimit {
size: usize,
limit: u64,
},
#[error("invalid APPEND date-time: {0}")]
InvalidAppendDate(String),
}
impl Error {
pub(crate) fn no_with_code(text: String, code: Option<ResponseCode>) -> Self {
Self::No { text, code }
}
pub(crate) fn bad_with_code(text: String, code: Option<ResponseCode>) -> Self {
Self::Bad { text, code }
}
pub(crate) fn auth_with_code(text: String, code: Option<ResponseCode>) -> Self {
Self::Auth { text, code }
}
pub(crate) fn bye_with_code(text: String, code: Option<ResponseCode>) -> Self {
Self::Bye { text, code }
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn io_variant_debug() {
let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
let err = Error::Io(io_err);
let dbg = format!("{err:?}");
assert!(dbg.contains("Io"));
assert!(dbg.contains("refused"));
}
#[test]
fn auth_variant_debug() {
let err = Error::Auth {
text: "bad credentials".into(),
code: None,
};
let dbg = format!("{err:?}");
assert!(dbg.contains("Auth"));
assert!(dbg.contains("bad credentials"));
}
#[test]
fn no_variant_debug() {
let err = Error::no_with_code("SELECT failed".into(), None);
let dbg = format!("{err:?}");
assert!(dbg.contains("No"));
assert!(dbg.contains("SELECT failed"));
}
#[test]
fn bad_variant_debug() {
let err = Error::bad_with_code("unknown command".into(), None);
let dbg = format!("{err:?}");
assert!(dbg.contains("Bad"));
assert!(dbg.contains("unknown command"));
}
#[test]
fn bye_variant_debug() {
let err = Error::Bye {
text: "server shutting down".into(),
code: None,
};
let dbg = format!("{err:?}");
assert!(dbg.contains("Bye"));
assert!(dbg.contains("server shutting down"));
}
#[test]
fn protocol_variant_debug() {
let err = Error::Protocol("unexpected tag".into());
let dbg = format!("{err:?}");
assert!(dbg.contains("Protocol"));
assert!(dbg.contains("unexpected tag"));
}
#[test]
fn parse_variant_debug() {
let err = Error::Parse("invalid envelope".into());
let dbg = format!("{err:?}");
assert!(dbg.contains("Parse"));
assert!(dbg.contains("invalid envelope"));
}
#[test]
fn timeout_variant_debug() {
let err = Error::Timeout;
let dbg = format!("{err:?}");
assert!(dbg.contains("Timeout"));
}
#[test]
fn closed_variant_debug() {
let err = Error::Closed;
let dbg = format!("{err:?}");
assert!(dbg.contains("Closed"));
}
#[test]
fn starttls_unavailable_variant_debug() {
let err = Error::StartTlsUnavailable;
let dbg = format!("{err:?}");
assert!(dbg.contains("StartTlsUnavailable"));
}
#[test]
fn missing_capability_variant_debug() {
let err = Error::MissingCapability("IDLE".into());
let dbg = format!("{err:?}");
assert!(dbg.contains("MissingCapability"));
assert!(dbg.contains("IDLE"));
}
#[test]
fn append_limit_variant_debug() {
let err = Error::AppendLimit {
size: 50_000_000,
limit: 25_000_000,
};
let dbg = format!("{err:?}");
assert!(dbg.contains("AppendLimit"));
assert!(dbg.contains("50000000"));
assert!(dbg.contains("25000000"));
}
#[test]
fn invalid_append_date_variant_debug() {
let err = Error::InvalidAppendDate("not a date".into());
let dbg = format!("{err:?}");
assert!(dbg.contains("InvalidAppendDate"));
assert!(dbg.contains("not a date"));
}
#[test]
fn display_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
let err = Error::Io(io_err);
let msg = err.to_string();
assert!(msg.contains("I/O error"));
assert!(msg.contains("pipe broken"));
}
#[test]
fn display_auth() {
let err = Error::Auth {
text: "LOGIN denied".into(),
code: None,
};
let msg = err.to_string();
assert_eq!(msg, "authentication failed: LOGIN denied");
}
#[test]
fn display_no() {
let err = Error::no_with_code("[NOPERM] SELECT not allowed".into(), None);
let msg = err.to_string();
assert_eq!(msg, "server rejected command: [NOPERM] SELECT not allowed");
}
#[test]
fn display_bad() {
let err = Error::bad_with_code("syntax error in FETCH".into(), None);
let msg = err.to_string();
assert_eq!(msg, "server reported bad command: syntax error in FETCH");
}
#[test]
fn display_bye() {
let err = Error::Bye {
text: "Too many connections".into(),
code: None,
};
let msg = err.to_string();
assert_eq!(msg, "server closing connection: Too many connections");
}
#[test]
fn display_protocol() {
let err = Error::Protocol("missing CRLF".into());
let msg = err.to_string();
assert_eq!(msg, "protocol error: missing CRLF");
}
#[test]
fn display_parse() {
let err = Error::Parse("unexpected NIL in address".into());
let msg = err.to_string();
assert_eq!(msg, "parse error: unexpected NIL in address");
}
#[test]
fn display_timeout() {
let err = Error::Timeout;
assert_eq!(err.to_string(), "operation timed out");
}
#[test]
fn display_closed() {
let err = Error::Closed;
assert_eq!(err.to_string(), "connection closed");
}
#[test]
fn display_starttls_unavailable() {
let err = Error::StartTlsUnavailable;
assert_eq!(err.to_string(), "STARTTLS not supported by server");
}
#[test]
fn display_missing_capability() {
let err = Error::MissingCapability("COMPRESS=DEFLATE".into());
assert_eq!(
err.to_string(),
"missing required capability: COMPRESS=DEFLATE"
);
}
#[test]
fn display_append_limit() {
let err = Error::AppendLimit {
size: 10_000,
limit: 5_000,
};
assert_eq!(
err.to_string(),
"message size 10000 exceeds server APPENDLIMIT of 5000"
);
}
#[test]
fn display_invalid_append_date() {
let err = Error::InvalidAppendDate("32-Jan-2025 00:00:00 +0000".into());
assert_eq!(
err.to_string(),
"invalid APPEND date-time: 32-Jan-2025 00:00:00 +0000"
);
}
#[test]
fn from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "connect timed out");
let err: Error = io_err.into();
assert!(matches!(err, Error::Io(_)));
assert!(err.to_string().contains("connect timed out"));
}
#[test]
fn from_io_error_preserves_kind() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
let err: Error = io_err.into();
match &err {
Error::Io(inner) => {
assert_eq!(inner.kind(), std::io::ErrorKind::PermissionDenied);
}
_ => panic!("expected Io variant"),
}
}
#[test]
fn io_variant_has_source() {
use std::error::Error as StdError;
let io_err = std::io::Error::other("disk full");
let err = Error::Io(io_err);
assert!(err.source().is_some());
}
#[test]
fn non_io_variants_have_no_source() {
use std::error::Error as StdError;
let variants: Vec<Error> = vec![
Error::auth_with_code("fail".into(), None),
Error::no_with_code("no".into(), None),
Error::bad_with_code("bad".into(), None),
Error::Bye {
text: "bye".into(),
code: None,
},
Error::Protocol("proto".into()),
Error::Parse("parse".into()),
Error::Timeout,
Error::Closed,
Error::StartTlsUnavailable,
Error::MissingCapability("CAP".into()),
Error::AppendLimit { size: 1, limit: 0 },
Error::InvalidAppendDate("bad".into()),
];
for variant in &variants {
assert!(
variant.source().is_none(),
"expected no source for: {variant:?}"
);
}
}
#[test]
fn pattern_match_io() {
let err = Error::Io(std::io::Error::other("oops"));
match &err {
Error::Io(io) => assert_eq!(io.kind(), std::io::ErrorKind::Other),
_ => panic!("expected Io"),
}
}
#[test]
fn pattern_match_auth() {
let err = Error::Auth {
text: "AUTHENTICATIONFAILED".into(),
code: None,
};
match &err {
Error::Auth { text, .. } => assert_eq!(text, "AUTHENTICATIONFAILED"),
_ => panic!("expected Auth"),
}
}
#[test]
fn pattern_match_no_extracts_response_text() {
let err = Error::no_with_code("[NOPERM] Cannot delete INBOX".into(), None);
match &err {
Error::No { text, .. } => {
assert!(text.contains("NOPERM"));
assert!(text.contains("Cannot delete INBOX"));
}
_ => panic!("expected No"),
}
}
#[test]
fn pattern_match_bad_extracts_response_text() {
let err = Error::bad_with_code("Command unknown: XYZZY".into(), None);
match &err {
Error::Bad { text, .. } => assert!(text.contains("XYZZY")),
_ => panic!("expected Bad"),
}
}
#[test]
fn pattern_match_bye_extracts_response_text() {
let err = Error::Bye {
text: "Autologout; idle for too long".into(),
code: None,
};
match &err {
Error::Bye { text, .. } => assert!(text.contains("Autologout")),
_ => panic!("expected Bye"),
}
}
#[test]
fn pattern_match_protocol() {
let err = Error::Protocol("unexpected continuation".into());
match &err {
Error::Protocol(msg) => assert!(msg.contains("continuation")),
_ => panic!("expected Protocol"),
}
}
#[test]
fn pattern_match_parse() {
let err = Error::Parse("malformed literal count".into());
match &err {
Error::Parse(msg) => assert!(msg.contains("literal")),
_ => panic!("expected Parse"),
}
}
#[test]
fn pattern_match_unit_variants() {
assert!(matches!(Error::Timeout, Error::Timeout));
assert!(matches!(Error::Closed, Error::Closed));
assert!(matches!(
Error::StartTlsUnavailable,
Error::StartTlsUnavailable
));
}
#[test]
fn pattern_match_missing_capability() {
let err = Error::MissingCapability("MOVE".into());
match &err {
Error::MissingCapability(cap) => assert_eq!(cap, "MOVE"),
_ => panic!("expected MissingCapability"),
}
}
#[test]
fn pattern_match_append_limit() {
let err = Error::AppendLimit {
size: 100_000,
limit: 50_000,
};
match &err {
Error::AppendLimit { size, limit } => {
assert_eq!(*size, 100_000);
assert_eq!(*limit, 50_000);
}
_ => panic!("expected AppendLimit"),
}
}
#[test]
fn pattern_match_invalid_append_date() {
let err = Error::InvalidAppendDate("missing timezone".into());
match &err {
Error::InvalidAppendDate(msg) => assert!(msg.contains("missing timezone")),
_ => panic!("expected InvalidAppendDate"),
}
}
#[test]
fn display_messages_contain_context_strings() {
let cases: Vec<(Error, &str)> = vec![
(
Error::auth_with_code("PLAIN rejected".into(), None),
"PLAIN rejected",
),
(
Error::no_with_code("SELECT denied for INBOX".into(), None),
"SELECT denied for INBOX",
),
(
Error::bad_with_code("Unrecognized argument XATTR".into(), None),
"Unrecognized argument XATTR",
),
(
Error::Bye {
text: "BYE Logging out".into(),
code: None,
},
"BYE Logging out",
),
(
Error::Protocol("tag mismatch: expected A001 got A002".into()),
"tag mismatch: expected A001 got A002",
),
(
Error::Parse("incomplete BODYSTRUCTURE at offset 42".into()),
"incomplete BODYSTRUCTURE at offset 42",
),
(Error::MissingCapability("UIDPLUS".into()), "UIDPLUS"),
];
for (err, expected_substr) in &cases {
let msg = err.to_string();
assert!(
msg.contains(expected_substr),
"expected {msg:?} to contain {expected_substr:?}"
);
}
}
#[test]
fn append_limit_display_contains_both_values() {
let err = Error::AppendLimit {
size: 1_048_576,
limit: 524_288,
};
let msg = err.to_string();
assert!(msg.contains("1048576"), "should contain the message size");
assert!(msg.contains("524288"), "should contain the server limit");
}
#[test]
fn bye_with_alert_code_preserved() {
let err = Error::bye_with_code("Server shutting down".into(), Some(ResponseCode::Alert));
match &err {
Error::Bye { code, text } => {
assert_eq!(text, "Server shutting down");
assert_eq!(*code, Some(ResponseCode::Alert));
}
_ => panic!("expected Bye variant"),
}
}
#[test]
fn bye_without_code_preserved() {
let err = Error::bye_with_code("Logging out".into(), None);
match &err {
Error::Bye { code, text } => {
assert_eq!(text, "Logging out");
assert_eq!(*code, None);
}
_ => panic!("expected Bye variant"),
}
}
#[test]
fn bye_with_unavailable_code_preserved() {
let err = Error::bye_with_code("Try again later".into(), Some(ResponseCode::Unavailable));
match &err {
Error::Bye {
code: Some(ResponseCode::Unavailable),
..
} => {}
_ => panic!("expected Bye with Unavailable code"),
}
}
#[test]
fn no_with_response_code_construction() {
let err = Error::no_with_code("not allowed".into(), Some(ResponseCode::NoPerm));
match &err {
Error::No { text, code } => {
assert_eq!(text, "not allowed");
assert_eq!(*code, Some(ResponseCode::NoPerm));
}
_ => panic!("expected No"),
}
}
#[test]
fn bad_with_response_code_construction() {
let err = Error::bad_with_code("syntax error".into(), Some(ResponseCode::ClientBug));
match &err {
Error::Bad { text, code } => {
assert_eq!(text, "syntax error");
assert_eq!(*code, Some(ResponseCode::ClientBug));
}
_ => panic!("expected Bad"),
}
}
#[test]
fn auth_with_response_code_construction() {
let err =
Error::auth_with_code("bad creds".into(), Some(ResponseCode::AuthenticationFailed));
match &err {
Error::Auth { text, code } => {
assert_eq!(text, "bad creds");
assert_eq!(*code, Some(ResponseCode::AuthenticationFailed));
}
_ => panic!("expected Auth"),
}
}
#[test]
fn no_with_code_display_shows_text_only() {
let err = Error::no_with_code("not allowed".into(), Some(ResponseCode::NoPerm));
assert_eq!(err.to_string(), "server rejected command: not allowed");
}
#[test]
fn bad_with_code_display_shows_text_only() {
let err = Error::bad_with_code("bad syntax".into(), Some(ResponseCode::ClientBug));
assert_eq!(err.to_string(), "server reported bad command: bad syntax");
}
#[test]
fn auth_with_code_display_shows_text_only() {
let err = Error::auth_with_code("denied".into(), Some(ResponseCode::AuthenticationFailed));
assert_eq!(err.to_string(), "authentication failed: denied");
}
#[test]
fn pattern_match_no_extracts_response_code() {
let err = Error::no_with_code("over quota".into(), Some(ResponseCode::OverQuota));
match &err {
Error::No {
code: Some(ResponseCode::OverQuota),
..
} => {}
_ => panic!("expected No with OverQuota code"),
}
}
#[test]
fn pattern_match_bad_extracts_response_code() {
let err = Error::bad_with_code("server bug".into(), Some(ResponseCode::ServerBug));
match &err {
Error::Bad {
code: Some(ResponseCode::ServerBug),
..
} => {}
_ => panic!("expected Bad with ServerBug code"),
}
}
#[test]
fn pattern_match_auth_extracts_response_code() {
let err = Error::auth_with_code("expired".into(), Some(ResponseCode::AuthenticationFailed));
match &err {
Error::Auth {
code: Some(ResponseCode::AuthenticationFailed),
..
} => {}
_ => panic!("expected Auth with AuthenticationFailed code"),
}
}
#[test]
fn all_variants_are_distinguishable() {
let errors: Vec<Error> = vec![
Error::Io(std::io::Error::other("test")),
Error::auth_with_code("a".into(), None),
Error::no_with_code("n".into(), None),
Error::bad_with_code("b".into(), None),
Error::Bye {
text: "y".into(),
code: None,
},
Error::Protocol("p".into()),
Error::Parse("r".into()),
Error::Timeout,
Error::Closed,
Error::StartTlsUnavailable,
Error::MissingCapability("c".into()),
Error::AppendLimit { size: 1, limit: 0 },
Error::InvalidAppendDate("bad date".into()),
];
for err in &errors {
let label = match err {
Error::Io(_) => "io",
Error::Auth { .. } => "auth",
Error::No { .. } => "no",
Error::Bad { .. } => "bad",
Error::Bye { .. } => "bye",
Error::Protocol(_) => "protocol",
Error::Parse(_) => "parse",
Error::Timeout => "timeout",
Error::Closed => "closed",
Error::StartTlsUnavailable => "starttls",
Error::MissingCapability(_) => "capability",
Error::AppendLimit { .. } => "appendlimit",
Error::InvalidAppendDate(_) => "invalidappenddate",
};
assert!(!label.is_empty());
}
}
}