├── .gitignore ├── .github └── workflows │ └── rust.yml ├── examples ├── smtp_raw_message.rs ├── smtp_tls_relay.rs ├── smtp_relay.rs ├── smtp_auth.rs └── smtp_dkim.rs ├── LICENSES ├── MIT.txt └── Apache-2.0.txt ├── Cargo.toml ├── src ├── smtp │ ├── mod.rs │ ├── envelope.rs │ ├── ehlo.rs │ ├── tls.rs │ ├── builder.rs │ ├── client.rs │ ├── message.rs │ └── auth.rs └── lib.rs ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --all-features --verbose 23 | -------------------------------------------------------------------------------- /examples/smtp_raw_message.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use mail_send::SmtpClientBuilder; 8 | use mail_send::smtp::message::Message; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | // Build a raw message 13 | let message = Message::empty() 14 | .from("jdoe@example.com") 15 | .to("jane@example.com") 16 | .to("james@smith.com") 17 | .body(&b"From: jdoe@example.com\nTo: jane@example.com\nSubject: Hi!\n\nHello, world!"[..]); 18 | 19 | // Connect to an SMTP relay server. 20 | // The library will upgrade the connection to TLS if the server supports it. 21 | SmtpClientBuilder::new("mail.smtp2go.com", 2525) 22 | .implicit_tls(false) 23 | .connect() 24 | .await 25 | .unwrap() 26 | .send(message) 27 | .await 28 | .unwrap(); 29 | } 30 | -------------------------------------------------------------------------------- /examples/smtp_tls_relay.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use mail_builder::MessageBuilder; 8 | use mail_send::SmtpClientBuilder; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | // Build a simple html message with a single attachment 13 | // More examples of how to build messages available at 14 | // https://github.com/stalwartlabs/mail-builder/tree/main/examples 15 | let message = MessageBuilder::new() 16 | .from(("John Doe", "john@example.com")) 17 | .to("jane@example.com") 18 | .subject("Hello, world!") 19 | .html_body("

Hello, world!

") 20 | .attachment("image/png", "kittens.png", [1, 2, 3, 4].as_ref()); 21 | 22 | // Connect to an SMTP relay server over TLS 23 | SmtpClientBuilder::new("smtp.gmail.com", 465) 24 | .connect() 25 | .await 26 | .unwrap() 27 | .send(message) 28 | .await 29 | .unwrap(); 30 | } 31 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /examples/smtp_relay.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use mail_builder::MessageBuilder; 8 | use mail_send::SmtpClientBuilder; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | // Build a simple text message with a single attachment 13 | // More examples of how to build messages available at 14 | // https://github.com/stalwartlabs/mail-builder/tree/main/examples 15 | let message = MessageBuilder::new() 16 | .from(("John Doe", "john@example.com")) 17 | .to("jane@example.com") 18 | .subject("Hello, world!") 19 | .text_body("Hello, world!") 20 | .attachment("image/png", "kittens.png", [1, 2, 3, 4].as_ref()); 21 | 22 | // Connect to an SMTP relay server. 23 | // The library will upgrade the connection to TLS if the server supports it. 24 | SmtpClientBuilder::new("mail.smtp2go.com", 2525) 25 | .implicit_tls(false) 26 | .connect() 27 | .await 28 | .unwrap() 29 | .send(message) 30 | .await 31 | .unwrap(); 32 | } 33 | -------------------------------------------------------------------------------- /examples/smtp_auth.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use mail_builder::MessageBuilder; 8 | use mail_send::SmtpClientBuilder; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | // Build a simple multipart message 13 | // More examples of how to build messages available at 14 | // https://github.com/stalwartlabs/mail-builder/tree/main/examples 15 | let message = MessageBuilder::new() 16 | .from(("John Doe", "john@example.com")) 17 | .to(vec![ 18 | ("Jane Doe", "jane@example.com"), 19 | ("James Smith", "james@test.com"), 20 | ]) 21 | .subject("Hi!") 22 | .html_body("

Hello, world!

