Skip to main content

use_smtp/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::error::Error;
6
7use use_email_address::AddressValidationError;
8use use_email_envelope::{MailFromPath, RcptToPath};
9
10/// Error returned by SMTP vocabulary constructors.
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub enum SmtpError {
13    /// Address validation failed.
14    Address(AddressValidationError),
15    /// Reply code was outside the SMTP reply-code range.
16    InvalidReplyCode,
17    /// The supplied text value was empty.
18    Empty,
19    /// The supplied enhanced status code was invalid.
20    InvalidEnhancedStatus,
21}
22
23impl fmt::Display for SmtpError {
24    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::Address(error) => write!(formatter, "{error}"),
27            Self::InvalidReplyCode => {
28                formatter.write_str("SMTP reply code must be between 100 and 599")
29            }
30            Self::Empty => formatter.write_str("SMTP value cannot be empty"),
31            Self::InvalidEnhancedStatus => formatter.write_str("invalid SMTP enhanced status code"),
32        }
33    }
34}
35
36impl Error for SmtpError {
37    fn source(&self) -> Option<&(dyn Error + 'static)> {
38        match self {
39            Self::Address(error) => Some(error),
40            Self::InvalidReplyCode | Self::Empty | Self::InvalidEnhancedStatus => None,
41        }
42    }
43}
44
45impl From<AddressValidationError> for SmtpError {
46    fn from(value: AddressValidationError) -> Self {
47        Self::Address(value)
48    }
49}
50
51/// SMTP command vocabulary.
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub enum SmtpCommand {
54    /// `HELO` command.
55    Helo(String),
56    /// `EHLO` command.
57    Ehlo(String),
58    /// `MAIL FROM` command.
59    MailFrom(MailFrom),
60    /// `RCPT TO` command.
61    RcptTo(RcptTo),
62    /// `DATA` command.
63    Data(DataCommand),
64    /// `QUIT` command.
65    Quit(QuitCommand),
66    /// `STARTTLS` command label.
67    StartTls,
68}
69
70impl fmt::Display for SmtpCommand {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Self::Helo(domain) => write!(formatter, "HELO {domain}"),
74            Self::Ehlo(domain) => write!(formatter, "EHLO {domain}"),
75            Self::MailFrom(command) => write!(formatter, "{command}"),
76            Self::RcptTo(command) => write!(formatter, "{command}"),
77            Self::Data(command) => write!(formatter, "{command}"),
78            Self::Quit(command) => write!(formatter, "{command}"),
79            Self::StartTls => formatter.write_str("STARTTLS"),
80        }
81    }
82}
83
84/// SMTP reply code.
85#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
86pub struct SmtpReplyCode(u16);
87
88impl SmtpReplyCode {
89    /// Creates a reply code in the inclusive range 100..=599.
90    pub const fn new(value: u16) -> Result<Self, SmtpError> {
91        if value >= 100 && value <= 599 {
92            Ok(Self(value))
93        } else {
94            Err(SmtpError::InvalidReplyCode)
95        }
96    }
97
98    /// Returns the numeric code.
99    #[must_use]
100    pub const fn value(self) -> u16 {
101        self.0
102    }
103}
104
105impl fmt::Display for SmtpReplyCode {
106    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
107        write!(formatter, "{}", self.0)
108    }
109}
110
111/// SMTP enhanced status code.
112#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
113pub struct SmtpEnhancedStatusCode {
114    class: u8,
115    subject: u16,
116    detail: u16,
117}
118
119impl SmtpEnhancedStatusCode {
120    /// Creates an enhanced status code such as `2.1.0`.
121    pub const fn new(class: u8, subject: u16, detail: u16) -> Result<Self, SmtpError> {
122        if matches!(class, 2 | 4 | 5) {
123            Ok(Self {
124                class,
125                subject,
126                detail,
127            })
128        } else {
129            Err(SmtpError::InvalidEnhancedStatus)
130        }
131    }
132}
133
134impl fmt::Display for SmtpEnhancedStatusCode {
135    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136        write!(formatter, "{}.{}.{}", self.class, self.subject, self.detail)
137    }
138}
139
140/// SMTP reply line metadata.
141#[derive(Clone, Debug, Eq, PartialEq)]
142pub struct SmtpReply {
143    code: SmtpReplyCode,
144    enhanced_status: Option<SmtpEnhancedStatusCode>,
145    text: String,
146}
147
148impl SmtpReply {
149    /// Creates a reply without enhanced status metadata.
150    pub fn new(code: SmtpReplyCode, text: impl AsRef<str>) -> Result<Self, SmtpError> {
151        Self::with_enhanced_status(code, None, text)
152    }
153
154    /// Creates a reply with optional enhanced status metadata.
155    pub fn with_enhanced_status(
156        code: SmtpReplyCode,
157        enhanced_status: Option<SmtpEnhancedStatusCode>,
158        text: impl AsRef<str>,
159    ) -> Result<Self, SmtpError> {
160        let text = validate_text(text.as_ref())?;
161        Ok(Self {
162            code,
163            enhanced_status,
164            text: text.to_owned(),
165        })
166    }
167
168    /// Returns the reply code.
169    #[must_use]
170    pub const fn code(&self) -> SmtpReplyCode {
171        self.code
172    }
173}
174
175impl fmt::Display for SmtpReply {
176    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177        if let Some(status) = self.enhanced_status {
178            write!(formatter, "{} {} {}", self.code, status, self.text)
179        } else {
180            write!(formatter, "{} {}", self.code, self.text)
181        }
182    }
183}
184
185/// SMTP extension labels.
186#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
187pub enum SmtpExtension {
188    /// STARTTLS extension.
189    StartTls,
190    /// 8BITMIME extension.
191    EightBitMime,
192    /// SMTPUTF8 extension.
193    SmtpUtf8,
194    /// SIZE extension with optional advertised limit.
195    Size(Option<u64>),
196    /// AUTH extension with mechanism list metadata.
197    Auth(String),
198    /// Other EHLO extension label.
199    Other(String),
200}
201
202impl fmt::Display for SmtpExtension {
203    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
204        match self {
205            Self::StartTls => formatter.write_str("STARTTLS"),
206            Self::EightBitMime => formatter.write_str("8BITMIME"),
207            Self::SmtpUtf8 => formatter.write_str("SMTPUTF8"),
208            Self::Size(Some(limit)) => write!(formatter, "SIZE {limit}"),
209            Self::Size(None) => formatter.write_str("SIZE"),
210            Self::Auth(mechanisms) => write!(formatter, "AUTH {mechanisms}"),
211            Self::Other(value) => formatter.write_str(value),
212        }
213    }
214}
215
216/// EHLO keyword metadata.
217#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
218pub struct EhloKeyword(String);
219
220impl EhloKeyword {
221    /// Creates an EHLO keyword.
222    pub fn new(value: impl AsRef<str>) -> Result<Self, SmtpError> {
223        validate_text(value.as_ref()).map(|value| Self(value.to_ascii_uppercase()))
224    }
225
226    /// Returns the keyword text.
227    #[must_use]
228    pub fn as_str(&self) -> &str {
229        &self.0
230    }
231}
232
233impl fmt::Display for EhloKeyword {
234    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
235        formatter.write_str(self.as_str())
236    }
237}
238
239/// MAIL FROM command.
240#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
241pub struct MailFrom(MailFromPath);
242
243impl MailFrom {
244    /// Creates a MAIL FROM command from address text.
245    pub fn new(value: impl AsRef<str>) -> Result<Self, SmtpError> {
246        Ok(Self(MailFromPath::new(value)?))
247    }
248
249    /// Creates a null MAIL FROM command.
250    #[must_use]
251    pub const fn null() -> Self {
252        Self(MailFromPath::null())
253    }
254
255    /// Returns the path.
256    #[must_use]
257    pub const fn path(&self) -> &MailFromPath {
258        &self.0
259    }
260}
261
262impl fmt::Display for MailFrom {
263    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264        write!(formatter, "MAIL FROM:{}", self.0)
265    }
266}
267
268/// RCPT TO command.
269#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
270pub struct RcptTo(RcptToPath);
271
272impl RcptTo {
273    /// Creates a RCPT TO command from address text.
274    pub fn new(value: impl AsRef<str>) -> Result<Self, SmtpError> {
275        Ok(Self(RcptToPath::new(value)?))
276    }
277
278    /// Returns the path.
279    #[must_use]
280    pub const fn path(&self) -> &RcptToPath {
281        &self.0
282    }
283}
284
285impl fmt::Display for RcptTo {
286    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
287        write!(formatter, "RCPT TO:{}", self.0)
288    }
289}
290
291/// DATA command marker.
292#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
293pub struct DataCommand;
294
295impl fmt::Display for DataCommand {
296    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
297        formatter.write_str("DATA")
298    }
299}
300
301/// QUIT command marker.
302#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
303pub struct QuitCommand;
304
305impl fmt::Display for QuitCommand {
306    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
307        formatter.write_str("QUIT")
308    }
309}
310
311/// STARTTLS capability marker.
312#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
313pub struct StartTlsCapability;
314
315/// 8BITMIME capability marker.
316#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
317pub struct EightBitMimeCapability;
318
319/// SMTPUTF8 capability marker.
320#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
321pub struct SmtpUtf8Capability;
322
323fn validate_text(value: &str) -> Result<&str, SmtpError> {
324    let trimmed = value.trim();
325    if trimmed.is_empty() {
326        return Err(SmtpError::Empty);
327    }
328    if trimmed
329        .chars()
330        .any(|character| matches!(character, '\r' | '\n'))
331    {
332        return Err(SmtpError::Empty);
333    }
334    Ok(trimmed)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::{MailFrom, RcptTo, SmtpCommand, SmtpEnhancedStatusCode, SmtpReply, SmtpReplyCode};
340
341    #[test]
342    fn renders_commands_and_replies() -> Result<(), Box<dyn std::error::Error>> {
343        let mail_from = SmtpCommand::MailFrom(MailFrom::new("bounce@example.com")?);
344        let rcpt_to = SmtpCommand::RcptTo(RcptTo::new("jane@example.com")?);
345        let reply = SmtpReply::new(SmtpReplyCode::new(250)?, "OK")?;
346        let enhanced = SmtpReply::with_enhanced_status(
347            SmtpReplyCode::new(250)?,
348            Some(SmtpEnhancedStatusCode::new(2, 0, 0)?),
349            "OK",
350        )?;
351
352        assert_eq!(mail_from.to_string(), "MAIL FROM:<bounce@example.com>");
353        assert_eq!(rcpt_to.to_string(), "RCPT TO:<jane@example.com>");
354        assert_eq!(reply.to_string(), "250 OK");
355        assert_eq!(enhanced.to_string(), "250 2.0.0 OK");
356        Ok(())
357    }
358}