avila_cell/
auth.rs

1//! SMTP Authentication mechanisms
2
3use crate::encoding::base64_encode;
4use avila_error::Result;
5
6/// SMTP Authentication mechanism
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum AuthMechanism {
9    /// PLAIN authentication
10    Plain,
11    /// LOGIN authentication
12    Login,
13    /// CRAM-MD5 authentication
14    CramMd5,
15    /// XOAUTH2 (for Gmail/Outlook)
16    XOAuth2,
17}
18
19impl AuthMechanism {
20    /// Returns the SASL name
21    pub fn as_str(&self) -> &'static str {
22        match self {
23            Self::Plain => "PLAIN",
24            Self::Login => "LOGIN",
25            Self::CramMd5 => "CRAM-MD5",
26            Self::XOAuth2 => "XOAUTH2",
27        }
28    }
29}
30
31/// Generates PLAIN authentication string
32pub fn auth_plain(username: &str, password: &str) -> String {
33    let auth_str = format!("\0{}\0{}", username, password);
34    base64_encode(auth_str.as_bytes())
35}
36
37/// Generates LOGIN authentication (step 1: username)
38pub fn auth_login_username(username: &str) -> String {
39    base64_encode(username.as_bytes())
40}
41
42/// Generates LOGIN authentication (step 2: password)
43pub fn auth_login_password(password: &str) -> String {
44    base64_encode(password.as_bytes())
45}
46
47/// Generates CRAM-MD5 response
48pub fn auth_cram_md5(username: &str, _password: &str, challenge: &str) -> Result<String> {
49    // Decode challenge
50    let _challenge_bytes = crate::encoding::base64_decode(challenge)?;
51
52    // TODO: Implement HMAC-MD5 when avila-crypto supports it
53    // For now, return a placeholder
54    let response = format!("{} {}", username, "placeholder_digest");
55    Ok(base64_encode(response.as_bytes()))
56}
57
58/// Generates XOAUTH2 string for Gmail/Outlook
59pub fn auth_xoauth2(username: &str, access_token: &str) -> String {
60    let auth_str = format!(
61        "user={}\x01auth=Bearer {}\x01\x01",
62        username, access_token
63    );
64    base64_encode(auth_str.as_bytes())
65}
66
67/// Supported authentication capabilities from EHLO response
68#[derive(Debug, Default)]
69pub struct AuthCapabilities {
70    /// Supports PLAIN
71    pub plain: bool,
72    /// Supports LOGIN
73    pub login: bool,
74    /// Supports CRAM-MD5
75    pub cram_md5: bool,
76    /// Supports XOAUTH2
77    pub xoauth2: bool,
78    /// Supports STARTTLS
79    pub starttls: bool,
80    /// Supports 8BITMIME
81    pub eight_bit_mime: bool,
82    /// Supports PIPELINING
83    pub pipelining: bool,
84    /// Maximum message size
85    pub size: Option<usize>,
86}
87
88impl AuthCapabilities {
89    /// Parses EHLO response
90    pub fn from_ehlo_response(response: &str) -> Self {
91        let mut caps = Self::default();
92
93        for line in response.lines() {
94            let line = line.trim();
95
96            if line.contains("AUTH") {
97                if line.contains("PLAIN") {
98                    caps.plain = true;
99                }
100                if line.contains("LOGIN") {
101                    caps.login = true;
102                }
103                if line.contains("CRAM-MD5") {
104                    caps.cram_md5 = true;
105                }
106                if line.contains("XOAUTH2") {
107                    caps.xoauth2 = true;
108                }
109            }
110
111            if line.contains("STARTTLS") {
112                caps.starttls = true;
113            }
114
115            if line.contains("8BITMIME") {
116                caps.eight_bit_mime = true;
117            }
118
119            if line.contains("PIPELINING") {
120                caps.pipelining = true;
121            }
122
123            if line.starts_with("250-SIZE") || line.starts_with("250 SIZE") {
124                if let Some(size_str) = line.split_whitespace().nth(1) {
125                    caps.size = size_str.parse().ok();
126                }
127            }
128        }
129
130        caps
131    }
132
133    /// Gets the best authentication mechanism available
134    pub fn best_auth_mechanism(&self) -> Option<AuthMechanism> {
135        if self.cram_md5 {
136            Some(AuthMechanism::CramMd5)
137        } else if self.plain {
138            Some(AuthMechanism::Plain)
139        } else if self.login {
140            Some(AuthMechanism::Login)
141        } else if self.xoauth2 {
142            Some(AuthMechanism::XOAuth2)
143        } else {
144            None
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_auth_plain() {
155        let auth = auth_plain("user", "pass");
156        assert!(!auth.is_empty());
157    }
158
159    #[test]
160    fn test_auth_login() {
161        let username = auth_login_username("user");
162        let password = auth_login_password("pass");
163        assert!(!username.is_empty());
164        assert!(!password.is_empty());
165    }
166
167    #[test]
168    fn test_auth_xoauth2() {
169        let auth = auth_xoauth2("user@gmail.com", "ya29.token123");
170        // O resultado é base64 de "user=user@gmail.com\x01auth=Bearer ya29.token123\x01\x01"
171        assert!(!auth.is_empty());
172        assert!(auth.len() > 20); // Verifica que tem conteúdo base64 razoável
173    }
174
175    #[test]
176    fn test_auth_capabilities() {
177        let response = "250-STARTTLS\r\n250-AUTH PLAIN LOGIN\r\n250 8BITMIME\r\n";
178        let caps = AuthCapabilities::from_ehlo_response(response);
179
180        assert!(caps.starttls);
181        assert!(caps.plain);
182        assert!(caps.login);
183        assert!(caps.eight_bit_mime);
184        assert_eq!(caps.best_auth_mechanism(), Some(AuthMechanism::Plain));
185    }
186}