") 23 | .text_body("Hello world!"); 24 | 25 | // Connect to the SMTP submissions port, upgrade to TLS and 26 | // authenticate using the provided credentials. 27 | SmtpClientBuilder::new("smtp.gmail.com", 587) 28 | .implicit_tls(false) 29 | .credentials(("john", "p4ssw0rd")) 30 | .connect() 31 | .await 32 | .unwrap() 33 | .send(message) 34 | .await 35 | .unwrap(); 36 | } 37 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mail-send" 3 | description = "E-mail delivery library with SMTP and DKIM support" 4 | version = "0.5.2" 5 | edition = "2024" 6 | authors = [ "Stalwart Labs "] 7 | license = "Apache-2.0 OR MIT" 8 | repository = "https://github.com/stalwartlabs/mail-send" 9 | homepage = "https://github.com/stalwartlabs/mail-send" 10 | keywords = ["smtp", "dkim", "email", "mime", "mail"] 11 | categories = ["email"] 12 | readme = "README.md" 13 | 14 | [lib] 15 | doctest = false 16 | 17 | [dependencies] 18 | smtp-proto = { version = "0.2" } 19 | mail-auth = { version = "0.7", optional = true } 20 | mail-builder = { version = "0.4", optional = true } 21 | mail-parser = { version = "0.11", optional = true } 22 | base64 = "0.22" 23 | rand = { version = "0.9", optional = true } 24 | md5 = { version = "0.8", optional = true } 25 | tokio = { version = "1.47", features = ["net", "io-util", "time"]} 26 | rustls = { version = "0.23", default-features = false, features = ["std"]} 27 | tokio-rustls = { version = "0.26", default-features = false } 28 | webpki-roots = { version = "1.0"} 29 | rustls-pki-types = { version = "1" } 30 | gethostname = { version = "1.0"} 31 | 32 | [dev-dependencies] 33 | tokio = { version = "1.16", features = ["net", "io-util", "time", "rt-multi-thread", "macros"] } 34 | env_logger = "0.11.0" 35 | 36 | [features] 37 | default = ["digest-md5", "cram-md5", "builder", "dkim", "ring", "tls12"] 38 | builder = ["mail-builder"] 39 | parser = ["mail-parser"] 40 | dkim = ["mail-auth"] 41 | digest-md5 = ["md5", "rand"] 42 | cram-md5 = ["md5"] 43 | aws_lc_rs = ["rustls/aws_lc_rs", "tokio-rustls/aws_lc_rs"] 44 | aws-lc-rs = ["aws_lc_rs"] 45 | fips = ["rustls/fips", "tokio-rustls/fips"] 46 | logging = ["rustls/logging", "tokio-rustls/logging"] 47 | ring = ["rustls/ring", "tokio-rustls/ring"] 48 | tls12 = ["rustls/tls12", "tokio-rustls/tls12"] 49 | -------------------------------------------------------------------------------- /src/smtp/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use smtp_proto::{Response, Severity}; 8 | 9 | pub mod auth; 10 | pub mod builder; 11 | pub mod client; 12 | pub mod ehlo; 13 | pub mod envelope; 14 | pub mod message; 15 | pub mod tls; 16 | 17 | impl From for crate::Error { 18 | fn from(err: auth::Error) -> Self { 19 | crate::Error::Auth(err) 20 | } 21 | } 22 | 23 | pub trait AssertReply: Sized { 24 | fn is_positive_completion(&self) -> bool; 25 | fn assert_positive_completion(self) -> crate::Result<()>; 26 | fn assert_severity(self, severity: Severity) -> crate::Result<()>; 27 | fn assert_code(self, code: u16) -> crate::Result<()>; 28 | } 29 | 30 | impl AssertReply for Response { 31 | /// Returns `true` if the reply is a positive completion. 32 | #[inline(always)] 33 | fn is_positive_completion(&self) -> bool { 34 | (200..=299).contains(&self.code) 35 | } 36 | 37 | /// Returns Ok if the reply has the specified severity. 38 | #[inline(always)] 39 | fn assert_severity(self, severity: Severity) -> crate::Result<()> { 40 | if self.severity() == severity { 41 | Ok(()) 42 | } else { 43 | Err(crate::Error::UnexpectedReply(self)) 44 | } 45 | } 46 | 47 | /// Returns Ok if the reply returned a 2xx code. 48 | #[inline(always)] 49 | fn assert_positive_completion(self) -> crate::Result<()> { 50 | if (200..=299).contains(&self.code) { 51 | Ok(()) 52 | } else { 53 | Err(crate::Error::UnexpectedReply(self)) 54 | } 55 | } 56 | 57 | /// Returns Ok if the reply has the specified status code. 58 | #[inline(always)] 59 | fn assert_code(self, code: u16) -> crate::Result<()> { 60 | if self.code() == code { 61 | Ok(()) 62 | } else { 63 | Err(crate::Error::UnexpectedReply(self)) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/smtp_dkim.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use mail_auth::{ 8 | common::crypto::{RsaKey, Sha256}, 9 | dkim::DkimSigner, 10 | }; 11 | use mail_builder::MessageBuilder; 12 | use mail_send::SmtpClientBuilder; 13 | 14 | const TEST_KEY: &str = r#"-----BEGIN RSA PRIVATE KEY----- 15 | MIICXwIBAAKBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYtIxN2SnFC 16 | jxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v/RtdC2UzJ1lWT947qR+Rcac2gb 17 | to/NMqJ0fzfVjH4OuKhitdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB 18 | AoGBALmn+XwWk7akvkUlqb+dOxyLB9i5VBVfje89Teolwc9YJT36BGN/l4e0l6QX 19 | /1//6DWUTB3KI6wFcm7TWJcxbS0tcKZX7FsJvUz1SbQnkS54DJck1EZO/BLa5ckJ 20 | gAYIaqlA9C0ZwM6i58lLlPadX/rtHb7pWzeNcZHjKrjM461ZAkEA+itss2nRlmyO 21 | n1/5yDyCluST4dQfO8kAB3toSEVc7DeFeDhnC1mZdjASZNvdHS4gbLIA1hUGEF9m 22 | 3hKsGUMMPwJBAPW5v/U+AWTADFCS22t72NUurgzeAbzb1HWMqO4y4+9Hpjk5wvL/ 23 | eVYizyuce3/fGke7aRYw/ADKygMJdW8H/OcCQQDz5OQb4j2QDpPZc0Nc4QlbvMsj 24 | 7p7otWRO5xRa6SzXqqV3+F0VpqvDmshEBkoCydaYwc2o6WQ5EBmExeV8124XAkEA 25 | qZzGsIxVP+sEVRWZmW6KNFSdVUpk3qzK0Tz/WjQMe5z0UunY9Ax9/4PVhp/j61bf 26 | eAYXunajbBSOLlx4D+TunwJBANkPI5S9iylsbLs6NkaMHV6k5ioHBBmgCak95JGX 27 | GMot/L2x0IYyMLAz6oLWh2hm7zwtb0CgOrPo1ke44hFYnfc= 28 | -----END RSA PRIVATE KEY-----"#; 29 | 30 | #[tokio::main] 31 | async fn main() { 32 | // Build a simple text message with a single attachment 33 | // More examples of how to build messages available at 34 | // https://github.com/stalwartlabs/mail-builder/tree/main/examples 35 | let message = MessageBuilder::new() 36 | .from(("John Doe", "john@example.com")) 37 | .to("jane@example.com") 38 | .subject("Howdy!") 39 | .text_body("These pretzels are making me thirsty.") 40 | .attachment("image/png", "pretzels.png", [1, 2, 3, 4].as_ref()); 41 | 42 | // Sign an e-mail message using RSA-SHA256 43 | let pk_rsa = RsaKey::::from_rsa_pem(TEST_KEY).unwrap(); 44 | let signer = DkimSigner::from_key(pk_rsa) 45 | .domain("example.com") 46 | .selector("default") 47 | .headers(["From", "To", "Subject"]) 48 | .expiration(60 * 60 * 7); // Number of seconds before this signature expires (optional) 49 | 50 | // Connect to an SMTP relay server over TLS. 51 | // Signs each message with the configured DKIM signer. 52 | SmtpClientBuilder::new("smtp.gmail.com", 465) 53 | .connect() 54 | .await 55 | .unwrap() 56 | .send_signed(message, &signer) 57 | .await 58 | .unwrap(); 59 | } 60 | -------------------------------------------------------------------------------- /src/smtp/envelope.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use super::{AssertReply, message::Parameters}; 8 | use crate::SmtpClient; 9 | use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; 10 | 11 | impl SmtpClient { 12 | /// Sends a MAIL FROM command to the server. 13 | pub async fn mail_from(&mut self, addr: &str, params: &Parameters<'_>) -> crate::Result<()> { 14 | self.cmd(format!("MAIL FROM:<{addr}>{params}\r\n").as_bytes()) 15 | .await? 16 | .assert_positive_completion() 17 | } 18 | 19 | /// Sends a RCPT TO command to the server. 20 | pub async fn rcpt_to(&mut self, addr: &str, params: &Parameters<'_>) -> crate::Result<()> { 21 | self.cmd(format!("RCPT TO:<{addr}>{params}\r\n").as_bytes()) 22 | .await? 23 | .assert_positive_completion() 24 | } 25 | 26 | /// Sends a DATA command to the server. 27 | pub async fn data(&mut self, message: impl AsRef<[u8]>) -> crate::Result<()> { 28 | self.cmd(b"DATA\r\n").await?.assert_code(354)?; 29 | tokio::time::timeout(self.timeout, async { 30 | // Write message 31 | self.write_message(message.as_ref()).await?; 32 | self.read().await 33 | }) 34 | .await 35 | .map_err(|_| crate::Error::Timeout)?? 36 | .assert_positive_completion() 37 | } 38 | 39 | /// Sends a BDAT command to the server. 40 | pub async fn bdat(&mut self, message: impl AsRef<[u8]>) -> crate::Result<()> { 41 | let message = message.as_ref(); 42 | tokio::time::timeout(self.timeout, async { 43 | self.stream 44 | .write_all(format!("BDAT {} LAST\r\n", message.len()).as_bytes()) 45 | .await?; 46 | self.stream.write_all(message).await?; 47 | self.stream.flush().await?; 48 | self.read().await 49 | }) 50 | .await 51 | .map_err(|_| crate::Error::Timeout)?? 52 | .assert_positive_completion() 53 | } 54 | 55 | /// Sends a RSET command to the server. 56 | pub async fn rset(&mut self) -> crate::Result<()> { 57 | self.cmd(b"RSET\r\n").await?.assert_positive_completion() 58 | } 59 | 60 | /// Sends a NOOP command to the server. 61 | pub async fn noop(&mut self) -> crate::Result<()> { 62 | self.cmd(b"NOOP\r\n").await?.assert_positive_completion() 63 | } 64 | 65 | /// Sends a QUIT command to the server. 66 | pub async fn quit(mut self) -> crate::Result<()> { 67 | self.cmd(b"QUIT\r\n").await?.assert_positive_completion() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | mail-send 0.5.2 2 | ================================ 3 | - Add an option to choose the sending IP (#41) 4 | 5 | mail-send 0.5.1 6 | ================================ 7 | - Bump `mail-parser` dependency to 0.11 8 | - Bump `mail-auth` dependency to 0.7 9 | - Bump `webpki-roots` dependency to 1.0 10 | 11 | mail-send 0.5.0 12 | ================================ 13 | - Bump `mail-parser` dependency to 0.10 14 | - Bump `mail-builder` dependency to 0.4 15 | - Bump `mail-auth` dependency to 0.6 16 | 17 | mail-send 0.4.9 18 | ================================ 19 | - Bump `rustls` dependency to 0.23 20 | - Bump `tokio-rustls` dependency to 0.26 21 | 22 | mail-send 0.4.8 23 | ================================ 24 | - Bump `mail-auth` dependency to 0.4 25 | - Bump `base64` dependency to 0.22 26 | 27 | mail-send 0.4.7 28 | ================================ 29 | - Added 'parser feature for `Message` conversion. 30 | 31 | mail-send 0.4.6 32 | ================================ 33 | - Improved transparency procedure to also escape . 34 | - Removed `skip-ehlo` feature. 35 | 36 | mail-send 0.4.4 37 | ================================ 38 | - Updated transparency procedure to escape . as well as . to prevent SMTP smuggling on vulnerable servers. 39 | 40 | mail-send 0.4.3 41 | ================================ 42 | - Bump `rustls` dependency to 0.22 43 | 44 | mail-send 0.4.2 45 | ================================ 46 | - Bump `webpki-roots` dependency to 0.26 47 | 48 | mail-send 0.4.1 49 | ================================ 50 | - Bump `webpki-roots` dependency to 0.25 51 | 52 | mail-send 0.4.0 53 | ================================ 54 | - Bump `mail-builder` dependency to 0.3 55 | 56 | mail-send 0.3.3 57 | ================================ 58 | - Bump `rustls` dependency to 0.21 59 | 60 | mail-send 0.3.2 61 | ================================ 62 | - Fix: Extend buffer from bytes read while reading EHLO (#12). 63 | - Add an impl std::error::Error for mail_send::Error (#11) 64 | 65 | mail-send 0.3.1 66 | ================================ 67 | - Fix: plain text connect issues (#10). 68 | 69 | mail-send 0.3.0 70 | ================================ 71 | - Use of generics on TCP streams instead of static dispatch with enums. 72 | - Switch to `mail-auth` for DKIM authentication. 73 | 74 | mail-send 0.2.3 75 | ================================ 76 | - Fix: Send gets stuck when the message has a binary attachment (#7) 77 | 78 | mail-send 0.2.2 79 | ================================ 80 | - Bump up to mail-builder v0.2.2 81 | 82 | mail-send 0.2.1 83 | ================================ 84 | - Fixes to support mail-builder v0.2.1 85 | 86 | mail-send 0.2.0 87 | ================================ 88 | - Removed HTTP support. 89 | - API cleanup. 90 | 91 | mail-send 0.1.0 92 | ================================ 93 | - Initial release. 94 | -------------------------------------------------------------------------------- /src/smtp/ehlo.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use smtp_proto::{ 8 | response::parser::{ResponseReceiver, MAX_RESPONSE_LENGTH}, 9 | EhloResponse, 10 | }; 11 | use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; 12 | 13 | use crate::SmtpClient; 14 | 15 | impl SmtpClient { 16 | /// Sends a EHLO command to the server. 17 | pub async fn ehlo(&mut self, hostname: &str) -> crate::Result> { 18 | tokio::time::timeout(self.timeout, async { 19 | self.stream 20 | .write_all(format!("EHLO {hostname}\r\n").as_bytes()) 21 | .await?; 22 | self.stream.flush().await?; 23 | self.read_ehlo().await 24 | }) 25 | .await 26 | .map_err(|_| crate::Error::Timeout)? 27 | } 28 | 29 | /// Sends a LHLO command to the server. 30 | pub async fn lhlo(&mut self, hostname: &str) -> crate::Result> { 31 | tokio::time::timeout(self.timeout, async { 32 | self.stream 33 | .write_all(format!("LHLO {hostname}\r\n").as_bytes()) 34 | .await?; 35 | self.stream.flush().await?; 36 | self.read_ehlo().await 37 | }) 38 | .await 39 | .map_err(|_| crate::Error::Timeout)? 40 | } 41 | 42 | pub async fn read_ehlo(&mut self) -> crate::Result> { 43 | let mut buf = vec![0u8; 1024]; 44 | let mut buf_concat = Vec::with_capacity(0); 45 | 46 | loop { 47 | let br = self.stream.read(&mut buf).await?; 48 | 49 | if br == 0 { 50 | return Err(crate::Error::UnparseableReply); 51 | } 52 | let mut iter = if buf_concat.is_empty() { 53 | buf[..br].iter() 54 | } else if br + buf_concat.len() < MAX_RESPONSE_LENGTH { 55 | buf_concat.extend_from_slice(&buf[..br]); 56 | buf_concat.iter() 57 | } else { 58 | return Err(crate::Error::UnparseableReply); 59 | }; 60 | 61 | match EhloResponse::parse(&mut iter) { 62 | Ok(reply) => return Ok(reply), 63 | Err(err) => match err { 64 | smtp_proto::Error::NeedsMoreData { .. } => { 65 | if buf_concat.is_empty() { 66 | buf_concat = buf[..br].to_vec(); 67 | } 68 | } 69 | smtp_proto::Error::InvalidResponse { code } => { 70 | match ResponseReceiver::from_code(code).parse(&mut iter) { 71 | Ok(response) => { 72 | return Err(crate::Error::UnexpectedReply(response)); 73 | } 74 | Err(smtp_proto::Error::NeedsMoreData { .. }) => { 75 | if buf_concat.is_empty() { 76 | buf_concat = buf[..br].to_vec(); 77 | } 78 | } 79 | Err(_) => return Err(crate::Error::UnparseableReply), 80 | } 81 | } 82 | _ => { 83 | return Err(crate::Error::UnparseableReply); 84 | } 85 | }, 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mail-send 2 | 3 | [![crates.io](https://img.shields.io/crates/v/mail-send)](https://crates.io/crates/mail-send) 4 | [![build](https://github.com/stalwartlabs/mail-send/actions/workflows/rust.yml/badge.svg)](https://github.com/stalwartlabs/mail-send/actions/workflows/rust.yml) 5 | [![docs.rs](https://img.shields.io/docsrs/mail-send)](https://docs.rs/mail-send) 6 | [![crates.io](https://img.shields.io/crates/l/mail-send)](http://www.apache.org/licenses/LICENSE-2.0) 7 | 8 | _mail-send_ is a Rust library to build, sign and send e-mail messages via SMTP. It includes the following features: 9 | 10 | - Generates **e-mail** messages conforming to the Internet Message Format standard (_RFC 5322_). 11 | - Full **MIME** support (_RFC 2045 - 2049_) with automatic selection of the most optimal encoding for each message body part. 12 | - DomainKeys Identified Mail (**DKIM**) Signatures (_RFC 6376_) with ED25519-SHA256, RSA-SHA256 and RSA-SHA1 support. 13 | - Simple Mail Transfer Protocol (**SMTP**; _RFC 5321_) delivery. 14 | - SMTP Service Extension for Secure SMTP over **TLS** (_RFC 3207_). 15 | - SMTP Service Extension for Authentication (_RFC 4954_) with automatic mechanism negotiation (from most secure to least secure): 16 | - CRAM-MD5 (_RFC 2195_) 17 | - DIGEST-MD5 (_RFC 2831_; obsolete but still supported) 18 | - XOAUTH2 (Google proprietary) 19 | - LOGIN 20 | - PLAIN 21 | - Full async (requires Tokio). 22 | 23 | ## Usage Example 24 | 25 | Send a message via an SMTP server that requires authentication: 26 | 27 | ```rust 28 | // Build a simple multipart message 29 | let message = MessageBuilder::new() 30 | .from(("John Doe", "john@example.com")) 31 | .to(vec![ 32 | ("Jane Doe", "jane@example.com"), 33 | ("James Smith", "james@test.com"), 34 | ]) 35 | .subject("Hi!") 36 | .html_body("

Hello, world!

") 37 | .text_body("Hello world!"); 38 | 39 | // Connect to the SMTP submissions port, upgrade to TLS and 40 | // authenticate using the provided credentials. 41 | SmtpClientBuilder::new("smtp.gmail.com", 587) 42 | .implicit_tls(false) 43 | .credentials(("john", "p4ssw0rd")) 44 | .connect() 45 | .await 46 | .unwrap() 47 | .send(message) 48 | .await 49 | .unwrap(); 50 | ``` 51 | 52 | Sign a message with DKIM and send it via an SMTP relay server: 53 | 54 | ```rust 55 | // Build a simple text message with a single attachment 56 | let message = MessageBuilder::new() 57 | .from(("John Doe", "john@example.com")) 58 | .to("jane@example.com") 59 | .subject("Howdy!") 60 | .text_body("These pretzels are making me thirsty.") 61 | .attachment("image/png", "pretzels.png", [1, 2, 3, 4].as_ref()); 62 | 63 | // Sign an e-mail message using RSA-SHA256 64 | let pk_rsa = RsaKey::::from_rsa_pem(TEST_KEY).unwrap(); 65 | let signer = DkimSigner::from_key(pk_rsa) 66 | .domain("example.com") 67 | .selector("default") 68 | .headers(["From", "To", "Subject"]) 69 | .expiration(60 * 60 * 7); // Number of seconds before this signature expires (optional) 70 | 71 | // Connect to an SMTP relay server over TLS. 72 | // Signs each message with the configured DKIM signer. 73 | SmtpClientBuilder::new("smtp.gmail.com", 465) 74 | .connect() 75 | .await 76 | .unwrap() 77 | .send_signed(message, &signer) 78 | .await 79 | .unwrap(); 80 | ``` 81 | 82 | More examples of how to build messages are available in the [`mail-builder`](https://crates.io/crates/mail-builder) crate. 83 | Please note that this library does not support parsing e-mail messages as this functionality is provided separately by the [`mail-parser`](https://crates.io/crates/mail-parser) crate. 84 | 85 | ## Testing 86 | 87 | To run the testsuite: 88 | 89 | ```bash 90 | $ cargo test --all-features 91 | ``` 92 | 93 | or, to run the testsuite with MIRI: 94 | 95 | ```bash 96 | $ cargo +nightly miri test --all-features 97 | ``` 98 | 99 | ## License 100 | 101 | Licensed under either of 102 | 103 | - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 104 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 105 | 106 | at your option. 107 | 108 | ## Copyright 109 | 110 | Copyright (C) 2020, Stalwart Labs LLC 111 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | #![doc = include_str!("../README.md")] 8 | 9 | pub mod smtp; 10 | use std::net::IpAddr; 11 | use std::{fmt::Display, hash::Hash, time::Duration}; 12 | use tokio::io::{AsyncRead, AsyncWrite}; 13 | use tokio_rustls::TlsConnector; 14 | 15 | #[cfg(feature = "builder")] 16 | pub use mail_builder; 17 | 18 | #[cfg(feature = "dkim")] 19 | pub use mail_auth; 20 | 21 | #[derive(Debug)] 22 | pub enum Error { 23 | /// I/O error 24 | Io(std::io::Error), 25 | 26 | /// TLS error 27 | Tls(Box), 28 | 29 | /// Base64 decode error 30 | Base64(base64::DecodeError), 31 | 32 | // SMTP authentication error. 33 | Auth(smtp::auth::Error), 34 | 35 | /// Failure parsing SMTP reply 36 | UnparseableReply, 37 | 38 | /// Unexpected SMTP reply. 39 | UnexpectedReply(smtp_proto::Response), 40 | 41 | /// SMTP authentication failure. 42 | AuthenticationFailed(smtp_proto::Response), 43 | 44 | /// Invalid TLS name provided. 45 | InvalidTLSName, 46 | 47 | /// Missing authentication credentials. 48 | MissingCredentials, 49 | 50 | /// Missing message sender. 51 | MissingMailFrom, 52 | 53 | /// Missing message recipients. 54 | MissingRcptTo, 55 | 56 | /// The server does no support any of the available authentication methods. 57 | UnsupportedAuthMechanism, 58 | 59 | /// Connection timeout. 60 | Timeout, 61 | 62 | /// STARTTLS not available 63 | MissingStartTls, 64 | } 65 | 66 | impl std::error::Error for Error { 67 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 68 | match self { 69 | Error::Io(err) => err.source(), 70 | Error::Tls(err) => err.source(), 71 | Error::Base64(err) => err.source(), 72 | _ => None, 73 | } 74 | } 75 | } 76 | 77 | pub type Result = std::result::Result; 78 | 79 | /// SMTP client builder 80 | #[derive(Clone)] 81 | pub struct SmtpClientBuilder + PartialEq + Eq + Hash> { 82 | pub timeout: Duration, 83 | pub tls_connector: TlsConnector, 84 | pub tls_hostname: T, 85 | pub tls_implicit: bool, 86 | pub credentials: Option>, 87 | pub addr: String, 88 | pub is_lmtp: bool, 89 | pub say_ehlo: bool, 90 | pub local_host: String, 91 | pub local_ip: Option, 92 | } 93 | 94 | /// SMTP client builder 95 | pub struct SmtpClient { 96 | pub stream: T, 97 | pub timeout: Duration, 98 | } 99 | 100 | #[derive(Clone, PartialEq, Eq, Hash)] 101 | pub enum Credentials + PartialEq + Eq + Hash> { 102 | Plain { username: T, secret: T }, 103 | OAuthBearer { token: T }, 104 | XOauth2 { username: T, secret: T }, 105 | } 106 | 107 | impl Default for Credentials { 108 | fn default() -> Self { 109 | Credentials::Plain { 110 | username: String::new(), 111 | secret: String::new(), 112 | } 113 | } 114 | } 115 | 116 | impl Display for Error { 117 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 118 | match self { 119 | Error::Io(e) => write!(f, "I/O error: {e}"), 120 | Error::Tls(e) => write!(f, "TLS error: {e}"), 121 | Error::Base64(e) => write!(f, "Base64 decode error: {e}"), 122 | Error::Auth(e) => write!(f, "SMTP authentication error: {e}"), 123 | Error::UnparseableReply => write!(f, "Unparseable SMTP reply"), 124 | Error::UnexpectedReply(e) => write!(f, "Unexpected reply: {e}"), 125 | Error::AuthenticationFailed(e) => write!(f, "Authentication failed: {e}"), 126 | Error::InvalidTLSName => write!(f, "Invalid TLS name provided"), 127 | Error::MissingCredentials => write!(f, "Missing authentication credentials"), 128 | Error::MissingMailFrom => write!(f, "Missing message sender"), 129 | Error::MissingRcptTo => write!(f, "Missing message recipients"), 130 | Error::UnsupportedAuthMechanism => write!( 131 | f, 132 | "The server does no support any of the available authentication methods" 133 | ), 134 | Error::Timeout => write!(f, "Connection timeout"), 135 | Error::MissingStartTls => write!(f, "STARTTLS extension unavailable"), 136 | } 137 | } 138 | } 139 | 140 | impl From for Error { 141 | fn from(err: std::io::Error) -> Self { 142 | Error::Io(err) 143 | } 144 | } 145 | 146 | impl From for Error { 147 | fn from(err: base64::DecodeError) -> Self { 148 | Error::Base64(err) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/smtp/tls.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use super::AssertReply; 8 | use crate::{Error, SmtpClient}; 9 | use rustls::{ 10 | ClientConfig, ClientConnection, RootCertStore, SignatureScheme, 11 | client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, 12 | }; 13 | use rustls_pki_types::{ServerName, TrustAnchor}; 14 | use std::{convert::TryFrom, io, sync::Arc}; 15 | use tokio::net::TcpStream; 16 | use tokio_rustls::{TlsConnector, client::TlsStream}; 17 | 18 | impl SmtpClient { 19 | /// Upgrade the connection to TLS. 20 | pub async fn start_tls( 21 | mut self, 22 | tls_connector: &TlsConnector, 23 | hostname: &str, 24 | ) -> crate::Result>> { 25 | // Send STARTTLS command 26 | self.cmd(b"STARTTLS\r\n") 27 | .await? 28 | .assert_positive_completion()?; 29 | 30 | self.into_tls(tls_connector, hostname).await 31 | } 32 | 33 | pub async fn into_tls( 34 | self, 35 | tls_connector: &TlsConnector, 36 | hostname: &str, 37 | ) -> crate::Result>> { 38 | tokio::time::timeout(self.timeout, async { 39 | Ok(SmtpClient { 40 | stream: tls_connector 41 | .connect( 42 | ServerName::try_from(hostname) 43 | .map_err(|_| crate::Error::InvalidTLSName)? 44 | .to_owned(), 45 | self.stream, 46 | ) 47 | .await 48 | .map_err(|err| { 49 | let kind = err.kind(); 50 | if let Some(inner) = err.into_inner() { 51 | match inner.downcast::() { 52 | Ok(error) => Error::Tls(error), 53 | Err(error) => Error::Io(io::Error::new(kind, error)), 54 | } 55 | } else { 56 | Error::Io(io::Error::new(kind, "Unspecified")) 57 | } 58 | })?, 59 | timeout: self.timeout, 60 | }) 61 | }) 62 | .await 63 | .map_err(|_| crate::Error::Timeout)? 64 | } 65 | } 66 | 67 | impl SmtpClient> { 68 | pub fn tls_connection(&self) -> &ClientConnection { 69 | self.stream.get_ref().1 70 | } 71 | } 72 | 73 | pub fn build_tls_connector(allow_invalid_certs: bool) -> TlsConnector { 74 | let config = if !allow_invalid_certs { 75 | let mut root_cert_store = RootCertStore::empty(); 76 | 77 | root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().map(|ta| TrustAnchor { 78 | subject: ta.subject.clone(), 79 | subject_public_key_info: ta.subject_public_key_info.clone(), 80 | name_constraints: ta.name_constraints.clone(), 81 | })); 82 | 83 | ClientConfig::builder() 84 | .with_root_certificates(root_cert_store) 85 | .with_no_client_auth() 86 | } else { 87 | ClientConfig::builder() 88 | .dangerous() 89 | .with_custom_certificate_verifier(Arc::new(DummyVerifier {})) 90 | .with_no_client_auth() 91 | }; 92 | 93 | TlsConnector::from(Arc::new(config)) 94 | } 95 | 96 | #[doc(hidden)] 97 | #[derive(Debug)] 98 | struct DummyVerifier; 99 | 100 | impl ServerCertVerifier for DummyVerifier { 101 | fn verify_server_cert( 102 | &self, 103 | _end_entity: &rustls_pki_types::CertificateDer<'_>, 104 | _intermediates: &[rustls_pki_types::CertificateDer<'_>], 105 | _server_name: &rustls_pki_types::ServerName<'_>, 106 | _ocsp_response: &[u8], 107 | _now: rustls_pki_types::UnixTime, 108 | ) -> Result { 109 | Ok(ServerCertVerified::assertion()) 110 | } 111 | 112 | fn verify_tls12_signature( 113 | &self, 114 | _message: &[u8], 115 | _cert: &rustls_pki_types::CertificateDer<'_>, 116 | _dss: &rustls::DigitallySignedStruct, 117 | ) -> Result { 118 | Ok(HandshakeSignatureValid::assertion()) 119 | } 120 | 121 | fn verify_tls13_signature( 122 | &self, 123 | _message: &[u8], 124 | _cert: &rustls_pki_types::CertificateDer<'_>, 125 | _dss: &rustls::DigitallySignedStruct, 126 | ) -> Result { 127 | Ok(HandshakeSignatureValid::assertion()) 128 | } 129 | 130 | fn supported_verify_schemes(&self) -> Vec { 131 | vec![ 132 | SignatureScheme::RSA_PKCS1_SHA1, 133 | SignatureScheme::ECDSA_SHA1_Legacy, 134 | SignatureScheme::RSA_PKCS1_SHA256, 135 | SignatureScheme::ECDSA_NISTP256_SHA256, 136 | SignatureScheme::RSA_PKCS1_SHA384, 137 | SignatureScheme::ECDSA_NISTP384_SHA384, 138 | SignatureScheme::RSA_PKCS1_SHA512, 139 | SignatureScheme::ECDSA_NISTP521_SHA512, 140 | SignatureScheme::RSA_PSS_SHA256, 141 | SignatureScheme::RSA_PSS_SHA384, 142 | SignatureScheme::RSA_PSS_SHA512, 143 | SignatureScheme::ED25519, 144 | SignatureScheme::ED448, 145 | ] 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/smtp/builder.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use smtp_proto::{EhloResponse, EXT_START_TLS}; 8 | use std::hash::Hash; 9 | use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; 10 | use std::time::Duration; 11 | use tokio::net::TcpSocket; 12 | use tokio::{ 13 | io, 14 | io::{AsyncRead, AsyncWrite}, 15 | net::TcpStream, 16 | }; 17 | use tokio_rustls::client::TlsStream; 18 | 19 | use crate::{Credentials, SmtpClient, SmtpClientBuilder}; 20 | 21 | use super::{tls::build_tls_connector, AssertReply}; 22 | 23 | impl + PartialEq + Eq + Hash> SmtpClientBuilder { 24 | pub fn new(hostname: T, port: u16) -> Self { 25 | SmtpClientBuilder { 26 | addr: format!("{}:{}", hostname.as_ref(), port), 27 | timeout: Duration::from_secs(60 * 60), 28 | tls_connector: build_tls_connector(false), 29 | tls_hostname: hostname, 30 | tls_implicit: true, 31 | is_lmtp: false, 32 | local_host: gethostname::gethostname() 33 | .to_str() 34 | .unwrap_or("[127.0.0.1]") 35 | .to_string(), 36 | credentials: None, 37 | say_ehlo: true, 38 | local_ip: None, 39 | } 40 | } 41 | 42 | /// Allow invalid TLS certificates 43 | pub fn allow_invalid_certs(mut self) -> Self { 44 | self.tls_connector = build_tls_connector(true); 45 | self 46 | } 47 | 48 | /// Start connection in TLS or upgrade with STARTTLS 49 | pub fn implicit_tls(mut self, tls_implicit: bool) -> Self { 50 | self.tls_implicit = tls_implicit; 51 | self 52 | } 53 | 54 | /// Use LMTP instead of SMTP 55 | pub fn lmtp(mut self, is_lmtp: bool) -> Self { 56 | self.is_lmtp = is_lmtp; 57 | self 58 | } 59 | 60 | // Say EHLO/LHLO 61 | pub fn say_ehlo(mut self, say_ehlo: bool) -> Self { 62 | self.say_ehlo = say_ehlo; 63 | self 64 | } 65 | 66 | /// Set the EHLO/LHLO hostname 67 | pub fn helo_host(mut self, host: impl Into) -> Self { 68 | self.local_host = host.into(); 69 | self 70 | } 71 | 72 | /// Sets the authentication credentials 73 | pub fn credentials(mut self, credentials: impl Into>) -> Self { 74 | self.credentials = Some(credentials.into()); 75 | self 76 | } 77 | 78 | /// Sets the SMTP connection timeout 79 | pub fn timeout(mut self, timeout: Duration) -> Self { 80 | self.timeout = timeout; 81 | self 82 | } 83 | 84 | /// Sets the local IP to use while sending the email. 85 | /// 86 | /// This is useful if your machine has multiple public IPs assigned and you want to ensure 87 | /// that you are using the intended one. Using an IP with good repudiation is quite important 88 | /// when you want to ensure deliverability. 89 | /// 90 | /// *NOTE:* If the IP is not available on that machine, the [`connect`] and [`connect_plain`] will return and error 91 | pub fn local_ip(mut self, local_ip: IpAddr) -> Self { 92 | self.local_ip = Some(local_ip); 93 | self 94 | } 95 | 96 | async fn tcp_stream(&self) -> io::Result { 97 | if let Some(local_addr) = self.local_ip { 98 | let remote_addrs = self.addr.to_socket_addrs()?; 99 | let mut last_err = None; 100 | 101 | for addr in remote_addrs { 102 | let local_addr = SocketAddr::new(local_addr, 0); 103 | let socket = match local_addr.ip() { 104 | IpAddr::V4(_) => TcpSocket::new_v4()?, 105 | IpAddr::V6(_) => TcpSocket::new_v6()?, 106 | }; 107 | socket.bind(local_addr)?; 108 | 109 | match socket.connect(addr).await { 110 | Ok(stream) => return Ok(stream), 111 | Err(e) => last_err = Some(e), 112 | } 113 | } 114 | 115 | Err(last_err.unwrap_or_else(|| { 116 | io::Error::new( 117 | io::ErrorKind::InvalidInput, 118 | "could not resolve to any address", 119 | ) 120 | })) 121 | } else { 122 | TcpStream::connect(&self.addr).await 123 | } 124 | } 125 | 126 | /// Connect over TLS 127 | pub async fn connect(&self) -> crate::Result>> { 128 | tokio::time::timeout(self.timeout, async { 129 | let mut client = SmtpClient { 130 | stream: self.tcp_stream().await?, 131 | timeout: self.timeout, 132 | }; 133 | 134 | let mut client = if self.tls_implicit { 135 | let mut client = client 136 | .into_tls(&self.tls_connector, self.tls_hostname.as_ref()) 137 | .await?; 138 | // Read greeting 139 | client.read().await?.assert_positive_completion()?; 140 | client 141 | } else { 142 | // Read greeting 143 | client.read().await?.assert_positive_completion()?; 144 | 145 | // Send EHLO 146 | let response = if !self.is_lmtp { 147 | client.ehlo(&self.local_host).await? 148 | } else { 149 | client.lhlo(&self.local_host).await? 150 | }; 151 | if response.has_capability(EXT_START_TLS) { 152 | client 153 | .start_tls(&self.tls_connector, self.tls_hostname.as_ref()) 154 | .await? 155 | } else { 156 | return Err(crate::Error::MissingStartTls); 157 | } 158 | }; 159 | 160 | if self.say_ehlo { 161 | // Obtain capabilities 162 | let capabilities = client.capabilities(&self.local_host, self.is_lmtp).await?; 163 | // Authenticate 164 | if let Some(credentials) = &self.credentials { 165 | client.authenticate(&credentials, &capabilities).await?; 166 | } 167 | } 168 | 169 | Ok(client) 170 | }) 171 | .await 172 | .map_err(|_| crate::Error::Timeout)? 173 | } 174 | 175 | /// Connect over clear text (should not be used) 176 | pub async fn connect_plain(&self) -> crate::Result> { 177 | let mut client = SmtpClient { 178 | stream: tokio::time::timeout(self.timeout, async { self.tcp_stream().await }) 179 | .await 180 | .map_err(|_| crate::Error::Timeout)??, 181 | timeout: self.timeout, 182 | }; 183 | 184 | // Read greeting 185 | client.read().await?.assert_positive_completion()?; 186 | 187 | if self.say_ehlo { 188 | // Obtain capabilities 189 | let capabilities = client.capabilities(&self.local_host, self.is_lmtp).await?; 190 | // Authenticate 191 | if let Some(credentials) = &self.credentials { 192 | client.authenticate(&credentials, &capabilities).await?; 193 | } 194 | } 195 | 196 | Ok(client) 197 | } 198 | } 199 | 200 | impl SmtpClient { 201 | pub async fn capabilities( 202 | &mut self, 203 | local_host: &str, 204 | is_lmtp: bool, 205 | ) -> crate::Result> { 206 | if !is_lmtp { 207 | self.ehlo(local_host).await 208 | } else { 209 | self.lhlo(local_host).await 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/smtp/client.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::{ 8 | net::{IpAddr, SocketAddr}, 9 | time::Duration, 10 | }; 11 | 12 | use smtp_proto::{response::parser::ResponseReceiver, Response}; 13 | use tokio::{ 14 | io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, 15 | net::{TcpSocket, TcpStream}, 16 | }; 17 | 18 | use crate::SmtpClient; 19 | 20 | impl SmtpClient { 21 | pub async fn read(&mut self) -> crate::Result> { 22 | let mut buf = vec![0u8; 1024]; 23 | let mut parser = ResponseReceiver::default(); 24 | 25 | loop { 26 | let br = self.stream.read(&mut buf).await?; 27 | 28 | if br > 0 { 29 | match parser.parse(&mut buf[..br].iter()) { 30 | Ok(reply) => return Ok(reply), 31 | Err(err) => match err { 32 | smtp_proto::Error::NeedsMoreData { .. } => (), 33 | _ => { 34 | return Err(crate::Error::UnparseableReply); 35 | } 36 | }, 37 | } 38 | } else { 39 | return Err(crate::Error::UnparseableReply); 40 | } 41 | } 42 | } 43 | 44 | pub async fn read_many(&mut self, num: usize) -> crate::Result>> { 45 | let mut buf = vec![0u8; 1024]; 46 | let mut response = Vec::with_capacity(num); 47 | let mut parser = ResponseReceiver::default(); 48 | 49 | 'outer: loop { 50 | let br = self.stream.read(&mut buf).await?; 51 | 52 | if br > 0 { 53 | let mut iter = buf[..br].iter(); 54 | 55 | loop { 56 | match parser.parse(&mut iter) { 57 | Ok(reply) => { 58 | response.push(reply); 59 | if response.len() != num { 60 | parser.reset(); 61 | } else { 62 | break 'outer; 63 | } 64 | } 65 | Err(err) => match err { 66 | smtp_proto::Error::NeedsMoreData { .. } => break, 67 | _ => { 68 | return Err(crate::Error::UnparseableReply); 69 | } 70 | }, 71 | } 72 | } 73 | } else { 74 | return Err(crate::Error::UnparseableReply); 75 | } 76 | } 77 | 78 | Ok(response) 79 | } 80 | 81 | /// Sends a command to the SMTP server and waits for a reply. 82 | pub async fn cmd(&mut self, cmd: impl AsRef<[u8]>) -> crate::Result> { 83 | tokio::time::timeout(self.timeout, async { 84 | self.stream.write_all(cmd.as_ref()).await?; 85 | self.stream.flush().await?; 86 | self.read().await 87 | }) 88 | .await 89 | .map_err(|_| crate::Error::Timeout)? 90 | } 91 | 92 | /// Pipelines multiple command to the SMTP server and waits for a reply. 93 | pub async fn cmds( 94 | &mut self, 95 | cmds: impl IntoIterator>, 96 | ) -> crate::Result>> { 97 | tokio::time::timeout(self.timeout, async { 98 | let mut num_replies = 0; 99 | for cmd in cmds { 100 | self.stream.write_all(cmd.as_ref()).await?; 101 | num_replies += 1; 102 | } 103 | self.stream.flush().await?; 104 | self.read_many(num_replies).await 105 | }) 106 | .await 107 | .map_err(|_| crate::Error::Timeout)? 108 | } 109 | } 110 | 111 | impl SmtpClient { 112 | /// Connects to a remote host address 113 | pub async fn connect(remote_addr: SocketAddr, timeout: Duration) -> crate::Result { 114 | tokio::time::timeout(timeout, async { 115 | Ok(SmtpClient { 116 | stream: TcpStream::connect(remote_addr).await?, 117 | timeout, 118 | }) 119 | }) 120 | .await 121 | .map_err(|_| crate::Error::Timeout)? 122 | } 123 | 124 | /// Connects to a remote host address using the provided local IP 125 | pub async fn connect_using( 126 | local_ip: IpAddr, 127 | remote_addr: SocketAddr, 128 | timeout: Duration, 129 | ) -> crate::Result { 130 | tokio::time::timeout(timeout, async { 131 | let socket = if local_ip.is_ipv4() { 132 | TcpSocket::new_v4()? 133 | } else { 134 | TcpSocket::new_v6()? 135 | }; 136 | socket.bind(SocketAddr::new(local_ip, 0))?; 137 | 138 | Ok(SmtpClient { 139 | stream: socket.connect(remote_addr).await?, 140 | timeout, 141 | }) 142 | }) 143 | .await 144 | .map_err(|_| crate::Error::Timeout)? 145 | } 146 | } 147 | 148 | #[cfg(test)] 149 | mod test { 150 | use std::time::Duration; 151 | 152 | use tokio::io::{AsyncRead, AsyncWrite}; 153 | 154 | use crate::{SmtpClient, SmtpClientBuilder}; 155 | 156 | #[tokio::test] 157 | async fn smtp_basic() { 158 | // StartTLS test 159 | env_logger::init(); 160 | let client = SmtpClientBuilder::new("mail.smtp2go.com", 2525) 161 | .implicit_tls(false) 162 | .connect() 163 | .await 164 | .unwrap(); 165 | client.quit().await.unwrap(); 166 | let client = SmtpClientBuilder::new("mail.smtp2go.com", 2525) 167 | .allow_invalid_certs() 168 | .implicit_tls(false) 169 | .connect() 170 | .await 171 | .unwrap(); 172 | client.quit().await.unwrap(); 173 | 174 | // Say hello to Google over TLS and quit 175 | let client = SmtpClientBuilder::new("smtp.gmail.com", 465) 176 | .connect() 177 | .await 178 | .unwrap(); 179 | client.quit().await.unwrap(); 180 | 181 | // Say hello to Google over TLS and quit 182 | let client = SmtpClientBuilder::new("smtp.gmail.com", 465) 183 | .allow_invalid_certs() 184 | .connect() 185 | .await 186 | .unwrap(); 187 | client.quit().await.unwrap(); 188 | } 189 | 190 | #[derive(Default)] 191 | struct AsyncBufWriter { 192 | buf: Vec, 193 | } 194 | 195 | impl AsyncRead for AsyncBufWriter { 196 | fn poll_read( 197 | self: std::pin::Pin<&mut Self>, 198 | _cx: &mut std::task::Context<'_>, 199 | _buf: &mut tokio::io::ReadBuf<'_>, 200 | ) -> std::task::Poll> { 201 | unreachable!() 202 | } 203 | } 204 | 205 | impl AsyncWrite for AsyncBufWriter { 206 | fn poll_write( 207 | mut self: std::pin::Pin<&mut Self>, 208 | _cx: &mut std::task::Context<'_>, 209 | buf: &[u8], 210 | ) -> std::task::Poll> { 211 | self.buf.extend_from_slice(buf); 212 | std::task::Poll::Ready(Ok(buf.len())) 213 | } 214 | 215 | fn poll_flush( 216 | self: std::pin::Pin<&mut Self>, 217 | _cx: &mut std::task::Context<'_>, 218 | ) -> std::task::Poll> { 219 | std::task::Poll::Ready(Ok(())) 220 | } 221 | 222 | fn poll_shutdown( 223 | self: std::pin::Pin<&mut Self>, 224 | _cx: &mut std::task::Context<'_>, 225 | ) -> std::task::Poll> { 226 | std::task::Poll::Ready(Ok(())) 227 | } 228 | } 229 | 230 | #[tokio::test] 231 | async fn transparency_procedure() { 232 | const SMUGGLER: &str = r#"From: Joe SixPack 233 | To: Suzie Q 234 | Subject: Is dinner ready? 235 | 236 | Hi. 237 | 238 | We lost the game. Are you hungry yet? 239 | 240 | Joe. 241 | 242 | . 243 | MAIL FROM: 244 | RCPT TO: 245 | DATA 246 | From: Joe SixPack 247 | To: Suzie Q 248 | Subject: smuggled message 249 | 250 | This is a smuggled message 251 | "#; 252 | 253 | for (test, result) in [ 254 | ( 255 | "A: b\r\n.\r\n".to_string(), 256 | "A: b\r\n..\r\n\r\n.\r\n".to_string(), 257 | ), 258 | ("A: b\r\n.".to_string(), "A: b\r\n..\r\n.\r\n".to_string()), 259 | ( 260 | "A: b\r\n..\r\n".to_string(), 261 | "A: b\r\n...\r\n\r\n.\r\n".to_string(), 262 | ), 263 | ("A: ...b".to_string(), "A: ...b\r\n.\r\n".to_string()), 264 | ( 265 | "A: \n.\r\nMAIL FROM:<>".to_string(), 266 | "A: \n..\r\nMAIL FROM:<>\r\n.\r\n".to_string(), 267 | ), 268 | ( 269 | "A: \r.\r\nMAIL FROM:<>".to_string(), 270 | "A: \r..\r\nMAIL FROM:<>\r\n.\r\n".to_string(), 271 | ), 272 | ( 273 | SMUGGLER 274 | .replace('\r', "") 275 | .replace('\n', "\r\n") 276 | .replace("", "\r"), 277 | SMUGGLER 278 | .replace('\r', "") 279 | .replace('\n', "\r\n") 280 | .replace("", "\r.") 281 | + "\r\n.\r\n", 282 | ), 283 | ( 284 | SMUGGLER 285 | .replace('\r', "") 286 | .replace('\n', "\r\n") 287 | .replace("", "\n"), 288 | SMUGGLER 289 | .replace('\r', "") 290 | .replace('\n', "\r\n") 291 | .replace("", "\n.") 292 | + "\r\n.\r\n", 293 | ), 294 | ] { 295 | let mut client = SmtpClient { 296 | stream: AsyncBufWriter::default(), 297 | timeout: Duration::from_secs(30), 298 | }; 299 | client.write_message(test.as_bytes()).await.unwrap(); 300 | assert_eq!(String::from_utf8(client.stream.buf).unwrap(), result); 301 | } 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /LICENSES/Apache-2.0.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/smtp/message.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::{ 8 | borrow::Cow, 9 | fmt::{Debug, Display}, 10 | }; 11 | 12 | #[cfg(feature = "builder")] 13 | use mail_builder::{ 14 | MessageBuilder, 15 | headers::{HeaderType, address}, 16 | }; 17 | #[cfg(feature = "parser")] 18 | use mail_parser::{HeaderName, HeaderValue}; 19 | use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; 20 | 21 | use crate::SmtpClient; 22 | 23 | #[derive(Debug, Default, Clone)] 24 | pub struct Message<'x> { 25 | pub mail_from: Address<'x>, 26 | pub rcpt_to: Vec>, 27 | pub body: Cow<'x, [u8]>, 28 | } 29 | 30 | #[derive(Debug, Default, Clone)] 31 | pub struct Address<'x> { 32 | pub email: Cow<'x, str>, 33 | pub parameters: Parameters<'x>, 34 | } 35 | 36 | #[derive(Debug, Default, Clone)] 37 | pub struct Parameters<'x> { 38 | params: Vec>, 39 | } 40 | 41 | #[derive(Debug, Default, Clone)] 42 | pub struct Parameter<'x> { 43 | key: Cow<'x, str>, 44 | value: Option>, 45 | } 46 | 47 | impl SmtpClient { 48 | /// Sends a message to the server. 49 | pub async fn send<'x>(&mut self, message: impl IntoMessage<'x>) -> crate::Result<()> { 50 | // Send mail-from 51 | let message = message.into_message()?; 52 | self.mail_from( 53 | message.mail_from.email.as_ref(), 54 | &message.mail_from.parameters, 55 | ) 56 | .await?; 57 | 58 | // Send rcpt-to 59 | for rcpt in &message.rcpt_to { 60 | self.rcpt_to(rcpt.email.as_ref(), &rcpt.parameters).await?; 61 | } 62 | 63 | // Send message 64 | self.data(message.body.as_ref()).await 65 | } 66 | 67 | /// Sends a message to the server. 68 | #[cfg(feature = "dkim")] 69 | pub async fn send_signed<'x, V: mail_auth::common::crypto::SigningKey>( 70 | &mut self, 71 | message: impl IntoMessage<'x>, 72 | signer: &mail_auth::dkim::DkimSigner, 73 | ) -> crate::Result<()> { 74 | // Send mail-from 75 | 76 | use mail_auth::common::headers::HeaderWriter; 77 | let message = message.into_message()?; 78 | self.mail_from( 79 | message.mail_from.email.as_ref(), 80 | &message.mail_from.parameters, 81 | ) 82 | .await?; 83 | 84 | // Send rcpt-to 85 | for rcpt in &message.rcpt_to { 86 | self.rcpt_to(rcpt.email.as_ref(), &rcpt.parameters).await?; 87 | } 88 | 89 | // Sign message 90 | let signature = signer 91 | .sign(message.body.as_ref()) 92 | .map_err(|_| crate::Error::MissingCredentials)?; 93 | let mut signed_message = Vec::with_capacity(message.body.len() + 64); 94 | signature.write_header(&mut signed_message); 95 | signed_message.extend_from_slice(message.body.as_ref()); 96 | 97 | // Send message 98 | self.data(&signed_message).await 99 | } 100 | 101 | pub async fn write_message(&mut self, message: &[u8]) -> tokio::io::Result<()> { 102 | // Transparency procedure 103 | let mut is_cr_or_lf = false; 104 | 105 | // As per RFC 5322bis, section 2.3: 106 | // CR and LF MUST only occur together as CRLF; they MUST NOT appear 107 | // independently in the body. 108 | // For this reason, we apply the transparency procedure when there is 109 | // a CR or LF followed by a dot. 110 | 111 | let mut last_pos = 0; 112 | for (pos, byte) in message.iter().enumerate() { 113 | if *byte == b'.' && is_cr_or_lf { 114 | if let Some(bytes) = message.get(last_pos..pos) { 115 | self.stream.write_all(bytes).await?; 116 | self.stream.write_all(b".").await?; 117 | last_pos = pos; 118 | } 119 | is_cr_or_lf = false; 120 | } else { 121 | is_cr_or_lf = *byte == b'\n' || *byte == b'\r'; 122 | } 123 | } 124 | if let Some(bytes) = message.get(last_pos..) { 125 | self.stream.write_all(bytes).await?; 126 | } 127 | self.stream.write_all("\r\n.\r\n".as_bytes()).await?; 128 | self.stream.flush().await 129 | } 130 | } 131 | 132 | impl<'x> Message<'x> { 133 | /// Create a new message 134 | pub fn new(from: T, to: U, body: V) -> Self 135 | where 136 | T: Into>, 137 | U: IntoIterator, 138 | V: Into>, 139 | { 140 | Message { 141 | mail_from: from.into(), 142 | rcpt_to: to.into_iter().map(Into::into).collect(), 143 | body: body.into(), 144 | } 145 | } 146 | 147 | /// Create a new empty message. 148 | pub fn empty() -> Self { 149 | Message { 150 | mail_from: Address::default(), 151 | rcpt_to: Vec::new(), 152 | body: Default::default(), 153 | } 154 | } 155 | 156 | /// Set the sender of the message. 157 | pub fn from(mut self, address: impl Into>) -> Self { 158 | self.mail_from = address.into(); 159 | self 160 | } 161 | 162 | /// Add a message recipient. 163 | pub fn to(mut self, address: impl Into>) -> Self { 164 | self.rcpt_to.push(address.into()); 165 | self 166 | } 167 | 168 | /// Set the message body. 169 | pub fn body(mut self, body: impl Into>) -> Self { 170 | self.body = body.into(); 171 | self 172 | } 173 | } 174 | 175 | impl<'x> From<&'x str> for Address<'x> { 176 | fn from(email: &'x str) -> Self { 177 | Address { 178 | email: email.into(), 179 | parameters: Parameters::default(), 180 | } 181 | } 182 | } 183 | 184 | impl From for Address<'_> { 185 | fn from(email: String) -> Self { 186 | Address { 187 | email: email.into(), 188 | parameters: Parameters::default(), 189 | } 190 | } 191 | } 192 | 193 | impl<'x> Address<'x> { 194 | pub fn new(email: impl Into>, parameters: Parameters<'x>) -> Self { 195 | Address { 196 | email: email.into(), 197 | parameters, 198 | } 199 | } 200 | } 201 | 202 | impl<'x> Parameters<'x> { 203 | pub fn new() -> Self { 204 | Self { params: Vec::new() } 205 | } 206 | 207 | pub fn add(&mut self, param: impl Into>) -> &mut Self { 208 | self.params.push(param.into()); 209 | self 210 | } 211 | } 212 | 213 | impl<'x> From<&'x str> for Parameter<'x> { 214 | fn from(value: &'x str) -> Self { 215 | Parameter { 216 | key: value.into(), 217 | value: None, 218 | } 219 | } 220 | } 221 | 222 | impl<'x> From<(&'x str, &'x str)> for Parameter<'x> { 223 | fn from(value: (&'x str, &'x str)) -> Self { 224 | Parameter { 225 | key: value.0.into(), 226 | value: Some(value.1.into()), 227 | } 228 | } 229 | } 230 | 231 | impl From<(String, String)> for Parameter<'_> { 232 | fn from(value: (String, String)) -> Self { 233 | Parameter { 234 | key: value.0.into(), 235 | value: Some(value.1.into()), 236 | } 237 | } 238 | } 239 | 240 | impl From for Parameter<'_> { 241 | fn from(value: String) -> Self { 242 | Parameter { 243 | key: value.into(), 244 | value: None, 245 | } 246 | } 247 | } 248 | 249 | impl Display for Parameters<'_> { 250 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 251 | if !self.params.is_empty() { 252 | for param in &self.params { 253 | f.write_str(" ")?; 254 | Display::fmt(¶m, f)?; 255 | } 256 | } 257 | Ok(()) 258 | } 259 | } 260 | 261 | impl Display for Parameter<'_> { 262 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 263 | if let Some(value) = &self.value { 264 | write!(f, "{}={}", self.key, value) 265 | } else { 266 | f.write_str(&self.key) 267 | } 268 | } 269 | } 270 | 271 | pub trait IntoMessage<'x> { 272 | fn into_message(self) -> crate::Result>; 273 | } 274 | 275 | impl<'x> IntoMessage<'x> for Message<'x> { 276 | fn into_message(self) -> crate::Result> { 277 | Ok(self) 278 | } 279 | } 280 | 281 | #[cfg(feature = "builder")] 282 | impl<'x> IntoMessage<'x> for MessageBuilder<'_> { 283 | fn into_message(self) -> crate::Result> { 284 | let mut mail_from = None; 285 | let mut rcpt_to = std::collections::HashSet::new(); 286 | 287 | for (key, value) in self.headers.iter() { 288 | if key.eq_ignore_ascii_case("from") { 289 | if let HeaderType::Address(address::Address::Address(addr)) = value { 290 | let email = addr.email.trim(); 291 | if !email.is_empty() { 292 | mail_from = email.to_string().into(); 293 | } 294 | } 295 | } else if (key.eq_ignore_ascii_case("to") 296 | || key.eq_ignore_ascii_case("cc") 297 | || key.eq_ignore_ascii_case("bcc")) 298 | && let HeaderType::Address(addr) = value 299 | { 300 | match addr { 301 | address::Address::Address(addr) => { 302 | let email = addr.email.trim(); 303 | if !email.is_empty() { 304 | rcpt_to.insert(email.to_string()); 305 | } 306 | } 307 | address::Address::Group(group) => { 308 | for addr in &group.addresses { 309 | if let address::Address::Address(addr) = addr { 310 | let email = addr.email.trim(); 311 | if !email.is_empty() { 312 | rcpt_to.insert(email.to_string()); 313 | } 314 | } 315 | } 316 | } 317 | address::Address::List(list) => { 318 | for addr in list { 319 | if let address::Address::Address(addr) = addr { 320 | let email = addr.email.trim(); 321 | if !email.is_empty() { 322 | rcpt_to.insert(email.to_string()); 323 | } 324 | } 325 | } 326 | } 327 | } 328 | } 329 | } 330 | 331 | if rcpt_to.is_empty() { 332 | return Err(crate::Error::MissingRcptTo); 333 | } 334 | 335 | Ok(Message { 336 | mail_from: mail_from.ok_or(crate::Error::MissingMailFrom)?.into(), 337 | rcpt_to: rcpt_to 338 | .into_iter() 339 | .map(|email| Address { 340 | email: email.into(), 341 | parameters: Parameters::default(), 342 | }) 343 | .collect(), 344 | body: self.write_to_vec()?.into(), 345 | }) 346 | } 347 | } 348 | 349 | #[cfg(feature = "parser")] 350 | impl<'x> IntoMessage<'x> for mail_parser::Message<'x> { 351 | fn into_message(self) -> crate::Result> { 352 | let mut mail_from = None; 353 | let mut rcpt_to = std::collections::HashSet::new(); 354 | 355 | let find_address = |addr: &mail_parser::Addr| -> Option { 356 | addr.address 357 | .as_ref() 358 | .filter(|address| !address.trim().is_empty()) 359 | .map(|address| address.trim().to_string()) 360 | }; 361 | 362 | for header in self.headers() { 363 | match &header.name { 364 | HeaderName::From => match header.value() { 365 | HeaderValue::Address(mail_parser::Address::List(addrs)) => { 366 | if let Some(email) = addrs.iter().find_map(find_address) { 367 | mail_from = email.to_string().into(); 368 | } 369 | } 370 | HeaderValue::Address(mail_parser::Address::Group(groups)) => { 371 | if let Some(grps) = groups.first() { 372 | if let Some(email) = grps.addresses.iter().find_map(find_address) { 373 | mail_from = email.to_string().into(); 374 | } 375 | } 376 | } 377 | _ => (), 378 | }, 379 | HeaderName::To | HeaderName::Cc | HeaderName::Bcc => match header.value() { 380 | HeaderValue::Address(mail_parser::Address::List(addrs)) => { 381 | rcpt_to.extend(addrs.iter().filter_map(find_address)); 382 | } 383 | HeaderValue::Address(mail_parser::Address::Group(grps)) => { 384 | rcpt_to.extend( 385 | grps.iter() 386 | .flat_map(|grp| grp.addresses.iter()) 387 | .filter_map(find_address), 388 | ); 389 | } 390 | _ => (), 391 | }, 392 | _ => (), 393 | }; 394 | } 395 | 396 | if rcpt_to.is_empty() { 397 | return Err(crate::Error::MissingRcptTo); 398 | } 399 | 400 | Ok(Message { 401 | mail_from: mail_from.ok_or(crate::Error::MissingMailFrom)?.into(), 402 | rcpt_to: rcpt_to 403 | .into_iter() 404 | .map(|email| Address { 405 | email: email.into(), 406 | parameters: Parameters::default(), 407 | }) 408 | .collect(), 409 | body: self.raw_message, 410 | }) 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /src/smtp/auth.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::{fmt::Display, hash::Hash}; 8 | 9 | use base64::{Engine, engine}; 10 | use smtp_proto::{ 11 | AUTH_CRAM_MD5, AUTH_DIGEST_MD5, AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, 12 | EhloResponse, response::generate::BitToString, 13 | }; 14 | use tokio::io::{AsyncRead, AsyncWrite}; 15 | 16 | use crate::{Credentials, SmtpClient}; 17 | 18 | impl SmtpClient { 19 | pub async fn authenticate( 20 | &mut self, 21 | credentials: impl AsRef>, 22 | capabilities: impl AsRef>, 23 | ) -> crate::Result<&mut Self> 24 | where 25 | U: AsRef + PartialEq + Eq + Hash, 26 | { 27 | let credentials = credentials.as_ref(); 28 | let capabilities = capabilities.as_ref(); 29 | let mut available_mechanisms = match &credentials { 30 | Credentials::Plain { .. } => AUTH_CRAM_MD5 | AUTH_DIGEST_MD5 | AUTH_LOGIN | AUTH_PLAIN, 31 | Credentials::OAuthBearer { .. } => AUTH_OAUTHBEARER, 32 | Credentials::XOauth2 { .. } => AUTH_XOAUTH2, 33 | } & capabilities.auth_mechanisms; 34 | 35 | // Try authenticating from most secure to least secure 36 | let mut has_err = None; 37 | let mut has_failed = false; 38 | 39 | while available_mechanisms != 0 && !has_failed { 40 | let mechanism = 1 << ((63 - available_mechanisms.leading_zeros()) as u64); 41 | available_mechanisms ^= mechanism; 42 | match self.auth(mechanism, credentials).await { 43 | Ok(_) => { 44 | return Ok(self); 45 | } 46 | Err(err) => match err { 47 | crate::Error::UnexpectedReply(reply) => { 48 | has_failed = reply.code() == 535; 49 | has_err = reply.into(); 50 | } 51 | crate::Error::UnsupportedAuthMechanism => (), 52 | _ => return Err(err), 53 | }, 54 | } 55 | } 56 | 57 | if let Some(has_err) = has_err { 58 | Err(crate::Error::AuthenticationFailed(has_err)) 59 | } else { 60 | Err(crate::Error::UnsupportedAuthMechanism) 61 | } 62 | } 63 | 64 | pub(crate) async fn auth( 65 | &mut self, 66 | mechanism: u64, 67 | credentials: &Credentials, 68 | ) -> crate::Result<()> 69 | where 70 | U: AsRef + PartialEq + Eq + Hash, 71 | { 72 | let mut reply = if (mechanism & (AUTH_PLAIN | AUTH_XOAUTH2 | AUTH_OAUTHBEARER)) != 0 { 73 | self.cmd( 74 | format!( 75 | "AUTH {} {}\r\n", 76 | mechanism.to_mechanism(), 77 | credentials.encode(mechanism, "")?, 78 | ) 79 | .as_bytes(), 80 | ) 81 | .await? 82 | } else { 83 | self.cmd(format!("AUTH {}\r\n", mechanism.to_mechanism()).as_bytes()) 84 | .await? 85 | }; 86 | 87 | for _ in 0..3 { 88 | match reply.code() { 89 | 334 => { 90 | reply = self 91 | .cmd( 92 | format!("{}\r\n", credentials.encode(mechanism, reply.message())?) 93 | .as_bytes(), 94 | ) 95 | .await?; 96 | } 97 | 235 => { 98 | return Ok(()); 99 | } 100 | _ => { 101 | return Err(crate::Error::UnexpectedReply(reply)); 102 | } 103 | } 104 | } 105 | 106 | Err(crate::Error::UnexpectedReply(reply)) 107 | } 108 | } 109 | 110 | #[derive(Debug, Clone)] 111 | pub enum Error { 112 | InvalidChallenge, 113 | } 114 | 115 | impl + PartialEq + Eq + Hash> Credentials { 116 | /// Creates a new `Credentials` instance. 117 | pub fn new(username: T, secret: T) -> Credentials { 118 | Credentials::Plain { username, secret } 119 | } 120 | 121 | /// Creates a new XOAuth2 `Credentials` instance. 122 | pub fn new_xoauth2(username: T, secret: T) -> Credentials { 123 | Credentials::XOauth2 { username, secret } 124 | } 125 | 126 | /// Creates a new OAuthBearer `Credentials` instance. 127 | pub fn new_oauth(payload: T) -> Credentials { 128 | Credentials::OAuthBearer { token: payload } 129 | } 130 | 131 | /// Creates a new OAuthBearer `Credentials` instance from a Bearer token. 132 | pub fn new_oauth_from_token(token: T) -> Credentials { 133 | Credentials::OAuthBearer { 134 | token: format!("auth=Bearer {}\x01\x01", token.as_ref()), 135 | } 136 | } 137 | 138 | pub fn encode(&self, mechanism: u64, challenge: &str) -> crate::Result { 139 | Ok(engine::general_purpose::STANDARD.encode( 140 | match (mechanism, self) { 141 | (AUTH_PLAIN, Credentials::Plain { username, secret }) => { 142 | format!("\u{0}{}\u{0}{}", username.as_ref(), secret.as_ref()) 143 | } 144 | 145 | (AUTH_LOGIN, Credentials::Plain { username, secret }) => { 146 | let challenge = engine::general_purpose::STANDARD.decode(challenge)?; 147 | let username = username.as_ref(); 148 | let secret = secret.as_ref(); 149 | 150 | if b"user name" 151 | .eq_ignore_ascii_case(challenge.get(0..9).ok_or(Error::InvalidChallenge)?) 152 | || b"username".eq_ignore_ascii_case( 153 | // Because Google makes its own standards 154 | challenge.get(0..8).ok_or(Error::InvalidChallenge)?, 155 | ) 156 | { 157 | &username 158 | } else if b"password" 159 | .eq_ignore_ascii_case(challenge.get(0..8).ok_or(Error::InvalidChallenge)?) 160 | { 161 | &secret 162 | } else { 163 | return Err(Error::InvalidChallenge.into()); 164 | } 165 | .to_string() 166 | } 167 | 168 | #[cfg(feature = "digest-md5")] 169 | (AUTH_DIGEST_MD5, Credentials::Plain { username, secret }) => { 170 | let mut buf = Vec::with_capacity(10); 171 | let mut key = None; 172 | let mut in_quote = false; 173 | let mut values = std::collections::HashMap::new(); 174 | let challenge = engine::general_purpose::STANDARD.decode(challenge)?; 175 | let challenge_len = challenge.len(); 176 | let username = username.as_ref(); 177 | let secret = secret.as_ref(); 178 | 179 | for (pos, byte) in challenge.into_iter().enumerate() { 180 | let add_key = match byte { 181 | b'=' if !in_quote => { 182 | if key.is_none() && !buf.is_empty() { 183 | key = String::from_utf8_lossy(&buf).into_owned().into(); 184 | buf.clear(); 185 | } else { 186 | return Err(Error::InvalidChallenge.into()); 187 | } 188 | false 189 | } 190 | b',' if !in_quote => true, 191 | b'"' => { 192 | in_quote = !in_quote; 193 | false 194 | } 195 | _ => { 196 | buf.push(byte); 197 | false 198 | } 199 | }; 200 | 201 | if (add_key || pos == challenge_len - 1) && key.is_some() && !buf.is_empty() 202 | { 203 | values.insert( 204 | key.take().unwrap(), 205 | String::from_utf8_lossy(&buf).into_owned(), 206 | ); 207 | buf.clear(); 208 | } 209 | } 210 | 211 | let (digest_uri, realm, realm_response) = 212 | if let Some(realm) = values.get("realm") { 213 | ( 214 | format!("smtp/{realm}"), 215 | realm.as_str(), 216 | format!(",realm=\"{realm}\""), 217 | ) 218 | } else { 219 | ("smtp/localhost".to_string(), "", "".to_string()) 220 | }; 221 | 222 | let credentials = 223 | md5::compute(format!("{username}:{realm}:{secret}").as_bytes()); 224 | 225 | let a2 = md5::compute( 226 | if values.get("qpop").is_some_and(|v| v == "auth") { 227 | format!("AUTHENTICATE:{digest_uri}") 228 | } else { 229 | format!("AUTHENTICATE:{digest_uri}:00000000000000000000000000000000") 230 | } 231 | .as_bytes(), 232 | ); 233 | 234 | #[allow(unused_variables)] 235 | let cnonce = { 236 | use rand::RngCore; 237 | let mut buf = [0u8; 16]; 238 | rand::rng().fill_bytes(&mut buf); 239 | engine::general_purpose::STANDARD.encode(buf) 240 | }; 241 | 242 | #[cfg(test)] 243 | let cnonce = "OA6MHXh6VqTrRk".to_string(); 244 | let nonce = values.remove("nonce").unwrap_or_default(); 245 | let qop = values.remove("qop").unwrap_or_default(); 246 | let charset = values 247 | .remove("charset") 248 | .unwrap_or_else(|| "utf-8".to_string()); 249 | 250 | format!( 251 | concat!( 252 | "charset={},username=\"{}\",realm=\"{}\",nonce=\"{}\",nc=00000001,", 253 | "cnonce=\"{}\",digest-uri=\"{}\",response={:x},qop={}" 254 | ), 255 | charset, 256 | username, 257 | realm_response, 258 | nonce, 259 | cnonce, 260 | digest_uri, 261 | md5::compute( 262 | format!("{credentials:x}:{nonce}:00000001:{cnonce}:{qop}:{a2:x}") 263 | .as_bytes() 264 | ), 265 | qop 266 | ) 267 | } 268 | 269 | #[cfg(feature = "cram-md5")] 270 | (AUTH_CRAM_MD5, Credentials::Plain { username, secret }) => { 271 | let mut secret_opad: Vec = vec![0x5c; 64]; 272 | let mut secret_ipad: Vec = vec![0x36; 64]; 273 | let username = username.as_ref(); 274 | let secret = secret.as_ref(); 275 | 276 | if secret.len() < 64 { 277 | for (pos, byte) in secret.as_bytes().iter().enumerate() { 278 | secret_opad[pos] = *byte ^ 0x5c; 279 | secret_ipad[pos] = *byte ^ 0x36; 280 | } 281 | } else { 282 | for (pos, byte) in md5::compute(secret.as_bytes()).iter().enumerate() { 283 | secret_opad[pos] = *byte ^ 0x5c; 284 | secret_ipad[pos] = *byte ^ 0x36; 285 | } 286 | } 287 | 288 | secret_ipad 289 | .extend_from_slice(&engine::general_purpose::STANDARD.decode(challenge)?); 290 | secret_opad.extend_from_slice(&md5::compute(&secret_ipad).0); 291 | 292 | format!("{} {:x}", username, md5::compute(&secret_opad)) 293 | } 294 | 295 | (AUTH_XOAUTH2, Credentials::XOauth2 { username, secret }) => format!( 296 | "user={}\x01auth=Bearer {}\x01\x01", 297 | username.as_ref(), 298 | secret.as_ref() 299 | ), 300 | (AUTH_OAUTHBEARER, Credentials::OAuthBearer { token }) => { 301 | token.as_ref().to_string() 302 | } 303 | _ => return Err(crate::Error::UnsupportedAuthMechanism), 304 | } 305 | .as_bytes(), 306 | )) 307 | } 308 | } 309 | 310 | impl<'x> From<(&'x str, &'x str)> for Credentials<&'x str> { 311 | fn from(credentials: (&'x str, &'x str)) -> Self { 312 | Credentials::Plain { 313 | username: credentials.0, 314 | secret: credentials.1, 315 | } 316 | } 317 | } 318 | 319 | impl From<(String, String)> for Credentials { 320 | fn from(credentials: (String, String)) -> Self { 321 | Credentials::Plain { 322 | username: credentials.0, 323 | secret: credentials.1, 324 | } 325 | } 326 | } 327 | 328 | impl + PartialEq + Eq + Hash> AsRef> for Credentials { 329 | fn as_ref(&self) -> &Credentials { 330 | self 331 | } 332 | } 333 | 334 | impl Display for Error { 335 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 336 | match self { 337 | Error::InvalidChallenge => write!(f, "Invalid challenge received."), 338 | } 339 | } 340 | } 341 | 342 | #[cfg(test)] 343 | mod test { 344 | 345 | use smtp_proto::{AUTH_CRAM_MD5, AUTH_DIGEST_MD5, AUTH_LOGIN, AUTH_PLAIN, AUTH_XOAUTH2}; 346 | 347 | use crate::smtp::auth::Credentials; 348 | 349 | #[test] 350 | fn auth_encode() { 351 | // Digest-MD5 352 | #[cfg(feature = "digest-md5")] 353 | assert_eq!( 354 | Credentials::new("chris", "secret") 355 | .encode( 356 | AUTH_DIGEST_MD5, 357 | concat!( 358 | "cmVhbG09ImVsd29vZC5pbm5vc29mdC5jb20iLG5vbmNlPSJPQTZNRzl0", 359 | "RVFHbTJoaCIscW9wPSJhdXRoIixhbGdvcml0aG09bWQ1LXNlc3MsY2hh", 360 | "cnNldD11dGYtOA==" 361 | ), 362 | ) 363 | .unwrap(), 364 | concat!( 365 | "Y2hhcnNldD11dGYtOCx1c2VybmFtZT0iY2hyaXMiLHJlYWxtPSIscmVhbG0", 366 | "9ImVsd29vZC5pbm5vc29mdC5jb20iIixub25jZT0iT0E2TUc5dEVRR20yaG", 367 | "giLG5jPTAwMDAwMDAxLGNub25jZT0iT0E2TUhYaDZWcVRyUmsiLGRpZ2Vzd", 368 | "C11cmk9InNtdHAvZWx3b29kLmlubm9zb2Z0LmNvbSIscmVzcG9uc2U9NDQ2", 369 | "NjIxODg3MzlmYzcxOGNlYmYyZjA4MTk4MWI4ZDIscW9wPWF1dGg=", 370 | ) 371 | ); 372 | 373 | // Challenge-Response Authentication Mechanism (CRAM) 374 | #[cfg(feature = "cram-md5")] 375 | assert_eq!( 376 | Credentials::new("tim", "tanstaaftanstaaf") 377 | .encode( 378 | AUTH_CRAM_MD5, 379 | "PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+", 380 | ) 381 | .unwrap(), 382 | "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw" 383 | ); 384 | 385 | // SASL XOAUTH2 386 | assert_eq!( 387 | Credentials::XOauth2 { 388 | username: "someuser@example.com", 389 | secret: "ya29.vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg" 390 | } 391 | .encode(AUTH_XOAUTH2, "",) 392 | .unwrap(), 393 | concat!( 394 | "dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5Ln", 395 | "ZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ==" 396 | ) 397 | ); 398 | 399 | // Login 400 | assert_eq!( 401 | Credentials::new("tim", "tanstaaftanstaaf") 402 | .encode(AUTH_LOGIN, "VXNlciBOYW1lAA==",) 403 | .unwrap(), 404 | "dGlt" 405 | ); 406 | assert_eq!( 407 | Credentials::new("tim", "tanstaaftanstaaf") 408 | .encode(AUTH_LOGIN, "UGFzc3dvcmQA",) 409 | .unwrap(), 410 | "dGFuc3RhYWZ0YW5zdGFhZg==" 411 | ); 412 | 413 | // Plain 414 | assert_eq!( 415 | Credentials::new("tim", "tanstaaftanstaaf") 416 | .encode(AUTH_PLAIN, "",) 417 | .unwrap(), 418 | "AHRpbQB0YW5zdGFhZnRhbnN0YWFm" 419 | ); 420 | } 421 | } 422 | --------------------------------------------------------------------------------