oxedyne/fe2o3/fe2o3_net/src/smtp/cmd.rs
10.2 KiB, 96 runs
created by r1870400018:593, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | use crate::{ |
| 2 | email::msg::EmailMessage, |
| 3 | smtp::codes::SmtpResponseCode, |
| 4 | }; |
| 5 | |
| 6 | use oxedyne_fe2o3_core::prelude::*; |
| 7 | |
| 8 | use std::{ |
| 9 | fmt, |
| 10 | }; |
| 11 | |
| 12 | |
| 13 | #[derive(Clone, Debug, PartialEq)] |
| 14 | pub enum SmtpCommand { |
| 15 | /// HELO argument is an unvalidated string -- per RFC 5321 §4.1.1.1 |
| 16 | /// the client identifier may be either an FQDN *or* an |
| 17 | /// address-literal like `[192.168.1.4]` or `[IPv6:::1]`, and even |
| 18 | /// then a server SHOULD NOT reject the connection on the basis of |
| 19 | /// the identifier failing to verify. |
| 20 | Helo(String), |
| 21 | /// EHLO argument: same rules as `Helo`. |
| 22 | Ehlo(String), |
| 23 | MailFrom(String), |
| 24 | RcptTo(String), |
| 25 | Data, |
| 26 | Quit, |
| 27 | Rset, |
| 28 | Vrfy(String), |
| 29 | Expn(String), |
| 30 | Help(Option<String>), |
| 31 | Noop, |
| 32 | Auth(String), |
| 33 | StartTls, |
| 34 | Email(EmailMessage), |
| 35 | Response(SmtpResponseCode, String), |
| 36 | } |
| 37 | |
| 38 | impl fmt::Display for SmtpCommand { |
| 39 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 40 | match self { |
| 41 | Self::Helo(domain) => write!(f, "HELO {}", domain), |
| 42 | Self::Ehlo(domain) => write!(f, "EHLO {}", domain), |
| 43 | Self::MailFrom(address) => write!(f, "MAIL FROM:<{}>", address), |
| 44 | Self::RcptTo(address) => write!(f, "RCPT TO:<{}>", address), |
| 45 | Self::Data => write!(f, "DATA"), |
| 46 | Self::Quit => write!(f, "QUIT"), |
| 47 | Self::Rset => write!(f, "RSET"), |
| 48 | Self::Vrfy(address) => write!(f, "VRFY {}", address), |
| 49 | Self::Expn(address) => write!(f, "EXPN {}", address), |
| 50 | Self::Help(Some(topic)) => write!(f, "HELP {}", topic), |
| 51 | Self::Help(None) => write!(f, "HELP"), |
| 52 | Self::Noop => write!(f, "NOOP"), |
| 53 | Self::Auth(mechanism) => write!(f, "AUTH {}", mechanism), |
| 54 | Self::StartTls => write!(f, "STARTTLS"), |
| 55 | Self::Email(email) => write!(f, "{}", email), |
| 56 | Self::Response(code, msg) => write!(f, "{} {}", code, msg), |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | impl FromStr for SmtpCommand { |
| 62 | type Err = Error<ErrTag>; |
| 63 | |
| 64 | fn from_str(s: &str) -> Result<Self, Self::Err> { |
| 65 | let parts: Vec<&str> = s.split_whitespace().collect(); |
| 66 | if parts.is_empty() { |
| 67 | return Err(err!("Message is empty."; Invalid, Input, Missing)); |
| 68 | } |
| 69 | |
| 70 | if let Ok(code) = SmtpResponseCode::from_str(parts[0]) { |
| 71 | let message = parts[1..].join(" "); |
| 72 | return Ok(SmtpCommand::Response(code, message)); |
| 73 | } |
| 74 | |
| 75 | let cmd = parts[0].to_uppercase(); |
| 76 | |
| 77 | match cmd.as_str() { |
| 78 | "HELO" => { |
| 79 | let rest = s[4..].trim(); |
| 80 | let domain = rest.split_whitespace().next().unwrap_or(""); |
| 81 | if domain.is_empty() { |
| 82 | Err(err!( |
| 83 | "'{}' invalid: HELO command requires a domain.", s; |
| 84 | Invalid, Input, Mismatch)) |
| 85 | } else { |
| 86 | Ok(Self::Helo(domain.to_string())) |
| 87 | } |
| 88 | } |
| 89 | "EHLO" => { |
| 90 | // RFC 5321 §4.1.1.1: argument is a domain *or* an |
| 91 | // address-literal `[IPv4]` / `[IPv6:...]`. Per |
| 92 | // §4.1.4 the server SHOULD NOT refuse based on the |
| 93 | // identifier failing to verify, so we accept any |
| 94 | // non-empty token as-is. |
| 95 | let rest = s[4..].trim(); |
| 96 | let domain = rest.split_whitespace().next().unwrap_or(""); |
| 97 | if domain.is_empty() { |
| 98 | Err(err!( |
| 99 | "'{}' invalid: EHLO command requires a domain.", s; |
| 100 | Invalid, Input, Mismatch)) |
| 101 | } else { |
| 102 | Ok(Self::Ehlo(domain.to_string())) |
| 103 | } |
| 104 | } |
| 105 | "MAIL" => { |
| 106 | // RFC 5321 §4.1.1.2: MAIL FROM:<reverse-path> [SP <esmtp-params>] |
| 107 | // Accept whitespace between FROM: and the angle-bracketed |
| 108 | // address, and silently ignore any ESMTP extension |
| 109 | // parameters that follow the closing '>'. |
| 110 | let after = res!(strip_verb(s, "MAIL")); |
| 111 | let after_upper = after.to_uppercase(); |
| 112 | if !after_upper.starts_with("FROM:") { |
| 113 | return Err(err!( |
| 114 | "'{}' invalid: MAIL command requires 'FROM:'.", s; |
| 115 | Invalid, Input)); |
| 116 | } |
| 117 | let after_colon = after[5..].trim_start(); |
| 118 | let addr = res!(parse_path(after_colon)); |
| 119 | Ok(SmtpCommand::MailFrom(addr)) |
| 120 | } |
| 121 | "RCPT" => { |
| 122 | // RFC 5321 §4.1.1.3: RCPT TO:<forward-path> [SP <esmtp-params>] |
| 123 | let after = res!(strip_verb(s, "RCPT")); |
| 124 | let after_upper = after.to_uppercase(); |
| 125 | if !after_upper.starts_with("TO:") { |
| 126 | return Err(err!( |
| 127 | "'{}' invalid: RCPT command requires 'TO:'.", s; |
| 128 | Invalid, Input)); |
| 129 | } |
| 130 | let after_colon = after[3..].trim_start(); |
| 131 | let addr = res!(parse_path(after_colon)); |
| 132 | Ok(SmtpCommand::RcptTo(addr)) |
| 133 | } |
| 134 | "DATA" => { |
| 135 | if parts.len() != 1 { |
| 136 | Err(err!( |
| 137 | "'{}' invalid: {} command should have no additional arguments.", s, cmd; |
| 138 | Invalid, Input, Excessive)) |
| 139 | } else { |
| 140 | Ok(SmtpCommand::Data) |
| 141 | } |
| 142 | } |
| 143 | "QUIT" => { |
| 144 | if parts.len() != 1 { |
| 145 | Err(err!( |
| 146 | "'{}' invalid: {} command should have no additional arguments.", s, cmd; |
| 147 | Invalid, Input, Excessive)) |
| 148 | } else { |
| 149 | Ok(SmtpCommand::Quit) |
| 150 | } |
| 151 | } |
| 152 | "RSET" => { |
| 153 | if parts.len() != 1 { |
| 154 | Err(err!( |
| 155 | "'{}' invalid: {} command should have no additional arguments.", s, cmd; |
| 156 | Invalid, Input, Excessive)) |
| 157 | } else { |
| 158 | Ok(SmtpCommand::Rset) |
| 159 | } |
| 160 | } |
| 161 | "VRFY" => { |
| 162 | if parts.len() != 2 { |
| 163 | Err(err!( |
| 164 | "'{}' invalid: {} command requires a single argument.", s, cmd; |
| 165 | Invalid, Input, Mismatch)) |
| 166 | } else { |
| 167 | let address = parts[1].trim().to_string(); |
| 168 | Ok(SmtpCommand::Vrfy(address)) |
| 169 | } |
| 170 | } |
| 171 | "EXPN" => { |
| 172 | if parts.len() != 2 { |
| 173 | Err(err!( |
| 174 | "'{}' invalid: {} command requires a single argument.", s, cmd; |
| 175 | Invalid, Input, Mismatch)) |
| 176 | } else { |
| 177 | let address = parts[1].trim().to_string(); |
| 178 | Ok(SmtpCommand::Expn(address)) |
| 179 | } |
| 180 | } |
| 181 | "HELP" => { |
| 182 | if parts.len() == 1 { |
| 183 | Ok(SmtpCommand::Help(None)) |
| 184 | } else if parts.len() == 2 { |
| 185 | let topic = parts[1].trim().to_string(); |
| 186 | Ok(SmtpCommand::Help(Some(topic))) |
| 187 | } else { |
| 188 | Err(err!( |
| 189 | "'{}' invalid: {} command allows at most one argument.", s, cmd; |
| 190 | Invalid, Input, Excessive)) |
| 191 | } |
| 192 | } |
| 193 | "NOOP" => { |
| 194 | if parts.len() != 1 { |
| 195 | Err(err!( |
| 196 | "'{}' invalid: {} command should have no additional arguments.", s, cmd; |
| 197 | Invalid, Input, Excessive)) |
| 198 | } else { |
| 199 | Ok(SmtpCommand::Noop) |
| 200 | } |
| 201 | } |
| 202 | "AUTH" => { |
| 203 | // AUTH takes a mechanism name plus an optional inline |
| 204 | // initial response, e.g. `AUTH PLAIN <base64>`. Pass |
| 205 | // everything after the command verb through verbatim |
| 206 | // so the session loop can split mech and payload. |
| 207 | let rest = s[4..].trim_start(); |
| 208 | if rest.is_empty() { |
| 209 | Err(err!( |
| 210 | "'{}' invalid: AUTH command requires a mechanism.", s; |
| 211 | Invalid, Input, Mismatch)) |
| 212 | } else { |
| 213 | Ok(SmtpCommand::Auth(rest.to_string())) |
| 214 | } |
| 215 | } |
| 216 | "STARTTLS" => { |
| 217 | if parts.len() != 1 { |
| 218 | Err(err!( |
| 219 | "'{}' invalid: {} command should have no additional arguments.", s, cmd; |
| 220 | Invalid, Input, Excessive)) |
| 221 | } else { |
| 222 | Ok(SmtpCommand::StartTls) |
| 223 | } |
| 224 | } |
| 225 | _ => Err(err!("Unrecognised command in '{}'.", s; Unknown, Input)), |
| 226 | } |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | |
| 231 | /// Strip the leading verb (`MAIL`, `RCPT`, ...) from a command line and |
| 232 | /// return the remainder, trimmed of leading whitespace. Used by the |
| 233 | /// `MAIL FROM:` and `RCPT TO:` parsers, which need the raw remainder |
| 234 | /// rather than a whitespace-split view. |
| 235 | fn strip_verb(line: &str, verb: &str) -> Outcome<String> { |
| 236 | if line.len() < verb.len() { |
| 237 | return Err(err!( |
| 238 | "Line '{}' shorter than verb '{}'.", line, verb; |
| 239 | Invalid, Input)); |
| 240 | } |
| 241 | let head = &line[..verb.len()]; |
| 242 | if !head.eq_ignore_ascii_case(verb) { |
| 243 | return Err(err!( |
| 244 | "Line '{}' does not start with verb '{}'.", line, verb; |
| 245 | Invalid, Input)); |
| 246 | } |
| 247 | Ok(line[verb.len()..].trim_start().to_string()) |
| 248 | } |
| 249 | |
| 250 | /// Parse an SMTP `<reverse-path>` or `<forward-path>` plus optional |
| 251 | /// trailing ESMTP parameters. Accepts both `<addr>` and the empty |
| 252 | /// path `<>`. Anything after the closing '>' is silently ignored |
| 253 | /// (this is where SIZE=, BODY=, ORCPT=, NOTIFY= etc. live). |
| 254 | fn parse_path(s: &str) -> Outcome<String> { |
| 255 | let s = s.trim_start(); |
| 256 | if !s.starts_with('<') { |
| 257 | return Err(err!( |
| 258 | "Path '{}' must begin with '<'.", s; |
| 259 | Invalid, Input)); |
| 260 | } |
| 261 | let end = match s.find('>') { |
| 262 | Some(i) => i, |
| 263 | None => return Err(err!( |
| 264 | "Path '{}' has no closing '>'.", s; |
| 265 | Invalid, Input)), |
| 266 | }; |
| 267 | Ok(s[1..end].to_string()) |
| 268 | } |